21 Commits

Author SHA1 Message Date
caoxiaozhu
f6f787ff38 refactor(frontend): split large reimbursement and audit modules 2026-05-21 23:53:03 +08:00
caoxiaozhu
2908dda024 fix(reimbursement): harden assistant draft and claim cleanup 2026-05-21 23:52:34 +08:00
caoxiaozhu
e701fa01da feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
2026-05-21 16:09:47 +08:00
caoxiaozhu
f28d7e6d16 feat: 完善差旅票据行程提取与费用明细回填逻辑
增强文档智能识别的票据场景关键词和字段提取能力,优化
会话关联草稿报销单的解析路径,修复费用明细合并和票据
去重边界问题,前端改进报销创建和审批详情交互,补充单
元测试覆盖。
2026-05-21 14:24:51 +08:00
caoxiaozhu
b183b0bd5e feat: 细化差旅票据费用明细分类并自动计算出差补贴
将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类
型,根据票据字段自动生成行程/事由描述,结合规则引擎自
动计算出差补贴金额,前端适配费用明细编辑和差旅票据审
核交互,补充单元测试覆盖。
2026-05-21 10:57:06 +08:00
caoxiaozhu
8f65661809 feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
2026-05-21 09:28:33 +08:00
caoxiaozhu
002bf4f756 feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
2026-05-20 21:00:47 +08:00
caoxiaozhu
f8b25a7ccc fix: 修复员工服务、报销单审批及前端交互细节
- 修复员工创建时组织架构关联与邮箱校验逻辑
- 修复报销单API端点参数及预审流程调用
- 优化审批中心、差旅详情等前端页面交互
- 更新侧边栏导航与请求视图模型
- 补充员工服务与报销单相关测试用例
2026-05-20 14:32:35 +08:00
caoxiaozhu
d7e98a58b9 feat: 增强员工管理与报销单全流程功能
- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点
- 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力
- 报销单提交补充身份回填、部门信息透传及预审结果展示优化
- 认证流程增加部门信息(departmentName)并在schema中同步扩展
- 用户Agent服务增加部门关联与报销单回填逻辑
- 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能
- 前端审批中心、审计、差旅报销等视图交互与样式优化
- 新增TableLoadingState共享组件及员工导入测试用例
2026-05-20 14:21:56 +08:00
caoxiaozhu
57957d11a0 feat: 重构报销单AI预审流程并添加平台风险规则引擎
- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核
- 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器
- 用户上下文增加部门信息(department_name),认证流程同步关联组织架构
- 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类
- 新增orchestrator审核流程测试用例
- 前端更新审计视图、差旅报销等相关页面
2026-05-20 09:36:01 +08:00
caoxiaozhu
2574bc81d1 chore: 更新个人工作台和差旅报销相关功能 2026-05-19 17:24:13 +00:00
caoxiaozhu
54ffef66d3 feat: 添加风险规则及 agent assets 功能增强 2026-05-19 16:19:03 +00:00
caoxiaozhu
d460ee0fe7 fix(agent): 修复规则中心表格版本和修改记录
补齐规则资产 JSON 读写接口和前端调用,修复 AuditView 导入缺失。

Excel 在线编辑改为比对所有页签并生成最近修改记录,版本快照统一保存到 rules/finance-rules/.versions。

隔离规则表测试存储,避免测试或旧入口写入真实规则目录与 storage/agent_assets。
2026-05-19 15:41:53 +00:00
caoxiaozhu
9472813739 refactor: 重构 AuditView 和 TravelReimbursementCreateView 相关代码
- 优化 agent_assets、agent_foundation、user_agent 服务层结构
- 更新 AuditView 视图和脚本
- 更新 TravelReimbursementCreateView 脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 20:23:58 +08:00
caoxiaozhu
dc007f948a feat(rules): 添加公司通信费报销规则 Excel 文件并更新差旅费规则表 2026-05-18 10:06:23 +00:00
caoxiaozhu
9db663e81f feat(AuditView): 扩展场景标签配置
- 新增通信费报销(communication_expense)和费用标准(expense_standard)场景标签
2026-05-18 10:02:04 +00:00
caoxiaozhu
813ac81950 feat(finance): 添加公司通信费报销规则
- 新增通信费报销规则代码和文件名常量
- 在初始化数据中创建公司通信费报销规则资产
- 添加对应的版本和审核记录
- 标记为 v1.0.0 版本并审核通过
2026-05-18 10:01:40 +00:00
caoxiaozhu
9902a3b968 test: 添加 OnlyOffice 回调摘要测试 2026-05-18 09:45:05 +00:00
caoxiaozhu
29df4eee3b test: 添加规则服务与电子表格变更记录的测试用例
- AgentAssetService 测试覆盖版本创建、送审、审核通过等流程
- 新增电子表格变更记录的前端测试
2026-05-18 09:44:04 +00:00
caoxiaozhu
5106d286a1 feat(agent_assets): 添加规则版本送审时的命名副本创建逻辑
当提交的版本与当前工作版本不同时,自动创建命名副本:
- 新增 _create_named_working_copy_for_review 方法处理送审时的版本复制
- 支持将工作版本快照复制为指定版本进行送审
- 新增 AgentAssetSpreadsheetChangeRecordRead schema
- API 端点新增 /rules/{id}/spreadsheet-versions/{version}/change-records 接口
2026-05-18 09:42:23 +00:00
caoxiaozhu
64ec27949f feat(AuditView): 删除差旅费报销规则详情的审核按钮并优化显示
- 删除详情中间区域的提交审核、审核通过、驳回版本三个按钮
- 删除底部 footer 的提交审核、审核通过、驳回版本、正式上线四个按钮
- 将下载 Excel 按钮改为下载表格
- 简化电子表格编辑器的 meta 信息显示
2026-05-18 09:39:41 +00:00
193 changed files with 57040 additions and 30344 deletions

View File

@@ -32,7 +32,21 @@ services:
- > - >
apt-get update && apt-get update &&
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
python3 python3-pip python3-venv && python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra &&
printf '%s\n'
'<?xml version="1.0"?>'
'<!DOCTYPE fontconfig SYSTEM "fonts.dtd">'
'<fontconfig>'
' <alias><family>SimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
' <alias><family>NSimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
' <alias><family>KaiTi</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
' <alias><family>FangSong</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
' <alias><family>SimHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
' <alias><family>DengXian</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
' <alias><family>Microsoft YaHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
'</fontconfig>'
> /etc/fonts/local.conf &&
fc-cache -f &&
mkdir -p /run/sshd && /usr/sbin/sshd && mkdir -p /run/sshd && /usr/sbin/sshd &&
printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh && printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh &&
chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh && chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&

View File

@@ -0,0 +1,32 @@
{
"schema_version": "1.0",
"rule_code": "risk.expense.consecutive_transport_receipts",
"name": "连号交通票据",
"enabled": true,
"risk_dimension": "consecutive_receipts",
"ontology_signal": "consecutive_transport_receipts",
"evaluator": "consecutive_transport_receipts",
"applies_to": {
"expense_types": ["transport", "travel"],
"min_attachments": 2
},
"inputs": {
"invoice_no": "attachment.invoice_no"
},
"params": {
"min_consecutive_count": 3
},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 三、车辆交通 / 连号票集中报销",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"rule_code": "risk.expense.entertainment_missing_detail",
"name": "招待费事由不完整",
"enabled": true,
"risk_dimension": "entertainment_detail",
"ontology_signal": "entertainment_missing_detail",
"evaluator": "entertainment_reason_missing",
"applies_to": {
"domains": ["meal"]
},
"inputs": {
"reason": "claim.reason_corpus"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 三、餐费招待 / 业务招待无事由对象",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.expense.meal_localized_as_travel",
"name": "同城餐饮混入差旅",
"enabled": true,
"risk_dimension": "meal_travel_mix",
"ontology_signal": "meal_as_travel",
"evaluator": "meal_as_travel_same_city",
"applies_to": {
"domains": ["travel"]
},
"inputs": {
"declared": "claim.location",
"meal_city": "attachment.cities"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 三、餐费招待 / 同城餐饮归集异地差旅",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"rule_code": "risk.expense.reason_too_brief",
"name": "报销事由过短",
"enabled": true,
"risk_dimension": "reason_quality",
"ontology_signal": "reason_too_brief",
"evaluator": "reason_too_brief",
"applies_to": {},
"inputs": {
"reason": "claim.reason_corpus"
},
"params": {
"min_reason_length": 6
},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 通用 / 事由不足以支撑真实性判断",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,32 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.claimant_buyer_name_match",
"name": "报销人与发票抬头一致",
"enabled": true,
"risk_dimension": "identity_consistency",
"ontology_signal": "buyer_name_mismatch",
"evaluator": "identity_consistency",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"claimant": "claim.employee_name",
"buyer": "attachment.buyer_name"
},
"params": {
"allow_keywords": ["代报", "集团", "公司", "有限公司"]
},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 抬头错误",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.cross_year_invoice",
"name": "跨年发票入账",
"enabled": true,
"risk_dimension": "cross_year_invoice",
"ontology_signal": "cross_year_invoice",
"evaluator": "cross_year_invoice",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"invoice_date": "attachment.invoice_date",
"claim_date": ["claim.occurred_at", "item.item_date"]
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 跨年发票",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.document_expense_mismatch",
"name": "开票内容与报销场景不符",
"enabled": true,
"risk_dimension": "document_expense_mismatch",
"ontology_signal": "document_expense_mismatch",
"evaluator": "document_expense_mismatch",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"document_type": "attachment.document_type",
"expense_type": ["claim.expense_type", "item.item_type"]
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 开票内容与业务不符",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.duplicate_invoice",
"name": "发票重复报销",
"enabled": true,
"risk_dimension": "duplicate_invoice",
"ontology_signal": "duplicate_invoice",
"evaluator": "duplicate_invoice",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"invoice_no": "attachment.invoice_no"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "block"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 重复报销",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.vague_goods_description",
"name": "发票品名过于笼统",
"enabled": true,
"risk_dimension": "vague_goods_description",
"ontology_signal": "vague_goods_description",
"evaluator": "vague_goods_description",
"applies_to": {
"expense_types": ["office", "other"],
"min_attachments": 1
},
"inputs": {
"ocr": "attachment.ocr_text"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 品名笼统",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.void_or_red_invoice",
"name": "作废或红冲发票",
"enabled": true,
"risk_dimension": "void_or_red_invoice",
"ontology_signal": "void_or_red_invoice",
"evaluator": "invoice_void_or_red",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"status": "attachment.invoice_status",
"ocr": "attachment.ocr_text"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "block"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 作废红冲发票",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.base_location_overlap",
"name": "常驻地重合出差风险",
"enabled": true,
"risk_dimension": "base_location_overlap",
"ontology_signal": "base_location_overlap",
"evaluator": "base_location_overlap",
"applies_to": {
"domains": ["travel"]
},
"inputs": {
"employee_base": "employee.location",
"declared": "claim.location"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 一、出差类 / 两头在外",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.destination_receipt_location",
"name": "申报地点与票据地点一致",
"risk_dimension": "location_consistency",
"ontology_signal": "location_mismatch",
"evaluator": "location_consistency",
"inputs": {
"declared": "claim.location",
"evidence": ["attachment.cities", "item.item_location"]
},
"params": {
"match_mode": "city_fuzzy",
"missing_evidence": "warn"
},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review",
"message_template": "申报地点 {declared} 与票据识别地点 {evidence} 不一致"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"updated_at": "2026-05-18"
}
}

View File

@@ -0,0 +1,32 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.hotel_without_itinerary",
"name": "住宿城市与行程不一致",
"enabled": true,
"risk_dimension": "hotel_itinerary",
"ontology_signal": "hotel_itinerary_mismatch",
"evaluator": "hotel_without_itinerary",
"applies_to": {
"domains": ["travel"],
"expense_types": ["hotel", "travel"]
},
"inputs": {
"declared": "claim.location",
"hotel": "attachment.hotel_city",
"itinerary": "attachment.route_cities"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 三、住宿费 / 夜间异地住宿、酒店连续多天",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.intracity_travel_claim",
"name": "同城虚报差旅补贴",
"enabled": true,
"risk_dimension": "intracity_travel",
"ontology_signal": "intracity_travel",
"evaluator": "intracity_travel_claim",
"applies_to": {
"domains": ["travel"]
},
"inputs": {
"declared": "claim.location",
"evidence": ["attachment.route", "attachment.cities"]
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 一、出差类 / 同城虚报差旅",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.multi_city_reason_required",
"name": "多城市行程需说明",
"enabled": true,
"risk_dimension": "multi_city_itinerary",
"ontology_signal": "multi_city_itinerary",
"evaluator": "multi_city_reason_required",
"applies_to": {
"domains": ["travel"]
},
"inputs": {
"reason": "claim.reason_corpus",
"cities": ["attachment.cities", "item.item_location"]
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 一、出差类 / 绕道出行、行程不符",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Sync platform risk rule assets from server/rules/risk-rules/*.json."""
from __future__ import annotations
import sys
from pathlib import Path
SERVER_SRC = Path(__file__).resolve().parents[1] / "src"
if str(SERVER_SRC) not in sys.path:
sys.path.insert(0, str(SERVER_SRC))
from app.db.session import get_session_factory # noqa: E402
from app.services.agent_foundation import AgentFoundationService # noqa: E402
def main() -> None:
db = get_session_factory()()
try:
count = AgentFoundationService(db).sync_platform_risk_rules_from_library()
db.commit()
print(f"Synced {count} risk rule manifest(s) from library.")
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import json
import urllib.request
base = "http://127.0.0.1:8000/api/v1"
items = json.loads(urllib.request.urlopen(f"{base}/agent-assets?asset_type=rule").read())
risk = next((i for i in items if str(i.get("code", "")).startswith("risk.")), None)
print("risk asset:", risk.get("code") if risk else None)
if not risk:
raise SystemExit(1)
resp = urllib.request.urlopen(f"{base}/agent-assets/{risk['id']}/rule-json")
payload = json.loads(resp.read())
print("rule-json ok:", payload.get("file_name"), payload.get("evaluator"))

View File

@@ -22,6 +22,7 @@ class CurrentUserContext:
name: str name: str
role_codes: list[str] role_codes: list[str]
is_admin: bool is_admin: bool
department_name: str = ""
def get_current_user( def get_current_user(
@@ -41,6 +42,10 @@ def get_current_user(
str | None, str | None,
Header(description="是否管理员,支持 `true/false/1/0`。"), Header(description="是否管理员,支持 `true/false/1/0`。"),
] = None, ] = None,
x_auth_department: Annotated[
str | None,
Header(description="当前登录人的所属部门。"),
] = None,
) -> CurrentUserContext: ) -> CurrentUserContext:
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()] role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"} is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
@@ -59,6 +64,7 @@ def get_current_user(
name=name or username, name=name or username,
role_codes=role_codes, role_codes=role_codes,
is_admin=is_admin, is_admin=is_admin,
department_name=(x_auth_department or "").strip(),
) )

View File

@@ -23,7 +23,9 @@ from app.schemas.agent_asset import (
AgentAssetRead, AgentAssetRead,
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetReviewRead, AgentAssetReviewRead,
AgentAssetVersionCompareRead, AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead,
AgentAssetUpdate, AgentAssetUpdate,
AgentAssetVersionCreate, AgentAssetVersionCreate,
AgentAssetVersionRead, AgentAssetVersionRead,
@@ -49,7 +51,7 @@ RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_u
def _handle_asset_error(exc: Exception) -> None: def _handle_asset_error(exc: Exception) -> None:
if isinstance(exc, LookupError): if isinstance(exc, (LookupError, FileNotFoundError)):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if isinstance(exc, PermissionError): if isinstance(exc, PermissionError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -110,6 +112,48 @@ def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
return asset return asset
@router.get(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="读取风险规则 JSON",
description="读取 JSON 风险规则资产绑定的规则文件内容。",
)
def get_agent_asset_rule_json(
asset_id: str,
_: CurrentUser,
db: DbSession,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db).read_rule_json(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.put(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="保存风险规则 JSON",
description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。",
)
def save_agent_asset_rule_json(
asset_id: str,
payload: AgentAssetRuleJsonWrite,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db).write_rule_json(
asset_id,
body=payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get( @router.get(
"/{asset_id}/spreadsheet/onlyoffice-config", "/{asset_id}/spreadsheet/onlyoffice-config",
response_model=AgentAssetOnlyOfficeConfigRead, response_model=AgentAssetOnlyOfficeConfigRead,
@@ -122,7 +166,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
db: DbSession, db: DbSession,
version: Annotated[ version: Annotated[
str | None, str | None,
Query(description="可选的规则版本号;不传时默认当前版本"), Query(description="兼容旧前端的可选参数;表格规则始终打开当前规则表"),
] = None, ] = None,
) -> AgentAssetOnlyOfficeConfigRead: ) -> AgentAssetOnlyOfficeConfigRead:
try: try:
@@ -139,7 +183,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
"/{asset_id}/spreadsheet/content", "/{asset_id}/spreadsheet/content",
response_class=FileResponse, response_class=FileResponse,
summary="下载或预览规则 Excel 文件", summary="下载或预览规则 Excel 文件",
description="按版本返回规则 Excel 快照,用于浏览器预览或下载。", description="返回当前规则 Excel 文件,用于浏览器预览或下载。",
) )
def get_agent_asset_spreadsheet_content( def get_agent_asset_spreadsheet_content(
asset_id: str, asset_id: str,
@@ -147,7 +191,7 @@ def get_agent_asset_spreadsheet_content(
db: DbSession, db: DbSession,
version: Annotated[ version: Annotated[
str | None, str | None,
Query(description="可选的规则版本号;不传时默认当前版本"), Query(description="兼容旧前端的可选参数;不传时返回当前规则表"),
] = None, ] = None,
) -> FileResponse: ) -> FileResponse:
try: try:
@@ -170,18 +214,18 @@ def get_agent_asset_spreadsheet_content(
def get_agent_asset_spreadsheet_onlyoffice_content( def get_agent_asset_spreadsheet_onlyoffice_content(
asset_id: str, asset_id: str,
db: DbSession, db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="规则版本号。"),
],
access_token: Annotated[ access_token: Annotated[
str, str,
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"), Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
], ],
version: Annotated[
str | None,
Query(description="兼容旧 ONLYOFFICE URL当前表格模式不再使用。"),
] = None,
) -> FileResponse: ) -> FileResponse:
try: try:
service = AgentAssetService(db) service = AgentAssetService(db)
service.validate_rule_spreadsheet_access_token(asset_id, version, access_token) service.validate_rule_spreadsheet_access_token(asset_id, access_token)
file_path, media_type, filename = service.get_rule_spreadsheet_content( file_path, media_type, filename = service.get_rule_spreadsheet_content(
asset_id, asset_id,
version=version, version=version,
@@ -201,7 +245,7 @@ def get_agent_asset_spreadsheet_onlyoffice_content(
response_model=AgentAssetRead, response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="上传规则 Excel 文件", summary="上传规则 Excel 文件",
description="为指定规则上传新的 Excel 快照,并自动生成新规则版本", description="为指定规则上传新的 Excel 文件,并记录本次表格修改",
) )
def upload_agent_asset_spreadsheet( def upload_agent_asset_spreadsheet(
asset_id: str, asset_id: str,
@@ -266,22 +310,27 @@ def import_agent_asset_spreadsheet_content(
"/{asset_id}/spreadsheet/onlyoffice/callback", "/{asset_id}/spreadsheet/onlyoffice/callback",
response_model=AgentAssetOnlyOfficeCallbackRead, response_model=AgentAssetOnlyOfficeCallbackRead,
summary="接收规则 Excel 的 ONLYOFFICE 回调", summary="接收规则 Excel 的 ONLYOFFICE 回调",
description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本", description="接收 ONLYOFFICE 回写内容,并记录本次表格修改",
) )
def handle_agent_asset_spreadsheet_onlyoffice_callback( def handle_agent_asset_spreadsheet_onlyoffice_callback(
asset_id: str, asset_id: str,
payload: AgentAssetOnlyOfficeCallbackWrite, payload: AgentAssetOnlyOfficeCallbackWrite,
db: DbSession, db: DbSession,
version: Annotated[ version: Annotated[
str, str | None,
Query(min_length=1, description="打开编辑器时对应的规则版本号"), Query(description="兼容旧 ONLYOFFICE 回调;当前表格模式不再使用"),
], ] = None,
actor_name: Annotated[
str | None,
Query(description="发起编辑的用户显示名。"),
] = None,
) -> AgentAssetOnlyOfficeCallbackRead: ) -> AgentAssetOnlyOfficeCallbackRead:
try: try:
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback( AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
asset_id, asset_id,
version=version, version=version,
payload=payload.model_dump(), payload=payload.model_dump(),
actor_name=actor_name,
) )
except Exception as exc: except Exception as exc:
_handle_asset_error(exc) _handle_asset_error(exc)
@@ -289,6 +338,24 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
return AgentAssetOnlyOfficeCallbackRead() return AgentAssetOnlyOfficeCallbackRead()
@router.get(
"/{asset_id}/spreadsheet/change-records",
response_model=list[AgentAssetSpreadsheetChangeRecordRead],
summary="读取规则表最近修改记录",
description="返回最近 30 次 ONLYOFFICE 保存级修改记录,用于展示操作者、时间和具体差异。",
)
def list_agent_asset_spreadsheet_change_records(
asset_id: str,
_: CurrentUser,
db: DbSession,
limit: Annotated[int, Query(ge=1, le=30, description="返回条数,最多 30 条。")] = 30,
) -> list[AgentAssetSpreadsheetChangeRecordRead]:
try:
return AgentAssetService(db).list_spreadsheet_change_records(asset_id, limit=limit)
except Exception as exc:
_handle_asset_error(exc)
@router.post( @router.post(
"", "",
response_model=AgentAssetRead, response_model=AgentAssetRead,
@@ -533,25 +600,3 @@ def get_agent_asset_version_timeline(
except Exception as exc: except Exception as exc:
_handle_asset_error(exc) _handle_asset_error(exc)
@router.get(
"/{asset_id}/versions/compare",
response_model=AgentAssetVersionCompareRead,
summary="比较两个规则表版本",
description="对比两个 Excel 规则表版本的工作表变化与单元格级差异。",
)
def compare_agent_asset_spreadsheet_versions(
asset_id: str,
_: CurrentUser,
db: DbSession,
base_version: Annotated[str, Query(min_length=1, description="基准版本号")],
target_version: Annotated[str, Query(min_length=1, description="对比版本号")],
) -> AgentAssetVersionCompareRead:
try:
return AgentAssetService(db).compare_spreadsheet_versions(
asset_id,
base_version=base_version,
target_version=target_version,
)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -2,12 +2,19 @@ from __future__ import annotations
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate from app.schemas.employee import (
EmployeeCreate,
EmployeeImportResultRead,
EmployeeMetaRead,
EmployeeRead,
EmployeeUpdate,
)
from app.services.employee import EmployeeService from app.services.employee import EmployeeService
router = APIRouter() router = APIRouter()
@@ -44,6 +51,67 @@ def list_employees(
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword) return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
@router.get(
"/import-template",
summary="下载员工导入模板",
description="下载固定格式的员工 Excel 导入模板。",
)
def download_employee_import_template(db: DbSession) -> Response:
content = EmployeeService(db).build_import_template()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": 'attachment; filename="employee-import-template.xlsx"'
},
)
@router.get(
"/export",
summary="导出员工 Excel",
description="按筛选条件导出员工目录 Excel 文件。",
)
def export_employees(
db: DbSession,
status_filter: Annotated[
str | None,
Query(alias="status", description="员工状态筛选值。"),
] = None,
keyword: Annotated[
str | None,
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
] = None,
) -> Response:
content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="employee-export.xlsx"'},
)
@router.post(
"/import",
response_model=EmployeeImportResultRead,
summary="导入员工 Excel",
description="按模板批量导入员工。全部校验通过后才写入数据库,任一行有错则整批不导入。",
)
async def import_employees(
db: DbSession,
file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
) -> EmployeeImportResultRead:
filename = (file.filename or "").lower()
if not filename.endswith(".xlsx"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前仅支持上传 .xlsx 格式的员工表格。",
)
content = await file.read()
return EmployeeService(db).import_employees(content)
@router.post( @router.post(
"", "",
response_model=EmployeeRead, response_model=EmployeeRead,

View File

@@ -12,15 +12,21 @@ from app.schemas.reimbursement import (
ExpenseClaimAttachmentActionResponse, ExpenseClaimAttachmentActionResponse,
ExpenseClaimActionResponse, ExpenseClaimActionResponse,
ExpenseClaimAttachmentRead, ExpenseClaimAttachmentRead,
ExpenseClaimApprovalPayload,
ExpenseClaimItemCreate, ExpenseClaimItemCreate,
ExpenseClaimItemActionResponse, ExpenseClaimItemActionResponse,
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimRead, ExpenseClaimRead,
ExpenseClaimReturnPayload,
ExpenseClaimUpdate,
ReimbursementCreate, ReimbursementCreate,
ReimbursementRead, ReimbursementRead,
TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse,
) )
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
router = APIRouter() router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]
@@ -48,6 +54,29 @@ def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> Reimbur
return ReimbursementService(db).create_reimbursement(payload) return ReimbursementService(db).create_reimbursement(payload)
@router.post(
"/travel-calculator",
response_model=TravelReimbursementCalculatorResponse,
summary="差旅报销标准测算",
description="根据规则中心的差旅报销表、当前员工职级、出差天数与地点测算住宿和补贴参考金额。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "测算入参或规则匹配失败。",
}
},
)
def calculate_travel_reimbursement(
payload: TravelReimbursementCalculatorRequest,
db: DbSession,
current_user: CurrentUser,
) -> TravelReimbursementCalculatorResponse:
try:
return TravelReimbursementCalculatorService(db).calculate(payload, current_user)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
@router.get( @router.get(
"/claims", "/claims",
response_model=list[ExpenseClaimRead], response_model=list[ExpenseClaimRead],
@@ -58,6 +87,16 @@ def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[Expens
return ExpenseClaimService(db).list_claims(current_user) return ExpenseClaimService(db).list_claims(current_user)
@router.get(
"/claims/approvals",
response_model=list[ExpenseClaimRead],
summary="查询当前用户审批待办报销单列表",
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
)
def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
return ExpenseClaimService(db).list_approval_claims(current_user)
@router.get( @router.get(
"/claims/{claim_id}", "/claims/{claim_id}",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,
@@ -77,6 +116,43 @@ def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -
return claim return claim
@router.patch(
"/claims/{claim_id}",
response_model=ExpenseClaimRead,
summary="更新草稿报销单",
description="更新草稿待提交报销单的主说明等草稿字段。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "报销单状态不允许更新。",
},
},
)
def update_expense_claim(
claim_id: str,
payload: ExpenseClaimUpdate,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.update_claim(
claim_id=claim_id,
payload=payload,
current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.patch( @router.patch(
"/claims/{claim_id}/items/{item_id}", "/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,
@@ -415,11 +491,11 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
return claim return claim
@router.delete( @router.post(
"/claims/{claim_id}", "/claims/{claim_id}/return",
response_model=ExpenseClaimActionResponse, response_model=ExpenseClaimRead,
summary="删除个人报销草稿", summary="退回报销单",
description="删除当前登录用户可见的草稿报销单", description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态",
responses={ responses={
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse, "model": ErrorResponse,
@@ -427,7 +503,73 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
}, },
status.HTTP_400_BAD_REQUEST: { status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse, "model": ErrorResponse,
"description": "仅草稿状态允许删除", "description": "当前用户或单据状态允许退回",
},
},
)
def return_expense_claim(
claim_id: str,
payload: ExpenseClaimReturnPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/approve",
response_model=ExpenseClaimRead,
summary="审批通过报销单",
description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许审批通过。",
},
},
)
def approve_expense_claim(
claim_id: str,
payload: ExpenseClaimApprovalPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.delete(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
summary="删除报销单",
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "当前用户或单据状态不允许删除。",
}, },
}, },
) )
@@ -442,7 +584,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return ExpenseClaimActionResponse( return ExpenseClaimActionResponse(
message=f"{claim.claim_no} 草稿已删除。", message=f"{claim.claim_no} 报销单已删除。",
claim_id=claim.id, claim_id=claim.id,
status="deleted", status="deleted",
) )

View File

@@ -93,6 +93,10 @@ class ExpenseClaimItem(Base):
claim = relationship("ExpenseClaim", back_populates="items") claim = relationship("ExpenseClaim", back_populates="items")
@property
def is_system_generated(self) -> bool:
return str(self.item_type or "").strip().lower() in {"travel_allowance"}
class AccountsReceivableRecord(Base): class AccountsReceivableRecord(Base):
__tablename__ = "accounts_receivable" __tablename__ = "accounts_receivable"

View File

@@ -56,6 +56,17 @@ class AgentAssetRepository:
stmt = stmt.limit(limit) stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
def list_versions_for_assets(self, asset_ids: list[str]) -> list[AgentAssetVersion]:
if not asset_ids:
return []
stmt = (
select(AgentAssetVersion)
.where(AgentAssetVersion.asset_id.in_(asset_ids))
.order_by(AgentAssetVersion.asset_id, AgentAssetVersion.created_at.desc())
)
return list(self.db.scalars(stmt).all())
def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None: def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
stmt = select(AgentAssetVersion).where( stmt = select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset_id, AgentAssetVersion.asset_id == asset_id,

View File

@@ -28,6 +28,28 @@ class AuditLogRepository:
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit) stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())
def list_for_resources(
self,
*,
resource_type: str,
resource_ids: list[str],
action: str | None = None,
limit: int | None = None,
) -> list[AuditLog]:
if not resource_ids:
return []
stmt = select(AuditLog).where(
AuditLog.resource_type == resource_type,
AuditLog.resource_id.in_(resource_ids),
)
if action:
stmt = stmt.where(AuditLog.action == action)
stmt = stmt.order_by(AuditLog.created_at.desc())
if limit is not None:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
def create(self, log: AuditLog) -> AuditLog: def create(self, log: AuditLog) -> AuditLog:
self.db.add(log) self.db.add(log)
self.db.commit() self.db.commit()

View File

@@ -93,6 +93,22 @@ class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
users: list[str] = Field(default_factory=list, description="当前编辑用户列表。") users: list[str] = Field(default_factory=list, description="当前编辑用户列表。")
class AgentAssetRuleJsonWrite(BaseModel):
payload: dict[str, Any] = Field(default_factory=dict)
class AgentAssetRuleJsonRead(BaseModel):
file_name: str
rule_code: str
name: str
description: str = ""
evaluator: str = ""
ontology_signal: str | None = None
inputs: dict[str, Any] = Field(default_factory=dict)
outcomes: dict[str, Any] = Field(default_factory=dict)
payload: dict[str, Any] = Field(default_factory=dict)
class AgentAssetVersionTimelineItemRead(BaseModel): class AgentAssetVersionTimelineItemRead(BaseModel):
event_type: str event_type: str
version: str version: str
@@ -117,15 +133,15 @@ class AgentAssetSpreadsheetDiffSheetRead(BaseModel):
change_type: str change_type: str
class AgentAssetVersionCompareRead(BaseModel): class AgentAssetSpreadsheetChangeRecordRead(BaseModel):
base_version: str id: str
target_version: str actor: str
added_sheet_count: int = 0 changed_at: datetime
removed_sheet_count: int = 0 summary: str
changed_sheet_count: int = 0
changed_cell_count: int = 0
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list) sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list) cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
changed_sheet_count: int = 0
changed_cell_count: int = 0
class AgentAssetVersionRead(BaseModel): class AgentAssetVersionRead(BaseModel):
@@ -162,6 +178,8 @@ class AgentAssetListItem(BaseModel):
published_version: str | None published_version: str | None
working_version: str | None working_version: str | None
config_json: dict[str, Any] config_json: dict[str, Any]
change_count: int = 0
modified_by: str | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
@@ -12,8 +14,16 @@ class AuthUserRead(BaseModel):
username: str username: str
name: str name: str
role: str role: str
department: str = ""
departmentName: str = ""
position: str = "" position: str = ""
grade: str = "" grade: str = ""
employeeNo: str = ""
managerName: str = ""
location: str = ""
costCenter: str = ""
financeOwnerName: str = ""
riskProfile: dict[str, Any] = Field(default_factory=dict)
roleCodes: list[str] = Field(default_factory=list) roleCodes: list[str] = Field(default_factory=list)
email: EmailStr | str email: EmailStr | str
avatar: str avatar: str

View File

@@ -50,6 +50,7 @@ class EmployeeMetaRead(BaseModel):
totalEmployees: int totalEmployees: int
statusSummary: list[EmployeeStatusSummaryRead] statusSummary: list[EmployeeStatusSummaryRead]
roleOptions: list[EmployeeRoleOptionRead] roleOptions: list[EmployeeRoleOptionRead]
organizationOptions: list[EmployeeOrganizationRead] = Field(default_factory=list)
class EmployeeRead(BaseModel): class EmployeeRead(BaseModel):
@@ -63,6 +64,7 @@ class EmployeeRead(BaseModel):
position: str position: str
grade: str grade: str
manager: str manager: str
managerEmployeeNo: str | None = None
financeOwner: str financeOwner: str
roles: list[str] = Field(default_factory=list) roles: list[str] = Field(default_factory=list)
roleCodes: list[str] = Field(default_factory=list) roleCodes: list[str] = Field(default_factory=list)
@@ -112,6 +114,28 @@ class EmployeeCreate(BaseModel):
return _parse_optional_date(self.join_date, "入职日期") return _parse_optional_date(self.join_date, "入职日期")
class EmployeeImportErrorRead(BaseModel):
row: int
column: str
employeeNo: str = ""
message: str
class EmployeeImportSummaryRead(BaseModel):
totalRows: int = 0
created: int = 0
updated: int = 0
errorCount: int = 0
class EmployeeImportResultRead(BaseModel):
success: bool
message: str
summary: EmployeeImportSummaryRead
errors: list[EmployeeImportErrorRead] = Field(default_factory=list)
importedAt: str | None = None
class EmployeeUpdate(BaseModel): class EmployeeUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100) name: str | None = Field(default=None, min_length=1, max_length=100)
gender: str | None = Field(default=None, max_length=20) gender: str | None = Field(default=None, max_length=20)
@@ -124,6 +148,8 @@ class EmployeeUpdate(BaseModel):
grade: str | None = Field(default=None, min_length=1, max_length=20) grade: str | None = Field(default=None, min_length=1, max_length=20)
cost_center: str | None = Field(default=None, max_length=50) cost_center: str | None = Field(default=None, max_length=50)
finance_owner_name: str | None = Field(default=None, max_length=100) finance_owner_name: str | None = Field(default=None, max_length=100)
organization_unit_code: str | None = Field(default=None, max_length=50)
manager_employee_no: str | None = Field(default=None, max_length=50)
role_codes: list[str] | None = None role_codes: list[str] | None = None
password: str | None = Field(default=None, min_length=5, max_length=128) password: str | None = Field(default=None, min_length=5, max_length=128)

View File

@@ -41,6 +41,7 @@ class ExpenseClaimItemRead(BaseModel):
item_location: str item_location: str
item_amount: Decimal item_amount: Decimal
invoice_id: str | None invoice_id: str | None
is_system_generated: bool = False
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -51,6 +52,7 @@ class ExpenseClaimAttachmentAnalysisRead(BaseModel):
headline: str headline: str
summary: str summary: str
points: list[str] = Field(default_factory=list) points: list[str] = Field(default_factory=list)
rule_basis: list[str] = Field(default_factory=list)
suggestion: str = "" suggestion: str = ""
@@ -112,6 +114,10 @@ class ExpenseClaimItemCreate(BaseModel):
invoice_id: str | None = None invoice_id: str | None = None
class ExpenseClaimUpdate(BaseModel):
reason: str | None = Field(default=None, max_length=500)
class ExpenseClaimRead(BaseModel): class ExpenseClaimRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -148,11 +154,54 @@ class ExpenseClaimActionResponse(BaseModel):
status: str | None = None status: str | None = None
class ExpenseClaimReturnPayload(BaseModel):
reason: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10)
class ExpenseClaimApprovalPayload(BaseModel):
opinion: str | None = Field(default=None, max_length=500)
class TravelReimbursementCalculatorRequest(BaseModel):
days: int = Field(ge=1, le=365)
location: str = Field(min_length=1, max_length=120)
grade: str | None = Field(default=None, max_length=30)
class TravelReimbursementCalculatorResponse(BaseModel):
days: int
location: str
matched_city: str
city_tier: str
grade: str
grade_band: str
grade_band_label: str
hotel_rate: Decimal
hotel_amount: Decimal
allowance_region: str
meal_allowance_rate: Decimal
basic_allowance_rate: Decimal
total_allowance_rate: Decimal
allowance_amount: Decimal
total_amount: Decimal
rule_name: str
rule_version: str
formula_text: str
summary_text: str
class ExpenseClaimAttachmentActionResponse(BaseModel): class ExpenseClaimAttachmentActionResponse(BaseModel):
message: str message: str
claim_id: str claim_id: str
item_id: str item_id: str
invoice_id: str | None = None invoice_id: str | None = None
item_date: date | None = None
item_type: str | None = None
item_reason: str | None = None
item_location: str | None = None
item_amount: Decimal | None = None
claim_amount: Decimal | None = None
attachment: ExpenseClaimAttachmentRead | None = None attachment: ExpenseClaimAttachmentRead | None = None

View File

@@ -22,6 +22,7 @@ class UserAgentSuggestedAction(BaseModel):
label: str = Field(description="建议动作文案。") label: str = Field(description="建议动作文案。")
action_type: str = Field(description="动作类型,例如 open_detail / create_draft。") action_type: str = Field(description="动作类型,例如 open_detail / create_draft。")
description: str = Field(default="", description="动作说明。") description: str = Field(default="", description="动作说明。")
payload: dict[str, Any] = Field(default_factory=dict, description="动作携带的结构化参数。")
class UserAgentDraftPayload(BaseModel): class UserAgentDraftPayload(BaseModel):
@@ -85,6 +86,8 @@ class UserAgentReviewRiskBrief(BaseModel):
title: str = Field(description="风险或注意事项标题。") title: str = Field(description="风险或注意事项标题。")
level: str = Field(default="info", description="级别,例如 info / warning / high。") level: str = Field(default="info", description="级别,例如 info / warning / high。")
content: str = Field(description="面向用户展示的摘要说明。") content: str = Field(description="面向用户展示的摘要说明。")
detail: str = Field(default="", description="点击风险项后展示的详细解释。")
suggestion: str = Field(default="", description="面向用户的处理建议。")
class UserAgentReviewSlotCard(BaseModel): class UserAgentReviewSlotCard(BaseModel):

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from app.core.config import SERVER_DIR
from app.services.agent_asset_spreadsheet import RULE_LIBRARY_NAMES
JSON_RULE_MIME_TYPE = "application/json"
class AgentAssetRuleLibraryManager:
def __init__(self, rule_root: Path | None = None) -> None:
self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve()
def ensure_rule_library_dirs(self) -> None:
for library in sorted(RULE_LIBRARY_NAMES):
(self.rule_root / library).mkdir(parents=True, exist_ok=True)
def resolve_rule_library_path(self, *, library: str, file_name: str) -> Path:
normalized_library = str(library or "").strip()
if normalized_library not in RULE_LIBRARY_NAMES:
raise ValueError("Invalid rule library.")
normalized_name = Path(str(file_name or "").strip()).name.strip()
if not normalized_name or not normalized_name.endswith(".json"):
raise ValueError("Rule JSON file name must end with .json.")
library_dir = (self.rule_root / normalized_library).resolve()
target_path = (library_dir / normalized_name).resolve()
try:
target_path.relative_to(library_dir)
except ValueError:
raise ValueError("Invalid rule JSON path.") from None
return target_path
def read_rule_library_json(self, *, library: str, file_name: str) -> dict[str, Any]:
target_path = self.resolve_rule_library_path(library=library, file_name=file_name)
if not target_path.exists():
raise FileNotFoundError("Rule JSON file not found.")
try:
payload = json.loads(target_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError("Rule JSON file is invalid.") from exc
if not isinstance(payload, dict):
raise ValueError("Rule JSON payload must be an object.")
return payload
def write_rule_library_json(
self,
*,
library: str,
file_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError("Rule JSON payload must be an object.")
rule_code = str(payload.get("rule_code") or "").strip()
if not rule_code:
raise ValueError("Rule JSON must include rule_code.")
evaluator = str(payload.get("evaluator") or "").strip()
if not evaluator:
raise ValueError("Rule JSON must include evaluator.")
target_path = self.resolve_rule_library_path(library=library, file_name=file_name)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(
f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n",
encoding="utf-8",
)
return payload
def list_rule_library_json_files(self, *, library: str) -> list[str]:
library_dir = self.resolve_rule_library_path(
library=library,
file_name="placeholder.json",
).parent
library_dir.mkdir(parents=True, exist_ok=True)
return sorted(path.name for path in library_dir.glob("*.json") if path.is_file())

View File

@@ -22,6 +22,8 @@ RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement" COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx" COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
FINANCE_RULES_LIBRARY = "finance-rules" FINANCE_RULES_LIBRARY = "finance-rules"
RISK_RULES_LIBRARY = "risk-rules" RISK_RULES_LIBRARY = "risk-rules"
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY} RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
@@ -67,26 +69,13 @@ class AgentAssetSpreadsheetManager:
actor_name: str, actor_name: str,
source: str = "upload", source: str = "upload",
) -> RuleSpreadsheetMeta: ) -> RuleSpreadsheetMeta:
normalized_name = Path(str(file_name or "").strip()).name.strip() return self.store_rule_library_spreadsheet_snapshot(
if not normalized_name: library=FINANCE_RULES_LIBRARY,
raise ValueError("规则表文件名不能为空。") asset_id=asset_id,
if not content: version=version,
raise ValueError("规则表文件内容不能为空。") file_name=file_name,
content=content,
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name actor_name=actor_name,
target_path = (self.storage_root / relative_path).resolve()
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
return RuleSpreadsheetMeta(
file_name=normalized_name,
storage_key=relative_path.as_posix(),
mime_type=mime_type,
size_bytes=len(content),
checksum=hashlib.sha256(content).hexdigest(),
updated_at=datetime.now(UTC).isoformat(),
updated_by=str(actor_name or "system").strip() or "system",
source=source, source=source,
) )
@@ -115,7 +104,74 @@ class AgentAssetSpreadsheetManager:
try: try:
target_path.relative_to(self.rule_root) target_path.relative_to(self.rule_root)
except ValueError: except ValueError:
raise ValueError("规则库文件路径不合法。") raise ValueError("规则库文件路径不合法。") from None
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
return RuleSpreadsheetMeta(
file_name=normalized_name,
storage_key=relative_path.as_posix(),
mime_type=mime_type,
size_bytes=len(content),
checksum=hashlib.sha256(content).hexdigest(),
updated_at=datetime.now(UTC).isoformat(),
updated_by=str(actor_name or "system").strip() or "system",
source=source,
)
def store_rule_library_spreadsheet_snapshot(
self,
*,
library: str,
asset_id: str,
version: str,
file_name: str,
content: bytes,
actor_name: str,
source: str = "rule-library-version",
) -> RuleSpreadsheetMeta:
normalized_library = str(library or "").strip()
if normalized_library not in RULE_LIBRARY_NAMES:
raise ValueError("规则库目录不合法。")
raw_asset_id = str(asset_id or "").strip()
raw_version = str(version or "").strip()
normalized_asset_id = Path(raw_asset_id).name.strip()
normalized_version = Path(raw_version).name.strip()
normalized_name = Path(str(file_name or "").strip()).name.strip()
if (
not normalized_asset_id
or normalized_asset_id in {".", ".."}
or normalized_asset_id != raw_asset_id
):
raise ValueError("规则资产 ID 不合法。")
if (
not normalized_version
or normalized_version in {".", ".."}
or normalized_version != raw_version
):
raise ValueError("规则表版本号不合法。")
if not normalized_name:
raise ValueError("规则表文件名不能为空。")
if not content:
raise ValueError("规则表文件内容不能为空。")
self.ensure_rule_library_dirs()
relative_path = (
Path("rules")
/ normalized_library
/ ".versions"
/ normalized_asset_id
/ normalized_version
/ normalized_name
)
target_path = (SERVER_DIR / relative_path).resolve()
try:
target_path.relative_to(self.rule_root)
except ValueError:
raise ValueError("规则库版本文件路径不合法。") from None
target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content) target_path.write_bytes(content)
@@ -147,7 +203,7 @@ class AgentAssetSpreadsheetManager:
try: try:
resolved.relative_to(allowed_root) resolved.relative_to(allowed_root)
except ValueError: except ValueError:
raise FileNotFoundError("规则表文件不存在。") raise FileNotFoundError("规则表文件不存在。") from None
return resolved return resolved
@staticmethod @staticmethod
@@ -228,11 +284,46 @@ class AgentAssetSpreadsheetManager:
def build_company_travel_rule_template() -> bytes: def build_company_travel_rule_template() -> bytes:
standard_rows = [ standard_rows = [
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"], ["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"], [
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"], "长途交通",
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"], "飞机、高铁、火车等跨城出行",
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"], "行程单、车票、发票",
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"], "据实报销",
"超预算需直属领导审批",
"优先选择公共交通",
],
[
"住宿费",
"出差住宿",
"酒店发票、入住清单",
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
"超标需总监审批",
"协议酒店优先",
],
[
"市内交通",
"出租车、网约车、地铁、公交",
"发票或电子行程单",
"150/天",
"超限需补充说明",
"夜间或无公共交通场景可豁免",
],
[
"餐补",
"出差期间日常补助",
"无需票据",
"120/天",
"系统自动核定",
"当天往返默认不享受",
],
[
"招待餐费",
"客户接待或项目宴请",
"餐饮发票、参与人清单",
"300/人",
"需业务负责人审批",
"需关联客户或项目",
],
] ]
instruction_rows = [ instruction_rows = [
["字段", "填写说明"], ["字段", "填写说明"],
@@ -306,21 +397,41 @@ def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str: def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
overrides = [ overrides = [
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>', (
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>', '<Override PartName="/xl/workbook.xml" '
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>', 'ContentType="application/vnd.openxmlformats-officedocument.'
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>', 'spreadsheetml.sheet.main+xml"/>'
),
(
'<Override PartName="/xl/styles.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.'
'spreadsheetml.styles+xml"/>'
),
(
'<Override PartName="/docProps/core.xml" '
'ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
),
(
'<Override PartName="/docProps/app.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.'
'extended-properties+xml"/>'
),
] ]
overrides.extend( overrides.extend(
[ [
f'<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' (
f'<Override PartName="/xl/worksheets/sheet{index}.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.'
'spreadsheetml.worksheet+xml"/>'
)
for index, _ in enumerate(sheets, start=1) for index, _ in enumerate(sheets, start=1)
] ]
) )
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' '<Default Extension="rels" '
'ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>' '<Default Extension="xml" ContentType="application/xml"/>'
f'{"".join(overrides)}' f'{"".join(overrides)}'
"</Types>" "</Types>"
@@ -331,9 +442,15 @@ def _build_root_rels_xml() -> str:
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' '<Relationship Id="rId1" '
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' 'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' 'relationships/officeDocument" Target="xl/workbook.xml"/>'
'<Relationship Id="rId2" '
'Type="http://schemas.openxmlformats.org/package/2006/relationships/'
'metadata/core-properties" Target="docProps/core.xml"/>'
'<Relationship Id="rId3" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships/extended-properties" Target="docProps/app.xml"/>'
"</Relationships>" "</Relationships>"
) )
@@ -345,11 +462,16 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
sheet_count = len(sheets) sheet_count = len(sheets)
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/'
'extended-properties" '
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' 'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
'<Application>Microsoft Excel</Application>' '<Application>Microsoft Excel</Application>'
f"<HeadingPairs><vt:vector size=\"2\" baseType=\"variant\"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant></vt:vector></HeadingPairs>" '<HeadingPairs><vt:vector size="2" baseType="variant">'
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>" "<vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant>"
f"<vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant>"
"</vt:vector></HeadingPairs>"
f'<TitlesOfParts><vt:vector size="{sheet_count}" baseType="lpstr">'
f"{titles}</vt:vector></TitlesOfParts>"
"</Properties>" "</Properties>"
) )
@@ -357,7 +479,8 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
def _build_core_xml(created_at: str) -> str: def _build_core_xml(created_at: str) -> str:
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/'
'2006/metadata/core-properties" '
'xmlns:dc="http://purl.org/dc/elements/1.1/" ' 'xmlns:dc="http://purl.org/dc/elements/1.1/" '
'xmlns:dcterms="http://purl.org/dc/terms/" ' 'xmlns:dcterms="http://purl.org/dc/terms/" '
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" ' 'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
@@ -390,7 +513,11 @@ def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str: def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
relationships = "".join( relationships = "".join(
[ [
f'<Relationship Id="rId{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{index}.xml"/>' (
f'<Relationship Id="rId{index}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
f'relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
)
for index, _ in enumerate(sheets, start=1) for index, _ in enumerate(sheets, start=1)
] ]
) )
@@ -412,10 +539,15 @@ def _build_styles_xml() -> str:
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>' '<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' '<fills count="2"><fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="gray125"/></fill></fills>'
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' '<cellStyleXfs count="1">'
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>' '<xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>'
"</cellStyleXfs>"
'<cellXfs count="1">'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
"</cellXfs>"
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
'</styleSheet>' '</styleSheet>'
) )

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,50 @@ STATEFUL_CONTEXT_KEYS = (
"attachment_count", "attachment_count",
"ocr_summary", "ocr_summary",
"ocr_documents", "ocr_documents",
"review_form_values",
"business_time_context",
)
REVIEW_FLOW_CONTEXT_KEYS = {
"draft_claim_id",
"draft_claim_no",
"draft_status",
"request_context",
"attachment_names",
"attachment_count",
"ocr_summary",
"ocr_documents",
"review_form_values",
"business_time_context",
}
REVIEW_FLOW_CONTINUATION_KEYWORDS = (
"补充",
"继续",
"继续上传",
"当前",
"这张",
"这个",
"该单据",
"现有",
"已有",
"关联",
"合并",
"修改",
"更正",
"改成",
"调整",
"下一步",
"保存草稿",
)
NEW_EXPENSE_PROMPT_KEYWORDS = (
"申请报销",
"我要报销",
"我想报销",
"帮我报销",
"发起报销",
"提交报销",
"生成报销",
"创建报销",
"新建报销",
) )
DEFAULT_CONVERSATION_RETENTION_DAYS = 3 DEFAULT_CONVERSATION_RETENTION_DAYS = 3
@@ -39,6 +83,7 @@ class AgentConversationService:
normalized_id = str(conversation_id or "").strip() normalized_id = str(conversation_id or "").strip()
normalized_user_id = str(user_id or "").strip() or None normalized_user_id = str(user_id or "").strip() or None
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense" incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense"
incoming_draft_claim_id = self._resolve_draft_claim_id(context_json)
conversation = self.get_conversation(normalized_id) if normalized_id else None conversation = self.get_conversation(normalized_id) if normalized_id else None
if conversation is not None and conversation.user_id != normalized_user_id: if conversation is not None and conversation.user_id != normalized_user_id:
normalized_id = "" normalized_id = ""
@@ -56,6 +101,7 @@ class AgentConversationService:
source=source, source=source,
entry_source=str(context_json.get("entry_source") or "").strip() or None, entry_source=str(context_json.get("entry_source") or "").strip() or None,
title=self._resolve_title(context_json), title=self._resolve_title(context_json),
draft_claim_id=incoming_draft_claim_id or None,
state_json=self._extract_state_json(context_json), state_json=self._extract_state_json(context_json),
) )
self.db.add(conversation) self.db.add(conversation)
@@ -69,6 +115,8 @@ class AgentConversationService:
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
if not conversation.title: if not conversation.title:
conversation.title = self._resolve_title(context_json) conversation.title = self._resolve_title(context_json)
if incoming_draft_claim_id:
conversation.draft_claim_id = incoming_draft_claim_id
conversation.state_json = self._merge_state_json( conversation.state_json = self._merge_state_json(
conversation.state_json, conversation.state_json,
self._extract_state_json(context_json), self._extract_state_json(context_json),
@@ -86,7 +134,11 @@ class AgentConversationService:
resolved_retention_days = retention_days or self._resolve_retention_days() resolved_retention_days = retention_days or self._resolve_retention_days()
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days)) cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff) stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff)
expired_conversations = list(self.db.scalars(stmt).all()) expired_conversations = [
conversation
for conversation in self.db.scalars(stmt).all()
if not self._is_saved_conversation(conversation)
]
if not expired_conversations: if not expired_conversations:
return 0 return 0
@@ -96,6 +148,13 @@ class AgentConversationService:
self.db.commit() self.db.commit()
return len(expired_conversations) return len(expired_conversations)
@staticmethod
def _is_saved_conversation(conversation: AgentConversation) -> bool:
if str(conversation.draft_claim_id or "").strip():
return True
state_json = dict(conversation.state_json or {})
return bool(str(state_json.get("draft_claim_id") or "").strip())
def _resolve_retention_days(self) -> int: def _resolve_retention_days(self) -> int:
try: try:
settings_row, _ = SettingsService(self.db).ensure_settings_ready() settings_row, _ = SettingsService(self.db).ensure_settings_ready()
@@ -178,10 +237,18 @@ class AgentConversationService:
*, *,
conversation: AgentConversation, conversation: AgentConversation,
context_json: dict[str, Any], context_json: dict[str, Any],
message: str | None = None,
history_limit: int = 8, history_limit: int = 8,
) -> dict[str, Any]: ) -> dict[str, Any]:
merged = dict(context_json or {}) merged = dict(context_json or {})
state_json = dict(conversation.state_json or {}) state_json = dict(conversation.state_json or {})
should_hydrate_review_flow = self._should_hydrate_review_flow_context(
context_json=merged,
message=message,
)
if not should_hydrate_review_flow:
for key in REVIEW_FLOW_CONTEXT_KEYS:
merged.pop(key, None)
merged["conversation_id"] = conversation.conversation_id merged["conversation_id"] = conversation.conversation_id
merged["conversation_history"] = self.list_message_history( merged["conversation_history"] = self.list_message_history(
@@ -192,16 +259,58 @@ class AgentConversationService:
merged.setdefault("conversation_scenario", conversation.last_scenario) merged.setdefault("conversation_scenario", conversation.last_scenario)
if conversation.last_intent: if conversation.last_intent:
merged.setdefault("conversation_intent", conversation.last_intent) merged.setdefault("conversation_intent", conversation.last_intent)
if conversation.draft_claim_id and not str(merged.get("draft_claim_id") or "").strip(): if (
should_hydrate_review_flow
and conversation.draft_claim_id
and not str(merged.get("draft_claim_id") or "").strip()
):
merged["draft_claim_id"] = conversation.draft_claim_id merged["draft_claim_id"] = conversation.draft_claim_id
merged["conversation_state"] = state_json merged["conversation_state"] = state_json
for key in STATEFUL_CONTEXT_KEYS: for key in STATEFUL_CONTEXT_KEYS:
if key in REVIEW_FLOW_CONTEXT_KEYS and not should_hydrate_review_flow:
continue
if self._is_empty_value(merged.get(key)) and not self._is_empty_value(state_json.get(key)): if self._is_empty_value(merged.get(key)) and not self._is_empty_value(state_json.get(key)):
merged[key] = state_json.get(key) merged[key] = state_json.get(key)
return merged return merged
@staticmethod
def _should_hydrate_review_flow_context(
*,
context_json: dict[str, Any],
message: str | None,
) -> bool:
if isinstance(context_json.get("expense_scene_selection"), dict):
return True
if AgentConversationService._resolve_draft_claim_id(context_json):
compact_message = str(message or "").replace(" ", "")
if compact_message and any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
return False
return True
if str(context_json.get("review_action") or "").strip():
return True
if str(context_json.get("entry_source") or "").strip() == "detail":
return True
if not AgentConversationService._is_empty_value(context_json.get("attachment_names")):
return True
if not AgentConversationService._is_empty_value(context_json.get("ocr_documents")):
return True
if str(context_json.get("ocr_summary") or "").strip():
return True
try:
if int(context_json.get("attachment_count") or 0) > 0:
return True
except (TypeError, ValueError):
pass
compact_message = str(message or "").replace(" ", "")
if not compact_message:
return False
if any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
return False
return any(keyword in compact_message for keyword in REVIEW_FLOW_CONTINUATION_KEYWORDS)
def append_message( def append_message(
self, self,
*, *,
@@ -354,6 +463,38 @@ class AgentConversationService:
self.db.commit() self.db.commit()
return len(conversations) return len(conversations)
def delete_conversations_for_draft_claim(
self,
*,
claim_id: str | None,
source: str | None = "user_message",
session_type: str | None = "expense",
) -> int:
normalized_claim_id = str(claim_id or "").strip()
if not normalized_claim_id:
return 0
stmt = select(AgentConversation).where(AgentConversation.draft_claim_id == normalized_claim_id)
if source:
stmt = stmt.where(AgentConversation.source == source)
conversations = list(self.db.scalars(stmt).all())
normalized_session_type = str(session_type or "").strip()
if normalized_session_type:
conversations = [
conversation
for conversation in conversations
if (str((conversation.state_json or {}).get("session_type") or "").strip() or "expense")
== normalized_session_type
]
if not conversations:
return 0
for conversation in conversations:
self.db.delete(conversation)
self.db.commit()
return len(conversations)
def delete_conversation( def delete_conversation(
self, self,
*, *,
@@ -478,11 +619,28 @@ class AgentConversationService:
continue continue
state_json[key] = value state_json[key] = value
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() draft_claim_id = AgentConversationService._resolve_draft_claim_id(context_json)
if draft_claim_id: if draft_claim_id:
state_json["draft_claim_id"] = draft_claim_id state_json["draft_claim_id"] = draft_claim_id
return state_json return state_json
@staticmethod
def _resolve_draft_claim_id(context_json: dict[str, Any]) -> str:
draft_claim_id = str((context_json or {}).get("draft_claim_id") or "").strip()
if draft_claim_id:
return draft_claim_id
request_context = (context_json or {}).get("request_context")
if isinstance(request_context, dict):
return str(
request_context.get("claim_id")
or request_context.get("claimId")
or request_context.get("draft_claim_id")
or request_context.get("draftClaimId")
or ""
).strip()
return ""
@staticmethod @staticmethod
def _merge_state_json( def _merge_state_json(
current_state: dict[str, Any] | None, current_state: dict[str, Any] | None,

View File

@@ -34,13 +34,20 @@ from app.models.financial_record import (
ExpenseClaim, ExpenseClaim,
ExpenseClaimItem, ExpenseClaimItem,
) )
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager, AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY, FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
RuleSpreadsheetMeta, RuleSpreadsheetMeta,
) )
PLATFORM_DESTINATION_LOCATION_RULE_CODE = "risk.travel.destination_receipt_location"
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME = "risk.travel.destination_receipt_location.json"
from app.services.expense_rule_runtime import ( from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown, build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown, build_travel_risk_control_standard_markdown,
@@ -88,6 +95,9 @@ LEGACY_RULE_CODES = (
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements" ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",)
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("费用科目",)
ATTACHMENT_RULE_RUNTIME_CONFIG = { ATTACHMENT_RULE_RUNTIME_CONFIG = {
"kind": "policy_rule_draft", "kind": "policy_rule_draft",
@@ -263,7 +273,7 @@ class AgentFoundationService:
name="公司差旅费报销规则", name="公司差旅费报销规则",
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
domain=AgentAssetDomain.EXPENSE.value, domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "travel_policy", "travel_standard"], scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
owner="财务制度管理组", owner="财务制度管理组",
reviewer="顾承宇", reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value, status=AgentAssetStatus.ACTIVE.value,
@@ -276,9 +286,36 @@ class AgentFoundationService:
"tag": "财务规则", "tag": "财务规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"rule_template_label": "差旅报销 Excel 模板", "rule_template_label": "差旅报销 Excel 模板",
}, },
) )
platform_risk_assets = self._build_platform_risk_seed_assets()
company_communication_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_COMMUNICATION_RULE_VERSION,
published_version=COMPANY_COMMUNICATION_RULE_VERSION,
working_version=COMPANY_COMMUNICATION_RULE_VERSION,
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"rule_template_label": "通信费报销 Excel 模板",
},
)
skill_expense_asset = AgentAsset( skill_expense_asset = AgentAsset(
asset_type=AgentAssetType.SKILL.value, asset_type=AgentAssetType.SKILL.value,
code="skill.expense.summary_lookup", code="skill.expense.summary_lookup",
@@ -405,7 +442,9 @@ class AgentFoundationService:
attachment_rule, attachment_rule,
scene_submission_rule, scene_submission_rule,
travel_policy_rule, travel_policy_rule,
*platform_risk_assets,
company_travel_rule, company_travel_rule,
company_communication_rule,
skill_expense_asset, skill_expense_asset,
skill_ar_asset, skill_ar_asset,
invoice_mcp_asset, invoice_mcp_asset,
@@ -423,6 +462,11 @@ class AgentFoundationService:
version=COMPANY_TRAVEL_RULE_VERSION, version=COMPANY_TRAVEL_RULE_VERSION,
actor_name="系统初始化", actor_name="系统初始化",
) )
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
company_communication_rule,
version=COMPANY_COMMUNICATION_RULE_VERSION,
actor_name="系统初始化",
)
self.db.add_all( self.db.add_all(
[ [
@@ -472,6 +516,17 @@ class AgentFoundationService:
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。", change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
created_by="系统初始化", created_by="系统初始化",
), ),
*[
AgentAssetVersion(
asset=asset,
version="v1.0.0",
content=self._platform_risk_rule_markdown(asset),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note=f"平台通用风险规则:{asset.name}",
created_by="系统初始化",
)
for asset in platform_risk_assets
],
AgentAssetVersion( AgentAssetVersion(
asset=company_travel_rule, asset=company_travel_rule,
version=COMPANY_TRAVEL_RULE_VERSION, version=COMPANY_TRAVEL_RULE_VERSION,
@@ -484,6 +539,18 @@ class AgentFoundationService:
change_note="初始化差旅费报销 Excel 规则表。", change_note="初始化差旅费报销 Excel 规则表。",
created_by="系统初始化", created_by="系统初始化",
), ),
AgentAssetVersion(
asset=company_communication_rule,
version=COMPANY_COMMUNICATION_RULE_VERSION,
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=company_communication_rule.name,
version=COMPANY_COMMUNICATION_RULE_VERSION,
metadata=company_communication_rule_meta,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化通信费报销 Excel 规则表。",
created_by="系统初始化",
),
AgentAssetVersion( AgentAssetVersion(
asset=skill_expense_asset, asset=skill_expense_asset,
version="v1.0.0", version="v1.0.0",
@@ -635,6 +702,14 @@ class AgentFoundationService:
review_note="首版 Excel 规则表已确认,可作为财务规则使用。", review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
), ),
AgentAssetReview(
asset=company_communication_rule,
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
reviewed_at=datetime.now(UTC),
),
] ]
) )
@@ -1001,6 +1076,9 @@ class AgentFoundationService:
company_travel_rule = self.db.scalar( company_travel_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
) )
company_communication_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
)
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes: if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
attachment_rule = self._create_seed_asset( attachment_rule = self._create_seed_asset(
@@ -1189,6 +1267,8 @@ class AgentFoundationService:
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
) )
self.sync_platform_risk_rules_from_library()
if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes: if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes:
company_travel_rule = self._create_seed_asset( company_travel_rule = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value, asset_type=AgentAssetType.RULE.value,
@@ -1196,7 +1276,7 @@ class AgentFoundationService:
name="公司差旅费报销规则", name="公司差旅费报销规则",
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
domain=AgentAssetDomain.EXPENSE.value, domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "travel_policy", "travel_standard"], scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
owner="财务制度管理组", owner="财务制度管理组",
reviewer="顾承宇", reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value, status=AgentAssetStatus.ACTIVE.value,
@@ -1206,11 +1286,36 @@ class AgentFoundationService:
"enabled": True, "enabled": True,
"tag": "财务规则", "tag": "财务规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"rule_template_label": "差旅报销 Excel 模板", "rule_template_label": "差旅报销 Excel 模板",
}, },
) )
if COMPANY_COMMUNICATION_EXPENSE_RULE_CODE not in existing_codes:
company_communication_rule = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_COMMUNICATION_RULE_VERSION,
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"rule_template_label": "通信费报销 Excel 模板",
},
)
if company_travel_rule is not None: if company_travel_rule is not None:
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
if not str(company_travel_rule.current_version or "").strip(): if not str(company_travel_rule.current_version or "").strip():
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
if not str(company_travel_rule.working_version or "").strip(): if not str(company_travel_rule.working_version or "").strip():
@@ -1227,6 +1332,8 @@ class AgentFoundationService:
"tag": "财务规则", "tag": "财务规则",
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY, "rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"rule_template_label": "差旅报销 Excel 模板", "rule_template_label": "差旅报销 Excel 模板",
} }
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
@@ -1256,6 +1363,55 @@ class AgentFoundationService:
reviewed_at=datetime.now(UTC), reviewed_at=datetime.now(UTC),
) )
if company_communication_rule is not None:
company_communication_rule.scenario_json = list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON)
if not str(company_communication_rule.current_version or "").strip():
company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION
if not str(company_communication_rule.working_version or "").strip():
company_communication_rule.working_version = company_communication_rule.current_version
if not str(company_communication_rule.published_version or "").strip():
company_communication_rule.published_version = company_communication_rule.current_version
if not str(company_communication_rule.status or "").strip():
company_communication_rule.status = AgentAssetStatus.ACTIVE.value
company_communication_rule.description = "通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。"
company_communication_rule.config_json = {
**(company_communication_rule.config_json or {}),
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"rule_template_label": "通信费报销 Excel 模板",
}
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
company_communication_rule,
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
actor_name="系统初始化",
)
self._ensure_asset_version(
company_communication_rule,
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=company_communication_rule.name,
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
metadata=company_communication_rule_meta,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note="初始化通信费报销 Excel 规则表。",
created_by="系统初始化",
)
if str(company_communication_rule.current_version or "").strip() == COMPANY_COMMUNICATION_RULE_VERSION:
self._ensure_asset_review(
company_communication_rule,
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
reviewed_at=datetime.now(UTC),
)
if "skill.ar.aging_summary" not in existing_codes: if "skill.ar.aging_summary" not in existing_codes:
asset = self._create_seed_asset( asset = self._create_seed_asset(
asset_type=AgentAssetType.SKILL.value, asset_type=AgentAssetType.SKILL.value,
@@ -1435,19 +1591,6 @@ class AgentFoundationService:
except FileNotFoundError: except FileNotFoundError:
existing_path = None existing_path = None
if existing_path is not None and existing_path.exists(): if existing_path is not None and existing_path.exists():
metadata = RuleSpreadsheetMeta(
file_name=str(existing_document.get("file_name") or COMPANY_TRAVEL_EXPENSE_RULE_FILENAME),
storage_key=storage_key,
mime_type=str(existing_document.get("mime_type") or "").strip()
or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size_bytes=int(existing_document.get("size_bytes") or existing_path.stat().st_size),
checksum=hashlib.sha256(existing_path.read_bytes()).hexdigest(),
updated_at=str(existing_document.get("updated_at") or "").strip()
or datetime.now(UTC).isoformat(),
updated_by=str(existing_document.get("updated_by") or actor_name).strip()
or actor_name,
source=str(existing_document.get("source") or "seed").strip() or "seed",
)
asset.config_json = { asset.config_json = {
**(asset.config_json or {}), **(asset.config_json or {}),
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
@@ -1461,17 +1604,8 @@ class AgentFoundationService:
"storage_key": live_document.storage_key, "storage_key": live_document.storage_key,
}, },
} }
return metadata return live_document
live_content = manager.resolve_storage_path(live_document.storage_key).read_bytes()
metadata = manager.store_spreadsheet(
asset_id=asset.id,
version=version,
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
content=live_content,
actor_name=actor_name,
source="seed",
)
asset.config_json = { asset.config_json = {
**(asset.config_json or {}), **(asset.config_json or {}),
"detail_mode": "spreadsheet", "detail_mode": "spreadsheet",
@@ -1485,7 +1619,22 @@ class AgentFoundationService:
"storage_key": live_document.storage_key, "storage_key": live_document.storage_key,
}, },
} }
return metadata return live_document
def _ensure_company_communication_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
):
return self._ensure_finance_rule_spreadsheet_seed(
asset,
version=version,
actor_name=actor_name,
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
fallback_sheet_name="通信费报销规则",
)
@staticmethod @staticmethod
def _read_or_build_company_travel_rule_file( def _read_or_build_company_travel_rule_file(
@@ -1501,6 +1650,91 @@ class AgentFoundationService:
return live_path.read_bytes() return live_path.read_bytes()
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则") return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
def _ensure_finance_rule_spreadsheet_seed(
self,
asset: AgentAsset,
*,
version: str,
actor_name: str,
file_name: str,
fallback_sheet_name: str,
):
manager = AgentAssetSpreadsheetManager()
manager.ensure_rule_library_dirs()
live_document = manager.store_rule_library_spreadsheet(
library=FINANCE_RULES_LIBRARY,
file_name=file_name,
content=self._read_or_build_finance_rule_file(
manager,
file_name=file_name,
fallback_sheet_name=fallback_sheet_name,
),
actor_name=actor_name,
source="rule-library",
)
existing_document = (
asset.config_json.get("rule_document")
if isinstance(asset.config_json, dict)
else None
)
storage_key = (
str(existing_document.get("storage_key") or "").strip()
if isinstance(existing_document, dict)
else ""
)
if storage_key:
try:
existing_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
existing_path = None
if existing_path is not None and existing_path.exists():
asset.config_json = {
**(asset.config_json or {}),
"detail_mode": "spreadsheet",
"tag": "财务规则",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
live_document,
asset_version=version,
),
"storage_key": live_document.storage_key,
},
}
return live_document
asset.config_json = {
**(asset.config_json or {}),
"detail_mode": "spreadsheet",
"tag": "财务规则",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
live_document,
asset_version=version,
),
"storage_key": live_document.storage_key,
},
}
return live_document
@staticmethod
def _read_or_build_finance_rule_file(
manager: AgentAssetSpreadsheetManager,
*,
file_name: str,
fallback_sheet_name: str,
) -> bytes:
live_key = (
Path("rules")
/ FINANCE_RULES_LIBRARY
/ file_name
).as_posix()
live_path = manager.resolve_storage_path(live_key)
if live_path.exists():
return live_path.read_bytes()
return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name)
def _create_seed_asset( def _create_seed_asset(
self, self,
*, *,
@@ -1710,6 +1944,229 @@ class AgentFoundationService:
def _travel_risk_control_standard_markdown(self, *, version: str = "v1.1.0") -> str: def _travel_risk_control_standard_markdown(self, *, version: str = "v1.1.0") -> str:
return self._markdown_content(build_travel_risk_control_standard_markdown()) return self._markdown_content(build_travel_risk_control_standard_markdown())
def _iter_platform_risk_manifests(self) -> list[tuple[str, dict[str, object]]]:
manager = AgentAssetRuleLibraryManager()
manifests: list[tuple[str, dict[str, object]]] = []
for file_name in sorted(manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)):
payload = manager.read_rule_library_json(library=RISK_RULES_LIBRARY, file_name=file_name)
if payload.get("enabled") is False:
continue
manifests.append((file_name, payload))
return manifests
@staticmethod
def _resolve_platform_risk_category(manifest: dict[str, object]) -> str:
explicit = str(manifest.get("risk_category") or "").strip()
if explicit:
return explicit
rule_code = str(manifest.get("rule_code") or "").strip().lower()
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
domains = {str(item or "").strip().lower() for item in applies_to.get("domains") or []}
expense_types = {
str(item or "").strip().lower() for item in applies_to.get("expense_types") or []
}
if rule_code.startswith("risk.invoice."):
return "发票"
if "meal" in domains or "entertainment" in expense_types:
return "餐饮招待"
if "transport" in expense_types or "consecutive_transport" in rule_code:
return "交通出行"
if "office" in expense_types:
return "办公物料"
if "travel" in domains or rule_code.startswith("risk.travel."):
return "差旅"
if rule_code.startswith("risk.expense."):
return "费用科目"
return "通用"
def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]:
category = self._resolve_platform_risk_category(manifest)
return [category] if category else ["通用"]
def _platform_risk_config_json(self, file_name: str, manifest: dict[str, object]) -> dict[str, object]:
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
risk_category = self._resolve_platform_risk_category(manifest)
return {
"severity": str(fail_outcome.get("severity") or "medium"),
"enabled": True,
"tag": "风险规则",
"detail_mode": "json_risk",
"risk_category": risk_category,
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {
"file_name": file_name,
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
},
"ontology_signal": str(manifest.get("ontology_signal") or "").strip(),
"evaluator": str(manifest.get("evaluator") or "").strip(),
"source_ref": (
(manifest.get("metadata") or {}).get("source_ref")
if isinstance(manifest.get("metadata"), dict)
else ""
),
}
def _build_platform_risk_seed_assets(self) -> list[AgentAsset]:
assets: list[AgentAsset] = []
for file_name, manifest in self._iter_platform_risk_manifests():
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code:
continue
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
source_ref = str(metadata.get("source_ref") or "").strip()
rule_description = str(manifest.get("description") or "").strip()
assets.append(
AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=rule_code,
name=str(manifest.get("name") or rule_code),
description=rule_description
or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=self._platform_risk_scenario_json(manifest),
owner=str(metadata.get("owner") or "风控与审计部"),
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
working_version="v1.0.0",
config_json=self._platform_risk_config_json(file_name, manifest),
)
)
return assets
def sync_platform_risk_rules_from_library(self) -> int:
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
before_count = len(existing_codes)
self._ensure_platform_risk_rules_from_library(existing_codes)
self.db.flush()
after_codes = set(self.db.scalars(select(AgentAsset.code)).all())
synced = max(len(after_codes) - before_count, 0)
manifest_count = len(self._iter_platform_risk_manifests())
logger.info(
"Platform risk rules synced from library",
extra={"manifest_count": manifest_count, "created_count": synced, "total": len(after_codes)},
)
return manifest_count
def _ensure_platform_risk_rules_from_library(self, existing_codes: set[str]) -> None:
for file_name, manifest in self._iter_platform_risk_manifests():
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code:
continue
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
source_ref = str(metadata.get("source_ref") or "").strip()
rule_description = str(manifest.get("description") or "").strip()
config_json = self._platform_risk_config_json(file_name, manifest)
scenario_json = self._platform_risk_scenario_json(manifest)
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == rule_code))
if asset is None and rule_code not in existing_codes:
asset = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=rule_code,
name=str(manifest.get("name") or rule_code),
description=rule_description
or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=scenario_json,
owner=str(metadata.get("owner") or "风控与审计部"),
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
config_json=config_json,
)
if asset is None:
continue
if not str(asset.current_version or "").strip():
asset.current_version = "v1.0.0"
if not str(asset.working_version or "").strip():
asset.working_version = asset.current_version
if not str(asset.published_version or "").strip():
asset.published_version = asset.current_version
asset.status = asset.status or AgentAssetStatus.ACTIVE.value
asset.name = str(manifest.get("name") or asset.name or rule_code)
if rule_description:
asset.description = rule_description
asset.config_json = config_json
asset.scenario_json = scenario_json
self._ensure_asset_version(
asset,
version="v1.0.0",
content=self._platform_risk_rule_markdown(asset, manifest=manifest, file_name=file_name),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note=f"平台通用风险规则:{asset.name}",
created_by="系统初始化",
)
self._ensure_asset_review(
asset,
version="v1.0.0",
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="平台内置风险规则,供提交验审与风险问答共用。",
reviewed_at=datetime.now(UTC),
)
@staticmethod
def _platform_risk_rule_markdown(
asset: AgentAsset,
*,
manifest: dict[str, object] | None = None,
file_name: str = "",
) -> str:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
resolved_file_name = file_name or str(rule_document.get("file_name") or "").strip()
evaluator = str(config.get("evaluator") or (manifest or {}).get("evaluator") or "").strip()
ontology_signal = str(config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or "").strip()
source_ref = str(config.get("source_ref") or "").strip()
if not source_ref and isinstance(manifest, dict):
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
source_ref = str(metadata.get("source_ref") or "").strip()
lines = [
f"# {asset.name}",
"",
"## 规则类型",
"",
"- 平台内置通用风险规则(`json_risk`",
]
if evaluator:
lines.append(f"- 检查器:`{evaluator}`")
if ontology_signal:
lines.append(f"- 本体信号:`{ontology_signal}`")
if source_ref:
lines.extend(["", "## 来源", "", f"- {source_ref}"])
if resolved_file_name:
lines.extend(
[
"",
"## 配置文件",
"",
f"- `rules/{RISK_RULES_LIBRARY}/{resolved_file_name}`",
]
)
return "\n".join(lines)
@staticmethod
def _platform_destination_location_risk_markdown() -> str:
return AgentFoundationService._platform_risk_rule_markdown(
AgentAsset(name="申报地点与票据地点一致", config_json={"evaluator": "location_consistency"}),
manifest={
"evaluator": "location_consistency",
"ontology_signal": "location_mismatch",
"metadata": {"source_ref": "常用risk.txt / 一、出差类 / 行程不符"},
},
file_name=PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
@staticmethod @staticmethod
def _markdown_content(content: str) -> str: def _markdown_content(content: str) -> str:
return content return content

View File

@@ -1,14 +1,17 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.core.config import get_settings from app.core.config import get_settings
from app.core.logging import get_logger from app.core.logging import get_logger
from app.core.security import verify_password from app.core.security import verify_password
from app.models.employee import Employee from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
from app.services.employee import EmployeeService from app.services.employee import EmployeeService
from app.services.employee_seed import ROLE_DISPLAY_ORDER from app.services.employee_seed import ROLE_DISPLAY_ORDER
@@ -31,8 +34,15 @@ class AuthenticatedUser:
username: str username: str
name: str name: str
role: str role: str
department: str
position: str position: str
grade: str grade: str
employee_no: str
manager_name: str
location: str
cost_center: str
finance_owner_name: str
risk_profile: dict[str, Any]
role_codes: list[str] role_codes: list[str]
email: str email: str
avatar: str avatar: str
@@ -78,8 +88,15 @@ class AuthService:
username=admin_username or admin_email, username=admin_username or admin_email,
name=display_name, name=display_name,
role="管理员", role="管理员",
department="",
position="系统管理员", position="系统管理员",
grade="", grade="",
employee_no="",
manager_name="",
location="",
cost_center="",
finance_owner_name="",
risk_profile={},
role_codes=["manager"], role_codes=["manager"],
email=admin_email or f"{admin_username}@local", email=admin_email or f"{admin_username}@local",
avatar=display_name[:1].upper(), avatar=display_name[:1].upper(),
@@ -94,7 +111,11 @@ class AuthService:
stmt = ( stmt = (
select(Employee) select(Employee)
.options(selectinload(Employee.roles)) .options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
)
.where(func.lower(Employee.email) == identifier.lower()) .where(func.lower(Employee.email) == identifier.lower())
) )
employee = self.db.execute(stmt).scalars().first() employee = self.db.execute(stmt).scalars().first()
@@ -115,27 +136,91 @@ class AuthService:
) )
role_codes = [role.role_code for role in sorted_roles] role_codes = [role.role_code for role in sorted_roles]
primary_role_code = role_codes[0] if role_codes else "user" primary_role_code = role_codes[0] if role_codes else "user"
department = employee.organization_unit.name if employee.organization_unit is not None else ""
manager_name = self._resolve_manager_name(employee)
return AuthenticatedUser( return AuthenticatedUser(
username=employee.email, username=employee.email,
name=employee.name, name=employee.name,
role=ROLE_LABELS.get(primary_role_code, "使用者"), role=ROLE_LABELS.get(primary_role_code, "使用者"),
department=department,
position=employee.position, position=employee.position,
grade=employee.grade, grade=employee.grade,
employee_no=employee.employee_no,
manager_name=manager_name,
location=employee.location or "",
cost_center=employee.cost_center or "",
finance_owner_name=employee.finance_owner_name or "",
risk_profile=self._build_risk_profile(employee),
role_codes=role_codes or ["user"], role_codes=role_codes or ["user"],
email=employee.email, email=employee.email,
avatar=(employee.name or "?")[:1].upper(), avatar=(employee.name or "?")[:1].upper(),
is_admin=False, is_admin=False,
) )
@staticmethod
def _resolve_manager_name(employee: Employee) -> str:
if employee.manager is not None and employee.manager.name:
return str(employee.manager.name).strip()
if employee.organization_unit is not None and employee.organization_unit.manager_name:
return str(employee.organization_unit.manager_name).strip()
return ""
def _build_risk_profile(self, employee: Employee) -> dict[str, Any]:
since = datetime.now(UTC) - timedelta(days=90)
identity_values = [
str(employee.name or "").strip(),
str(employee.email or "").strip(),
str(employee.employee_no or "").strip(),
]
name_candidates = [item for item in dict.fromkeys(identity_values) if item]
conditions = [ExpenseClaim.employee_id == employee.id]
if name_candidates:
conditions.append(ExpenseClaim.employee_name.in_(name_candidates))
stmt = (
select(ExpenseClaim)
.where(or_(*conditions), ExpenseClaim.occurred_at >= since)
.order_by(ExpenseClaim.occurred_at.desc())
.limit(30)
)
claims = list(self.db.scalars(stmt).all())
recent_risk_flags: list[str] = []
for claim in claims:
for flag in claim.risk_flags_json or []:
normalized = str(flag or "").strip()
if normalized and normalized not in recent_risk_flags:
recent_risk_flags.append(normalized)
if len(recent_risk_flags) >= 6:
break
if len(recent_risk_flags) >= 6:
break
return {
"windowDays": 90,
"totalClaimCount": len(claims),
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
"recentRiskFlags": recent_risk_flags,
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
}
@staticmethod @staticmethod
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead: def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
return AuthUserRead( return AuthUserRead(
username=user.username, username=user.username,
name=user.name, name=user.name,
role=user.role, role=user.role,
department=user.department,
departmentName=user.department,
position=user.position, position=user.position,
grade=user.grade, grade=user.grade,
employeeNo=user.employee_no,
managerName=user.manager_name,
location=user.location,
costCenter=user.cost_center,
financeOwnerName=user.finance_owner_name,
riskProfile=user.risk_profile,
roleCodes=user.role_codes, roleCodes=user.role_codes,
email=user.email, email=user.email,
avatar=user.avatar, avatar=user.avatar,

View File

@@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
scene_code="travel", scene_code="travel",
scene_label="差旅票据", scene_label="差旅票据",
expense_type="travel", expense_type="travel",
keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"), keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"),
score_bias=0.32, score_bias=0.32,
), ),
DocumentRule( DocumentRule(
@@ -104,7 +104,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
scene_code="transport", scene_code="transport",
scene_label="交通票据", scene_label="交通票据",
expense_type="transport", expense_type="transport",
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"), keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "乘车", "用车", "叫车", "车费", "车资", "的士", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
score_bias=0.38, score_bias=0.38,
), ),
DocumentRule( DocumentRule(
@@ -177,13 +177,14 @@ SUPPORTED_DOCUMENT_TYPES = tuple(DOCUMENT_TYPE_RULE_MAP.keys()) + ("other",)
AMOUNT_PATTERNS = ( AMOUNT_PATTERNS = (
re.compile( re.compile(
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" r"[:\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
), ),
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"), re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"), re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
) )
DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)") DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
TIME_PATTERN = re.compile(r"(?<!\d)([01]?\d|2[0-3])[:]([0-5]\d)(?!\d)")
INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[:\s]*([A-Za-z0-9-]{6,24})") INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[:\s]*([A-Za-z0-9-]{6,24})")
INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[:\s]*([A-Za-z0-9-]{6,24})") INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[:\s]*([A-Za-z0-9-]{6,24})")
TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[:\s]*([A-Za-z0-9]{2,12})") TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[:\s]*([A-Za-z0-9]{2,12})")
@@ -192,6 +193,58 @@ MERCHANT_PATTERNS = (
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[:\s]*([A-Za-z0-9\u4e00-\u9fa5()·&\\-]{2,40})"), re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[:\s]*([A-Za-z0-9\u4e00-\u9fa5()·&\\-]{2,40})"),
re.compile(r"([A-Za-z0-9\u4e00-\u9fa5()·&\\-]{2,40}(?:酒店|宾馆|饭店|酒楼|餐厅|航空|铁路|滴滴出行|停车场|服务区))"), re.compile(r"([A-Za-z0-9\u4e00-\u9fa5()·&\\-]{2,40}(?:酒店|宾馆|饭店|酒楼|餐厅|航空|铁路|滴滴出行|停车场|服务区))"),
) )
DATE_FIELD_KEYS = {
"date",
"time",
"issued_at",
"invoice_date",
"issue_date",
"travel_date",
"trip_date",
"journey_date",
"departure_date",
"departure_time",
"depart_date",
"depart_time",
"boarding_date",
"boarding_time",
"train_date",
"train_time",
"train_departure_time",
"scheduled_departure_time",
"flight_date",
"flight_time",
"ride_date",
"ride_time",
"pickup_time",
"start_time",
}
TRIP_DATE_LABEL_BY_DOCUMENT_TYPE = {
"train_ticket": "列车出发时间",
"flight_itinerary": "起飞日期",
"taxi_receipt": "乘车时间",
"transport_receipt": "乘车时间",
"parking_toll_receipt": "通行日期",
}
TRIP_DATE_FIELD_LABEL_TOKENS = (
"日期",
"时间",
"开票日期",
"发生时间",
"行程日期",
"出发日期",
"出发时间",
"列车出发时间",
"发车日期",
"发车时间",
"开车时间",
"乘车日期",
"乘车时间",
"起飞日期",
"航班日期",
"上车时间",
"用车时间",
)
class DocumentIntelligenceService: class DocumentIntelligenceService:
@@ -212,7 +265,10 @@ class DocumentIntelligenceService:
compact = re.sub(r"\s+", "", raw_text).lower() compact = re.sub(r"\s+", "", raw_text).lower()
rule_match = _match_document_rule(compact) rule_match = _match_document_rule(compact)
base_rule = rule_match.rule or DEFAULT_RULE base_rule = rule_match.rule or DEFAULT_RULE
fields = tuple(_extract_document_fields(raw_text)) fields = _apply_document_type_field_labels(
tuple(_extract_document_fields(raw_text, base_rule.document_type)),
base_rule.document_type,
)
rule_insight = DocumentInsight( rule_insight = DocumentInsight(
document_type=base_rule.document_type, document_type=base_rule.document_type,
document_type_label=base_rule.document_type_label, document_type_label=base_rule.document_type_label,
@@ -275,7 +331,10 @@ class DocumentIntelligenceService:
for item in parsed.evidence for item in parsed.evidence
if str(item or "").strip() if str(item or "").strip()
][:4] ][:4]
normalized_fields = _normalize_llm_document_fields(parsed.fields) normalized_fields = _apply_document_type_field_labels(
tuple(_normalize_llm_document_fields(parsed.fields)),
normalized_type,
)
return LlmDocumentClassification( return LlmDocumentClassification(
document_type=normalized_type, document_type=normalized_type,
@@ -312,7 +371,10 @@ class DocumentIntelligenceService:
scene_code=rule_insight.scene_code, scene_code=rule_insight.scene_code,
scene_label=rule_insight.scene_label, scene_label=rule_insight.scene_label,
expense_type=rule_insight.expense_type, expense_type=rule_insight.expense_type,
fields=merged_fields, fields=_apply_document_type_field_labels(
merged_fields,
rule_insight.document_type,
),
classification_source=rule_insight.classification_source, classification_source=rule_insight.classification_source,
classification_confidence=rule_insight.classification_confidence, classification_confidence=rule_insight.classification_confidence,
evidence=rule_insight.evidence, evidence=rule_insight.evidence,
@@ -337,7 +399,10 @@ class DocumentIntelligenceService:
scene_code=rule_insight.scene_code, scene_code=rule_insight.scene_code,
scene_label=rule_insight.scene_label, scene_label=rule_insight.scene_label,
expense_type=rule_insight.expense_type, expense_type=rule_insight.expense_type,
fields=merged_fields, fields=_apply_document_type_field_labels(
merged_fields,
rule_insight.document_type,
),
classification_source=rule_insight.classification_source, classification_source=rule_insight.classification_source,
classification_confidence=rule_insight.classification_confidence, classification_confidence=rule_insight.classification_confidence,
evidence=rule_insight.evidence, evidence=rule_insight.evidence,
@@ -354,7 +419,7 @@ class DocumentIntelligenceService:
scene_code=rule.scene_code if parsed.scene_code == "other" else parsed.scene_code, scene_code=rule.scene_code if parsed.scene_code == "other" else parsed.scene_code,
scene_label=rule.scene_label if parsed.scene_label == "其他票据" else parsed.scene_label, scene_label=rule.scene_label if parsed.scene_label == "其他票据" else parsed.scene_label,
expense_type=rule.expense_type if parsed.expense_type == "other" else parsed.expense_type, expense_type=rule.expense_type if parsed.expense_type == "other" else parsed.expense_type,
fields=merged_fields, fields=_apply_document_type_field_labels(merged_fields, rule.document_type),
classification_source=source, classification_source=source,
classification_confidence=max(parsed.confidence, rule_insight.classification_confidence), classification_confidence=max(parsed.confidence, rule_insight.classification_confidence),
evidence=tuple(parsed.evidence or rule_insight.evidence), evidence=tuple(parsed.evidence or rule_insight.evidence),
@@ -464,8 +529,49 @@ def _normalize_llm_document_field_key(key: str, label: str) -> str:
token in compact_label for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额") token in compact_label for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
): ):
return "amount" return "amount"
if compact_key in {"date", "time", "issued_at", "invoice_date"} or any( if compact_key in {
token in compact_label for token in ("日期", "时间", "开票日期", "发生时间") "travel_date",
"trip_date",
"journey_date",
"departure_date",
"departure_time",
"depart_date",
"depart_time",
"boarding_date",
"boarding_time",
"train_date",
"train_time",
"train_departure_time",
"scheduled_departure_time",
"flight_date",
"flight_time",
"ride_date",
"ride_time",
"pickup_time",
"start_time",
} or any(
token in compact_label
for token in (
"行程日期",
"出发日期",
"出发时间",
"列车出发时间",
"发车日期",
"发车时间",
"开车时间",
"乘车日期",
"乘车时间",
"起飞日期",
"航班日期",
"上车时间",
"用车时间",
)
):
return "trip_date"
if compact_key in {"issued_at", "issue_date", "invoice_date"} or "开票日期" in compact_label:
return "invoice_date"
if compact_key in {"date", "time"} or any(
token in compact_label for token in ("日期", "时间", "发生时间")
): ):
return "date" return "date"
if compact_key in {"merchant_name", "merchant", "seller_name", "vendor_name"} or any( if compact_key in {"merchant_name", "merchant", "seller_name", "vendor_name"} or any(
@@ -504,7 +610,7 @@ def _normalize_llm_document_field_value(key: str, value: str) -> str:
return "" return ""
text_value = format(candidate.quantize(Decimal("0.01")), "f").rstrip("0").rstrip(".") text_value = format(candidate.quantize(Decimal("0.01")), "f").rstrip("0").rstrip(".")
return f"{text_value}" return f"{text_value}"
if key == "date": if key in {"date", "time", "invoice_date", "trip_date"}:
return _extract_date(raw_value) or _clean_field_value(raw_value) return _extract_date(raw_value) or _clean_field_value(raw_value)
if key == "route": if key == "route":
return _extract_route(raw_value) or _clean_field_value( return _extract_route(raw_value) or _clean_field_value(
@@ -517,6 +623,8 @@ def _llm_document_field_label(key: str) -> str:
return { return {
"amount": "金额", "amount": "金额",
"date": "日期", "date": "日期",
"invoice_date": "开票日期",
"trip_date": "行程日期",
"merchant_name": "商户", "merchant_name": "商户",
"invoice_number": "票据号码", "invoice_number": "票据号码",
"invoice_code": "发票代码", "invoice_code": "发票代码",
@@ -525,6 +633,35 @@ def _llm_document_field_label(key: str) -> str:
}.get(key, key) }.get(key, key)
def _apply_document_type_field_labels(
fields: tuple[DocumentField, ...],
document_type: str,
) -> tuple[DocumentField, ...]:
date_label = TRIP_DATE_LABEL_BY_DOCUMENT_TYPE.get(
str(document_type or "").strip().lower()
)
if not date_label:
return fields
adjusted: list[DocumentField] = []
for field in fields:
compact_key = str(field.key or "").strip().lower()
compact_label = str(field.label or "").replace(" ", "")
if compact_key in {"issued_at", "issue_date", "invoice_date"} or any(
token in compact_label for token in ("开票日期", "发票日期")
):
adjusted.append(field)
continue
is_date_field = compact_key in DATE_FIELD_KEYS or any(
token in compact_label for token in TRIP_DATE_FIELD_LABEL_TOKENS
)
if is_date_field:
adjusted.append(DocumentField(key=field.key, label=date_label, value=field.value))
continue
adjusted.append(field)
return tuple(adjusted)
def _merge_document_fields( def _merge_document_fields(
base_fields: tuple[DocumentField, ...], base_fields: tuple[DocumentField, ...],
override_fields: tuple[DocumentField, ...], override_fields: tuple[DocumentField, ...],
@@ -540,13 +677,13 @@ def _merge_document_fields(
return tuple(merged[key] for key in order if key in merged) return tuple(merged[key] for key in order if key in merged)
def _extract_document_fields(text: str) -> list[DocumentField]: def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
fields: list[DocumentField] = [] fields: list[DocumentField] = []
amount = _extract_amount(text) amount = _extract_amount(text)
if amount: if amount:
fields.append(DocumentField(key="amount", label="金额", value=amount)) fields.append(DocumentField(key="amount", label="金额", value=amount))
date_value = _extract_date(text) date_value = _extract_date(text, document_type=document_type)
if date_value: if date_value:
fields.append(DocumentField(key="date", label="日期", value=date_value)) fields.append(DocumentField(key="date", label="日期", value=date_value))
@@ -584,6 +721,8 @@ def _extract_amount(text: str) -> str:
continue continue
if candidate <= Decimal("0.00"): if candidate <= Decimal("0.00"):
continue continue
if _is_amount_match_date_fragment(candidate, text, match.start(1), match.end(1)):
continue
if best_value is None or candidate > best_value: if best_value is None or candidate > best_value:
best_value = candidate best_value = candidate
@@ -594,10 +733,49 @@ def _extract_amount(text: str) -> str:
return f"{text_value}" return f"{text_value}"
def _extract_date(text: str) -> str: def _is_amount_match_date_fragment(amount: Decimal, text: str, start: int, end: int) -> bool:
match = DATE_PATTERN.search(text) if start < 0 or end < 0:
if not match: return False
normalized = amount.quantize(Decimal("0.01"))
if normalized != normalized.to_integral_value() or normalized < Decimal("1900") or normalized > Decimal("2099"):
return False
before = str(text or "")[max(0, start - 8):start]
after = str(text or "")[end:end + 10]
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
return True
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
return True
return False
def _extract_date(text: str, *, document_type: str = "") -> str:
matches = list(DATE_PATTERN.finditer(text))
if not matches:
return "" return ""
normalized_type = str(document_type or "").strip().lower()
if normalized_type in TRIP_DATE_LABEL_BY_DOCUMENT_TYPE:
candidates: list[tuple[int, int, bool, str]] = []
for index, match in enumerate(matches):
value = _format_date_match_with_time(text, match)
if not value:
continue
invoice_context = _is_invoice_date_context(text, match)
score = _score_trip_date_context(text, match, value, invoice_context)
candidates.append((score, index, invoice_context, value))
non_invoice_candidates = [candidate for candidate in candidates if not candidate[2]]
if non_invoice_candidates:
return max(non_invoice_candidates, key=lambda candidate: (candidate[0], -candidate[1]))[3]
if candidates:
return ""
return ""
return _format_date_match_with_time(text, matches[0])
def _format_date_match_with_time(text: str, match: re.Match[str]) -> str:
raw_value = str(match.group(1) or "").strip() raw_value = str(match.group(1) or "").strip()
normalized = raw_value.replace("", "-").replace("", "-").replace("", "") normalized = raw_value.replace("", "-").replace("", "-").replace("", "")
normalized = normalized.replace("/", "-").replace(".", "-") normalized = normalized.replace("/", "-").replace(".", "-")
@@ -605,7 +783,60 @@ def _extract_date(text: str) -> str:
if len(parts) != 3: if len(parts) != 3:
return raw_value return raw_value
year, month, day = parts year, month, day = parts
return f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}" date_value = f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
surrounding = str(text or "")[max(0, match.start() - 18): match.end() + 24]
time_match = TIME_PATTERN.search(surrounding)
if time_match:
hour = str(time_match.group(1) or "").zfill(2)
minute = str(time_match.group(2) or "").zfill(2)
return f"{date_value} {hour}:{minute}"
return date_value
def _is_invoice_date_context(text: str, match: re.Match[str]) -> bool:
window = str(text or "")[max(0, match.start() - 12): match.end() + 8]
compact = window.replace(" ", "")
return any(token in compact for token in ("开票日期", "发票日期", "开票时间", "开票"))
def _score_trip_date_context(
text: str,
match: re.Match[str],
value: str,
invoice_context: bool,
) -> int:
window = str(text or "")[max(0, match.start() - 32): match.end() + 32]
compact = window.replace(" ", "")
score = -20 if invoice_context else 0
if ":" in value or "" in value:
score += 8
if any(
token in compact
for token in (
"行程日期",
"出发日期",
"出发时间",
"列车出发时间",
"发车日期",
"发车时间",
"开车时间",
"乘车日期",
"乘车时间",
"起飞日期",
"起飞时间",
"航班日期",
"上车时间",
"用车时间",
)
):
score += 6
if any(token in compact for token in ("车次", "检票", "二等座", "一等座", "商务座", "软卧", "硬卧")):
score += 3
if re.search(r"[A-Z]\d{1,4}", compact):
score += 2
if re.search(r"[\u4e00-\u9fa5A-Za-z0-9()·]{2,20}(?:至|到|→|->|—||-)[\u4e00-\u9fa5A-Za-z0-9()·]{2,20}", compact):
score += 2
return score
def _extract_merchant(text: str) -> str: def _extract_merchant(text: str) -> str:

View File

@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from collections import Counter from collections import Counter
from datetime import date, datetime from datetime import UTC, date, datetime
from typing import Any from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import inspect, text from sqlalchemy import inspect, select, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
@@ -20,6 +21,9 @@ from app.repositories.employee import EmployeeRepository
from app.schemas.employee import ( from app.schemas.employee import (
EmployeeCreate, EmployeeCreate,
EmployeeHistoryRead, EmployeeHistoryRead,
EmployeeImportErrorRead,
EmployeeImportResultRead,
EmployeeImportSummaryRead,
EmployeeMetaRead, EmployeeMetaRead,
EmployeeOrganizationRead, EmployeeOrganizationRead,
EmployeeRead, EmployeeRead,
@@ -27,8 +31,16 @@ from app.schemas.employee import (
EmployeeStatusSummaryRead, EmployeeStatusSummaryRead,
EmployeeUpdate, EmployeeUpdate,
) )
from app.services.employee_spreadsheet import (
EmployeeImportRow,
EmployeeSpreadsheetError,
build_export_workbook_bytes,
build_import_template_bytes,
parse_employee_workbook,
)
from app.services.employee_seed import ( from app.services.employee_seed import (
EMPLOYEE_DEFINITIONS, EMPLOYEE_DEFINITIONS,
EMPLOYEE_PROFILE_REPAIRS,
ORGANIZATION_DEFINITIONS, ORGANIZATION_DEFINITIONS,
ROLE_DEFINITIONS, ROLE_DEFINITIONS,
ROLE_DISPLAY_ORDER, ROLE_DISPLAY_ORDER,
@@ -37,6 +49,8 @@ from app.services.employee_seed import (
logger = get_logger("app.services.employee") logger = get_logger("app.services.employee")
DEFAULT_EMPLOYEE_PASSWORD = "123456" DEFAULT_EMPLOYEE_PASSWORD = "123456"
MAX_EMPLOYEE_CHANGE_LOGS = 5
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
STATUS_TONE_MAP = { STATUS_TONE_MAP = {
"在职": "success", "在职": "success",
@@ -57,7 +71,9 @@ def prepare_employee_directory() -> None:
session_factory = get_session_factory() session_factory = get_session_factory()
with session_factory() as db: with session_factory() as db:
EmployeeService(db).ensure_directory_ready() service = EmployeeService(db)
service.ensure_directory_ready()
service.apply_profile_repairs()
class EmployeeService: class EmployeeService:
@@ -120,10 +136,27 @@ class EmployeeService:
for role in self._sorted_roles(self.repository.list_roles()) for role in self._sorted_roles(self.repository.list_roles())
] ]
organization_options = [
EmployeeOrganizationRead(
id=unit.id,
code=unit.unit_code,
name=unit.name,
unitType=unit.unit_type,
costCenter=unit.cost_center,
location=unit.location,
managerName=unit.manager_name,
)
for unit in sorted(
self.repository.list_organization_units(),
key=lambda item: item.name,
)
]
return EmployeeMetaRead( return EmployeeMetaRead(
totalEmployees=len(employees), totalEmployees=len(employees),
statusSummary=status_summary, statusSummary=status_summary,
roleOptions=role_options, roleOptions=role_options,
organizationOptions=organization_options,
) )
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead: def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
@@ -152,7 +185,7 @@ class EmployeeService:
sync_state=payload.sync_state, sync_state=payload.sync_state,
spotlight=payload.spotlight, spotlight=payload.spotlight,
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD), password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
last_sync_at=datetime.now(), last_sync_at=datetime.now(UTC),
) )
if payload.organization_unit_code: if payload.organization_unit_code:
@@ -261,6 +294,43 @@ class EmployeeService:
employee.finance_owner_name = finance_owner_name employee.finance_owner_name = finance_owner_name
changed_fields.append("财务归口") changed_fields.append("财务归口")
if "organization_unit_code" in payload.model_fields_set:
organization_code = self._normalize_optional_text(payload.organization_unit_code)
current_code = (
employee.organization_unit.unit_code if employee.organization_unit else None
)
if organization_code != current_code:
if organization_code:
organization = self.repository.get_organization_by_code(organization_code)
if organization is None:
raise ValueError(f"部门编码 {organization_code} 不存在")
employee.organization_unit = organization
else:
employee.organization_unit = None
changed_fields.append("所属部门")
if "manager_employee_no" in payload.model_fields_set:
manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
current_manager_no = employee.manager.employee_no if employee.manager else None
if manager_employee_no:
if manager_employee_no == employee.employee_no:
raise ValueError("直属上级不能是员工本人")
manager = self.repository.get_by_employee_no(manager_employee_no)
if manager is None:
raise ValueError(f"直属上级工号 {manager_employee_no} 不存在")
if manager_employee_no != current_manager_no:
employee.manager = manager
changed_fields.append("直属上级")
elif current_manager_no is not None:
employee.manager = None
changed_fields.append("直属上级")
role_changed = False
sorted_roles: list[Role] = []
if "role_codes" in payload.model_fields_set and payload.role_codes is not None: if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
requested_codes = list(dict.fromkeys(payload.role_codes)) requested_codes = list(dict.fromkeys(payload.role_codes))
roles: list[Role] = [] roles: list[Role] = []
@@ -280,7 +350,7 @@ class EmployeeService:
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))] current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
if next_role_codes != current_role_codes: if next_role_codes != current_role_codes:
employee.roles = sorted_roles employee.roles = sorted_roles
changed_fields.append("系统角色") role_changed = True
if "password" in payload.model_fields_set and payload.password: if "password" in payload.model_fields_set and payload.password:
password = payload.password.strip() password = payload.password.strip()
@@ -289,10 +359,10 @@ class EmployeeService:
employee.password_hash = hash_password(password) employee.password_hash = hash_password(password)
password_changed = True password_changed = True
if not changed_fields and not password_changed: if not changed_fields and not password_changed and not role_changed:
return self._serialize_employee(employee) return self._serialize_employee(employee)
now = datetime.now() now = datetime.now(UTC)
employee.last_sync_at = now employee.last_sync_at = now
employee.sync_state = "已同步" employee.sync_state = "已同步"
@@ -303,13 +373,25 @@ class EmployeeService:
occurred_at=now, occurred_at=now,
) )
if role_changed:
role_labels = "".join(role.name for role in sorted_roles)
self._append_change_log(
employee,
action=f"更新系统角色({role_labels}",
occurred_at=now,
)
if password_changed: if password_changed:
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now) self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
saved = self.repository.save(employee) hydrated = self._save_employee_and_reload(employee)
hydrated = self.repository.get(saved.id) logger.info(
logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields)) "Updated employee id=%s fields=%s role_changed=%s",
return self._serialize_employee(hydrated or saved) employee.id,
",".join(changed_fields),
role_changed,
)
return self._serialize_employee(hydrated)
def disable_employee(self, employee_id: str) -> EmployeeRead: def disable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready() self.ensure_directory_ready()
@@ -321,17 +403,16 @@ class EmployeeService:
if employee.employment_status == "停用": if employee.employment_status == "停用":
return self._serialize_employee(employee) return self._serialize_employee(employee)
now = datetime.now() now = datetime.now(UTC)
employee.employment_status = "停用" employee.employment_status = "停用"
employee.sync_state = "已同步" employee.sync_state = "已同步"
employee.last_sync_at = now employee.last_sync_at = now
employee.spotlight = False employee.spotlight = False
self._append_change_log(employee, action="停用员工账号", occurred_at=now) self._append_change_log(employee, action="停用员工账号", occurred_at=now)
saved = self.repository.save(employee) hydrated = self._save_employee_and_reload(employee)
hydrated = self.repository.get(saved.id)
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no) logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
return self._serialize_employee(hydrated or saved) return self._serialize_employee(hydrated)
def enable_employee(self, employee_id: str) -> EmployeeRead: def enable_employee(self, employee_id: str) -> EmployeeRead:
self.ensure_directory_ready() self.ensure_directory_ready()
@@ -343,16 +424,305 @@ class EmployeeService:
if employee.employment_status != "停用": if employee.employment_status != "停用":
return self._serialize_employee(employee) return self._serialize_employee(employee)
now = datetime.now() now = datetime.now(UTC)
employee.employment_status = "在职" employee.employment_status = "在职"
employee.sync_state = "已同步" employee.sync_state = "已同步"
employee.last_sync_at = now employee.last_sync_at = now
self._append_change_log(employee, action="启用员工账号", occurred_at=now) self._append_change_log(employee, action="启用员工账号", occurred_at=now)
saved = self.repository.save(employee) hydrated = self._save_employee_and_reload(employee)
hydrated = self.repository.get(saved.id)
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no) logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
return self._serialize_employee(hydrated or saved) return self._serialize_employee(hydrated)
def build_import_template(self) -> bytes:
self.ensure_directory_ready()
return build_import_template_bytes()
def export_employees(self, status: str | None = None, keyword: str | None = None) -> bytes:
self.ensure_directory_ready()
employees = self.repository.list(status=status, keyword=keyword)
rows: list[list[str]] = []
for employee in employees:
organization = employee.organization_unit
role_codes = ",".join(role.role_code for role in self._sorted_roles(list(employee.roles)))
rows.append(
[
employee.employee_no,
employee.name,
employee.email,
employee.gender or "",
self._format_date(employee.birth_date) or "",
employee.phone or "",
self._format_date(employee.join_date) or "",
employee.location or "",
employee.position,
employee.grade,
organization.unit_code if organization else "",
employee.manager.employee_no if employee.manager else "",
employee.finance_owner_name or "",
employee.cost_center or "",
employee.employment_status,
role_codes,
]
)
return build_export_workbook_bytes(rows)
def import_employees(self, content: bytes, actor: str = "系统管理员") -> EmployeeImportResultRead:
self.ensure_directory_ready()
parsed_rows, parse_errors = parse_employee_workbook(content)
if parse_errors:
return self._build_import_failure(parse_errors, total_rows=len(parsed_rows))
validation_errors = self._validate_import_rows(parsed_rows)
if validation_errors:
return self._build_import_failure(validation_errors, total_rows=len(parsed_rows))
try:
summary = self._apply_import_rows(parsed_rows, actor=actor)
except Exception:
self.db.rollback()
logger.exception("Employee import failed during database write")
raise
imported_at = self._format_datetime(datetime.now(UTC)) or ""
message = f"导入成功:新增 {summary['created']} 人,更新 {summary['updated']} 人。"
logger.info(
"Imported employees created=%d updated=%d total=%d",
summary["created"],
summary["updated"],
len(parsed_rows),
)
return EmployeeImportResultRead(
success=True,
message=message,
summary=EmployeeImportSummaryRead(
totalRows=len(parsed_rows),
created=summary["created"],
updated=summary["updated"],
errorCount=0,
),
errors=[],
importedAt=imported_at,
)
def _validate_import_rows(
self, rows: list[EmployeeImportRow]
) -> list[EmployeeSpreadsheetError]:
errors: list[EmployeeSpreadsheetError] = []
employee_nos_in_file: dict[str, int] = {}
emails_in_file: dict[str, int] = {}
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
employees_by_no = {
employee.employee_no: employee for employee in self.repository.list()
}
import_employee_nos = {row.employee_no for row in rows}
for row in rows:
if row.employee_no in employee_nos_in_file:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="员工编号*",
employee_no=row.employee_no,
message=f"员工编号 {row.employee_no} 在文件中重复。",
)
)
else:
employee_nos_in_file[row.employee_no] = row.row_number
if row.email in emails_in_file:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="邮箱*",
employee_no=row.employee_no,
message=f"邮箱 {row.email} 在文件中重复。",
)
)
else:
emails_in_file[row.email] = row.row_number
existing_by_email = self.repository.get_by_email(row.email)
if existing_by_email is not None and existing_by_email.employee_no != row.employee_no:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="邮箱*",
employee_no=row.employee_no,
message=(
f"邮箱 {row.email} 已被员工 "
f"{existing_by_email.employee_no} 使用。"
),
)
)
if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="部门编码",
employee_no=row.employee_no,
message=f"部门编码 {row.organization_unit_code} 不存在。",
)
)
if row.manager_employee_no:
manager_exists = (
row.manager_employee_no in employees_by_no
or row.manager_employee_no in import_employee_nos
)
if not manager_exists:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="直属上级工号",
employee_no=row.employee_no,
message=f"直属上级工号 {row.manager_employee_no} 不存在。",
)
)
if row.manager_employee_no == row.employee_no:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="直属上级工号",
employee_no=row.employee_no,
message="直属上级不能是员工本人。",
)
)
invalid_role_codes = [
code for code in row.role_codes if code not in roles_by_code
]
if invalid_role_codes:
errors.append(
EmployeeSpreadsheetError(
row=row.row_number,
column="角色编码",
employee_no=row.employee_no,
message=f"角色不存在:{''.join(invalid_role_codes)}",
)
)
return errors
def _apply_import_rows(
self,
rows: list[EmployeeImportRow],
*,
actor: str,
) -> dict[str, int]:
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
employees_by_no = {
employee.employee_no: employee for employee in self.repository.list()
}
created = 0
updated = 0
now = datetime.now(UTC)
try:
for row in rows:
employee = employees_by_no.get(row.employee_no)
is_new = employee is None
if is_new:
employee = Employee(
employee_no=row.employee_no,
name=row.name,
email=row.email,
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
)
self.db.add(employee)
employees_by_no[row.employee_no] = employee
created += 1
else:
updated += 1
employee.name = row.name
employee.email = row.email
employee.gender = row.gender
employee.birth_date = row.birth_date
employee.phone = row.phone
employee.join_date = row.join_date
employee.location = row.location
employee.position = row.position
employee.grade = row.grade
employee.finance_owner_name = row.finance_owner_name
employee.cost_center = row.cost_center
employee.employment_status = row.employment_status
employee.sync_state = "已同步"
employee.last_sync_at = now
if row.organization_unit_code:
employee.organization_unit = organizations_by_code[row.organization_unit_code]
else:
employee.organization_unit = None
employee.roles = self._sorted_roles(
[roles_by_code[code] for code in row.role_codes if code in roles_by_code]
)
action = (
"通过 Excel 导入新建员工档案"
if is_new
else "通过 Excel 导入更新员工档案"
)
self._append_change_log(employee, action=action, owner=actor, occurred_at=now)
self.db.flush()
for row in rows:
employee = employees_by_no[row.employee_no]
if row.manager_employee_no:
employee.manager = employees_by_no.get(row.manager_employee_no)
else:
employee.manager = None
self.db.commit()
except Exception:
self.db.rollback()
raise
return {"created": created, "updated": updated}
def _build_import_failure(
self,
errors: list[EmployeeSpreadsheetError],
*,
total_rows: int,
) -> EmployeeImportResultRead:
error_reads = [
EmployeeImportErrorRead(
row=item.row,
column=item.column,
employeeNo=item.employee_no,
message=item.message,
)
for item in errors
]
return EmployeeImportResultRead(
success=False,
message=(
f"导入未执行:共发现 {len(error_reads)} 处错误,请修正后重新导入。"
"原有员工数据未变更。"
),
summary=EmployeeImportSummaryRead(
totalRows=total_rows,
created=0,
updated=0,
errorCount=len(error_reads),
),
errors=error_reads,
importedAt=None,
)
def _seed_roles(self) -> None: def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()} existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
@@ -471,6 +841,69 @@ class EmployeeService:
self.db.flush() self.db.flush()
def apply_profile_repairs(self) -> None:
"""Apply one-off demo profile repairs. Intended for startup/bootstrap only."""
try:
self._repair_employee_profiles()
self._trim_all_employee_change_logs()
self.db.commit()
except Exception:
self.db.rollback()
logger.exception("Failed to apply employee profile repairs")
raise
def _repair_employee_profiles(self) -> None:
if not EMPLOYEE_PROFILE_REPAIRS:
return
employees = self.repository.list()
employees_by_email = {employee.email.lower(): employee for employee in employees if employee.email}
employees_by_no = {employee.employee_no: employee for employee in employees if employee.employee_no}
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for definition in EMPLOYEE_PROFILE_REPAIRS:
email = str(definition.get("email") or "").strip().lower()
employee_no = str(definition.get("employee_no") or "").strip()
employee = employees_by_email.get(email) or employees_by_no.get(employee_no)
if employee is None:
continue
for field_name in (
"position",
"grade",
"location",
"cost_center",
"finance_owner_name",
"employment_status",
"sync_state",
):
value = definition.get(field_name)
if value:
setattr(employee, field_name, value)
organization_code = definition.get("organization_unit_code")
if organization_code:
employee.organization_unit = organizations_by_code.get(organization_code)
manager_employee_no = definition.get("manager_employee_no")
if manager_employee_no:
employee.manager = employees_by_no.get(manager_employee_no)
if not employee.password_hash:
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code]
if role_codes:
merged_roles = {role.role_code: role for role in employee.roles}
for role_code in role_codes:
merged_roles[role_code] = roles_by_code[role_code]
employee.roles = self._sorted_roles(list(merged_roles.values()))
self.db.flush()
def _prune_extra_seed_employees(self) -> None: def _prune_extra_seed_employees(self) -> None:
if not EXTRA_SEED_EMPLOYEE_NOS: if not EXTRA_SEED_EMPLOYEE_NOS:
return return
@@ -530,6 +963,12 @@ class EmployeeService:
) )
existing_keys.add(identity) existing_keys.add(identity)
def _save_employee_and_reload(self, employee: Employee) -> Employee:
saved = self.repository.save(employee)
self._trim_employee_change_logs(saved.id)
self.db.commit()
return self.repository.get(saved.id) or saved
def _append_change_log( def _append_change_log(
self, self,
employee: Employee, employee: Employee,
@@ -542,10 +981,30 @@ class EmployeeService:
employee=employee, employee=employee,
action=action, action=action,
owner=owner, owner=owner,
occurred_at=occurred_at or datetime.now(), occurred_at=occurred_at or datetime.now(UTC),
) )
) )
def _trim_all_employee_change_logs(self) -> None:
for employee in self.repository.list():
self._trim_employee_change_logs(employee.id)
def _sorted_change_logs(self, employee: Employee) -> list[EmployeeChangeLog]:
return sorted(employee.change_logs, key=lambda item: item.occurred_at, reverse=True)
def _trim_employee_change_logs(self, employee_id: str) -> None:
stmt = (
select(EmployeeChangeLog)
.where(EmployeeChangeLog.employee_id == employee_id)
.order_by(EmployeeChangeLog.occurred_at.desc())
)
logs = list(self.db.execute(stmt).scalars().all())
if len(logs) <= MAX_EMPLOYEE_CHANGE_LOGS:
return
for stale in logs[MAX_EMPLOYEE_CHANGE_LOGS:]:
self.db.delete(stale)
def _serialize_employee(self, employee: Employee) -> EmployeeRead: def _serialize_employee(self, employee: Employee) -> EmployeeRead:
organization = employee.organization_unit organization = employee.organization_unit
roles = self._sorted_roles(list(employee.roles)) roles = self._sorted_roles(list(employee.roles))
@@ -556,10 +1015,10 @@ class EmployeeService:
EmployeeHistoryRead( EmployeeHistoryRead(
action=item.action, action=item.action,
owner=item.owner, owner=item.owner,
time=self._format_datetime(item.occurred_at) or "", time=self._format_history_datetime(item.occurred_at),
occurredAt=self._format_datetime(item.occurred_at) or "", occurredAt=self._format_history_datetime(item.occurred_at),
) )
for item in employee.change_logs for item in self._sorted_change_logs(employee)[:MAX_EMPLOYEE_CHANGE_LOGS]
] ]
return EmployeeRead( return EmployeeRead(
@@ -571,6 +1030,7 @@ class EmployeeService:
position=employee.position, position=employee.position,
grade=employee.grade, grade=employee.grade,
manager=employee.manager.name if employee.manager else "CEO", manager=employee.manager.name if employee.manager else "CEO",
managerEmployeeNo=employee.manager.employee_no if employee.manager else None,
financeOwner=employee.finance_owner_name or "", financeOwner=employee.finance_owner_name or "",
roles=role_labels, roles=role_labels,
roleCodes=role_codes, roleCodes=role_codes,
@@ -648,11 +1108,30 @@ class EmployeeService:
return None return None
return value.strftime("%Y-%m-%d") return value.strftime("%Y-%m-%d")
@staticmethod
def _to_display_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
normalized = value.replace(tzinfo=UTC)
else:
normalized = value.astimezone(UTC)
return normalized.astimezone(DISPLAY_TIMEZONE)
@staticmethod @staticmethod
def _format_datetime(value: datetime | None) -> str | None: def _format_datetime(value: datetime | None) -> str | None:
if value is None: if value is None:
return None return None
return value.strftime("%Y-%m-%d %H:%M") local = EmployeeService._to_display_datetime(value)
return local.strftime("%Y-%m-%d %H:%M")
@staticmethod
def _format_history_datetime(value: datetime | None) -> str:
if value is None:
return ""
local = EmployeeService._to_display_datetime(value)
return (
f"{local.year}{local.month}{local.day}"
f"{local.hour}{local.minute}"
)
@staticmethod @staticmethod
def _calculate_age(birth_date: date | None) -> int | None: def _calculate_age(birth_date: date | None) -> int | None:

View File

@@ -144,6 +144,24 @@ ORGANIZATION_DEFINITIONS = [
}, },
] ]
EMPLOYEE_PROFILE_REPAIRS = [
{
"employee_no": "E90919",
"name": "曹笑竹",
"email": "caoxiaozhu@xf.com",
"location": "武汉",
"position": "财务智能化产品经理",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6112",
"employment_status": "在职",
"sync_state": "已同步",
"role_codes": ["user"],
},
]
EMPLOYEE_DEFINITIONS = [ EMPLOYEE_DEFINITIONS = [
{ {
"employee_no": "E10018", "employee_no": "E10018",

View File

@@ -0,0 +1,368 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from email.utils import parseaddr
from io import BytesIO
from typing import Any
from openpyxl import Workbook, load_workbook
EMPLOYEE_SHEET_NAME = "员工目录"
INSTRUCTION_SHEET_NAME = "填表说明"
EMPLOYEE_HEADERS: tuple[str, ...] = (
"员工编号*",
"姓名*",
"邮箱*",
"性别",
"出生日期",
"手机号",
"入职日期",
"办公地点",
"岗位*",
"职级*",
"部门编码",
"直属上级工号",
"财务归口",
"成本中心",
"在职状态*",
"角色编码",
)
HEADER_TO_FIELD: dict[str, str] = {
"员工编号*": "employee_no",
"姓名*": "name",
"邮箱*": "email",
"性别": "gender",
"出生日期": "birth_date",
"手机号": "phone",
"入职日期": "join_date",
"办公地点": "location",
"岗位*": "position",
"职级*": "grade",
"部门编码": "organization_unit_code",
"直属上级工号": "manager_employee_no",
"财务归口": "finance_owner_name",
"成本中心": "cost_center",
"在职状态*": "employment_status",
"角色编码": "role_codes",
}
VALID_EMPLOYMENT_STATUSES = {"在职", "试用中", "停用"}
DEFAULT_ROLE_CODES = ("user",)
MAX_IMPORT_ROWS = 2000
MAX_IMPORT_BYTES = 5 * 1024 * 1024
@dataclass(frozen=True)
class EmployeeImportRow:
row_number: int
employee_no: str
name: str
email: str
gender: str | None
birth_date: date | None
phone: str | None
join_date: date | None
location: str | None
position: str
grade: str
organization_unit_code: str | None
manager_employee_no: str | None
finance_owner_name: str | None
cost_center: str | None
employment_status: str
role_codes: list[str]
@dataclass(frozen=True)
class EmployeeSpreadsheetError:
row: int
column: str
employee_no: str
message: str
def build_import_template_bytes() -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = EMPLOYEE_SHEET_NAME
sheet.append(list(EMPLOYEE_HEADERS))
instructions = workbook.create_sheet(INSTRUCTION_SHEET_NAME)
instructions.append(["字段", "说明"])
instruction_rows = [
("员工编号*", "必填,全局唯一,导入时用于判断新建或覆盖。"),
("姓名*", "必填。"),
("邮箱*", "必填,全局唯一。"),
("性别", "可选:男、女,留空表示不填写。"),
("出生日期", "可选,格式 YYYY-MM-DD。"),
("手机号", "可选。"),
("入职日期", "可选,格式 YYYY-MM-DD。"),
("办公地点", "可选。"),
("岗位*", "必填。"),
("职级*", "必填,例如 P3、P5。"),
("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC。"),
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
("财务归口", "可选。"),
("成本中心", "可选。"),
("在职状态*", "必填:在职、试用中、停用。"),
("角色编码", "可选,多个角色用英文逗号分隔,例如 user,finance留空默认为 user。"),
("导入规则", "全部校验通过后才写入数据库;任一行有错则整批不导入,原有数据保持不变。"),
]
for row in instruction_rows:
instructions.append(list(row))
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def build_export_workbook_bytes(rows: list[list[Any]]) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = EMPLOYEE_SHEET_NAME
sheet.append(list(EMPLOYEE_HEADERS))
for row in rows:
sheet.append(row)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def parse_employee_workbook(content: bytes) -> tuple[list[EmployeeImportRow], list[EmployeeSpreadsheetError]]:
errors: list[EmployeeSpreadsheetError] = []
if not content:
return [], [
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message="上传文件不能为空。",
)
]
if len(content) > MAX_IMPORT_BYTES:
return [], [
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message=f"文件大小不能超过 {MAX_IMPORT_BYTES // (1024 * 1024)}MB。",
)
]
try:
workbook = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
except Exception:
return [], [
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message="无法解析 Excel 文件,请使用系统提供的 .xlsx 模板。",
)
]
if EMPLOYEE_SHEET_NAME not in workbook.sheetnames:
return [], [
EmployeeSpreadsheetError(
row=0,
column="工作表",
employee_no="",
message=f"缺少工作表“{EMPLOYEE_SHEET_NAME}”。",
)
]
worksheet = workbook[EMPLOYEE_SHEET_NAME]
raw_rows = list(worksheet.iter_rows(values_only=True))
if not raw_rows:
return [], [
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message="Excel 中没有可导入的数据行。",
)
]
header_row = [_normalize_cell(value) for value in raw_rows[0]]
if list(header_row) != list(EMPLOYEE_HEADERS):
return [], [
EmployeeSpreadsheetError(
row=1,
column="表头",
employee_no="",
message="表头与员工导入模板不一致,请下载最新模板后重试。",
)
]
parsed_rows: list[EmployeeImportRow] = []
for index, raw_row in enumerate(raw_rows[1:], start=2):
if index - 1 > MAX_IMPORT_ROWS:
errors.append(
EmployeeSpreadsheetError(
row=index,
column="文件",
employee_no="",
message=f"单次最多导入 {MAX_IMPORT_ROWS} 行数据。",
)
)
break
if _is_empty_data_row(raw_row):
continue
row_errors, parsed = _parse_data_row(index, raw_row)
errors.extend(row_errors)
if parsed is not None:
parsed_rows.append(parsed)
if not parsed_rows and not errors:
errors.append(
EmployeeSpreadsheetError(
row=0,
column="文件",
employee_no="",
message="Excel 中没有可导入的数据行。",
)
)
return parsed_rows, errors
def _parse_data_row(
row_number: int,
raw_row: tuple[Any, ...],
) -> tuple[list[EmployeeSpreadsheetError], EmployeeImportRow | None]:
errors: list[EmployeeSpreadsheetError] = []
values = {
HEADER_TO_FIELD[header]: _normalize_cell(raw_row[index] if index < len(raw_row) else "")
for index, header in enumerate(EMPLOYEE_HEADERS)
}
employee_no = values["employee_no"]
def add_error(column: str, message: str) -> None:
errors.append(
EmployeeSpreadsheetError(
row=row_number,
column=column,
employee_no=employee_no,
message=message,
)
)
if not employee_no:
add_error("员工编号*", "员工编号不能为空。")
name = values["name"]
if not name:
add_error("姓名*", "姓名不能为空。")
email = values["email"].lower() if values["email"] else ""
if not email:
add_error("邮箱*", "邮箱不能为空。")
elif not _is_valid_email(email):
add_error("邮箱*", "邮箱格式不正确。")
position = values["position"]
if not position:
add_error("岗位*", "岗位不能为空。")
grade = values["grade"]
if not grade:
add_error("职级*", "职级不能为空。")
employment_status = values["employment_status"]
if not employment_status:
add_error("在职状态*", "在职状态不能为空。")
elif employment_status not in VALID_EMPLOYMENT_STATUSES:
add_error("在职状态*", "在职状态必须为:在职、试用中、停用。")
gender = values["gender"] or None
if gender and gender not in {"", ""}:
add_error("性别", "性别只能填写:男、女,或留空。")
birth_date, birth_error = _parse_optional_date(values["birth_date"], "出生日期")
if birth_error:
add_error("出生日期", birth_error)
join_date, join_error = _parse_optional_date(values["join_date"], "入职日期")
if join_error:
add_error("入职日期", join_error)
role_codes = _parse_role_codes(values["role_codes"])
if values["role_codes"] and not role_codes:
add_error("角色编码", "角色编码不能为空片段,多个角色请用英文逗号分隔。")
if errors:
return errors, None
return (
[],
EmployeeImportRow(
row_number=row_number,
employee_no=employee_no,
name=name,
email=email,
gender=gender,
birth_date=birth_date,
phone=values["phone"] or None,
join_date=join_date,
location=values["location"] or None,
position=position,
grade=grade,
organization_unit_code=values["organization_unit_code"] or None,
manager_employee_no=values["manager_employee_no"] or None,
finance_owner_name=values["finance_owner_name"] or None,
cost_center=values["cost_center"] or None,
employment_status=employment_status,
role_codes=role_codes or list(DEFAULT_ROLE_CODES),
),
)
def _parse_role_codes(value: str) -> list[str]:
if not value:
return []
codes = [item.strip() for item in value.replace("", ",").split(",")]
return list(dict.fromkeys(code for code in codes if code))
def _parse_optional_date(value: str, label: str) -> tuple[date | None, str | None]:
if not value:
return None, None
if isinstance(value, datetime):
return value.date(), None
if isinstance(value, date):
return value, None
text = str(value).strip()
try:
return datetime.strptime(text, "%Y-%m-%d").date(), None
except ValueError:
return None, f"{label}格式必须为 YYYY-MM-DD。"
def _is_valid_email(value: str) -> bool:
_, address = parseaddr(value)
return bool(address) and "@" in address
def _normalize_cell(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
return str(value).strip()
def _is_empty_data_row(raw_row: tuple[Any, ...]) -> bool:
return not any(_normalize_cell(value) for value in raw_row)

View File

@@ -0,0 +1,206 @@
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from typing import Any
DOCUMENT_AMOUNT_PATTERNS = (
re.compile(
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
r"[:\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
),
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
)
DOCUMENT_AMOUNT_FIELD_KEYS = {
"amount",
"totalamount",
"paymentamount",
"paidamount",
"actualamount",
}
DOCUMENT_AMOUNT_LABEL_TOKENS = (
"金额",
"价税合计",
"合计",
"总额",
"总计",
"票价",
"支付金额",
"实付金额",
"实收金额",
)
DOCUMENT_TEXT_AMOUNT_PATTERNS = (
r"(?:金额|价税合计|合计|小写|实收金额|支付金额|订单金额|总额|总计|总费用|费用总计|票价|房费|住宿费|餐费)[:\s¥¥人民币为是]*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"[¥¥]\s*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"([0-9]{1,6}(?:[.,][0-9]{1,2})?)\s*元",
)
def resolve_document_item_amount(document: dict[str, Any]) -> Decimal | None:
text = " ".join(
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
field_amount = resolve_document_field_amount(document)
text_amount = resolve_document_text_amount(text)
if field_amount is not None:
if is_date_like_amount_candidate(field_amount, text):
return text_amount
return field_amount
return text_amount
def resolve_document_field_amount(document: dict[str, Any]) -> Decimal | None:
for field in list(document.get("document_fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
is_amount_field = key in DOCUMENT_AMOUNT_FIELD_KEYS or any(
token in label for token in DOCUMENT_AMOUNT_LABEL_TOKENS
)
if not is_amount_field:
continue
raw_value = str(field.get("value") or "")
value = parse_document_amount_value(raw_value) or parse_plain_document_amount_value(
raw_value
)
if value is not None:
return value
return None
def resolve_document_text_amount(text: str) -> Decimal | None:
candidates = [
candidate
for candidate in extract_amount_candidates(text)
if not is_date_like_amount_candidate(candidate, text)
]
if not candidates:
return None
return max(candidates)
def parse_document_amount_value(value: str) -> Decimal | None:
raw_value = str(value or "").strip()
if not raw_value:
return None
for pattern in DOCUMENT_AMOUNT_PATTERNS:
match = pattern.search(raw_value)
if not match:
continue
numeric = str(match.group(1) or "").replace(",", ".").strip()
try:
amount = Decimal(numeric).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
continue
if amount > Decimal("0.00"):
return amount
return None
def parse_plain_document_amount_value(value: str) -> Decimal | None:
raw_value = str(value or "").strip()
if not re.fullmatch(r"[0-9]{1,6}(?:[.,][0-9]{1,2})?", raw_value):
return None
try:
amount = Decimal(raw_value.replace(",", ".")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount > Decimal("0.00") else None
def is_probable_year_amount(amount: Decimal | None) -> bool:
if amount is None:
return False
try:
normalized = Decimal(amount).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return False
return (
normalized == normalized.to_integral_value()
and Decimal("1900") <= normalized <= Decimal("2099")
)
def is_date_like_amount_candidate(amount: Decimal | None, text: str) -> bool:
if not is_probable_year_amount(amount):
return False
year = str(int(Decimal(amount or 0)))
pattern = re.compile(rf"(?<!\d){re.escape(year)}\s*(?:年|[-/.])\s*\d{{1,2}}")
return bool(pattern.search(str(text or "")))
def format_decimal_amount(amount: Decimal | None) -> str:
if amount is None:
return ""
normalized = Decimal(amount).quantize(Decimal("0.01"))
return format(normalized, "f")
def extract_amount_candidates(text: str) -> list[Decimal]:
values: list[Decimal] = []
seen: set[Decimal] = set()
def append_candidate(
raw: str,
*,
source_text: str = "",
start: int = -1,
end: int = -1,
) -> None:
compact = str(raw or "").replace(",", ".").strip()
if not compact:
return
try:
candidate = Decimal(compact).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return
if is_amount_match_date_fragment(candidate, source_text, start, end):
return
if candidate in seen:
return
seen.add(candidate)
values.append(candidate)
for pattern in DOCUMENT_TEXT_AMOUNT_PATTERNS:
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
append_candidate(
match.group(1),
source_text=text,
start=match.start(1),
end=match.end(1),
)
if values:
return values
for match in re.finditer(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text):
append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
return values
def is_amount_match_date_fragment(
amount: Decimal,
text: str,
start: int,
end: int,
) -> bool:
if start < 0 or end < 0 or not is_probable_year_amount(amount):
return False
before = str(text or "")[max(0, start - 8):start]
after = str(text or "")[end:end + 10]
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
return True
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
return True
return False

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,17 @@ from dataclasses import dataclass, field
from decimal import Decimal from decimal import Decimal
from typing import Any, Literal from typing import Any, Literal
from openpyxl import load_workbook
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetVersion from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.services.agent_asset_spreadsheet import (
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
AgentAssetSpreadsheetManager,
)
EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL) EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL)
@@ -351,6 +356,11 @@ class TravelPolicyConfig(BaseModel):
band_labels: dict[str, str] = Field(default_factory=dict) band_labels: dict[str, str] = Field(default_factory=dict)
city_tiers: dict[str, str] = Field(default_factory=dict) city_tiers: dict[str, str] = Field(default_factory=dict)
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict) hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
standard_rule_code: str = ""
standard_rule_name: str = ""
standard_rule_version: str = ""
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict) transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
flight_classes: list[TravelClassConfig] = Field(default_factory=list) flight_classes: list[TravelClassConfig] = Field(default_factory=list)
train_classes: list[TravelClassConfig] = Field(default_factory=list) train_classes: list[TravelClassConfig] = Field(default_factory=list)
@@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService:
).all() ).all()
) )
if not assets: if not assets:
return catalog assets = []
asset_ids = {asset.id for asset in assets}
travel_spreadsheet_asset = self.db.scalar(
select(AgentAsset)
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
.limit(1)
)
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids:
assets.append(travel_spreadsheet_asset)
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
for asset in assets: for asset in assets:
version = self._get_current_version(asset) version = self._get_current_version(asset)
if version is None: if version is None:
continue continue
is_travel_spreadsheet_asset = (
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
)
runtime_payload = self._extract_runtime_payload( runtime_payload = self._extract_runtime_payload(
markdown_content=str(version.content or ""), markdown_content=str(version.content or ""),
config_json=asset.config_json, config_json=asset.config_json,
) )
if not isinstance(runtime_payload, dict): if not isinstance(runtime_payload, dict):
spreadsheet_assets.append((asset, version))
continue continue
self._apply_runtime_payload( self._apply_runtime_payload(
catalog, catalog,
@@ -594,6 +622,15 @@ class ExpenseRuleRuntimeService:
asset=asset, asset=asset,
version=version, version=version,
) )
if is_travel_spreadsheet_asset:
spreadsheet_assets.append((asset, version))
for asset, version in spreadsheet_assets:
self._apply_spreadsheet_runtime_payload(
catalog,
asset=asset,
version=version,
)
return catalog return catalog
@@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService:
) )
except ValidationError: except ValidationError:
return return
def _apply_spreadsheet_runtime_payload(
self,
catalog: ExpenseRuleCatalog,
*,
asset: AgentAsset,
version: AgentAssetVersion,
) -> None:
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE:
return
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
return
manager = AgentAssetSpreadsheetManager()
metadata = manager.parse_version_markdown(str(version.content or ""))
rule_document = (asset.config_json or {}).get("rule_document")
if not isinstance(rule_document, dict):
rule_document = {}
storage_key = str(metadata.storage_key if metadata is not None else "").strip()
if storage_key:
try:
workbook_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
workbook_path = None
if workbook_path is not None and not workbook_path.exists():
workbook_path = None
else:
workbook_path = None
if workbook_path is None:
fallback_storage_key = str(rule_document.get("storage_key") or "").strip()
if not fallback_storage_key:
return
try:
workbook_path = manager.resolve_storage_path(fallback_storage_key)
except FileNotFoundError:
return
if not workbook_path.exists():
return
try:
workbook = load_workbook(
workbook_path,
read_only=True,
data_only=True,
)
except (FileNotFoundError, OSError):
return
try:
standards = self._extract_travel_amount_standards_from_workbook(workbook)
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
finally:
workbook.close()
standard_rule_version = str(
rule_document.get("asset_version") or asset.current_version or version.version
).strip()
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None:
payload = catalog.travel_policy.model_dump()
payload["standard_rule_code"] = asset.code
payload["standard_rule_name"] = asset.name
payload["standard_rule_version"] = standard_rule_version
if hotel_city_limits:
payload["hotel_city_limits"] = {
**payload.get("hotel_city_limits", {}),
**hotel_city_limits,
}
if allowance_limits:
payload["allowance_limits"] = {
**payload.get("allowance_limits", {}),
**allowance_limits,
}
if transport_limits:
payload["transport_limits"] = {
**payload.get("transport_limits", {}),
**transport_limits,
}
catalog.travel_policy = RuntimeTravelPolicy(**payload)
for expense_type, amount in standards.items():
current = catalog.scene_policies.get(expense_type)
if current is None:
continue
limit_attr = "item_amount_limit" if expense_type == "transport" else "claim_amount_limit"
base_limit = getattr(current, limit_attr, None)
next_limit = self._replace_amount_limit_warn_amount(
base_limit,
amount=amount,
metric_label=self._spreadsheet_metric_label(expense_type),
)
payload = current.model_dump()
payload["rule_code"] = asset.code
payload["rule_name"] = asset.name
payload["rule_version"] = standard_rule_version
payload[limit_attr] = next_limit.model_dump()
catalog.scene_policies[expense_type] = ExpenseScenePolicy(**payload)
@staticmethod
def _extract_travel_amount_standards_from_workbook(workbook: Any) -> dict[str, Decimal]:
standards: dict[str, Decimal] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header_index = -1
category_index = -1
standard_index = -1
for index, row in enumerate(rows[:8]):
values = [str(value or "").strip() for value in row]
if "费用分类" in values and "报销标准" in values:
header_index = index
category_index = values.index("费用分类")
standard_index = values.index("报销标准")
break
if header_index < 0:
continue
for row in rows[header_index + 1 :]:
category = str(row[category_index] or "").strip() if len(row) > category_index else ""
standard_text = str(row[standard_index] or "").strip() if len(row) > standard_index else ""
amount = ExpenseRuleRuntimeService._extract_first_standard_amount(standard_text)
if not category or amount is None:
continue
normalized_type = ExpenseRuleRuntimeService._map_spreadsheet_category_to_expense_type(category)
if normalized_type:
standards[normalized_type] = amount
return standards
@staticmethod
def _extract_hotel_city_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
city_limits: dict[str, dict[str, Decimal]] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header_index = -1
city_index = -1
band_indexes: dict[str, int] = {}
for index, row in enumerate(rows[:10]):
values = [str(value or "").strip() for value in row]
for candidate in ("地区(城市)", "城市", "地区"):
if candidate in values:
city_index = values.index(candidate)
break
if city_index < 0:
continue
for column_index, header in enumerate(values):
compact = re.sub(r"\s+", "", header)
if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
band_indexes["junior"] = column_index
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
band_indexes["mid"] = column_index
band_indexes["senior"] = column_index
if any(keyword in compact for keyword in ("P7", "高层经理", "公司级管理")):
band_indexes["manager"] = column_index
band_indexes["executive"] = column_index
if band_indexes:
header_index = index
break
if header_index < 0:
continue
for row in rows[header_index + 1 :]:
raw_city = str(row[city_index] or "").strip() if len(row) > city_index else ""
cities = ExpenseRuleRuntimeService._extract_city_names_from_cell(raw_city)
if not cities:
continue
for city in cities:
city_entry = city_limits.setdefault(city, {})
for band, column_index in band_indexes.items():
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
row[column_index] if len(row) > column_index else None
)
if amount is not None:
city_entry[band] = amount
return city_limits
@staticmethod
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
allowance_limits: dict[str, dict[str, Decimal]] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header_index = -1
type_index = -1
region_indexes: dict[str, int] = {}
for index, row in enumerate(rows[:10]):
values = [str(value or "").strip() for value in row]
if "补助类型" not in values:
continue
header_index = index
type_index = values.index("补助类型")
for column_index, header in enumerate(values):
if column_index <= type_index:
continue
normalized = str(header or "").strip()
if not normalized or normalized == "项目":
continue
region_indexes[normalized] = column_index
break
if header_index < 0 or type_index < 0 or not region_indexes:
continue
for row in rows[header_index + 1 :]:
raw_type = str(row[type_index] or "").strip() if len(row) > type_index else ""
allowance_key = ExpenseRuleRuntimeService._map_allowance_type_to_key(raw_type)
if not allowance_key:
continue
entry: dict[str, Decimal] = {}
for region_label, column_index in region_indexes.items():
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
row[column_index] if len(row) > column_index else None
)
if amount is not None:
entry[region_label] = amount
if entry:
allowance_limits[allowance_key] = entry
return allowance_limits
@staticmethod
def _map_allowance_type_to_key(value: str) -> str:
normalized = re.sub(r"\s+", "", str(value or ""))
if "伙食" in normalized or "" in normalized:
return "meal"
if "基本" in normalized:
return "basic"
if "合计" in normalized or "总计" in normalized:
return "total"
return ""
@staticmethod
def _extract_transport_class_limits_from_workbook(workbook: Any) -> dict[str, dict[str, int]]:
limits: dict[str, dict[str, int]] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
employee_index = -1
flight_index = -1
train_index = -1
for row_index, row in enumerate(rows[:10]):
values = [str(value or "").strip() for value in row]
if "员工职级" in values:
employee_index = values.index("员工职级")
for next_row in rows[row_index + 1 : row_index + 4]:
next_values = [str(value or "").strip() for value in next_row]
if "飞机" in next_values:
flight_index = next_values.index("飞机")
if "火车" in next_values:
train_index = next_values.index("火车")
if flight_index >= 0 and train_index >= 0:
break
break
if employee_index < 0 or (flight_index < 0 and train_index < 0):
continue
for row in rows:
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text)
if not bands:
continue
flight_level = (
ExpenseRuleRuntimeService._transport_class_level_for_text(
row[flight_index] if len(row) > flight_index else None,
kind="flight",
)
if flight_index >= 0
else None
)
train_level = (
ExpenseRuleRuntimeService._transport_class_level_for_text(
row[train_index] if len(row) > train_index else None,
kind="train",
)
if train_index >= 0
else None
)
for band in bands:
entry = limits.setdefault(band, {})
if flight_level is not None:
entry["flight"] = flight_level
if train_level is not None:
entry["train"] = train_level
return limits
@staticmethod
def _map_transport_grade_row_to_bands(value: str) -> list[str]:
normalized = re.sub(r"\s+", "", str(value or "").upper())
if not normalized or normalized.startswith(""):
return []
bands: list[str] = []
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")):
bands.extend(["junior", "mid"])
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")):
bands.extend(["mid", "senior", "manager", "executive"])
return list(dict.fromkeys(bands))
@staticmethod
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
normalized = re.sub(r"\s+", "", str(value or ""))
if not normalized:
return None
if kind == "flight":
if any(keyword in normalized for keyword in ("头等舱",)):
return 4
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
return 3
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
return 2
if "经济舱" in normalized:
return 1
if kind == "train":
if "商务座" in normalized:
return 3
if any(keyword in normalized for keyword in ("一等座", "软卧")):
return 2
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
return 1
return None
@staticmethod
def _extract_city_names_from_cell(value: str) -> list[str]:
normalized = re.sub(r"[;,、/]+", "", str(value or "").strip())
if not normalized:
return []
names: list[str] = []
for part in normalized.split(""):
cleaned = re.sub(r"\s+", "", part)
cleaned = re.sub(r"[(].*?[)]", "", cleaned)
if not cleaned or any(keyword in cleaned for keyword in ("不含", "中心城区", "新区")):
continue
if len(cleaned) <= 12:
names.append(cleaned)
return list(dict.fromkeys(names))
@staticmethod
def _coerce_decimal_cell(value: Any) -> Decimal | None:
if value is None:
return None
try:
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
except (ArithmeticError, ValueError):
return None
@staticmethod
def _extract_first_standard_amount(text: str) -> Decimal | None:
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)\s*/\s*(?:天|人|晚|次|笔)", str(text or ""))
if match is None:
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", str(text or ""))
if match is None:
return None
try:
return Decimal(match.group(1)).quantize(Decimal("0.01"))
except (ArithmeticError, ValueError):
return None
@staticmethod
def _map_spreadsheet_category_to_expense_type(category: str) -> str:
normalized = re.sub(r"\s+", "", str(category or ""))
if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")):
return "transport"
if "招待" in normalized and "" in normalized:
return "entertainment"
if "餐补" in normalized or normalized == "餐费":
return "meal"
return ""
@staticmethod
def _spreadsheet_metric_label(expense_type: str) -> str:
return {
"transport": "单笔交通金额",
"meal": "差旅餐补金额",
"entertainment": "人均招待餐费",
}.get(expense_type, "金额")
@staticmethod
def _replace_amount_limit_warn_amount(
base_limit: AmountLimitConfig | None,
*,
amount: Decimal,
metric_label: str,
) -> AmountLimitConfig:
if base_limit is None:
return AmountLimitConfig(
warn_amount=amount,
block_amount=None,
metric_label=metric_label,
)
payload = base_limit.model_dump()
payload["warn_amount"] = amount
payload["metric_label"] = metric_label
return AmountLimitConfig(**payload)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import re
import shutil import shutil
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -27,6 +28,7 @@ class PreparedOcrInput:
page_index: int | None = None page_index: int | None = None
preview_kind: str = "" preview_kind: str = ""
preview_data_url: str = "" preview_data_url: str = ""
text_layer: str = ""
@dataclass(slots=True) @dataclass(slots=True)
@@ -38,6 +40,7 @@ class AggregatedOcrDocument:
model: str = "PP-OCRv5_mobile" model: str = "PP-OCRv5_mobile"
summary_fragments: list[str] = field(default_factory=list) summary_fragments: list[str] = field(default_factory=list)
text_fragments: list[str] = field(default_factory=list) text_fragments: list[str] = field(default_factory=list)
text_layer_fragments: list[str] = field(default_factory=list)
score_values: list[float] = field(default_factory=list) score_values: list[float] = field(default_factory=list)
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
lines: list[OcrRecognizeLineRead] = field(default_factory=list) lines: list[OcrRecognizeLineRead] = field(default_factory=list)
@@ -112,12 +115,14 @@ class OcrService:
if suffix == ".pdf": if suffix == ".pdf":
try: try:
text_layer = self._extract_pdf_text_layer(temp_path)
prepared_inputs.extend( prepared_inputs.extend(
self._prepare_pdf_inputs( self._prepare_pdf_inputs(
pdf_path=temp_path, pdf_path=temp_path,
filename=normalized_name, filename=normalized_name,
media_type=resolved_media_type, media_type=resolved_media_type,
cleanup_paths=cleanup_paths, cleanup_paths=cleanup_paths,
text_layer=text_layer,
) )
) )
except RuntimeError as exc: except RuntimeError as exc:
@@ -261,6 +266,7 @@ class OcrService:
filename: str, filename: str,
media_type: str, media_type: str,
cleanup_paths: list[Path], cleanup_paths: list[Path],
text_layer: str = "",
) -> list[PreparedOcrInput]: ) -> list[PreparedOcrInput]:
output_dir = pdf_path.with_suffix("") output_dir = pdf_path.with_suffix("")
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
@@ -283,10 +289,33 @@ class OcrService:
page_index=page_index, page_index=page_index,
preview_kind="image" if page_index == 0 else "", preview_kind="image" if page_index == 0 else "",
preview_data_url=preview_data_url if page_index == 0 else "", preview_data_url=preview_data_url if page_index == 0 else "",
text_layer=text_layer if page_index == 0 else "",
) )
) )
return descriptors return descriptors
def _extract_pdf_text_layer(self, pdf_path: Path) -> str:
try:
completed = subprocess.run(
[
"pdftotext",
"-layout",
str(pdf_path),
"-",
],
capture_output=True,
text=True,
timeout=self.settings.ocr_timeout_seconds,
check=False,
)
except (OSError, subprocess.SubprocessError, UnicodeError):
return ""
if completed.returncode != 0:
return ""
return self._normalize_extracted_text(completed.stdout)
def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]: def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
prefix = output_dir / "page" prefix = output_dir / "page"
completed = subprocess.run( completed = subprocess.run(
@@ -367,6 +396,8 @@ class OcrService:
aggregated.preview_kind = descriptor.preview_kind aggregated.preview_kind = descriptor.preview_kind
if descriptor.preview_data_url and not aggregated.preview_data_url: if descriptor.preview_data_url and not aggregated.preview_data_url:
aggregated.preview_data_url = descriptor.preview_data_url aggregated.preview_data_url = descriptor.preview_data_url
if descriptor.text_layer and descriptor.text_layer not in aggregated.text_layer_fragments:
aggregated.text_layer_fragments.append(descriptor.text_layer)
page_summary = str(payload.get("summary", "") or "").strip() page_summary = str(payload.get("summary", "") or "").strip()
if page_summary: if page_summary:
@@ -401,6 +432,20 @@ class OcrService:
aggregated = aggregated_by_source.get(source_key) aggregated = aggregated_by_source.get(source_key)
if aggregated is None: if aggregated is None:
first_descriptor = descriptors[0] first_descriptor = descriptors[0]
text_layer = self._collect_descriptor_text_layer(descriptors)
if text_layer:
fallback = AggregatedOcrDocument(
filename=first_descriptor.filename,
media_type=first_descriptor.media_type,
source_key=first_descriptor.source_key,
page_count=max(1, len(descriptors)),
preview_kind=first_descriptor.preview_kind,
preview_data_url=first_descriptor.preview_data_url,
warnings=["OCR worker 未返回该文件的识别结果,已使用 PDF 文本层。"],
)
fallback.text_layer_fragments.append(text_layer)
documents.append(self._finalize_document(fallback))
continue
documents.append( documents.append(
OcrRecognizeDocumentRead( OcrRecognizeDocumentRead(
filename=first_descriptor.filename, filename=first_descriptor.filename,
@@ -416,6 +461,13 @@ class OcrService:
return documents return documents
@staticmethod
def _collect_descriptor_text_layer(descriptors: list[PreparedOcrInput]) -> str:
for descriptor in descriptors:
if descriptor.text_layer:
return descriptor.text_layer
return ""
@staticmethod @staticmethod
def _build_lines( def _build_lines(
items: list[dict], items: list[dict],
@@ -451,13 +503,26 @@ class OcrService:
return summary return summary
def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead: def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead:
full_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip() ocr_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
text_layer = "\n".join(fragment for fragment in aggregated.text_layer_fragments if fragment).strip()
full_text, used_text_layer = self._choose_document_text(ocr_text=ocr_text, text_layer=text_layer)
summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments) summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments)
if used_text_layer or self._placeholder_ratio(summary) >= 0.12:
summary = self._summarize_text(full_text)
preview_kind = aggregated.preview_kind
preview_data_url = aggregated.preview_data_url
if (
used_text_layer
and aggregated.media_type == "application/pdf"
and self._placeholder_ratio(ocr_text) >= 0.12
):
preview_kind = ""
preview_data_url = ""
insight = self.document_intelligence_service.build_document_insight( insight = self.document_intelligence_service.build_document_insight(
filename=aggregated.filename, filename=aggregated.filename,
summary=summary, summary=summary,
text=full_text, text=full_text,
preview_data_url=aggregated.preview_data_url, preview_data_url=preview_data_url,
) )
warnings = list(aggregated.warnings) warnings = list(aggregated.warnings)
for warning in insight.warnings: for warning in insight.warnings:
@@ -493,8 +558,8 @@ class OcrService:
) )
for field in insight.fields for field in insight.fields
], ],
preview_kind=aggregated.preview_kind, preview_kind=preview_kind,
preview_data_url=aggregated.preview_data_url, preview_data_url=preview_data_url,
warnings=warnings, warnings=warnings,
lines=sorted( lines=sorted(
aggregated.lines, aggregated.lines,
@@ -502,6 +567,45 @@ class OcrService:
), ),
) )
@classmethod
def _choose_document_text(cls, *, ocr_text: str, text_layer: str) -> tuple[str, bool]:
normalized_ocr_text = cls._normalize_extracted_text(ocr_text)
normalized_text_layer = cls._normalize_extracted_text(text_layer)
if not normalized_text_layer:
return normalized_ocr_text, False
if not normalized_ocr_text:
return normalized_text_layer, True
if cls._placeholder_ratio(normalized_ocr_text) >= 0.12 and cls._meaningful_char_count(normalized_text_layer) >= 8:
return normalized_text_layer, True
if cls._meaningful_char_count(normalized_text_layer) > cls._meaningful_char_count(normalized_ocr_text) * 1.3:
return normalized_text_layer, True
return normalized_ocr_text, False
@staticmethod
def _normalize_extracted_text(value: str) -> str:
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in str(value or "").replace("\r", "\n").split("\n")]
return "\n".join(line for line in lines if line).strip()
@staticmethod
def _summarize_text(value: str) -> str:
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
summary = "".join(lines[:3])
if len(summary) > 180:
return f"{summary[:177]}..."
return summary
@staticmethod
def _meaningful_char_count(value: str) -> int:
return len(re.findall(r"[0-9A-Za-z\u4e00-\u9fff]", str(value or "")))
@staticmethod
def _placeholder_ratio(value: str) -> float:
chars = [char for char in str(value or "") if not char.isspace()]
if not chars:
return 0.0
placeholder_count = sum(1 for char in chars if char in {"", "<EFBFBD>"})
return placeholder_count / len(chars)
@staticmethod @staticmethod
def _cleanup_temp_paths(paths: list[Path]) -> None: def _cleanup_temp_paths(paths: list[Path]) -> None:
for path in reversed(paths): for path in reversed(paths):

View File

@@ -64,6 +64,8 @@ TOP_N_PATTERN = re.compile(r"(?:top|TOP|前|最高的?|最低的?)\s*(?P<top>\d+
SCENARIO_KEYWORDS = { SCENARIO_KEYWORDS = {
"expense": ( "expense": (
("报销", 0.20), ("报销", 0.20),
("报销单", 0.20),
("单据报销", 0.18),
("报账", 0.20), ("报账", 0.20),
("差旅", 0.20), ("差旅", 0.20),
("费用", 0.14), ("费用", 0.14),
@@ -122,6 +124,8 @@ RISK_KEYWORDS = ("风险", "异常", "重复", "超标", "超预算", "逾期",
DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备") DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备")
DRAFT_FOLLOW_UP_KEYWORDS = ( DRAFT_FOLLOW_UP_KEYWORDS = (
"继续", "继续",
"下一步",
"核对",
"补充", "补充",
"补一下", "补一下",
"修改", "修改",
@@ -138,6 +142,13 @@ DRAFT_FOLLOW_UP_KEYWORDS = (
"日期是", "日期是",
"时间是", "时间是",
) )
EXPENSE_REVIEW_ACTIONS = {
"save_draft",
"next_step",
"edit_review",
"link_to_existing_draft",
"create_new_claim_from_documents",
}
OPERATE_KEYWORDS = ( OPERATE_KEYWORDS = (
"直接付款", "直接付款",
"帮我付款", "帮我付款",
@@ -162,6 +173,11 @@ EXPENSE_TYPE_KEYWORDS = {
"打车": "transport", "打车": "transport",
"网约车": "transport", "网约车": "transport",
"出租车": "transport", "出租车": "transport",
"乘车": "transport",
"乘车费": "transport",
"用车": "transport",
"叫车": "transport",
"车资": "transport",
"停车费": "transport", "停车费": "transport",
"餐费": "meal", "餐费": "meal",
"用餐": "meal", "用餐": "meal",
@@ -195,6 +211,11 @@ EXPENSE_NARRATIVE_KEYWORDS = (
"垫付", "垫付",
"打车", "打车",
"车费", "车费",
"乘车",
"乘车费",
"用车",
"叫车",
"车资",
"餐费", "餐费",
"吃饭", "吃饭",
"用餐", "用餐",
@@ -231,16 +252,51 @@ MISSING_SLOT_LABELS = {
} }
STATUS_KEYWORDS = { STATUS_KEYWORDS = {
"草稿": "draft",
"待提交": "draft",
"待补充": "supplement",
"退回": "returned",
"已退回": "returned",
"进行中": "review",
"审批中": "review",
"审核中": "review",
"流转中": "review",
"已提交": "submitted",
"逾期": "overdue", "逾期": "overdue",
"待审批": "pending", "待审批": "pending",
"待审": "pending", "待审": "pending",
"已审批": "approved", "已审批": "approved",
"已通过": "approved", "已通过": "approved",
"已审核": "approved",
"已入账": "paid",
"已付款": "paid", "已付款": "paid",
"未付款": "unpaid", "未付款": "unpaid",
"未回款": "unreceived", "未回款": "unreceived",
} }
LOCATION_KEYWORDS = (
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"苏州",
"成都",
"重庆",
"天津",
"武汉",
"西安",
"郑州",
"长沙",
"青岛",
"厦门",
"宁波",
"合肥",
"济南",
"福州",
)
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"} PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"} CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
KNOWLEDGE_INTENTS = {"query", "explain", "compare"} KNOWLEDGE_INTENTS = {"query", "explain", "compare"}
@@ -641,6 +697,11 @@ class SemanticOntologyService:
value = str(context_json.get("conversation_scenario") or "").strip() value = str(context_json.get("conversation_scenario") or "").strip()
if value in CONTEXTUAL_SCENARIOS: if value in CONTEXTUAL_SCENARIOS:
return value return value
review_action = str(context_json.get("review_action") or "").strip()
if review_action in EXPENSE_REVIEW_ACTIONS:
return "expense"
if str(context_json.get("draft_claim_id") or "").strip():
return "expense"
return None return None
@staticmethod @staticmethod
@@ -661,6 +722,10 @@ class SemanticOntologyService:
best_scenario = max(scores, key=scores.get) best_scenario = max(scores, key=scores.get)
best_score = scores[best_scenario] best_score = scores[best_scenario]
if best_score <= 0: if best_score <= 0:
if "单据" in compact_query and any(
keyword in compact_query for keyword in STATUS_KEYWORDS
):
return "expense", 0.14
return "unknown", 0.0 return "unknown", 0.0
if best_scenario == "knowledge": if best_scenario == "knowledge":
@@ -687,6 +752,40 @@ class SemanticOntologyService:
) -> tuple[str, float]: ) -> tuple[str, float]:
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS): if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
return "operate", 0.30 return "operate", 0.30
status_document_query = (
"单据" in compact_query
and any(keyword in compact_query for keyword in STATUS_KEYWORDS)
and not any(keyword in compact_query for keyword in DRAFT_KEYWORDS if keyword != "草稿")
)
historical_document_query = any(
keyword in compact_query
for keyword in ("报销的单据", "报销单据", "报销过的单据", "报销记录")
)
if scenario == "expense" and any(
keyword in compact_query
for keyword in (
"报销了吗",
"报销了么",
"报销了没",
"报销了没有",
"报销没",
"单据状态",
"审批状态",
"报销进度",
"到哪了",
"到了哪",
"有没有报销",
"是否报销",
"进行中的单据",
"草稿单据",
"草稿的单据",
"待补充单据",
"审批中的单据",
"已提交单据",
"已入账单据",
)
) or (scenario == "expense" and (status_document_query or historical_document_query)):
return "query", 0.24
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS): if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
return "draft", 0.26 return "draft", 0.26
if scenario == "expense" and self._is_generic_expense_prompt(compact_query): if scenario == "expense" and self._is_generic_expense_prompt(compact_query):
@@ -739,6 +838,9 @@ class SemanticOntologyService:
) -> bool: ) -> bool:
context_scenario = self._resolve_context_scenario(context_json) context_scenario = self._resolve_context_scenario(context_json)
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
review_action = str(context_json.get("review_action") or "").strip()
if review_action in EXPENSE_REVIEW_ACTIONS:
return True
if context_scenario != "expense" and not draft_claim_id: if context_scenario != "expense" and not draft_claim_id:
return False return False
@@ -1154,6 +1256,9 @@ class SemanticOntologyService:
upsert(self._make_entity("invoice", code, code.upper())) upsert(self._make_entity("invoice", code, code.upper()))
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE): for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("contract", code, code.upper())) upsert(self._make_entity("contract", code, code.upper()))
for location in LOCATION_KEYWORDS:
if location in query:
upsert(self._make_entity("location", location, location, role="filter", confidence=0.86))
for label, normalized in EXPENSE_TYPE_KEYWORDS.items(): for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
if label in query: if label in query:
@@ -1173,7 +1278,10 @@ class SemanticOntologyService:
) )
) )
if any(keyword in query for keyword in ("打车", "网约车", "出租车", "车费", "停车费", "过路费")): if any(
keyword in query
for keyword in ("打车", "网约车", "出租车", "车费", "乘车", "用车", "叫车", "车资", "停车费", "过路费")
):
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9)) upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")): if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")):
@@ -1314,6 +1422,12 @@ class SemanticOntologyService:
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"), self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
0.10, 0.10,
) )
if "去年" in query or "上一年" in query:
year = today.year - 1
return (
self._range(date(year, 1, 1), date(year, 12, 31), "去年", "year"),
0.10,
)
match = DATE_RANGE_PATTERN.search(query) match = DATE_RANGE_PATTERN.search(query)
if match: if match:
@@ -1463,6 +1577,7 @@ class SemanticOntologyService:
"customer", "customer",
"vendor", "vendor",
"project", "project",
"location",
"expense_type", "expense_type",
}: }:
upsert( upsert(
@@ -1682,7 +1797,8 @@ class SemanticOntologyService:
) -> bool: ) -> bool:
if scenario != "expense" or intent != "draft": if scenario != "expense" or intent != "draft":
return False return False
return str(context_json.get("review_action") or "").strip() == "save_draft" review_action = str(context_json.get("review_action") or "").strip()
return review_action in EXPENSE_REVIEW_ACTIONS
@staticmethod @staticmethod
def _display_slot_label(slot: str) -> str: def _display_slot_label(slot: str) -> str:

View File

@@ -122,6 +122,7 @@ class OrchestratorService:
context_json = self.conversation_service.hydrate_context_json( context_json = self.conversation_service.hydrate_context_json(
conversation=conversation, conversation=conversation,
context_json=context_json, context_json=context_json,
message=payload.message,
) )
route_json: dict[str, Any] = { route_json: dict[str, Any] = {
@@ -173,8 +174,10 @@ class OrchestratorService:
task_asset=task_asset, task_asset=task_asset,
) )
selected_capability_codes = self._flatten_capability_codes(capabilities) selected_capability_codes = self._flatten_capability_codes(capabilities)
is_expense_review_action = self._is_expense_review_action(context_json)
requires_confirmation = ( requires_confirmation = (
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
and not is_expense_review_action
) )
route_json = { route_json = {
@@ -526,7 +529,11 @@ class OrchestratorService:
failed_tool_count=1 if degraded else 0, failed_tool_count=1 if degraded else 0,
) )
next_step = self._resolve_next_step(ontology, payload.source) next_step = self._resolve_next_step(
ontology,
payload.source,
context_json=context_json,
)
if next_step == "query_database": if next_step == "query_database":
tool_payload, degraded = self._invoke_tool( tool_payload, degraded = self._invoke_tool(
run_id=run_id, run_id=run_id,
@@ -658,13 +665,22 @@ class OrchestratorService:
"draft_only": True, "draft_only": True,
} }
fallback_factory = lambda exc: { fallback_factory = lambda exc: {
"message": f"草稿生成暂时不可用,请稍后再试:{exc}", "message": f"内容整理暂时不可用,请稍后再试:{exc}",
"degraded": True, "degraded": True,
} }
if ontology.scenario == "expense": if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
tool_type = AgentToolType.DATABASE.value is_persistence_action = self._is_expense_persistence_action(context_json)
tool_name = "database.expense_claims.save_or_submit" tool_type = (
AgentToolType.DATABASE.value
if is_persistence_action
else AgentToolType.LLM.value
)
tool_name = (
"database.expense_claims.save_or_submit"
if is_persistence_action
else "user_agent.expense_review_preview"
)
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology( executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
run_id=run_id, run_id=run_id,
user_id=payload.user_id, user_id=payload.user_id,
@@ -673,7 +689,11 @@ class OrchestratorService:
context_json=context_json, context_json=context_json,
) )
fallback_factory = lambda exc: { fallback_factory = lambda exc: {
"message": f"报销草稿落库失败,请稍后再试:{exc}", "message": (
f"报销草稿落库失败,请稍后再试:{exc}"
if is_persistence_action
else f"报销内容预览生成失败,请稍后再试:{exc}"
),
"degraded": True, "degraded": True,
} }
@@ -782,7 +802,14 @@ class OrchestratorService:
) )
@staticmethod @staticmethod
def _resolve_next_step(ontology: OntologyParseResult, source: str) -> str: def _resolve_next_step(
ontology: OntologyParseResult,
source: str,
*,
context_json: dict[str, Any] | None = None,
) -> str:
if OrchestratorService._is_expense_review_action(context_json or {}):
return "create_draft"
if ontology.clarification_required: if ontology.clarification_required:
return "ask_clarification" return "ask_clarification"
if ontology.intent == "draft": if ontology.intent == "draft":
@@ -795,6 +822,27 @@ class OrchestratorService:
return "query_database" return "query_database"
return "create_draft" return "create_draft"
@staticmethod
def _is_expense_review_action(context_json: dict[str, Any]) -> bool:
review_action = str((context_json or {}).get("review_action") or "").strip()
return review_action in {
"save_draft",
"next_step",
"edit_review",
"link_to_existing_draft",
"create_new_claim_from_documents",
}
@staticmethod
def _is_expense_persistence_action(context_json: dict[str, Any]) -> bool:
review_action = str((context_json or {}).get("review_action") or "").strip()
return review_action in {
"save_draft",
"next_step",
"link_to_existing_draft",
"create_new_claim_from_documents",
}
@staticmethod @staticmethod
def _flatten_capability_codes( def _flatten_capability_codes(
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]], capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
@@ -1147,6 +1195,8 @@ class OrchestratorService:
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip() if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
) )
) )
project_values = self._collect_expense_query_filter_values(ontology, "project")
location_values = self._collect_expense_query_filter_values(ontology, "location")
status_values = list( status_values = list(
dict.fromkeys( dict.fromkeys(
str(item.value).strip() str(item.value).strip()
@@ -1168,6 +1218,20 @@ class OrchestratorService:
conditions.append(ExpenseClaim.expense_type.in_(expense_types)) conditions.append(ExpenseClaim.expense_type.in_(expense_types))
if status_values: if status_values:
conditions.append(ExpenseClaim.status.in_(status_values)) conditions.append(ExpenseClaim.status.in_(status_values))
if project_values:
project_conditions = []
for value in project_values:
pattern = f"%{value}%"
project_conditions.append(ExpenseClaim.project_code.ilike(pattern))
project_conditions.append(ExpenseClaim.reason.ilike(pattern))
conditions.append(or_(*project_conditions))
if location_values:
location_conditions = []
for value in location_values:
pattern = f"%{value}%"
location_conditions.append(ExpenseClaim.location.ilike(pattern))
location_conditions.append(ExpenseClaim.reason.ilike(pattern))
conditions.append(or_(*location_conditions))
for item in amount_constraints: for item in amount_constraints:
amount_value = float(item.value) amount_value = float(item.value)
@@ -1229,6 +1293,26 @@ class OrchestratorService:
return conditions, scope_label, scoped_to_current_user return conditions, scope_label, scoped_to_current_user
@staticmethod
def _collect_expense_query_filter_values(
ontology: OntologyParseResult,
field_name: str,
) -> list[str]:
values: list[str] = []
for entity in ontology.entities:
if entity.type != field_name:
continue
value = str(entity.normalized_value or entity.value or "").strip()
if value:
values.append(value)
for constraint in ontology.constraints:
if constraint.field != field_name or constraint.operator != "=":
continue
value = str(constraint.value or "").strip()
if value:
values.append(value)
return list(dict.fromkeys(values))
def _build_current_user_claim_conditions( def _build_current_user_claim_conditions(
self, self,
*, *,

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
from app.schemas.ontology import OntologyParseResult
RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
"location_mismatch": ["risk.travel.destination_receipt_location"],
"base_location_overlap": ["risk.travel.base_location_overlap"],
"intracity_travel": ["risk.travel.intracity_travel_claim"],
"multi_city_itinerary": ["risk.travel.multi_city_reason_required"],
"hotel_itinerary_mismatch": ["risk.travel.hotel_without_itinerary"],
"duplicate_invoice": ["risk.invoice.duplicate_invoice"],
"buyer_name_mismatch": ["risk.invoice.claimant_buyer_name_match"],
"document_expense_mismatch": ["risk.invoice.document_expense_mismatch"],
"cross_year_invoice": ["risk.invoice.cross_year_invoice"],
"void_or_red_invoice": ["risk.invoice.void_or_red_invoice"],
"vague_goods_description": ["risk.invoice.vague_goods_description"],
"entertainment_missing_detail": ["risk.expense.entertainment_missing_detail"],
"meal_as_travel": ["risk.expense.meal_localized_as_travel"],
"consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"],
"reason_too_brief": ["risk.expense.reason_too_brief"],
}
TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
"location_mismatch": ("地点", "行程", "出差地", "票据地", "城市不一致"),
"duplicate_invoice": ("重复", "同一张票", "重复报销", "发票重复"),
"buyer_name_mismatch": ("购买方", "抬头", "开票单位"),
"document_expense_mismatch": ("附件", "票据", "单据", "材料不一致"),
"cross_year_invoice": ("跨年", "以前年度", "去年发票"),
"void_or_red_invoice": ("作废", "红冲", "红字"),
"vague_goods_description": ("商品名称", "品名", "笼统"),
"entertainment_missing_detail": ("招待", "宴请", "陪同", "客户餐"),
"meal_as_travel": ("餐费", "差旅餐", "本地餐"),
"consecutive_transport_receipts": ("连续交通", "多张车票", "打车"),
"reason_too_brief": ("事由", "说明太短", "理由不足"),
}
def list_all_platform_risk_rule_codes() -> list[str]:
return sorted({code for codes in RISK_SIGNAL_TO_RULE_CODES.values() for code in codes})
def resolve_rule_codes_from_ontology(ontology: OntologyParseResult) -> list[str]:
resolved: list[str] = []
for signal in ontology.risk_flags:
for rule_code in RISK_SIGNAL_TO_RULE_CODES.get(str(signal or "").strip(), []):
if rule_code not in resolved:
resolved.append(rule_code)
return resolved
def infer_risk_signals_from_text(text: str) -> list[str]:
normalized = str(text or "").strip().lower()
if not normalized:
return []
signals: list[str] = []
for signal, keywords in TEXT_SIGNAL_KEYWORDS.items():
if any(keyword.lower() in normalized for keyword in keywords):
signals.append(signal)
return signals
def resolve_rule_codes_for_risk_check(
ontology: OntologyParseResult,
*,
query_text: str = "",
) -> list[str]:
if ontology.intent != "risk_check":
return []
resolved = resolve_rule_codes_from_ontology(ontology)
for signal in infer_risk_signals_from_text(query_text):
for rule_code in RISK_SIGNAL_TO_RULE_CODES.get(signal, []):
if rule_code not in resolved:
resolved.append(rule_code)
return resolved or list_all_platform_risk_rule_codes()

View File

@@ -0,0 +1,593 @@
from __future__ import annotations
import re
from decimal import Decimal
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetType
from app.models.employee import Employee
from app.schemas.reimbursement import (
TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse,
)
from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService
OTHER_REGION_LOCATION_KEYWORDS = {
"河北",
"石家庄",
"唐山",
"秦皇岛",
"邯郸",
"邢台",
"保定",
"张家口",
"承德",
"沧州",
"廊坊",
"衡水",
"山西",
"太原",
"大同",
"长治",
"晋城",
"晋中",
"运城",
"临汾",
"吕梁",
"内蒙古",
"呼和浩特",
"包头",
"赤峰",
"通辽",
"鄂尔多斯",
"辽宁",
"鞍山",
"抚顺",
"本溪",
"丹东",
"锦州",
"营口",
"盘锦",
"吉林",
"长春",
"吉林市",
"四平",
"通化",
"白山",
"松原",
"延边",
"黑龙江",
"哈尔滨",
"齐齐哈尔",
"牡丹江",
"佳木斯",
"大庆",
"江苏",
"常州",
"南通",
"连云港",
"淮安",
"盐城",
"扬州",
"镇江",
"泰州",
"宿迁",
"浙江",
"温州",
"嘉兴",
"湖州",
"绍兴",
"金华",
"衢州",
"舟山",
"台州",
"丽水",
"安徽",
"芜湖",
"蚌埠",
"淮南",
"马鞍山",
"淮北",
"铜陵",
"安庆",
"黄山",
"滁州",
"阜阳",
"宿州",
"六安",
"亳州",
"池州",
"宣城",
"福建",
"泉州",
"漳州",
"莆田",
"三明",
"南平",
"龙岩",
"宁德",
"江西",
"南昌",
"景德镇",
"萍乡",
"九江",
"新余",
"鹰潭",
"赣州",
"吉安",
"宜春",
"抚州",
"上饶",
"山东",
"淄博",
"枣庄",
"东营",
"烟台",
"潍坊",
"济宁",
"泰安",
"威海",
"日照",
"临沂",
"德州",
"聊城",
"滨州",
"菏泽",
"河南",
"洛阳",
"开封",
"平顶山",
"安阳",
"鹤壁",
"新乡",
"焦作",
"濮阳",
"许昌",
"漯河",
"三门峡",
"南阳",
"商丘",
"信阳",
"周口",
"驻马店",
"湖北",
"黄石",
"十堰",
"宜昌",
"襄阳",
"鄂州",
"荆门",
"孝感",
"荆州",
"黄冈",
"咸宁",
"随州",
"恩施",
"湖南",
"株洲",
"湘潭",
"衡阳",
"邵阳",
"岳阳",
"常德",
"张家界",
"益阳",
"郴州",
"永州",
"怀化",
"娄底",
"湘西",
"广东",
"惠州",
"江门",
"湛江",
"茂名",
"肇庆",
"梅州",
"汕尾",
"河源",
"阳江",
"清远",
"潮州",
"揭阳",
"云浮",
"广西",
"南宁",
"柳州",
"桂林",
"梧州",
"北海",
"防城港",
"钦州",
"贵港",
"玉林",
"百色",
"贺州",
"河池",
"来宾",
"崇左",
"海南",
"儋州",
"四川",
"自贡",
"攀枝花",
"泸州",
"德阳",
"绵阳",
"广元",
"遂宁",
"内江",
"乐山",
"南充",
"眉山",
"宜宾",
"广安",
"达州",
"雅安",
"巴中",
"资阳",
"阿坝",
"甘孜",
"凉山",
"贵州",
"贵阳",
"遵义",
"六盘水",
"安顺",
"毕节",
"铜仁",
"黔东南",
"黔南",
"黔西南",
"云南",
"曲靖",
"玉溪",
"保山",
"昭通",
"丽江",
"普洱",
"临沧",
"楚雄",
"红河",
"文山",
"西双版纳",
"大理",
"德宏",
"怒江",
"迪庆",
"陕西",
"宝鸡",
"咸阳",
"铜川",
"渭南",
"延安",
"汉中",
"榆林",
"安康",
"商洛",
"甘肃",
"兰州",
"嘉峪关",
"金昌",
"白银",
"天水",
"武威",
"张掖",
"平凉",
"酒泉",
"庆阳",
"定西",
"陇南",
"临夏",
"甘南",
"青海",
"西宁",
"海东",
"海北",
"黄南",
"海南州",
"果洛",
"玉树",
"海西",
"宁夏",
"银川",
"石嘴山",
"吴忠",
"固原",
"中卫",
}
OTHER_REGION_PROVINCE_KEYWORDS = {
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"四川",
"贵州",
"云南",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"西藏",
"台湾",
"香港",
"澳门",
}
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}
class TravelReimbursementCalculatorService:
def __init__(self, db: Session) -> None:
self.db = db
def calculate(
self,
payload: TravelReimbursementCalculatorRequest,
current_user: CurrentUserContext,
) -> TravelReimbursementCalculatorResponse:
days = max(1, int(payload.days))
location = str(payload.location or "").strip()
if not location:
raise ValueError("请先填写出差地点。")
policy = self._load_travel_policy()
grade = self._resolve_grade(payload.grade, current_user)
if not grade:
raise ValueError("未识别到当前员工职级,请在个人信息中维护职级后再计算。")
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
if not grade_band:
raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位。")
matched_city = self._resolve_city(location, policy)
matched_other_region = "" if matched_city else self._resolve_other_region(location)
if not matched_city and not matched_other_region:
raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。")
city_tier = policy.city_tiers.get(matched_city, "tier_3") if matched_city else "tier_3"
hotel_rate = self._resolve_hotel_rate(policy, grade_band, matched_city, city_tier)
allowance_region = self._resolve_allowance_region(location, matched_city or matched_other_region)
meal_rate = self._resolve_allowance_rate(policy, "meal", allowance_region)
basic_rate = self._resolve_allowance_rate(policy, "basic", allowance_region)
total_allowance_rate = self._resolve_total_allowance_rate(policy, allowance_region, meal_rate, basic_rate)
hotel_amount = hotel_rate * Decimal(days)
allowance_amount = total_allowance_rate * Decimal(days)
total_amount = hotel_amount + allowance_amount
band_label = policy.band_labels.get(grade_band, grade_band)
rule_name = policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则"
rule_version = policy.standard_rule_version or policy.rule_version or ""
display_city = matched_city or self._format_other_region_display(matched_other_region)
formula_text = (
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
f"{self._format_money(total_amount)}"
)
summary_text = (
f"按《{rule_name}{f'{rule_version}' if rule_version else ''}测算:"
f"当前职级 {grade} 对应 {band_label} 档,出差地点“{location}”匹配为“{display_city}”,"
f"住宿标准 {self._format_money(hotel_rate)} 元/天,补贴区域为“{allowance_region}”,"
f"补贴标准 {self._format_money(total_allowance_rate)} 元/天"
f"(伙食 {self._format_money(meal_rate)} + 基本 {self._format_money(basic_rate)})。"
f"{days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
f"补贴合计 {self._format_money(allowance_amount)} 元,"
f"参考可报销总金额为 {self._format_money(total_amount)} 元。"
)
return TravelReimbursementCalculatorResponse(
days=days,
location=location,
matched_city=display_city,
city_tier=city_tier,
grade=grade,
grade_band=grade_band,
grade_band_label=band_label,
hotel_rate=hotel_rate,
hotel_amount=hotel_amount,
allowance_region=allowance_region,
meal_allowance_rate=meal_rate,
basic_allowance_rate=basic_rate,
total_allowance_rate=total_allowance_rate,
allowance_amount=allowance_amount,
total_amount=total_amount,
rule_name=rule_name,
rule_version=rule_version,
formula_text=formula_text,
summary_text=summary_text,
)
def _load_travel_policy(self) -> RuntimeTravelPolicy:
AgentAssetService(self.db).list_assets(asset_type=AgentAssetType.RULE.value)
policy = ExpenseRuleRuntimeService(self.db).load_catalog().travel_policy
if policy is None:
raise ValueError("规则中心暂未配置差旅报销规则。")
return policy
def _resolve_grade(
self,
grade: str | None,
current_user: CurrentUserContext,
) -> str:
normalized_grade = str(grade or "").strip()
if normalized_grade:
return normalized_grade
employee = self._resolve_current_employee(current_user)
if employee is not None and str(employee.grade or "").strip():
return str(employee.grade).strip()
return ""
@staticmethod
def _resolve_other_region(location: str) -> str:
normalized = re.sub(r"\s+", "", str(location or "").strip())
if not normalized:
return ""
if any(keyword in normalized for keyword in ("国外", "境外", "海外")):
return "国外"
for keyword in ("香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"):
if keyword in normalized:
return keyword
city_matches = []
province_matches = []
for keyword in OTHER_REGION_LOCATION_KEYWORDS:
if not keyword or keyword not in normalized:
continue
if keyword in OTHER_REGION_PROVINCE_KEYWORDS:
province_matches.append(keyword)
else:
city_matches.append(keyword)
candidates = city_matches or province_matches
if candidates:
return sorted(candidates, key=len, reverse=True)[0]
return ""
@staticmethod
def _format_other_region_display(region: str) -> str:
normalized = str(region or "").strip()
if not normalized:
return ""
if normalized in {"国外", "香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"}:
return normalized
return f"{normalized}(其他地区)"
def _resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None:
candidates = [
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
]
normalized_candidates = [
item
for item in dict.fromkeys(candidate for candidate in candidates if candidate)
if item
]
if not normalized_candidates:
return None
for candidate in normalized_candidates:
employee = self.db.scalar(
select(Employee)
.where(
or_(
func.lower(Employee.email) == candidate.lower(),
func.lower(Employee.employee_no) == candidate.lower(),
)
)
.limit(1)
)
if employee is not None:
return employee
for candidate in normalized_candidates:
matches = list(
self.db.scalars(
select(Employee)
.where(Employee.name == candidate)
.limit(2)
).all()
)
if len(matches) == 1:
return matches[0]
return None
@staticmethod
def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str:
normalized = str(location or "").strip()
if not normalized:
return ""
city_names = set(policy.city_tiers.keys())
city_names.update(policy.hotel_city_limits.keys())
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
if city in AMBIGUOUS_PROVINCE_CITY_NAMES and normalized != city and f"{city}" not in normalized:
continue
if city and city in normalized:
return city
compact = re.sub(r"(省|市|区|县|自治州|特别行政区)$", "", normalized)
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
if city in AMBIGUOUS_PROVINCE_CITY_NAMES and compact != city and f"{city}" not in normalized:
continue
if city and city in compact:
return city
return ""
@staticmethod
def _resolve_hotel_rate(
policy: RuntimeTravelPolicy,
grade_band: str,
matched_city: str,
city_tier: str,
) -> Decimal:
city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {}
if city_limits.get(grade_band) is not None:
return Decimal(city_limits[grade_band])
band_limits = policy.hotel_limits.get(grade_band, {})
if band_limits.get(city_tier) is not None:
return Decimal(band_limits[city_tier])
if band_limits.get("tier_3") is not None:
return Decimal(band_limits["tier_3"])
return Decimal("0")
@staticmethod
def _resolve_allowance_region(location: str, matched_city: str) -> str:
text = f"{location} {matched_city}".strip()
if any(keyword in text for keyword in ("国外", "境外", "海外")):
return "国外"
if any(keyword in text for keyword in ("香港", "澳门", "台湾", "港澳台")):
return "港澳台"
if "乌鲁木齐" in text:
return "新疆-乌鲁木齐"
if "新疆" in text:
return "新疆-其他"
if "西藏" in text or "拉萨" in text:
return "西藏"
if any(keyword in text for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")):
return "直辖市/特区"
return "其他地区"
@staticmethod
def _resolve_allowance_rate(policy: RuntimeTravelPolicy, allowance_key: str, region: str) -> Decimal:
limits = policy.allowance_limits.get(allowance_key, {})
if limits.get(region) is not None:
return Decimal(limits[region])
if limits.get("其他地区") is not None:
return Decimal(limits["其他地区"])
return Decimal("0")
def _resolve_total_allowance_rate(
self,
policy: RuntimeTravelPolicy,
region: str,
meal_rate: Decimal,
basic_rate: Decimal,
) -> Decimal:
total_limits = policy.allowance_limits.get("total", {})
if total_limits.get(region) is not None:
return Decimal(total_limits[region])
if total_limits.get("其他地区") is not None:
return Decimal(total_limits["其他地区"])
return meal_rate + basic_rate
@staticmethod
def _format_money(value: Decimal | int | float | str) -> str:
return f"{Decimal(str(value)).quantize(Decimal('0.01'))}"

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
{
"file_name": "酒店1.jpg",
"storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg",
"media_type": "image/jpeg",
"size_bytes": 135977,
"uploaded_at": "2026-05-21T07:21:03.814491+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.preview.jpg",
"preview_media_type": "image/jpeg",
"preview_file_name": "酒店1.preview.jpg",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为酒店住宿票据。",
"附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。",
"金额字段:已识别到与当前明细接近的金额 2026.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "2026元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-23"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "hotel_ticket",
"current_expense_type_label": "住宿票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "hotel",
"recognized_scene_label": "住宿票据",
"recognized_document_type": "hotel_invoice",
"recognized_document_type_label": "酒店住宿票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为住宿票,已识别为酒店住宿票据。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "上海喜来登酒店(样例)\n住宿发票\n发票编号SH-SAMPLE-20260223-002\n开票日期2026年2月23日\n客姓名曹笑\n住晚数3晚\n住期2026年220\n房型豪华床房\n离店期2026年223\n预订渠道酒店官\n日期\n项目\n房费单价\n数量\n金额\n2026-02-20至2026-02-22\n住宿费\n¥276/晚\n3晚\n¥828\n合计¥828\n额写捌佰贰拾捌元整\n备注\n以上费用已由酒店收取并开具发票。\n本发票仅含住宿费不含其他增值服务费。\n如有疑问请联系酒店前台或致电酒店财务部。\n样例票据|仅供系统测试|无效凭证",
"ocr_summary": "上海喜来登酒店样例住宿发票发票编号SH-SAMPLE-20260223-002",
"ocr_avg_score": 0.9884135921796163,
"ocr_line_count": 27,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.84,
"ocr_classification_evidence": [
"住宿",
"房费",
"离店",
"酒店"
],
"ocr_warnings": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -20,7 +20,7 @@
"ingest_document_name": "远光《公司支出管理办法2024》.pdf", "ingest_document_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", "ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
"ingest_agent_run_id": "run_8b0ead1e3c734a53" "ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
}, },
{ {
"id": "a8f8465df08e455ebe133351721d49f8", "id": "a8f8465df08e455ebe133351721d49f8",
@@ -35,13 +35,13 @@
"updated_at": "2026-05-17T13:00:09.485818+00:00", "updated_at": "2026-05-17T13:00:09.485818+00:00",
"uploaded_by": "admin", "uploaded_by": "admin",
"version_number": 1, "version_number": 1,
"ingest_status": 1, "ingest_status": 4,
"ingest_status_updated_at": "2026-05-17T13:00:09.485818+00:00", "ingest_status_updated_at": "2026-05-20T16:00:02.515903+00:00",
"ingest_completed_at": "", "ingest_completed_at": "",
"ingest_document_name": "", "ingest_document_name": "",
"ingest_document_updated_at": "", "ingest_document_updated_at": "",
"ingest_document_sha256": "", "ingest_document_sha256": "",
"ingest_agent_run_id": "" "ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
} }
] ]
} }

View File

@@ -24,5 +24,28 @@
"processing_start_time": 1779011842, "processing_start_time": 1779011842,
"processing_end_time": 1779012093 "processing_end_time": 1779012093
} }
},
"a8f8465df08e455ebe133351721d49f8": {
"status": "failed",
"error_msg": "Embedding func: Worker execution timeout after 60s",
"chunks_count": 6,
"chunks_list": [
"chunk-07de6ea74f60535b689f977295770273",
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8",
"chunk-1746bd83138e85e66a78e0cb9ad79272",
"chunk-ce44e4483e4119265b43eacb72e0326a",
"chunk-2187fa0609874bdda339c9850da45a26",
"chunk-2224d777c0b72d0b2dab622c79096c2c"
],
"content_summary": "# 产品需求文档\n## 文档信息\n| 项目 | 内容 |\n|------|------|\n| 项目名称 |\n无单报销\n|\n| 版本 | V1.0 |\n| 日期 | 2026-05-06 |\n| 状态 | 正式版 |\n---\n## 1. 项目概述\n### 1.1 项目背景\n面向\n大型企业\n从业务人员视角出发解决现有ERP使用体验不佳的问题。\n在ERP的发展历程中“单据化”曾是财务合规的一大进步它确保了每笔支出都有据可查。但不可否认传统的人工填单确实\n也制造了很多\n“枷锁”。在AI时代解...",
"content_length": 9088,
"created_at": "2026-05-19T15:59:57.283110+00:00",
"updated_at": "2026-05-19T16:00:57.323299+00:00",
"file_path": "/app/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
"track_id": "insert_20260519_155957_88c49850",
"metadata": {
"processing_start_time": 1779206397,
"processing_end_time": 1779206457
}
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -15,9 +15,8 @@ def test_rule_spreadsheet_onlyoffice_key_uses_safe_characters() -> None:
key = AgentAssetService._build_onlyoffice_document_key( key = AgentAssetService._build_onlyoffice_document_key(
"asset:id", "asset:id",
"v1.0.0",
metadata, metadata,
) )
assert key == "asset_id-v1.0.0-abc123" assert key == "asset_id-abc123"
assert ":" not in key assert ":" not in key

View File

@@ -1,14 +1,17 @@
from __future__ import annotations from __future__ import annotations
import shutil
import uuid import uuid
from io import BytesIO from io import BytesIO
from pathlib import Path
import pytest import pytest
from openpyxl import Workbook, load_workbook from openpyxl import Workbook, load_workbook
from sqlalchemy import create_engine from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.core.agent_enums import ( from app.core.agent_enums import (
AgentAssetContentType, AgentAssetContentType,
AgentAssetDomain, AgentAssetDomain,
@@ -19,16 +22,61 @@ from app.core.agent_enums import (
AgentRunSource, AgentRunSource,
AgentRunStatus, AgentRunStatus,
) )
from app.core.config import SERVER_DIR
from app.db.base import Base from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.schemas.agent_asset import ( from app.schemas.agent_asset import (
AgentAssetCreate, AgentAssetCreate,
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetVersionCreate, AgentAssetVersionCreate,
) )
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
)
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService from app.services.audit import AuditLogService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
from app.services.settings import OnlyOfficeRuntimeConfig
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
@pytest.fixture(autouse=True)
def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
temp_server_dir = tmp_path / "server"
temp_rules_root = temp_server_dir / "rules"
temp_finance_rules = temp_rules_root / FINANCE_RULES_LIBRARY
temp_finance_rules.mkdir(parents=True, exist_ok=True)
real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY
for file_name in (
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
):
source_path = real_finance_rules / file_name
if source_path.exists():
shutil.copy2(source_path, temp_finance_rules / file_name)
monkeypatch.setattr(
"app.services.agent_asset_spreadsheet.SERVER_DIR",
temp_server_dir,
)
def init_manager(self, storage_root=None, rule_root=None) -> None:
self.storage_root = Path(storage_root or tmp_path / "storage").resolve()
self.asset_root = (self.storage_root / "agent_assets").resolve()
self.rule_root = Path(rule_root or temp_rules_root).resolve()
monkeypatch.setattr(
"app.services.agent_asset_spreadsheet.AgentAssetSpreadsheetManager.__init__",
init_manager,
)
def build_session() -> Session: def build_session() -> Session:
@@ -53,6 +101,19 @@ def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则
return buffer.getvalue() return buffer.getvalue()
def build_multi_sheet_workbook_bytes(sheets: dict[str, list[list[object]]]) -> bytes:
workbook = Workbook()
default_sheet = workbook.active
workbook.remove(default_sheet)
for sheet_name, rows in sheets.items():
sheet = workbook.create_sheet(sheet_name)
for row in rows:
sheet.append(row)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None: def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)
@@ -60,7 +121,8 @@ def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation(
rules = service.list_assets(asset_type=AgentAssetType.RULE.value) rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
assert len(rules) >= 3 assert len(rules) >= 3
assert any( assert any(
item.code == "rule.expense.travel_risk_control_standard" and item.status == AgentAssetStatus.ACTIVE.value item.code == "rule.expense.travel_risk_control_standard"
and item.status == AgentAssetStatus.ACTIVE.value
for item in rules for item in rules
) )
assert all( assert all(
@@ -89,6 +151,26 @@ def test_agent_asset_service_seeds_all_foundation_asset_types() -> None:
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3 assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
def test_finance_rules_use_risk_rule_scenario_categories() -> None:
with build_session() as db:
service = AgentAssetService(db)
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
travel_rule = next(item for item in rules if item.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
communication_rule = next(
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
)
travel_config = travel_rule.config_json or {}
communication_config = communication_rule.config_json or {}
assert travel_rule.scenario_json == ["差旅"]
assert travel_config["scenario_category"] == "差旅"
assert travel_config["ai_review_category"] == "差旅"
assert communication_rule.scenario_json == ["费用科目"]
assert communication_config["scenario_category"] == "费用科目"
assert communication_config["ai_review_category"] == "费用科目"
def test_agent_asset_service_can_activate_rule_after_review() -> None: def test_agent_asset_service_can_activate_rule_after_review() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)
@@ -173,6 +255,36 @@ def test_rule_working_version_does_not_replace_published_version_until_activatio
assert detail.latest_review is None assert detail.latest_review is None
def test_pending_review_can_name_new_working_version_before_submission() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
item
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
if item.code == "rule.expense.travel_risk_control_standard"
)
review = service.create_review(
rule.id,
AgentAssetReviewCreate(
version="v1.2.0",
reviewer="manager_user",
review_status=AgentReviewStatus.PENDING,
review_note="请审核",
),
actor="finance_user",
)
detail = service.get_asset(rule.id)
assert review.version == "v1.2.0"
assert detail is not None
assert detail.current_version == "v1.2.0"
assert detail.working_version == "v1.2.0"
assert detail.published_version == "v1.1.0"
assert detail.latest_review is not None
assert detail.latest_review.reviewer == "manager_user"
def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None: def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)
@@ -186,7 +298,10 @@ def test_expense_rule_runtime_uses_published_version_instead_of_working_version(
rule.id, rule.id,
AgentAssetVersionCreate( AgentAssetVersionCreate(
version="v1.1.1", version="v1.1.1",
content="# 工作稿\n\n```expense-rule\n{\"kind\":\"travel_policy\",\"version\":1}\n```", content=(
"# 工作稿\n\n"
'```expense-rule\n{"kind":"travel_policy","version":1}\n```'
),
content_type=AgentAssetContentType.MARKDOWN, content_type=AgentAssetContentType.MARKDOWN,
change_note="未上线草稿", change_note="未上线草稿",
created_by="finance_user", created_by="finance_user",
@@ -221,7 +336,7 @@ def test_restore_version_creates_new_working_copy_without_rewriting_published_ve
assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿" assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿"
def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None: def test_spreadsheet_upload_records_sheet_and_cell_changes_without_versions() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)
rule = next( rule = next(
@@ -236,28 +351,30 @@ def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]), content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
actor="finance_user", actor="finance_user",
) )
base_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
service.upload_rule_spreadsheet( service.upload_rule_spreadsheet(
rule.id, rule.id,
filename="公司差旅费报销规则.xlsx", filename="公司差旅费报销规则.xlsx",
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]), content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
actor="finance_user", actor="finance_user",
) )
target_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
diff = service.compare_spreadsheet_versions( records = service.list_spreadsheet_change_records(rule.id)
rule.id, latest = records[0]
base_version=base_version or "",
target_version=target_version or "", assert latest.changed_sheet_count == 1
assert latest.changed_cell_count == 3
assert any(
item.cell == "B2" and item.change_type == "modified"
for item in latest.cell_changes
) )
assert any(
assert diff.changed_sheet_count == 1 item.cell == "A3" and item.change_type == "added"
assert diff.changed_cell_count == 3 for item in latest.cell_changes
assert any(item.cell == "B2" and item.change_type == "modified" for item in diff.cell_changes) )
assert any(item.cell == "A3" and item.change_type == "added" for item in diff.cell_changes) assert not hasattr(latest, "version")
def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_copy() -> None: def test_spreadsheet_content_reads_current_rule_file_without_version_snapshot() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)
rule = next( rule = next(
@@ -274,26 +391,143 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
) )
detail = service.get_asset(rule.id) detail = service.get_asset(rule.id)
assert detail is not None assert detail is not None
working_version = detail.working_version or ""
current_asset = service.repository.get(rule.id) current_asset = service.repository.get(rule.id)
assert current_asset is not None assert current_asset is not None
live_storage_key = str((current_asset.config_json or {})["rule_document"]["storage_key"]) live_storage_key = str((current_asset.config_json or {})["rule_document"]["storage_key"])
assert live_storage_key.startswith(f"rules/{FINANCE_RULES_LIBRARY}/")
assert "agent_assets" not in live_storage_key
live_path = service.spreadsheet_manager.resolve_storage_path(live_storage_key) live_path = service.spreadsheet_manager.resolve_storage_path(live_storage_key)
original_live_bytes = live_path.read_bytes() assert not service.spreadsheet_manager.asset_root.exists()
try:
live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]]))
snapshot_path, _, _ = service.get_rule_spreadsheet_content( current_path, _, _ = service.get_rule_spreadsheet_content(rule.id)
rule.id,
version=working_version, assert current_path == live_path
assert ".versions" not in current_path.parts
workbook = load_workbook(current_path, data_only=False)
assert workbook.active["B2"].value == 500
def test_spreadsheet_change_records_return_recent_edit_details() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
item
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
if item.code == "rule.expense.company_travel_expense_reimbursement"
)
service.audit_service.log_action(
actor="manager_user",
action="edit_rule_spreadsheet",
resource_type=rule.asset_type,
resource_id=rule.id,
after_json={
"summary": "在线编辑:共 1 处改动。",
"changed_sheet_count": 1,
"changed_cell_count": 1,
"sheet_changes": [],
"cell_changes": [
{
"sheet_name": "规则表",
"cell": "B2",
"change_type": "modified",
"before_value": 500,
"after_value": 550,
}
],
},
) )
assert snapshot_path != live_path records = service.list_spreadsheet_change_records(rule.id)
workbook = load_workbook(snapshot_path, data_only=False)
assert workbook.active["B2"].value == 500 assert len(records) == 1
finally: assert records[0].actor == "manager_user"
live_path.write_bytes(original_live_bytes) assert records[0].changed_cell_count == 1
assert records[0].cell_changes[0].cell == "B2"
def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
item
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
if item.code == "rule.expense.company_travel_expense_reimbursement"
)
service.upload_rule_spreadsheet(
rule.id,
filename="公司差旅费报销规则.xlsx",
content=build_multi_sheet_workbook_bytes(
{
"差旅标准": [["城市", "住宿"], ["北京", 500]],
"填表说明": [["字段", "说明"], ["住宿", "按城市标准"]],
}
),
actor="finance_user",
)
detail = service.get_asset(rule.id)
assert detail is not None
service.upload_rule_spreadsheet(
rule.id,
filename="公司差旅费报销规则.xlsx",
content=build_multi_sheet_workbook_bytes(
{
"差旅标准": [["城市", "住宿"], ["北京", 550]],
"填表说明": [["字段", "说明"], ["住宿", "按城市等级标准"]],
}
),
actor="finance_user",
)
records = service.list_spreadsheet_change_records(rule.id)
latest = records[0]
changed_sheets = {item.sheet_name for item in latest.sheet_changes}
changed_cell_sheets = {item.sheet_name for item in latest.cell_changes}
assert not hasattr(latest, "version")
assert latest.changed_sheet_count == 2
assert {"差旅标准", "填表说明"}.issubset(changed_sheets)
assert {"差旅标准", "填表说明"}.issubset(changed_cell_sheets)
assert "差旅标准" in latest.summary
assert "填表说明" in latest.summary
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
with build_session() as db:
monkeypatch.setattr(
"app.services.agent_assets.resolve_onlyoffice_settings",
lambda: OnlyOfficeRuntimeConfig(
enabled=True,
public_url="http://onlyoffice.example.com",
backend_url="http://backend.example.com",
jwt_secret="secret",
),
)
service = AgentAssetService(db)
rule = next(
item
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
if item.code == "rule.expense.company_travel_expense_reimbursement"
)
config = service.build_rule_spreadsheet_onlyoffice_config(
rule.id,
CurrentUserContext(
username="finance_user",
name="财务人员",
role_codes=["finance"],
is_admin=False,
),
)
customization = config.config["editorConfig"]["customization"]
assert config.config["editorConfig"]["mode"] == "edit"
assert customization["forcesave"] is True
assert "version=" not in config.config["document"]["url"]
assert "version=" not in config.config["editorConfig"]["callbackUrl"]
def test_version_timeline_contains_created_review_and_publish_events() -> None: def test_version_timeline_contains_created_review_and_publish_events() -> None:
@@ -388,6 +622,126 @@ def test_agent_asset_service_returns_travel_policy_rule_detail() -> None:
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content) assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -> None:
with build_session() as db:
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
travel_spreadsheet_rule = db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
)
assert travel_spreadsheet_rule is not None
travel_spreadsheet_rule.status = AgentAssetStatus.REVIEW.value
db.commit()
catalog = ExpenseRuleRuntimeService(db).load_catalog()
assert catalog.travel_policy is not None
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则"
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
with build_session() as db:
db.add(
Employee(
employee_no="E9001",
name="测试员工",
email="traveler@example.com",
position="产品经理",
grade="P4",
)
)
db.commit()
result = TravelReimbursementCalculatorService(db).calculate(
TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"),
CurrentUserContext(
username="traveler@example.com",
name="测试员工",
role_codes=[],
is_admin=False,
),
)
assert result.rule_name == "公司差旅费报销规则"
assert result.grade == "P4"
assert result.grade_band == "mid"
assert result.matched_city == "北京"
assert result.hotel_rate == 450
assert result.hotel_amount == 1350
assert result.allowance_region == "直辖市/特区"
assert result.total_allowance_rate == 100
assert result.allowance_amount == 300
assert result.total_amount == 1650
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text
assert "参考可报销总金额为 1650.00 元" in result.summary_text
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
with build_session() as db:
db.add(
Employee(
employee_no="E9002",
name="其他地区员工",
email="other-region@example.com",
position="产品经理",
grade="P4",
)
)
db.commit()
result = TravelReimbursementCalculatorService(db).calculate(
TravelReimbursementCalculatorRequest(days=2, location="吉林延边"),
CurrentUserContext(
username="other-region@example.com",
name="其他地区员工",
role_codes=[],
is_admin=False,
),
)
assert result.matched_city == "延边(其他地区)"
assert result.city_tier == "tier_3"
assert result.hotel_rate == 380
assert result.hotel_amount == 760
assert result.allowance_region == "其他地区"
assert result.total_allowance_rate == 90
assert result.allowance_amount == 180
assert result.total_amount == 940
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:
with build_session() as db:
db.add(
Employee(
employee_no="E9003",
name="无效地点员工",
email="invalid-location@example.com",
position="产品经理",
grade="P4",
)
)
db.commit()
with pytest.raises(ValueError, match="未识别为有效出差地区"):
TravelReimbursementCalculatorService(db).calculate(
TravelReimbursementCalculatorRequest(days=2, location="背景"),
CurrentUserContext(
username="invalid-location@example.com",
name="无效地点员工",
role_codes=[],
is_admin=False,
),
)
def test_agent_run_service_lists_seeded_trace_data() -> None: def test_agent_run_service_lists_seeded_trace_data() -> None:
with build_session() as db: with build_session() as db:
service = AgentRunService(db) service = AgentRunService(db)

View File

@@ -51,6 +51,57 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields) assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
def test_document_intelligence_extracts_hotel_total_fee_instead_of_date_year() -> None:
insight = build_document_insight(
filename="hotel-invoice.png",
summary="酒店住宿票据",
text="北京中心酒店 金额 2026-02-20 入住 总费用是828元 离店日期 2026-02-21",
)
assert insight.document_type == "hotel_invoice"
assert any(field.label == "金额" and field.value == "828元" for field in insight.fields)
assert not any(field.label == "金额" and field.value == "2026元" for field in insight.fields)
def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None:
insight = build_document_insight(
filename="铁路电子客票.pdf",
summary="电子发票(铁路电子客票)",
text=(
"电子发票(铁路电子客票)\n"
"发票号码:26319166100006175398\n"
"上海虹桥站\n"
"武汉站\n"
"G456\n"
"二等座\n"
"票价:¥354.00"
),
)
assert insight.document_type == "train_ticket"
assert insight.document_type_label == "火车/高铁票"
assert insight.scene_code == "travel"
assert any(field.label == "金额" and field.value == "354元" for field in insight.fields)
def test_document_intelligence_labels_train_ticket_date_as_train_departure_time() -> None:
insight = build_document_insight(
filename="铁路电子客票.pdf",
summary="铁路电子客票",
text=(
"中国铁路电子客票 开票日期 2026-02-18 "
"G456 上海虹桥-武汉 2026-02-20 08:30开 票价:¥354.00"
),
)
assert insight.document_type == "train_ticket"
assert any(
field.key == "date" and field.label == "列车出发时间" and field.value == "2026-02-20 08:30"
for field in insight.fields
)
assert not any(field.label == "开票日期" for field in insight.fields)
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None: def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
engine = create_engine( engine = create_engine(
"sqlite+pysqlite:///:memory:", "sqlite+pysqlite:///:memory:",

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
import pytest import pytest
from sqlalchemy import create_engine, func, select from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -135,6 +137,120 @@ def test_enable_employee_restores_status_and_logs_change() -> None:
assert any(item.action == "启用员工账号" for item in updated.history) assert any(item.action == "启用员工账号" for item in updated.history)
def test_profile_repairs_do_not_run_on_every_list() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
updated = service.update_employee(
employee.id,
EmployeeUpdate(position="测试岗位-不会被回滚"),
)
listed = next(item for item in service.list_employees() if item.id == employee.id)
assert updated.position == "测试岗位-不会被回滚"
assert listed.position == "测试岗位-不会被回滚"
def test_role_update_appends_recent_history() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
current_codes = list(employee.roleCodes)
next_codes = ["finance", "user"] if "finance" not in current_codes else ["user"]
updated = service.update_employee(employee.id, EmployeeUpdate(role_codes=next_codes))
assert any("更新系统角色" in item.action for item in updated.history)
def test_employee_change_logs_keep_only_latest_five() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
persisted = db.get(Employee, employee.id)
assert persisted is not None
for index in range(7):
service._append_change_log(
persisted,
action=f"测试变更-{index}",
owner="单元测试",
)
db.commit()
service._trim_employee_change_logs(persisted.id)
db.commit()
hydrated = db.get(Employee, employee.id)
assert hydrated is not None
assert len(hydrated.change_logs) == 5
assert hydrated.change_logs[0].action == "测试变更-6"
def test_employee_meta_includes_organization_options() -> None:
with build_session() as db:
service = EmployeeService(db)
meta = service.get_employee_meta()
assert meta.organizationOptions
assert all(item.code and item.name for item in meta.organizationOptions)
def test_update_employee_changes_organization() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
organizations = service.repository.list_organization_units()
current_code = employee.organization.code if employee.organization else None
target = next(unit for unit in organizations if unit.unit_code != current_code)
updated = service.update_employee(
employee.id,
EmployeeUpdate(organization_unit_code=target.unit_code),
)
assert updated.organization is not None
assert updated.organization.code == target.unit_code
assert updated.department == target.name
assert any("更新员工信息" in item.action for item in updated.history)
def test_update_employee_rejects_unknown_organization() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
with pytest.raises(ValueError, match="部门编码"):
service.update_employee(
employee.id,
EmployeeUpdate(organization_unit_code="ORG-NOT-EXISTS"),
)
def test_update_employee_changes_manager() -> None:
with build_session() as db:
service = EmployeeService(db)
employees = service.list_employees()
employee = employees[0]
manager = next(item for item in employees if item.id != employee.id)
updated = service.update_employee(
employee.id,
EmployeeUpdate(manager_employee_no=manager.employeeNo),
)
assert updated.managerEmployeeNo == manager.employeeNo
assert updated.manager == manager.name
def test_format_history_datetime_uses_local_timezone_without_seconds() -> None:
value = datetime(2026, 5, 20, 6, 30, 45, tzinfo=UTC)
formatted = EmployeeService._format_history_datetime(value)
assert formatted == "2026年5月20日14时30分"
assert "" not in formatted
def test_update_employee_rejects_invalid_date_format() -> None: def test_update_employee_rejects_invalid_date_format() -> None:
with build_session() as db: with build_session() as db:
service = EmployeeService(db) service = EmployeeService(db)

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from io import BytesIO
from openpyxl import Workbook
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.services.employee import EmployeeService
from app.services.employee_spreadsheet import EMPLOYEE_HEADERS, EMPLOYEE_SHEET_NAME
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def build_workbook_bytes(rows: list[list[object]]) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = EMPLOYEE_SHEET_NAME
sheet.append(list(EMPLOYEE_HEADERS))
for row in rows:
sheet.append(row)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def test_import_employees_rejects_invalid_row_without_writing() -> None:
with build_session() as db:
service = EmployeeService(db)
first = service.list_employees()[0]
content = build_workbook_bytes(
[
[
first.employeeNo,
"",
first.email,
"",
"",
"",
"",
"",
first.position,
first.grade,
"",
"",
"",
"",
"在职",
"user",
]
]
)
result = service.import_employees(content)
assert result.success is False
assert result.summary.errorCount >= 1
assert any("姓名" in item.message for item in result.errors)
refreshed = service.get_employee(first.id)
assert refreshed is not None
assert refreshed.name == first.name
def test_import_employees_updates_existing_employee() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
new_name = f"{employee.name}-导入"
content = build_workbook_bytes(
[
[
employee.employeeNo,
new_name,
employee.email,
"",
"",
"13900000001",
"",
"上海",
employee.position,
employee.grade,
"FIN-SSC",
"",
"华东财务组",
"CC-TEST",
"在职",
"user",
]
]
)
result = service.import_employees(content, actor="测试管理员")
assert result.success is True
assert result.summary.updated == 1
updated = service.get_employee(employee.id)
assert updated is not None
assert updated.name == new_name
assert updated.phone == "13900000001"
def test_import_employees_creates_new_employee() -> None:
with build_session() as db:
service = EmployeeService(db)
service.list_employees()
content = build_workbook_bytes(
[
[
"E90001",
"导入新员工",
"import.new.user@xfinance.com",
"",
"",
"13811112222",
"2025-01-01",
"上海",
"业务专员",
"P3",
"FIN-SSC",
"E10234",
"华东财务组",
"CC-9001",
"在职",
"user",
]
]
)
result = service.import_employees(content)
assert result.success is True
assert result.summary.created == 1
imported = db.execute(
select(Employee).where(Employee.employee_no == "E90001")
).scalar_one()
assert imported.name == "导入新员工"
assert imported.email == "import.new.user@xfinance.com"

File diff suppressed because it is too large Load Diff

View File

@@ -177,3 +177,80 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
assert any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields) assert any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields)
assert recognized.lines[0].page_index == 0 assert recognized.lines[0].page_index == 0
assert recognized.lines[1].page_index == 1 assert recognized.lines[1].page_index == 1
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
monkeypatch,
tmp_path: Path,
) -> None:
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
page = output_dir / "page-1.png"
page.write_bytes(b"fake-page")
return [page]
def fake_invoke_worker(
self,
*,
python_bin: str,
worker_path: str,
input_paths: list[Path],
) -> dict:
return {
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"documents": [
{
"input_path": str(input_paths[0]),
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"text": "□□□□□□\n□□□□26319166100006175398\nG456\n□□:□354.00",
"summary": "□□□□□□□□□□26319166100006175398",
"avg_score": 0.88,
"line_count": 4,
"page_count": 1,
"warnings": [],
"lines": [
{
"text": "□□□□□□",
"score": 0.88,
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
}
],
}
],
}
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
monkeypatch.setattr(
OcrService,
"_extract_pdf_text_layer",
lambda self, pdf_path: (
"电子发票(铁路电子客票)\n"
"发票号码:26319166100006175398\n"
"上海虹桥站\n"
"武汉站\n"
"G456\n"
"票价:¥354.00"
),
)
get_settings.cache_clear()
try:
result = OcrService().recognize_files(
[
("train-ticket.pdf", b"%PDF-1.4 fake", "application/pdf"),
]
)
finally:
get_settings.cache_clear()
recognized = result.documents[0]
assert "电子发票(铁路电子客票)" in recognized.text
assert "上海虹桥站" in recognized.text
assert "□□□□" not in recognized.summary
assert recognized.document_type == "train_ticket"
assert recognized.preview_kind == ""
assert recognized.preview_data_url == ""

View File

@@ -0,0 +1,78 @@
import pytest
from unittest.mock import MagicMock, patch
from io import BytesIO
from openpyxl import Workbook
from app.services.agent_assets import AgentAssetService
from app.schemas.agent_asset import AgentAssetOnlyOfficeCallbackWrite
def test_onlyoffice_callback_generates_summary_note():
# Setup mock DB and repository
db = MagicMock()
service = AgentAssetService(db)
service.repository = MagicMock()
service.spreadsheet_manager = MagicMock()
service._ensure_ready = MagicMock()
# Mock asset and metadata
asset = MagicMock()
asset.id = "test-asset"
asset.name = "测试规则"
service._require_spreadsheet_rule = MagicMock(return_value=asset)
service._resolve_working_version = MagicMock(return_value="v1")
base_meta = MagicMock()
base_meta.file_name = "test.xlsx"
base_meta.storage_key = "old-key"
base_meta.checksum = "old-checksum"
service._resolve_spreadsheet_version_meta = MagicMock(return_value=("v1", base_meta))
# Create base workbook
base_wb = Workbook()
base_ws = base_wb.active
base_ws["A1"] = "old value"
# Mock loading base workbook
service._load_spreadsheet_for_compare = MagicMock(return_value=base_wb)
service.spreadsheet_manager.resolve_storage_path = MagicMock()
# Create new content (modified)
new_wb = Workbook()
new_ws = new_wb.active
new_ws["A1"] = "new value" # 1 cell changed
new_ws["B2"] = "added" # 1 more cell changed
# Mock URL open to return new content
new_content_bio = BytesIO()
new_wb.save(new_content_bio)
new_content = new_content_bio.getvalue()
with patch("app.services.agent_assets.urlopen") as mock_urlopen:
mock_response = MagicMock()
mock_response.read.return_value = new_content
mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response
# Mock upload_rule_spreadsheet
service.upload_rule_spreadsheet = MagicMock()
# Execute callback handler
payload = {
"status": 2,
"url": "http://onlyoffice/download",
"users": ["test_user"]
}
service.handle_rule_spreadsheet_onlyoffice_callback(
"test-asset",
version="v1",
payload=payload
)
# Verify upload_rule_spreadsheet was called with correct change_note
service.upload_rule_spreadsheet.assert_called_once()
call_args = service.upload_rule_spreadsheet.call_args[1]
assert "涉及 1 个 Sheet共 2 处改动" in call_args["change_note"]
assert call_args["actor"] == "test_user"
if __name__ == "__main__":
pytest.main([__file__])

View File

@@ -310,7 +310,9 @@ def test_semantic_ontology_service_keeps_travel_amount_follow_up_in_knowledge_qu
assert result.clarification_required is False assert result.clarification_required is False
def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(monkeypatch) -> None: def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(
monkeypatch,
) -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
service = SemanticOntologyService(db) service = SemanticOntologyService(db)
@@ -348,6 +350,28 @@ def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session
assert result.clarification_question is None assert result.clarification_question is None
def test_review_next_step_context_inherits_expense_draft_flow() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我已核对右侧识别结果,请进入下一步。",
user_id="pytest",
context_json={
"review_action": "next_step",
"draft_claim_id": "claim-1",
"attachment_count": 1,
},
)
)
assert result.scenario == "expense"
assert result.intent == "draft"
assert result.permission.level == "draft_write"
assert result.clarification_required is False
assert result.clarification_question is None
def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None: def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
@@ -409,6 +433,71 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc
assert result.time_range.end_date == "2026-05-11" assert result.time_range.end_date == "2026-05-11"
def test_semantic_ontology_service_treats_status_document_text_as_query() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="查询草稿的单据",
user_id="pytest",
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.permission.level == "read"
assert any(
item.field == "status" and item.value == "draft"
for item in result.constraints
)
def test_semantic_ontology_service_extracts_history_query_time_and_location() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我去年去北京报销的单据",
user_id="pytest",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.time_range.raw == "去年"
assert result.time_range.start_date == "2025-01-01"
assert result.time_range.end_date == "2025-12-31"
assert any(
item.type == "location" and item.normalized_value == "北京"
for item in result.entities
)
def test_semantic_ontology_service_understands_last_week_claim_progress_query() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="我上周提交的单据报销了么?",
user_id="pytest",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
assert result.scenario == "expense"
assert result.intent == "query"
assert result.time_range.raw == "上周"
assert result.time_range.start_date == "2026-05-11"
assert result.time_range.end_date == "2026-05-17"
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None: def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
@@ -427,6 +516,24 @@ def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type()
) )
def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="业务发生时间:2026-03-04送客户去林萃小区办事请报销乘车费用",
user_id="pytest",
)
)
assert result.scenario == "expense"
assert result.intent == "draft"
assert any(
item.type == "expense_type" and item.normalized_value == "transport"
for item in result.entities
)
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None: def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

View File

@@ -0,0 +1,399 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.orchestrator import OrchestratorRequest
from app.services.agent_conversations import AgentConversationService
from app.services.orchestrator import OrchestratorService
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
manager = Employee(
employee_no="E9000",
name="李经理",
email="manager-next@example.com",
)
employee = Employee(
employee_no="E9001",
name="张三",
email="emp-next@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-next-step",
claim_no="EXP-202605-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="销售部",
expense_type="office",
reason="采购办公用品",
location="上海",
amount=Decimal("128.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
status="draft",
approval_stage="待提交",
items=[
ExpenseClaimItem(
item_date=date(2026, 5, 20),
item_type="office",
item_reason="采购办公用品",
item_location="上海",
item_amount=Decimal("128.00"),
invoice_id="office-invoice.png",
)
],
)
db.add_all([manager, employee, claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="emp-next@example.com",
message="我已核对右侧识别结果,请进入下一步。",
context_json={
"review_action": "next_step",
"draft_claim_id": claim.id,
"attachment_count": 1,
"name": "张三",
},
)
)
db.refresh(claim)
assert response.status == "succeeded"
assert response.requires_confirmation is False
assert response.result["draft_payload"]["status"] == "submitted"
assert response.result["draft_payload"]["approval_stage"] == "直属领导审批"
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert claim.submitted_at is not None
assert response.conversation_id
assert AgentConversationService(db).get_conversation(response.conversation_id) is None
def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
employee_no="E9011",
name="张三",
email="emp-blocked@example.com",
)
claim = ExpenseClaim(
id="claim-next-step-blocked",
claim_no="EXP-202605-002",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="待补充",
expense_type="office",
reason="采购办公用品",
location="上海",
amount=Decimal("128.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
status="draft",
approval_stage="待提交",
items=[
ExpenseClaimItem(
item_date=date(2026, 5, 20),
item_type="office",
item_reason="采购办公用品",
item_location="上海",
item_amount=Decimal("128.00"),
invoice_id="office-invoice.png",
)
],
)
db.add_all([employee, claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="emp-blocked@example.com",
message="我已核对右侧识别结果,请进入下一步。",
context_json={
"review_action": "next_step",
"draft_claim_id": claim.id,
"attachment_count": 1,
"name": "张三",
},
)
)
result = response.result
review_payload = result["review_payload"]
actions = {
str(item.get("action_type") or "").strip()
for item in review_payload["confirmation_actions"]
}
assert response.status == "succeeded"
assert result["draft_payload"]["status"] == "draft"
assert response.conversation_id
assert AgentConversationService(db).get_conversation(response.conversation_id) is not None
assert "AI预审暂未通过" in result["answer"]
assert "所属部门未完善" in result["answer"]
assert "next_step" not in actions
assert "save_draft" in actions
assert any(
"所属部门未完善" in str(item.get("content") or "")
for item in review_payload["risk_briefs"]
)
def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_prompt() -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = AgentConversationService(db)
conversation = service.get_or_create_conversation(
conversation_id="conv-review-type-lock",
user_id="emp-review-type@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
"attachment_names": ["old-train-ticket.pdf"],
"attachment_count": 1,
"review_form_values": {
"expense_type": "差旅费",
"business_location": "北京",
},
},
)
fresh_context = service.hydrate_context_json(
conversation=conversation,
context_json={"draft_claim_id": "claim-old"},
message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销",
)
continued_context = service.hydrate_context_json(
conversation=conversation,
context_json={},
message="继续补充酒店发票",
)
assert "draft_claim_id" not in fresh_context
assert "attachment_names" not in fresh_context
assert "review_form_values" not in fresh_context
assert fresh_context["conversation_state"]["review_form_values"]["expense_type"] == "差旅费"
assert continued_context["draft_claim_id"] == "claim-old"
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
def test_orchestrator_history_query_filters_location_time_and_returns_real_amount(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
id="emp-history-query",
employee_no="E9020",
name="张三",
email="history-query@example.com",
)
beijing_claim = ExpenseClaim(
id="claim-history-beijing",
claim_no="EXP-202506-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去北京支持客户项目",
location="北京",
amount=Decimal("321.45"),
currency="CNY",
invoice_count=2,
occurred_at=datetime(2025, 6, 18, 9, 0, tzinfo=UTC),
submitted_at=datetime(2025, 6, 19, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
shanghai_claim = ExpenseClaim(
id="claim-history-shanghai",
claim_no="EXP-202507-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去上海支持项目",
location="上海",
amount=Decimal("888.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2025, 7, 8, 9, 0, tzinfo=UTC),
submitted_at=datetime(2025, 7, 9, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
current_year_claim = ExpenseClaim(
id="claim-history-beijing-current",
claim_no="EXP-202601-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="去北京支持年度项目",
location="北京",
amount=Decimal("666.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 1, 8, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 1, 9, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="已入账",
)
db.add_all([employee, beijing_claim, shanghai_claim, current_year_claim])
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="history-query@example.com",
message="我去年去北京报销的单据",
context_json={
"client_now_iso": "2026-05-21T04:00:00.000Z",
"client_timezone_offset_minutes": -480,
},
)
)
query_payload = response.result["query_payload"]
assert response.status == "succeeded"
assert response.trace_summary.scenario == "expense"
assert response.trace_summary.intent == "query"
assert query_payload["record_count"] == 1
assert query_payload["total_amount"] == 321.45
assert [item["claim_no"] for item in query_payload["records"]] == ["EXP-202506-001"]
assert "321.45" in response.result["answer"]
def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
employee_no="E9030",
name="预览员工",
email="preview-orchestrator@example.com",
)
db.add(employee)
db.commit()
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="preview-orchestrator@example.com",
message="业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报",
context_json={
"name": "预览员工",
"user_input_text": "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报",
},
)
)
user_claims = [
claim
for claim in db.query(ExpenseClaim).all()
if claim.employee_name == "预览员工"
]
assert response.status == "succeeded"
assert response.result.get("review_payload") is not None
assert response.result.get("draft_payload") is None
assert "交通费通常以实际票据金额为基础" in response.result["answer"]
assert user_claims == []
def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_expense(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
service = AgentConversationService(db)
conversation = service.get_or_create_conversation(
conversation_id="conv-scene-choice",
user_id="emp-scene-choice@example.com",
source="user_message",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
"review_form_values": {
"expense_type": "差旅费",
"business_location": "北京",
},
},
)
response = OrchestratorService(db).run(
OrchestratorRequest(
source="user_message",
user_id="emp-scene-choice@example.com",
conversation_id=conversation.conversation_id,
message="业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销",
context_json={
"session_type": "expense",
"draft_claim_id": "claim-old",
},
)
)
result = response.result
assert response.status == "succeeded"
assert result.get("review_payload") is None
assert result.get("draft_payload") is None
assert "请先在下面选择报销场景" in result["answer"]
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]

View File

@@ -158,6 +158,11 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
assert upload_payload["attachment"]["document_info"]["document_type"] == "office_invoice" assert upload_payload["attachment"]["document_info"]["document_type"] == "office_invoice"
assert upload_payload["attachment"]["requirement_check"]["matches"] is True assert upload_payload["attachment"]["requirement_check"]["matches"] is True
assert upload_payload["invoice_id"] assert upload_payload["invoice_id"]
assert upload_payload["item_type"] == "office"
assert upload_payload["item_reason"] == "识别到办公用品发票,金额 88 元。"
assert upload_payload["item_location"] == "深圳南山"
assert upload_payload["item_date"] == "2026-05-13"
assert upload_payload["item_amount"] == "88.00"
meta_response = client.get( meta_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta", f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",
@@ -289,6 +294,75 @@ def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monke
assert any("附件内容" in point for point in analysis["points"]) assert any("附件内容" in point for point in analysis["points"])
def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() -> None:
client, session_factory = build_client()
with session_factory() as db:
manager = Employee(
id="mgr-approve-1",
employee_no="E21001",
name="李经理",
email="manager-approve-api@example.com",
)
employee = Employee(
id="emp-approve-1",
employee_no="E11001",
name="张三",
email="zhangsan-approve-api@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-approve-1",
claim_no="EXP-APP-API-001",
employee_id=employee.id,
employee_name="张三",
department_id="dept-1",
department_name="市场部",
project_code=None,
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
submitted_at=datetime(2026, 5, 13, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add_all([manager, employee, claim])
db.commit()
response = client.post(
"/api/v1/reimbursements/claims/claim-approve-1/approve",
json={"opinion": "情况属实,同意报销。"},
headers={
"X-Auth-Username": "manager-approve-api@example.com",
"X-Auth-Name": "manager-approve-api@example.com",
"X-Auth-Role-Codes": "manager",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "submitted"
assert payload["approval_stage"] == "财务审批"
assert any(
item["source"] == "manual_approval"
and item["opinion"] == "情况属实,同意报销。"
and item["operator"] == "李经理"
and item["next_approval_stage"] == "财务审批"
for item in payload["risk_flags_json"]
)
approval_events = [
item
for item in payload["risk_flags_json"]
if item["source"] == "manual_approval"
]
assert approval_events[0]["operator"] == "李经理"
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None: def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
preview_bytes = b"fake-preview-png" preview_bytes = b"fake-preview-png"
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}" preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"

File diff suppressed because it is too large Load Diff

BIN
web/UI/流程输入.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai/1.501/lxgw-wenkai.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai/1.501/lxgw-wenkai.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
<title>ReimburseOps - 企业报销智能运营台</title> <title>ReimburseOps - 企业报销智能运营台</title>

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f766e"/>
<path fill="#ffffff" d="M36 10c10 2 17 10 18 20-5-2-10-1-14 1-5 3-8 8-9 15-8-5-12-12-11-20 0-7 6-14 16-16Z"/>
<path fill="#d1fae5" d="M18 15c-6 6-8 13-6 20 2 8 9 13 19 15-4 3-9 5-15 4-7-5-10-12-9-19 0-8 4-15 11-20Z"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@@ -103,3 +103,39 @@ h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; fon
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; } *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
} }
.table-loading__spinner {
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
border: 3px solid #e2e8f0;
border-top-color: #10b981;
border-radius: 50%;
animation: table-spinner-rotate .8s linear infinite !important;
}
.table-loading.sky .table-loading__spinner {
border-top-color: #0ea5e9;
}
.table-loading.detail .table-loading__spinner {
width: 34px;
height: 34px;
}
.table-loading.banner .table-loading__spinner {
width: 18px;
height: 18px;
border-width: 2px;
}
.table-loading__spinner i {
display: none;
}
@keyframes table-spinner-rotate {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,336 @@
.opinion-wrap textarea {
width: 100%;
min-height: 100px;
resize: none;
border: 1px solid #d7e0ea;
border-radius: 4px;
padding: 10px 12px;
color: #0f172a;
font-size: 13px;
line-height: 1.55;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.opinion-wrap textarea::placeholder {
color: #94a3b8;
}
.opinion-wrap textarea:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
outline: none;
}
/* ── Side Cards ── */
.side-card {
border-radius: 4px;
background: #fff;
border: 1px solid #edf2f7;
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
overflow: hidden;
transition: box-shadow 200ms ease;
}
.side-card:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .06); }
.side-card .card-header {
padding: 12px 16px;
}
.side-card.compact {
border-radius: 4px;
}
/* ── Risk Card ── */
.risk-total {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 5px 12px;
border-radius: 3px;
font-size: 11px;
}
.risk-total span { font-weight: 750; }
.risk-total.high {
background: #fee2e2;
color: #dc2626;
}
.risk-total.high strong { font-size: 16px; font-weight: 900; }
.risk-items {
padding: 4px 14px 14px;
display: grid;
gap: 8px;
}
.risk-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid #f1f5f9;
transition: all 160ms ease;
}
.risk-row:hover { border-color: #e2e8f0; background: #fafbfd; }
.risk-icon {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 4px;
font-size: 16px;
flex-shrink: 0;
}
.risk-row.high .risk-icon { background: #fef2f2; color: #ef4444; }
.risk-row.medium .risk-icon { background: #fff7ed; color: #f97316; }
.risk-text {
flex: 1;
color: #334155;
font-size: 13px;
line-height: 1.4;
}
.risk-level {
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 800;
flex-shrink: 0;
}
.risk-level.high { background: #fef2f2; color: #ef4444; }
.risk-level.medium { background: #fff7ed; color: #f97316; }
/* ── Side Dual ── */
.side-dual {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.reminder-list {
margin: 0;
padding: 10px 16px 14px;
display: grid;
gap: 8px;
list-style: none;
}
.reminder-list li {
display: flex;
align-items: flex-start;
gap: 8px;
color: #334155;
font-size: 12px;
line-height: 1.5;
}
.reminder-list li i {
margin-top: 2px;
color: #f59e0b;
font-size: 14px;
flex-shrink: 0;
}
.info-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 14px;
margin: 0;
padding: 10px 16px 14px;
font-size: 13px;
}
.info-list dt { color: #94a3b8; font-weight: 700; }
.info-list dd { margin: 0; color: #0f172a; font-weight: 850; }
/* ── Modal Footer ── */
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 28px;
background: #fff;
border-top: 1px solid #e8eef6;
}
.footer-right {
display: flex;
gap: 10px;
}
.action-btn {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 20px;
border-radius: 4px;
font-size: 14px;
font-weight: 800;
transition: all 180ms ease;
border: 1px solid transparent;
}
.action-btn.back {
background: #f8fafc;
border-color: #e2e8f0;
color: #475569;
}
.action-btn.back:hover {
background: #f1f5f9;
border-color: #cbd5e1;
color: #0f172a;
}
.action-btn.supplement {
background: #fff;
border-color: #fed7aa;
color: #ea580c;
}
.action-btn.supplement:hover {
background: #fff7ed;
box-shadow: 0 4px 12px rgba(234, 88, 12, .12);
}
.action-btn.reject {
background: #fff;
border-color: #fecaca;
color: #ef4444;
}
.action-btn.reject:hover {
background: #fef2f2;
box-shadow: 0 4px 12px rgba(239, 68, 68, .12);
}
.action-btn.approve {
background: #059669;
color: #fff;
box-shadow: 0 4px 16px rgba(5, 150, 105, .25);
}
.action-btn.approve:hover {
background: #047857;
box-shadow: 0 8px 24px rgba(5, 150, 105, .30);
}
.action-btn:active { transform: scale(.97); }
/* ── Modal Transitions ── */
.detail-modal-enter-active { transition: opacity 260ms ease; }
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
.detail-modal-leave-active { transition: opacity 200ms ease; }
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
.detail-modal-enter-from { opacity: 0; }
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
.detail-modal-leave-to { opacity: 0; }
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
/* ── Responsive ── */
@media (max-width: 1320px) {
.list-toolbar, .list-foot { grid-template-columns: 1fr; }
.detail-hero {
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(0, 1fr));
}
.hero-summary-panel {
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
}
.detail-expense-table {
overflow-x: auto;
}
.detail-expense-table table {
min-width: 980px;
}
.detail-modal {
width: calc(100vw - 40px);
height: calc(100vh - 40px);
}
.body-grid { grid-template-columns: 1fr; }
.metrics-strip { grid-template-columns: repeat(2, 1fr); }
.side-dual { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.approval-list { padding: 16px; }
.status-tabs { gap: 18px; overflow-x: auto; }
.filter-set { width: 100%; }
.filter-btn, .page-size { width: 100%; }
.list-foot { justify-items: stretch; }
.pager, .page-size { justify-self: stretch; }
.detail-hero {
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
}
.applicant-card,
.hero-summary-panel,
.progress-line {
grid-column: 1 / -1;
}
.hero-summary-panel {
grid-template-columns: 1fr;
}
.hero-summary-item {
padding: 14px 0;
}
.detail-card {
padding: 14px 16px;
}
.detail-card-head {
flex-direction: column;
align-items: stretch;
}
.detail-total {
align-self: flex-start;
}
.detail-expense-table table {
min-width: 980px;
}
.detail-modal {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
}
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
.header-right { width: 100%; justify-content: flex-end; }
.metrics-strip { grid-template-columns: 1fr 1fr; }
.summary-grid { grid-template-columns: 1fr; }
.progress-track { overflow-x: auto; padding-bottom: 8px; }
.node-label strong { font-size: 11px; }
.modal-footer { flex-direction: column; padding: 14px 18px; }
.footer-right { width: 100%; }
.action-btn { flex: 1; }
}

View File

@@ -1477,339 +1477,3 @@ tbody tr:last-child td { border-bottom: 0; }
padding: 12px 14px; padding: 12px 14px;
} }
.opinion-wrap textarea {
width: 100%;
min-height: 100px;
resize: none;
border: 1px solid #d7e0ea;
border-radius: 4px;
padding: 10px 12px;
color: #0f172a;
font-size: 13px;
line-height: 1.55;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.opinion-wrap textarea::placeholder {
color: #94a3b8;
}
.opinion-wrap textarea:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
outline: none;
}
/* ── Side Cards ── */
.side-card {
border-radius: 4px;
background: #fff;
border: 1px solid #edf2f7;
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
overflow: hidden;
transition: box-shadow 200ms ease;
}
.side-card:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .06); }
.side-card .card-header {
padding: 12px 16px;
}
.side-card.compact {
border-radius: 4px;
}
/* ── Risk Card ── */
.risk-total {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 5px 12px;
border-radius: 3px;
font-size: 11px;
}
.risk-total span { font-weight: 750; }
.risk-total.high {
background: #fee2e2;
color: #dc2626;
}
.risk-total.high strong { font-size: 16px; font-weight: 900; }
.risk-items {
padding: 4px 14px 14px;
display: grid;
gap: 8px;
}
.risk-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid #f1f5f9;
transition: all 160ms ease;
}
.risk-row:hover { border-color: #e2e8f0; background: #fafbfd; }
.risk-icon {
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 4px;
font-size: 16px;
flex-shrink: 0;
}
.risk-row.high .risk-icon { background: #fef2f2; color: #ef4444; }
.risk-row.medium .risk-icon { background: #fff7ed; color: #f97316; }
.risk-text {
flex: 1;
color: #334155;
font-size: 13px;
line-height: 1.4;
}
.risk-level {
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 800;
flex-shrink: 0;
}
.risk-level.high { background: #fef2f2; color: #ef4444; }
.risk-level.medium { background: #fff7ed; color: #f97316; }
/* ── Side Dual ── */
.side-dual {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.reminder-list {
margin: 0;
padding: 10px 16px 14px;
display: grid;
gap: 8px;
list-style: none;
}
.reminder-list li {
display: flex;
align-items: flex-start;
gap: 8px;
color: #334155;
font-size: 12px;
line-height: 1.5;
}
.reminder-list li i {
margin-top: 2px;
color: #f59e0b;
font-size: 14px;
flex-shrink: 0;
}
.info-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 14px;
margin: 0;
padding: 10px 16px 14px;
font-size: 13px;
}
.info-list dt { color: #94a3b8; font-weight: 700; }
.info-list dd { margin: 0; color: #0f172a; font-weight: 850; }
/* ── Modal Footer ── */
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 28px;
background: #fff;
border-top: 1px solid #e8eef6;
}
.footer-right {
display: flex;
gap: 10px;
}
.action-btn {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 20px;
border-radius: 4px;
font-size: 14px;
font-weight: 800;
transition: all 180ms ease;
border: 1px solid transparent;
}
.action-btn.back {
background: #f8fafc;
border-color: #e2e8f0;
color: #475569;
}
.action-btn.back:hover {
background: #f1f5f9;
border-color: #cbd5e1;
color: #0f172a;
}
.action-btn.supplement {
background: #fff;
border-color: #fed7aa;
color: #ea580c;
}
.action-btn.supplement:hover {
background: #fff7ed;
box-shadow: 0 4px 12px rgba(234, 88, 12, .12);
}
.action-btn.reject {
background: #fff;
border-color: #fecaca;
color: #ef4444;
}
.action-btn.reject:hover {
background: #fef2f2;
box-shadow: 0 4px 12px rgba(239, 68, 68, .12);
}
.action-btn.approve {
background: #059669;
color: #fff;
box-shadow: 0 4px 16px rgba(5, 150, 105, .25);
}
.action-btn.approve:hover {
background: #047857;
box-shadow: 0 8px 24px rgba(5, 150, 105, .30);
}
.action-btn:active { transform: scale(.97); }
/* ── Modal Transitions ── */
.detail-modal-enter-active { transition: opacity 260ms ease; }
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
.detail-modal-leave-active { transition: opacity 200ms ease; }
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
.detail-modal-enter-from { opacity: 0; }
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
.detail-modal-leave-to { opacity: 0; }
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
/* ── Responsive ── */
@media (max-width: 1320px) {
.list-toolbar, .list-foot { grid-template-columns: 1fr; }
.detail-hero {
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(0, 1fr));
}
.hero-summary-panel {
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
}
.detail-expense-table {
overflow-x: auto;
}
.detail-expense-table table {
min-width: 980px;
}
.detail-modal {
width: calc(100vw - 40px);
height: calc(100vh - 40px);
}
.body-grid { grid-template-columns: 1fr; }
.metrics-strip { grid-template-columns: repeat(2, 1fr); }
.side-dual { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.approval-list { padding: 16px; }
.status-tabs { gap: 18px; overflow-x: auto; }
.filter-set { width: 100%; }
.filter-btn, .page-size { width: 100%; }
.list-foot { justify-items: stretch; }
.pager, .page-size { justify-self: stretch; }
.detail-hero {
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
}
.applicant-card,
.hero-summary-panel,
.progress-line {
grid-column: 1 / -1;
}
.hero-summary-panel {
grid-template-columns: 1fr;
}
.hero-summary-item {
padding: 14px 0;
}
.detail-card {
padding: 14px 16px;
}
.detail-card-head {
flex-direction: column;
align-items: stretch;
}
.detail-total {
align-self: flex-start;
}
.detail-expense-table table {
min-width: 980px;
}
.detail-modal {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
}
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
.header-right { width: 100%; justify-content: flex-end; }
.metrics-strip { grid-template-columns: 1fr 1fr; }
.summary-grid { grid-template-columns: 1fr; }
.progress-track { overflow-x: auto; padding-bottom: 8px; }
.node-label strong { font-size: 11px; }
.modal-footer { flex-direction: column; padding: 14px 18px; }
.footer-right { width: 100%; }
.action-btn { flex: 1; }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -142,6 +142,8 @@
.picker-trigger, .picker-trigger,
.ghost-filter-btn, .ghost-filter-btn,
.template-btn,
.export-btn,
.create-btn, .create-btn,
.row-action { .row-action {
min-height: 38px; min-height: 38px;
@@ -282,6 +284,24 @@
color: #047857; color: #047857;
} }
.template-btn,
.export-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 14px;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.template-btn:hover,
.export-btn:hover {
border-color: rgba(16, 185, 129, 0.34);
background: #f6fffb;
color: #0f9f78;
}
.create-btn { .create-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -293,6 +313,50 @@
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18); box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
} }
.create-btn:disabled,
.template-btn:disabled,
.export-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.import-file-input {
display: none;
}
.import-error-table-wrap {
max-height: 280px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.import-error-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.import-error-table th,
.import-error-table td {
padding: 10px 12px;
border-bottom: 1px solid #eef2f7;
text-align: left;
vertical-align: top;
}
.import-error-table th {
position: sticky;
top: 0;
background: #f8fafc;
color: #475569;
font-weight: 700;
}
.import-error-table td:last-child {
color: #b45309;
}
.hint { .hint {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -333,13 +397,14 @@
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%); background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: stretch;
justify-content: center; justify-content: flex-start;
} }
.table-wrap table { .table-wrap table {
width: 100%; width: 100%;
align-self: flex-start; flex: 0 0 auto;
align-self: stretch;
} }
.list-foot { .list-foot {
@@ -503,7 +568,7 @@
} }
table { table {
height: 100%; height: auto;
width: 100%; width: 100%;
min-width: 1180px; min-width: 1180px;
border-collapse: collapse; border-collapse: collapse;
@@ -659,9 +724,12 @@ tbody tr:last-child td {
} }
.role-stack { .role-stack {
display: flex; display: inline-flex;
gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 6px;
max-width: 100%;
} }
.role-pill { .role-pill {
@@ -770,6 +838,7 @@ tbody tr:last-child td {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr); grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
gap: 16px; gap: 16px;
align-items: start;
} }
.detail-main, .detail-main,
@@ -777,6 +846,7 @@ tbody tr:last-child td {
display: grid; display: grid;
gap: 16px; gap: 16px;
align-content: start; align-content: start;
align-items: start;
} }
.detail-card, .detail-card,
@@ -821,6 +891,7 @@ tbody tr:last-child td {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 14px;
overflow: visible;
} }
.field { .field {
@@ -850,6 +921,118 @@ tbody tr:last-child td {
color: #64748b; color: #64748b;
} }
.manager-picker,
.department-picker {
position: relative;
z-index: 2;
}
.manager-picker.open,
.department-picker.open {
z-index: 12;
}
.manager-picker-trigger {
width: 100%;
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
text-align: left;
}
.manager-picker.open .manager-picker-trigger,
.manager-picker-trigger:hover {
border-color: rgba(16, 185, 129, 0.34);
background: #f6fffb;
}
.manager-picker-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.manager-picker-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: min(420px, 100%);
z-index: 30;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #d7e0ea;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
}
.manager-picker-panel input[type='search'] {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
padding: 10px 12px;
}
.manager-picker-panel input[type='search']:focus {
outline: none;
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.manager-picker-options {
max-height: 240px;
overflow: auto;
display: grid;
gap: 8px;
}
.manager-picker-option {
display: grid;
gap: 4px;
width: 100%;
padding: 10px 12px;
border: 1px solid #edf2f7;
border-radius: 10px;
background: #fbfdff;
text-align: left;
}
.manager-picker-option strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.manager-picker-option span {
color: #64748b;
font-size: 12px;
}
.manager-picker-option:hover,
.manager-picker-option.active {
border-color: rgba(16, 185, 129, 0.32);
background: linear-gradient(180deg, rgba(240, 253, 244, 0.85), #ffffff);
}
.manager-picker-empty {
margin: 0;
padding: 8px 4px;
color: #64748b;
font-size: 12px;
}
.role-grid { .role-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -911,10 +1094,10 @@ tbody tr:last-child td {
} }
.history-row { .history-row {
display: flex; display: grid;
align-items: flex-start; grid-template-columns: minmax(0, 1fr) 128px 112px;
justify-content: space-between; align-items: center;
gap: 10px; column-gap: 16px;
padding: 12px 0; padding: 12px 0;
border-top: 1px solid #edf2f7; border-top: 1px solid #edf2f7;
} }
@@ -925,19 +1108,45 @@ tbody tr:last-child td {
} }
.history-row strong { .history-row strong {
display: block; min-width: 0;
color: #0f172a; color: #0f172a;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.history-row span, .history-row-owner,
.history-row small { .history-row-time {
display: block; display: inline-block;
margin-top: 4px; min-width: 0;
margin-top: 0;
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-row-owner {
padding-left: 16px;
border-left: 1px solid #e2e8f0;
color: #475569;
font-weight: 700;
}
.history-row-time {
color: #64748b;
font-variant-numeric: tabular-nums;
text-align: right;
}
td.cell-updated {
vertical-align: middle;
white-space: nowrap;
} }
.publish-card { .publish-card {
@@ -1087,4 +1296,18 @@ tbody tr:last-child td {
.role-grid { .role-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.history-row {
grid-template-columns: minmax(0, 1fr);
row-gap: 6px;
}
.history-row-owner {
padding-left: 0;
border-left: 0;
}
.history-row-time {
text-align: left;
}
} }

View File

@@ -707,6 +707,15 @@
text-align: center; text-align: center;
} }
.inline-empty.is-loading {
padding: 0;
background: transparent;
}
.inline-empty.is-loading > .table-loading {
min-height: 220px;
}
.inspector-empty { .inspector-empty {
display: grid; display: grid;
align-content: center; align-content: center;

View File

@@ -423,6 +423,14 @@ th {
text-align: center; text-align: center;
} }
.table-loading-row {
padding: 0;
}
.table-loading-row > .table-loading {
min-height: 220px;
}
.list-foot { .list-foot {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,465 @@
.review-preview-modal {
width: min(980px, calc(100vw - 40px));
max-height: min(92vh, calc(100vh - 32px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%),
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
box-shadow:
0 24px 80px rgba(15, 23, 42, 0.22),
0 2px 12px rgba(15, 23, 42, 0.08);
border: 1px solid #e7eef6;
}
.review-preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 18px;
border-bottom: 1px solid #eef2f7;
}
.review-preview-head h3 {
margin-top: 12px;
color: #0f172a;
font-size: 22px;
font-weight: 900;
line-height: 1.35;
}
.review-preview-body {
min-height: 0;
display: grid;
place-items: center;
padding: 18px;
background: rgba(248, 250, 252, 0.88);
}
.review-preview-body.image img {
max-width: 100%;
max-height: calc(92vh - 170px);
display: block;
border-radius: 20px;
object-fit: contain;
box-shadow: 0 16px 34px rgba(148, 163, 184, 0.26);
}
.review-preview-body.pdf iframe {
width: 100%;
height: min(78vh, 820px);
border: 0;
border-radius: 18px;
background: #fff;
}
.welcome-quick-actions {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed rgba(203, 213, 225, 0.82);
}
.welcome-quick-actions-title {
margin: 0 0 22px;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.welcome-quick-action-grid {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.welcome-quick-action-btn {
min-height: 30px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 11px;
border: 1px solid rgba(191, 219, 254, 0.92);
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.94) 100%);
color: #1d4ed8;
font-size: var(--wb-fs-chip);
font-weight: 700;
line-height: 1.2;
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.07);
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.welcome-quick-action-btn i {
font-size: 13px;
color: #2563eb;
}
.welcome-quick-action-btn:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(59, 130, 246, 0.34);
box-shadow: 0 7px 14px rgba(59, 130, 246, 0.12);
}
.welcome-quick-action-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
}
.welcome-grid {
display: grid;
gap: 12px;
}
.welcome-card {
padding: 14px;
border-radius: 18px;
background: #f8fafc;
}
.welcome-card i {
color: #10b981;
font-size: var(--wb-fs-welcome);
}
.welcome-card strong {
display: block;
margin-top: 10px;
}
.assistant-modal-enter-active,
.assistant-modal-leave-active {
transition: opacity 220ms ease;
}
.assistant-modal-enter-active .assistant-modal,
.assistant-modal-leave-active .assistant-modal {
transition: transform 260ms ease, opacity 220ms ease;
}
.assistant-modal-enter-from,
.assistant-modal-leave-to {
opacity: 0;
}
.assistant-modal-enter-from .assistant-modal,
.assistant-modal-leave-to .assistant-modal {
transform: translateY(10px) scale(0.985);
opacity: 0;
}
.insight-switch-enter-active,
.insight-switch-leave-active {
transition: opacity 180ms ease, transform 180ms ease;
}
.insight-switch-enter-from,
.insight-switch-leave-to {
opacity: 0;
transform: translateY(8px);
}
/* 笔记本 / 中等屏:工作台正文字号整体下调一档 */
@media (max-width: 1680px) {
.assistant-modal-stage {
--wb-fs-title: 19px;
--wb-fs-desc: 12px;
--wb-fs-badge: 11px;
--wb-fs-bubble: 12px;
--wb-fs-bubble-meta: 11px;
--wb-fs-bubble-time: 11px;
--wb-fs-chip: 11px;
--wb-fs-composer: 13px;
--wb-fs-tool-icon: 16px;
--wb-fs-md-h1: 12px;
--wb-fs-md-h2: 12px;
--wb-fs-md-h3: 12px;
--wb-fs-insight-title: 17px;
--wb-fs-insight-num: 17px;
--wb-fs-insight-body: 11px;
--wb-fs-insight-h4: 14px;
--wb-fs-metric: 12px;
--wb-fs-metric-strong: 12px;
--wb-fs-welcome: 16px;
}
.assistant-modal-stage .message-answer-markdown table {
font-size: 12px;
}
.assistant-modal-stage .intent-pill {
font-size: var(--wb-fs-chip);
}
}
@media (max-width: 1440px) {
.assistant-modal-stage {
--wb-fs-title: 18px;
--wb-fs-bubble: 12px;
--wb-fs-bubble-meta: 11px;
--wb-fs-composer: 12px;
--wb-fs-insight-title: 16px;
--wb-fs-insight-num: 16px;
--wb-fs-md-h1: 12px;
--wb-fs-md-h2: 12px;
--wb-fs-md-h3: 12px;
--wb-fs-insight-h4: 13px;
--wb-fs-welcome: 16px;
}
}
/* 大屏:左右分栏;右侧详情区宽度随视口收缩 */
@media (min-width: 1441px) and (max-width: 1680px) {
.insight-panel-shell {
width: clamp(280px, 26vw, 360px);
}
}
/* 笔记本常见宽度:改为上下布局,对话区占满宽度,避免侧栏挤占 */
@media (max-width: 1440px) {
.assistant-layout {
flex-direction: column;
}
.dialog-panel {
flex: 1 1 auto;
min-height: 0;
}
.insight-panel-shell {
width: 100%;
flex: 0 0 auto;
max-height: min(38dvh, 400px);
transition:
max-height 320ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 240ms cubic-bezier(0.22, 1, 0.36, 1),
transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.insight-panel-shell.collapsed {
max-height: 0;
}
.insight-panel {
width: 100%;
min-height: min(280px, 32dvh);
}
.insight-panel-shell.collapsed .insight-panel {
transform: translateY(-12px);
}
.review-side-grid.compact {
grid-template-columns: 1fr;
}
}
/* 矮屏笔记本(如 1366×768压缩顶栏与间距把高度留给对话列表 */
@media (max-height: 820px) {
.assistant-modal-stage {
--wb-fs-title: 17px;
--wb-fs-bubble: 12px;
--wb-fs-composer: 12px;
--wb-fs-insight-title: 15px;
--wb-fs-insight-num: 15px;
}
.assistant-header {
padding-top: 12px;
padding-bottom: 10px;
}
.assistant-header-actions {
top: 12px;
right: 12px;
}
.assistant-layout {
padding: 10px;
gap: 10px;
}
.dialog-toolbar {
padding: 12px 14px 10px;
}
.message-list {
padding: 12px;
gap: 10px;
}
.composer-shell-body {
padding: 4px 10px;
}
}
@media (max-width: 1280px) {
.insight-panel-shell:not(.collapsed) {
max-height: min(34dvh, 360px);
}
}
@media (max-width: 760px) {
.assistant-overlay {
--assistant-viewport-inset: 10px;
}
.assistant-modal,
.assistant-modal-stage {
border-radius: 18px;
}
.assistant-header {
padding: 18px 18px 16px;
align-items: flex-start;
flex-direction: column;
}
.assistant-header-actions {
top: 18px;
right: 18px;
gap: 10px;
width: auto;
justify-content: space-between;
}
.assistant-toggle-btn,
.session-trash-btn,
.assistant-close-btn,
.close-btn {
width: 40px;
height: 40px;
border-radius: 14px;
font-size: 16px;
}
.flow-step-card header {
align-items: flex-start;
}
.assistant-layout {
padding: 14px;
}
.composer-row {
gap: 8px;
--composer-control-size: 40px;
}
.composer-shell textarea {
min-height: 32px;
}
.travel-calculator-form {
grid-template-columns: 1fr;
}
.dialog-toolbar {
padding: 16px 16px 12px;
}
.shortcut-chip {
width: 100%;
justify-content: center;
}
.message-list {
padding: 16px;
}
.message-row,
.message-row.user {
grid-template-columns: 34px minmax(0, 1fr);
}
.message-row.user .message-avatar {
order: 0;
}
.message-row.user .message-bubble {
order: 0;
justify-self: stretch;
}
.message-suggested-actions {
grid-template-columns: 1fr;
}
.composer {
padding: 0 16px 16px;
}
.composer-files-head,
.review-insight-title-row,
.review-document-stage-head,
.review-document-switch-head {
align-items: flex-start;
flex-direction: column;
}
.composer-files-actions,
.review-document-nav {
width: 100%;
justify-content: space-between;
}
.metric-grid {
grid-template-columns: 1fr;
}
.review-side-grid,
.review-side-category-grid,
.review-document-edit-grid {
grid-template-columns: 1fr;
}
.review-pending-item {
grid-template-columns: 42px minmax(0, 1fr);
}
.review-pending-status {
grid-column: 2;
justify-self: start;
}
.review-footer-btn-row {
flex-direction: column;
}
.review-footer-btn {
width: 100%;
}
.review-slot-grid,
.review-doc-field-grid,
.review-mini-grid {
grid-template-columns: 1fr;
}
.review-document-plain,
.review-document-bubble {
grid-template-columns: 1fr;
}
.review-preview-modal {
width: calc(100vw - 24px);
}
.review-confirm-actions {
padding: 0 18px 18px;
justify-content: stretch;
}
.review-upload-decision-actions {
width: 100%;
}
.primary-dialog-btn,
.secondary-dialog-btn,
.danger-dialog-btn {
width: 100%;
}
}

View File

@@ -0,0 +1,945 @@
.validation-pill.pending {
background: #fff7ed;
border-color: #fed7aa;
color: #c2410c;
}
.validation-pill.warning {
background: #fef2f2;
border-color: #fecaca;
color: #b91c1c;
}
.validation-summary {
margin: 0;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.validation-sections {
display: grid;
gap: 18px;
margin-top: 16px;
}
.validation-section {
display: grid;
gap: 10px;
padding-top: 14px;
border-top: 1px solid #e5e7eb;
}
.validation-section:first-child {
padding-top: 0;
border-top: none;
}
.validation-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: #0f172a;
font-size: 13px;
font-weight: 800;
line-height: 1.4;
}
.validation-section-title::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
background: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.validation-section--risk .validation-section-title {
color: #b91c1c;
}
.validation-section--risk .validation-section-title::before {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.validation-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0 0 0 18px;
color: #0f766e;
font-size: 13px;
line-height: 1.55;
}
.validation-list li::marker {
color: #14b8a6;
}
.validation-section--risk .risk-advice-list {
display: grid;
gap: 10px;
margin-top: 0;
}
.validation-section--risk .risk-advice-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.validation-section--risk .risk-advice-card-head span {
min-height: 20px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: #fef2f2;
color: #b91c1c;
font-size: 10px;
font-weight: 800;
white-space: nowrap;
}
.validation-section--risk .risk-advice-card.medium .risk-advice-card-head span {
background: #fff7ed;
color: #c2410c;
}
.validation-section--risk .risk-advice-card.low .risk-advice-card-head span {
background: #eff6ff;
color: #2563eb;
}
.validation-section--risk .risk-advice-card-head strong {
min-width: 0;
color: #0f172a;
font-size: 12px;
line-height: 1.4;
text-align: right;
}
.validation-section--risk .risk-advice-point {
margin: 0;
color: #334155;
font-size: 13px;
line-height: 1.5;
}
.validation-section--risk .risk-advice-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.validation-section--risk .risk-advice-meta > div {
min-width: 0;
display: grid;
gap: 4px;
padding: 8px 9px;
border-radius: 8px;
background: #f8fafc;
}
.validation-section--risk .risk-advice-meta span {
color: #64748b;
font-size: 10px;
font-weight: 800;
}
.risk-advice-card.medium {
border-color: #fed7aa;
background: #fffaf2;
}
.risk-advice-card.low {
border-color: #bfdbfe;
background: #f8fbff;
}
.risk-advice-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.risk-advice-card-head span {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 999px;
background: #fee2e2;
color: #b91c1c;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.risk-advice-card.medium .risk-advice-card-head span {
background: #ffedd5;
color: #c2410c;
}
.risk-advice-card.low .risk-advice-card-head span {
background: #dbeafe;
color: #2563eb;
}
.risk-advice-card-head strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
line-height: 1.45;
text-align: right;
}
.risk-advice-point {
margin: 0;
color: #7f1d1d;
font-size: 14px;
font-weight: 800;
line-height: 1.5;
}
.risk-advice-card.medium .risk-advice-point {
color: #9a3412;
}
.risk-advice-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
}
.risk-advice-meta > div {
min-width: 0;
display: grid;
gap: 6px;
padding: 10px;
border-radius: 8px;
background: rgba(255, 255, 255, .72);
}
.risk-advice-meta span {
color: #64748b;
font-size: 11px;
font-weight: 850;
}
.risk-advice-meta ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 16px;
color: #334155;
font-size: 12px;
line-height: 1.55;
}
.risk-advice-meta p {
margin: 0;
color: #334155;
font-size: 12px;
line-height: 1.55;
}
.detail-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: rgba(15, 23, 42, .45);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.detail-modal {
position: relative;
width: calc(100vw - 80px);
max-width: 1440px;
height: calc(100vh - 64px);
max-height: 960px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-radius: 28px;
background: #f8fafc;
box-shadow:
0 0 0 1px rgba(15, 23, 42, .08),
0 20px 60px rgba(15, 23, 42, .18),
0 4px 16px rgba(15, 23, 42, .06);
overflow: hidden;
}
.ai-entry-modal {
max-width: 1320px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 28px;
background: linear-gradient(135deg, #fff 0%, #f9fbff 100%);
border-bottom: 1px solid #e8eef6;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.req-badge {
padding: 6px 14px;
border-radius: 999px;
background: #eff6ff;
border: 1px solid rgba(29, 78, 216, .16);
color: #1d4ed8;
font-size: 13px;
font-weight: 850;
letter-spacing: .02em;
}
.header-title-group h2 {
color: #0f172a;
font-size: 20px;
font-weight: 900;
letter-spacing: -.01em;
}
.header-title-group p {
margin-top: 3px;
color: #64748b;
font-size: 13px;
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.close-btn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
color: #64748b;
font-size: 18px;
transition: all 160ms ease;
}
.close-btn:hover {
border-color: #cbd5e1;
background: #f1f5f9;
color: #0f172a;
}
.modal-body {
min-height: 0;
padding: 20px 28px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
.ai-entry-grid {
min-height: 100%;
display: grid;
grid-template-columns: minmax(0, 1.2fr) 360px;
gap: 20px;
}
.ai-chat-card,
.ai-preview-card {
min-height: 0;
border-radius: 22px;
background: #fff;
border: 1px solid #edf2f7;
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
}
.ai-chat-card {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
}
.ai-chat-scroll {
min-height: 0;
display: grid;
align-content: start;
gap: 12px;
padding: 18px;
overflow: auto;
background:
linear-gradient(180deg, rgba(240, 253, 244, .5) 0%, rgba(255, 255, 255, 0) 140px),
#fff;
}
.ai-chat-bubble {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 10px;
}
.ai-chat-bubble.user {
grid-template-columns: minmax(0, 1fr) 34px;
}
.ai-chat-bubble.user .ai-chat-avatar {
order: 2;
background: #dbeafe;
color: #2563eb;
}
.ai-chat-bubble.user .ai-chat-content {
order: 1;
justify-self: end;
background: #eff6ff;
}
.ai-chat-avatar {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 999px;
background: #ecfdf5;
color: #059669;
font-size: 18px;
}
.ai-chat-content {
max-width: min(100%, 640px);
padding: 12px 14px;
border-radius: 18px;
background: #f8fafc;
border: 1px solid #edf2f7;
}
.ai-chat-content header {
margin-bottom: 6px;
}
.ai-chat-content strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.ai-chat-content p {
color: #334155;
font-size: 13px;
line-height: 1.6;
}
.ai-composer {
display: grid;
gap: 12px;
padding: 14px 16px 16px;
border-top: 1px solid #edf2f7;
background: linear-gradient(180deg, #fff, #fbfdff);
}
.ai-file-input {
display: none;
}
.ai-composer-surface {
min-height: 78px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 12px;
padding: 8px 8px 8px 14px;
border: 1px solid #cbd8e5;
border-radius: 22px;
background: linear-gradient(180deg, #fff, #fbfdff);
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.ai-composer-surface:focus-within {
border-color: rgba(16, 185, 129, .58);
background: #fff;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .11), 0 10px 24px rgba(15, 23, 42, .06);
}
.ai-composer textarea {
width: 100%;
min-height: 60px;
height: 60px;
resize: none;
border: 0;
border-radius: 0;
padding: 8px 0;
background: transparent;
color: #0f172a;
font-size: 14px;
line-height: 1.6;
}
.ai-composer textarea:focus {
outline: none;
}
.ai-composer-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding-bottom: 2px;
}
.ai-upload-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ai-upload-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #eef6ff;
border: 1px solid #d7e8fb;
color: #334155;
font-size: 12px;
font-weight: 700;
}
.ai-upload-chip i {
color: #2563eb;
}
.ai-upload-btn,
.ai-send-btn {
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border-radius: 12px;
font-size: 20px;
transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, color 160ms ease;
}
.ai-upload-btn {
border: 0;
background: #f1f5f9;
color: #475569;
box-shadow: none;
}
.ai-upload-btn:hover {
background: #e2e8f0;
color: #0f172a;
}
.ai-send-btn {
border: 0;
background: #10b981;
color: #fff;
box-shadow: 0 8px 18px rgba(16, 185, 129, .20);
}
.ai-send-btn:hover {
background: #0ea672;
box-shadow: 0 10px 22px rgba(16, 185, 129, .24);
}
.ai-upload-btn:active,
.ai-send-btn:active {
transform: scale(.96);
}
.ai-preview-card {
padding: 18px;
display: grid;
align-content: start;
gap: 16px;
}
.ai-preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.ai-preview-head h3 {
margin: 0;
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.ai-preview-head p {
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.ai-preview-fields {
display: grid;
gap: 10px;
}
.preview-field {
padding: 12px 14px;
border-radius: 18px;
background: #f8fafc;
border: 1px solid #edf2f7;
}
.preview-field.full {
min-height: 90px;
}
.preview-field span {
display: block;
color: #64748b;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .04em;
}
.preview-field strong {
display: block;
margin-top: 5px;
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.5;
}
.preview-field p {
margin-top: 4px;
color: #475569;
font-size: 12px;
line-height: 1.55;
}
.ai-preview-empty {
min-height: 280px;
display: grid;
place-items: center;
gap: 10px;
padding: 24px;
border: 1px dashed #cbd5e1;
border-radius: 20px;
color: #94a3b8;
text-align: center;
}
.ai-preview-empty i {
font-size: 32px;
color: #10b981;
}
.ai-preview-actions {
display: flex;
gap: 10px;
margin-top: 4px;
}
.ai-preview-secondary,
.ai-preview-primary {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 20px;
border-radius: 999px;
font-size: 13px;
font-weight: 800;
transition: all 180ms ease;
flex: 1;
}
.ai-preview-secondary {
border: 1px solid #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.ai-preview-primary {
border: 1px solid #059669;
background: #059669;
color: #fff;
box-shadow: 0 8px 20px rgba(5, 150, 105, .18);
}
.ai-preview-secondary:hover {
background: #ffedd5;
}
.ai-preview-primary:hover {
background: #047857;
}
.ai-preview-secondary:disabled,
.ai-preview-primary:disabled,
.approve-action:disabled,
.return-action:disabled,
.ai-send-btn:disabled {
opacity: .45;
cursor: not-allowed;
box-shadow: none;
}
.detail-modal-enter-active { transition: opacity 260ms ease; }
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
.detail-modal-leave-active { transition: opacity 200ms ease; }
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
.detail-modal-enter-from { opacity: 0; }
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
.detail-modal-leave-to { opacity: 0; }
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
@media (max-width: 1320px) {
.hero-banner-main {
grid-template-columns: 1fr;
gap: 16px;
min-height: 0;
}
.hero-fact-grid {
grid-template-columns: repeat(5, minmax(132px, 1fr));
overflow-x: auto;
}
.hero-fact {
min-width: 132px;
}
.detail-expense-table {
overflow-x: auto;
}
.detail-expense-table table {
min-width: 1080px;
}
.ai-entry-grid {
grid-template-columns: 1fr;
}
.detail-modal {
width: calc(100vw - 40px);
height: calc(100vh - 40px);
}
}
@media (max-width: 760px) {
.detail-hero { gap: 10px; padding: 16px; }
.progress-card { padding: 16px; }
.applicant-card {
grid-template-columns: 60px minmax(0, 1fr);
gap: 12px;
}
.portrait {
width: 60px;
height: 60px;
}
.applicant-copy {
gap: 8px;
}
.applicant-card h2 {
font-size: 16px;
}
.applicant-profile-meta {
display: grid;
gap: 10px;
}
.applicant-profile-meta__role {
display: grid;
gap: 6px;
}
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item {
margin-left: 0;
}
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item::before {
content: none;
}
.hero-fact-grid {
grid-template-columns: 1fr 1fr;
gap: 0;
overflow: hidden;
border-top: 1px solid #edf2f7;
}
.hero-fact {
min-width: 0;
min-height: 78px;
padding: 14px 12px 12px;
border-left: 0;
border-bottom: 1px solid #edf2f7;
}
.hero-fact:nth-child(2n) {
border-left: 1px solid #edf2f7;
}
.hero-fact:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
.hero-fact:nth-last-child(-n + 2) {
border-bottom: 0;
}
.hero-fact strong {
white-space: normal;
}
.detail-card {
padding: 14px 16px;
}
.detail-card-head {
flex-direction: column;
align-items: stretch;
}
.detail-card-actions {
width: 100%;
justify-content: flex-start;
}
.smart-entry-btn { align-self: flex-start; }
.detail-expense-table table {
min-width: 1080px;
}
.detail-actions {
flex-direction: column;
}
.approval-action-group {
width: 100%;
}
.validation-head {
flex-direction: column;
}
.detail-modal {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
}
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
.modal-body { padding: 16px 18px; }
.ai-composer-actions { flex-direction: column; align-items: stretch; }
.ai-preview-actions { flex-direction: column; }
.attachment-preview-mask {
padding: 14px;
}
.attachment-preview-card {
width: min(calc(100vw - 28px), 920px);
max-height: calc(100vh - 28px);
padding: 18px;
border-radius: 20px;
}
.attachment-preview-head {
flex-wrap: wrap;
}
.attachment-preview-toolbar {
order: 2;
width: 100%;
justify-content: flex-start;
}
.attachment-preview-body {
grid-template-columns: minmax(0, 1fr);
}
.attachment-insight-pane {
max-height: 320px;
}
.risk-advice-meta {
grid-template-columns: minmax(0, 1fr);
}
.attachment-preview-image,
.attachment-preview-frame {
min-height: 360px;
}
}
.validation-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.validation-head h3 {
margin-bottom: 4px;
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.validation-head p {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.validation-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 11px;
font-weight: 800;
}
.validation-pill.ready {
background: #f0fdf4;
border-color: #bbf7d0;
color: #166534;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
# Workbench Icons
Icons in this folder are sourced from [Heroicons](https://heroicons.com) (MIT License).
Used on the Personal Workbench todo and progress lists.

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z"/>
</svg>

After

Width:  |  Height:  |  Size: 806 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"/>
</svg>

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 10.5V6a3.75 3.75 0 1 0-7.5 0v4.5m11.356-1.993 1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 0 1-1.12-1.243l1.264-12A1.125 1.125 0 0 1 5.513 7.5h12.974c.576 0 1.059.435 1.119 1.007ZM8.625 10.5a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm7.5 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 0 1-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 0 0-3.213-9.193 2.056 2.056 0 0 0-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 0 0-10.026 0 1.106 1.106 0 0 0-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12"/>
</svg>

After

Width:  |  Height:  |  Size: 636 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

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