Compare commits
21 Commits
8814fe7dfa
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f787ff38 | ||
|
|
2908dda024 | ||
|
|
e701fa01da | ||
|
|
f28d7e6d16 | ||
|
|
b183b0bd5e | ||
|
|
8f65661809 | ||
|
|
002bf4f756 | ||
|
|
f8b25a7ccc | ||
|
|
d7e98a58b9 | ||
|
|
57957d11a0 | ||
|
|
2574bc81d1 | ||
|
|
54ffef66d3 | ||
|
|
d460ee0fe7 | ||
|
|
9472813739 | ||
|
|
dc007f948a | ||
|
|
9db663e81f | ||
|
|
813ac81950 | ||
|
|
9902a3b968 | ||
|
|
29df4eee3b | ||
|
|
5106d286a1 | ||
|
|
64ec27949f |
@@ -30,10 +30,24 @@ services:
|
||||
- /bin/sh
|
||||
- -lc
|
||||
- >
|
||||
apt-get update &&
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
||||
python3 python3-pip python3-venv &&
|
||||
mkdir -p /run/sshd && /usr/sbin/sshd &&
|
||||
apt-get update &&
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
||||
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 &&
|
||||
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 &&
|
||||
touch /root/.bashrc /root/.profile &&
|
||||
|
||||
@@ -1,457 +1,457 @@
|
||||
# 语义本体协议设计
|
||||
|
||||
## 1. 定位
|
||||
|
||||
语义本体协议是用户问题、定时任务、规则中心、MCP、数据库查询和 Agent 之间的统一中间层。
|
||||
|
||||
它解决的问题是:
|
||||
|
||||
- 用户到底在问哪个业务域?
|
||||
- 这属于什么场景?
|
||||
- 用户想做什么?
|
||||
- 问题中涉及哪些对象?
|
||||
- 有没有时间、金额、状态、部门等过滤条件?
|
||||
- 是否涉及风险?
|
||||
- 下一步应该查知识库、查数据库、跑规则、调 MCP,还是追问?
|
||||
|
||||
## 2. 第一版核心字段
|
||||
|
||||
第一版建议只强制落 8 个字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "",
|
||||
"scenario": "",
|
||||
"intent": "",
|
||||
"entities": [],
|
||||
"time_range": {},
|
||||
"constraints": {},
|
||||
"risk_signals": [],
|
||||
"next_step": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 2.1 domain
|
||||
|
||||
一级业务域。
|
||||
|
||||
建议枚举:
|
||||
|
||||
```text
|
||||
reimbursement
|
||||
accounts_receivable
|
||||
accounts_payable
|
||||
general_finance
|
||||
system_operation
|
||||
```
|
||||
|
||||
含义:
|
||||
|
||||
- `reimbursement`:报销、差旅、发票、补件。
|
||||
- `accounts_receivable`:应收账款、客户开票、收款、账龄。
|
||||
- `accounts_payable`:应付账款、供应商发票、付款、对账。
|
||||
- `general_finance`:通用财务知识、制度、统计。
|
||||
- `system_operation`:系统巡检、任务运行、规则维护、MCP 健康检查。
|
||||
|
||||
### 2.2 scenario
|
||||
|
||||
细分场景。
|
||||
|
||||
报销:
|
||||
|
||||
```text
|
||||
travel_reimbursement
|
||||
daily_expense
|
||||
invoice_validation
|
||||
attachment_review
|
||||
policy_overrun
|
||||
reimbursement_audit
|
||||
```
|
||||
|
||||
应收:
|
||||
|
||||
```text
|
||||
customer_invoice
|
||||
collection_followup
|
||||
receivable_aging
|
||||
payment_matching
|
||||
bad_debt_risk
|
||||
contract_receivable
|
||||
```
|
||||
|
||||
应付:
|
||||
|
||||
```text
|
||||
vendor_invoice
|
||||
payment_request
|
||||
payable_aging
|
||||
vendor_reconciliation
|
||||
invoice_matching
|
||||
cash_outflow_forecast
|
||||
```
|
||||
|
||||
系统运营:
|
||||
|
||||
```text
|
||||
daily_risk_scan
|
||||
daily_finance_statistics
|
||||
knowledge_accumulation
|
||||
mcp_health_check
|
||||
rule_quality_review
|
||||
```
|
||||
|
||||
### 2.3 intent
|
||||
|
||||
用户或任务的意图。
|
||||
|
||||
建议枚举:
|
||||
|
||||
```text
|
||||
query
|
||||
explain
|
||||
create
|
||||
validate
|
||||
summarize
|
||||
reconcile
|
||||
monitor
|
||||
predict
|
||||
remind
|
||||
generate
|
||||
optimize
|
||||
```
|
||||
|
||||
### 2.4 entities
|
||||
|
||||
识别出的业务对象。
|
||||
|
||||
统一结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "invoice",
|
||||
"value": "INV-202605001",
|
||||
"normalized_value": "INV-202605001",
|
||||
"role": "target",
|
||||
"confidence": 0.92
|
||||
}
|
||||
```
|
||||
|
||||
常见实体:
|
||||
|
||||
```text
|
||||
employee
|
||||
department
|
||||
customer
|
||||
vendor
|
||||
invoice
|
||||
contract
|
||||
reimbursement_request
|
||||
payment_order
|
||||
receipt
|
||||
bank_transaction
|
||||
cost_center
|
||||
project
|
||||
policy
|
||||
approval_node
|
||||
rule
|
||||
task
|
||||
```
|
||||
|
||||
### 2.5 time_range
|
||||
|
||||
统一描述时间。
|
||||
|
||||
```json
|
||||
{
|
||||
"raw": "上个月",
|
||||
"start": "2026-04-01",
|
||||
"end": "2026-04-30",
|
||||
"granularity": "month"
|
||||
}
|
||||
```
|
||||
|
||||
Hermes 定时任务也使用同一字段。
|
||||
|
||||
例如每日风险巡检:
|
||||
|
||||
```json
|
||||
{
|
||||
"raw": "昨日",
|
||||
"start": "2026-05-09",
|
||||
"end": "2026-05-09",
|
||||
"granularity": "day"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 constraints
|
||||
|
||||
查询、判断或执行条件。
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "overdue",
|
||||
"aging_days": ">30",
|
||||
"amount": {
|
||||
"operator": ">",
|
||||
"value": 50000,
|
||||
"currency": "CNY"
|
||||
},
|
||||
"department": "销售部",
|
||||
"risk_level": ["medium", "high"]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.7 risk_signals
|
||||
|
||||
风险信号。
|
||||
|
||||
建议枚举:
|
||||
|
||||
```text
|
||||
duplicate_invoice
|
||||
missing_attachment
|
||||
policy_overrun
|
||||
over_budget
|
||||
overdue_receivable
|
||||
bad_debt_risk
|
||||
vendor_payment_risk
|
||||
payment_mismatch
|
||||
contract_mismatch
|
||||
cashflow_pressure
|
||||
mcp_unavailable
|
||||
rule_quality_issue
|
||||
```
|
||||
|
||||
### 2.8 next_step
|
||||
|
||||
下一步动作。
|
||||
|
||||
建议枚举:
|
||||
|
||||
```text
|
||||
answer
|
||||
ask_clarification
|
||||
query_database
|
||||
run_rule
|
||||
call_mcp
|
||||
search_knowledge
|
||||
create_draft
|
||||
create_task
|
||||
generate_report
|
||||
notify_user
|
||||
escalate_to_human
|
||||
```
|
||||
|
||||
## 3. 扩展字段
|
||||
|
||||
后续可以增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.1",
|
||||
"confidence": 0.86,
|
||||
"ambiguity": [],
|
||||
"missing_slots": [],
|
||||
"required_capabilities": [],
|
||||
"normalized_query": "",
|
||||
"permission_scope": {},
|
||||
"audit_tags": []
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 混合语义解析架构
|
||||
|
||||
第一版可上线实现不应只依赖关键词和正则。
|
||||
|
||||
推荐采用:
|
||||
|
||||
```text
|
||||
输入上下文装配
|
||||
用户文本 + 页面上下文 + 附件名称 + OCR/VLM 摘要
|
||||
↓
|
||||
预抽取
|
||||
时间、金额、单号、显式对象
|
||||
↓
|
||||
LLM 结构化解析
|
||||
输出 scenario / intent / entities / missing_slots / ambiguity
|
||||
↓
|
||||
Schema 校验
|
||||
JSON 解析、字段枚举、必填校验、类型归一化
|
||||
↓
|
||||
规则兜底
|
||||
模型失败、低置信度或字段缺失时回退到规则解析
|
||||
↓
|
||||
澄清追问
|
||||
低置信度、歧义、缺槽位时不允许直接查库
|
||||
```
|
||||
|
||||
设计原则:
|
||||
|
||||
- 模型优先负责“理解意图和场景”。
|
||||
- 规则优先负责“校验、补全和兜底”。
|
||||
- 附件名称、OCR、VLM 结果只能作为证据,不等于已确认事实。
|
||||
- 所有语义输出都必须标记置信度和来源。
|
||||
|
||||
## 5. 推荐新增字段
|
||||
|
||||
为支持模型优先解析,建议在扩展字段中至少增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"missing_slots": [],
|
||||
"ambiguity": [],
|
||||
"field_confidence": {},
|
||||
"field_source": {},
|
||||
"attachment_context": [],
|
||||
"parse_strategy": "llm_primary_with_rule_fallback"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `missing_slots`:还缺哪些关键字段,例如费用类型、单据号、客户单位。
|
||||
- `ambiguity`:当前可能混淆的理解结果。
|
||||
- `field_confidence`:字段级置信度,而不是只给整体分数。
|
||||
- `field_source`:字段来自 `llm`、`rule`、`ocr`、`vlm` 还是 `user_context`。
|
||||
- `attachment_context`:本次可供语义解析使用的附件摘要。
|
||||
- `parse_strategy`:标记本次是模型主解析还是规则回退。
|
||||
|
||||
## 6. 叙述型财务输入
|
||||
|
||||
语义层必须支持“不是查询句”的自然叙述。
|
||||
|
||||
典型样例:
|
||||
|
||||
```text
|
||||
我今天去客户现场,招待了客户,花销了1000元
|
||||
我垫付了打车费和餐费,帮我看看怎么报
|
||||
上传了三张票,帮我整理成报销草稿
|
||||
```
|
||||
|
||||
这类输入不能默认识别成 `query`。
|
||||
|
||||
建议默认策略:
|
||||
|
||||
- 优先识别为 `reimbursement` 域。
|
||||
- 场景优先落到 `daily_expense`、`travel_reimbursement` 或 `attachment_review`。
|
||||
- 意图优先落到 `create`、`generate` 或 `validate`。
|
||||
- 缺失关键字段时返回 `ask_clarification`,而不是直接查数据库。
|
||||
|
||||
## 7. 模糊短句与澄清规则
|
||||
|
||||
以下输入应优先追问:
|
||||
|
||||
```text
|
||||
我要报销
|
||||
这个为什么还没处理
|
||||
帮我看一下这个
|
||||
上传好了,下一步呢
|
||||
```
|
||||
|
||||
处理原则:
|
||||
|
||||
- 不允许直接执行工具。
|
||||
- 不允许直接落到应收、应付查询。
|
||||
- 必须生成澄清问题。
|
||||
- 必须在审计中记录触发追问的原因。
|
||||
|
||||
扩展原则:
|
||||
|
||||
- 先不要把所有字段都做成数据库列。
|
||||
- 语义结果建议存 JSONB。
|
||||
- 使用 `schema_version` 管理版本。
|
||||
- Orchestrator 只依赖稳定字段。
|
||||
- 新字段以可选方式加入,不影响老任务。
|
||||
|
||||
## 4. 示例
|
||||
|
||||
### 4.1 用户查询应收账龄
|
||||
|
||||
用户问:
|
||||
|
||||
```text
|
||||
上个月哪些客户应收逾期超过 30 天?
|
||||
```
|
||||
|
||||
解析:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "accounts_receivable",
|
||||
"scenario": "receivable_aging",
|
||||
"intent": "query",
|
||||
"entities": [
|
||||
{
|
||||
"type": "customer",
|
||||
"value": "客户",
|
||||
"role": "group_by"
|
||||
}
|
||||
],
|
||||
"time_range": {
|
||||
"raw": "上个月",
|
||||
"start": "2026-04-01",
|
||||
"end": "2026-04-30",
|
||||
"granularity": "month"
|
||||
},
|
||||
"constraints": {
|
||||
"aging_days": ">30",
|
||||
"status": "overdue"
|
||||
},
|
||||
"risk_signals": ["overdue_receivable"],
|
||||
"next_step": "query_database"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 用户解释发票拦截
|
||||
|
||||
用户问:
|
||||
|
||||
```text
|
||||
这张发票为什么报销被拦截?
|
||||
```
|
||||
|
||||
解析:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "reimbursement",
|
||||
"scenario": "invoice_validation",
|
||||
"intent": "explain",
|
||||
"entities": [
|
||||
{
|
||||
"type": "invoice",
|
||||
"value": "这张发票",
|
||||
"role": "target"
|
||||
}
|
||||
],
|
||||
"time_range": {},
|
||||
"constraints": {},
|
||||
"risk_signals": ["unknown"],
|
||||
"next_step": "run_rule"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Hermes 每日风险巡检
|
||||
|
||||
任务配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "reimbursement",
|
||||
"scenario": "daily_risk_scan",
|
||||
"intent": "monitor",
|
||||
"entities": [],
|
||||
"time_range": {
|
||||
"raw": "昨日"
|
||||
},
|
||||
"constraints": {
|
||||
"risk_level": ["medium", "high"]
|
||||
},
|
||||
"risk_signals": [
|
||||
"duplicate_invoice",
|
||||
"missing_attachment",
|
||||
"policy_overrun"
|
||||
],
|
||||
"next_step": "run_rule"
|
||||
}
|
||||
```
|
||||
# 语义本体协议设计
|
||||
|
||||
## 1. 定位
|
||||
|
||||
语义本体协议是用户问题、定时任务、规则中心、MCP、数据库查询和 Agent 之间的统一中间层。
|
||||
|
||||
它解决的问题是:
|
||||
|
||||
- 用户到底在问哪个业务域?
|
||||
- 这属于什么场景?
|
||||
- 用户想做什么?
|
||||
- 问题中涉及哪些对象?
|
||||
- 有没有时间、金额、状态、部门等过滤条件?
|
||||
- 是否涉及风险?
|
||||
- 下一步应该查知识库、查数据库、跑规则、调 MCP,还是追问?
|
||||
|
||||
## 2. 第一版核心字段
|
||||
|
||||
第一版建议只强制落 8 个字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "",
|
||||
"scenario": "",
|
||||
"intent": "",
|
||||
"entities": [],
|
||||
"time_range": {},
|
||||
"constraints": {},
|
||||
"risk_signals": [],
|
||||
"next_step": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 2.1 domain
|
||||
|
||||
一级业务域。
|
||||
|
||||
建议枚举:
|
||||
|
||||
```text
|
||||
reimbursement
|
||||
accounts_receivable
|
||||
accounts_payable
|
||||
general_finance
|
||||
system_operation
|
||||
```
|
||||
|
||||
含义:
|
||||
|
||||
- `reimbursement`:报销、差旅、发票、补件。
|
||||
- `accounts_receivable`:应收账款、客户开票、收款、账龄。
|
||||
- `accounts_payable`:应付账款、供应商发票、付款、对账。
|
||||
- `general_finance`:通用财务知识、制度、统计。
|
||||
- `system_operation`:系统巡检、任务运行、规则维护、MCP 健康检查。
|
||||
|
||||
### 2.2 scenario
|
||||
|
||||
细分场景。
|
||||
|
||||
报销:
|
||||
|
||||
```text
|
||||
travel_reimbursement
|
||||
daily_expense
|
||||
invoice_validation
|
||||
attachment_review
|
||||
policy_overrun
|
||||
reimbursement_audit
|
||||
```
|
||||
|
||||
应收:
|
||||
|
||||
```text
|
||||
customer_invoice
|
||||
collection_followup
|
||||
receivable_aging
|
||||
payment_matching
|
||||
bad_debt_risk
|
||||
contract_receivable
|
||||
```
|
||||
|
||||
应付:
|
||||
|
||||
```text
|
||||
vendor_invoice
|
||||
payment_request
|
||||
payable_aging
|
||||
vendor_reconciliation
|
||||
invoice_matching
|
||||
cash_outflow_forecast
|
||||
```
|
||||
|
||||
系统运营:
|
||||
|
||||
```text
|
||||
daily_risk_scan
|
||||
daily_finance_statistics
|
||||
knowledge_accumulation
|
||||
mcp_health_check
|
||||
rule_quality_review
|
||||
```
|
||||
|
||||
### 2.3 intent
|
||||
|
||||
用户或任务的意图。
|
||||
|
||||
建议枚举:
|
||||
|
||||
```text
|
||||
query
|
||||
explain
|
||||
create
|
||||
validate
|
||||
summarize
|
||||
reconcile
|
||||
monitor
|
||||
predict
|
||||
remind
|
||||
generate
|
||||
optimize
|
||||
```
|
||||
|
||||
### 2.4 entities
|
||||
|
||||
识别出的业务对象。
|
||||
|
||||
统一结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "invoice",
|
||||
"value": "INV-202605001",
|
||||
"normalized_value": "INV-202605001",
|
||||
"role": "target",
|
||||
"confidence": 0.92
|
||||
}
|
||||
```
|
||||
|
||||
常见实体:
|
||||
|
||||
```text
|
||||
employee
|
||||
department
|
||||
customer
|
||||
vendor
|
||||
invoice
|
||||
contract
|
||||
reimbursement_request
|
||||
payment_order
|
||||
receipt
|
||||
bank_transaction
|
||||
cost_center
|
||||
project
|
||||
policy
|
||||
approval_node
|
||||
rule
|
||||
task
|
||||
```
|
||||
|
||||
### 2.5 time_range
|
||||
|
||||
统一描述时间。
|
||||
|
||||
```json
|
||||
{
|
||||
"raw": "上个月",
|
||||
"start": "2026-04-01",
|
||||
"end": "2026-04-30",
|
||||
"granularity": "month"
|
||||
}
|
||||
```
|
||||
|
||||
Hermes 定时任务也使用同一字段。
|
||||
|
||||
例如每日风险巡检:
|
||||
|
||||
```json
|
||||
{
|
||||
"raw": "昨日",
|
||||
"start": "2026-05-09",
|
||||
"end": "2026-05-09",
|
||||
"granularity": "day"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 constraints
|
||||
|
||||
查询、判断或执行条件。
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "overdue",
|
||||
"aging_days": ">30",
|
||||
"amount": {
|
||||
"operator": ">",
|
||||
"value": 50000,
|
||||
"currency": "CNY"
|
||||
},
|
||||
"department": "销售部",
|
||||
"risk_level": ["medium", "high"]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.7 risk_signals
|
||||
|
||||
风险信号。
|
||||
|
||||
建议枚举:
|
||||
|
||||
```text
|
||||
duplicate_invoice
|
||||
missing_attachment
|
||||
policy_overrun
|
||||
over_budget
|
||||
overdue_receivable
|
||||
bad_debt_risk
|
||||
vendor_payment_risk
|
||||
payment_mismatch
|
||||
contract_mismatch
|
||||
cashflow_pressure
|
||||
mcp_unavailable
|
||||
rule_quality_issue
|
||||
```
|
||||
|
||||
### 2.8 next_step
|
||||
|
||||
下一步动作。
|
||||
|
||||
建议枚举:
|
||||
|
||||
```text
|
||||
answer
|
||||
ask_clarification
|
||||
query_database
|
||||
run_rule
|
||||
call_mcp
|
||||
search_knowledge
|
||||
create_draft
|
||||
create_task
|
||||
generate_report
|
||||
notify_user
|
||||
escalate_to_human
|
||||
```
|
||||
|
||||
## 3. 扩展字段
|
||||
|
||||
后续可以增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.1",
|
||||
"confidence": 0.86,
|
||||
"ambiguity": [],
|
||||
"missing_slots": [],
|
||||
"required_capabilities": [],
|
||||
"normalized_query": "",
|
||||
"permission_scope": {},
|
||||
"audit_tags": []
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 混合语义解析架构
|
||||
|
||||
第一版可上线实现不应只依赖关键词和正则。
|
||||
|
||||
推荐采用:
|
||||
|
||||
```text
|
||||
输入上下文装配
|
||||
用户文本 + 页面上下文 + 附件名称 + OCR/VLM 摘要
|
||||
↓
|
||||
预抽取
|
||||
时间、金额、单号、显式对象
|
||||
↓
|
||||
LLM 结构化解析
|
||||
输出 scenario / intent / entities / missing_slots / ambiguity
|
||||
↓
|
||||
Schema 校验
|
||||
JSON 解析、字段枚举、必填校验、类型归一化
|
||||
↓
|
||||
规则兜底
|
||||
模型失败、低置信度或字段缺失时回退到规则解析
|
||||
↓
|
||||
澄清追问
|
||||
低置信度、歧义、缺槽位时不允许直接查库
|
||||
```
|
||||
|
||||
设计原则:
|
||||
|
||||
- 模型优先负责“理解意图和场景”。
|
||||
- 规则优先负责“校验、补全和兜底”。
|
||||
- 附件名称、OCR、VLM 结果只能作为证据,不等于已确认事实。
|
||||
- 所有语义输出都必须标记置信度和来源。
|
||||
|
||||
## 5. 推荐新增字段
|
||||
|
||||
为支持模型优先解析,建议在扩展字段中至少增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"missing_slots": [],
|
||||
"ambiguity": [],
|
||||
"field_confidence": {},
|
||||
"field_source": {},
|
||||
"attachment_context": [],
|
||||
"parse_strategy": "llm_primary_with_rule_fallback"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `missing_slots`:还缺哪些关键字段,例如费用类型、单据号、客户单位。
|
||||
- `ambiguity`:当前可能混淆的理解结果。
|
||||
- `field_confidence`:字段级置信度,而不是只给整体分数。
|
||||
- `field_source`:字段来自 `llm`、`rule`、`ocr`、`vlm` 还是 `user_context`。
|
||||
- `attachment_context`:本次可供语义解析使用的附件摘要。
|
||||
- `parse_strategy`:标记本次是模型主解析还是规则回退。
|
||||
|
||||
## 6. 叙述型财务输入
|
||||
|
||||
语义层必须支持“不是查询句”的自然叙述。
|
||||
|
||||
典型样例:
|
||||
|
||||
```text
|
||||
我今天去客户现场,招待了客户,花销了1000元
|
||||
我垫付了打车费和餐费,帮我看看怎么报
|
||||
上传了三张票,帮我整理成报销草稿
|
||||
```
|
||||
|
||||
这类输入不能默认识别成 `query`。
|
||||
|
||||
建议默认策略:
|
||||
|
||||
- 优先识别为 `reimbursement` 域。
|
||||
- 场景优先落到 `daily_expense`、`travel_reimbursement` 或 `attachment_review`。
|
||||
- 意图优先落到 `create`、`generate` 或 `validate`。
|
||||
- 缺失关键字段时返回 `ask_clarification`,而不是直接查数据库。
|
||||
|
||||
## 7. 模糊短句与澄清规则
|
||||
|
||||
以下输入应优先追问:
|
||||
|
||||
```text
|
||||
我要报销
|
||||
这个为什么还没处理
|
||||
帮我看一下这个
|
||||
上传好了,下一步呢
|
||||
```
|
||||
|
||||
处理原则:
|
||||
|
||||
- 不允许直接执行工具。
|
||||
- 不允许直接落到应收、应付查询。
|
||||
- 必须生成澄清问题。
|
||||
- 必须在审计中记录触发追问的原因。
|
||||
|
||||
扩展原则:
|
||||
|
||||
- 先不要把所有字段都做成数据库列。
|
||||
- 语义结果建议存 JSONB。
|
||||
- 使用 `schema_version` 管理版本。
|
||||
- Orchestrator 只依赖稳定字段。
|
||||
- 新字段以可选方式加入,不影响老任务。
|
||||
|
||||
## 4. 示例
|
||||
|
||||
### 4.1 用户查询应收账龄
|
||||
|
||||
用户问:
|
||||
|
||||
```text
|
||||
上个月哪些客户应收逾期超过 30 天?
|
||||
```
|
||||
|
||||
解析:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "accounts_receivable",
|
||||
"scenario": "receivable_aging",
|
||||
"intent": "query",
|
||||
"entities": [
|
||||
{
|
||||
"type": "customer",
|
||||
"value": "客户",
|
||||
"role": "group_by"
|
||||
}
|
||||
],
|
||||
"time_range": {
|
||||
"raw": "上个月",
|
||||
"start": "2026-04-01",
|
||||
"end": "2026-04-30",
|
||||
"granularity": "month"
|
||||
},
|
||||
"constraints": {
|
||||
"aging_days": ">30",
|
||||
"status": "overdue"
|
||||
},
|
||||
"risk_signals": ["overdue_receivable"],
|
||||
"next_step": "query_database"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 用户解释发票拦截
|
||||
|
||||
用户问:
|
||||
|
||||
```text
|
||||
这张发票为什么报销被拦截?
|
||||
```
|
||||
|
||||
解析:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "reimbursement",
|
||||
"scenario": "invoice_validation",
|
||||
"intent": "explain",
|
||||
"entities": [
|
||||
{
|
||||
"type": "invoice",
|
||||
"value": "这张发票",
|
||||
"role": "target"
|
||||
}
|
||||
],
|
||||
"time_range": {},
|
||||
"constraints": {},
|
||||
"risk_signals": ["unknown"],
|
||||
"next_step": "run_rule"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Hermes 每日风险巡检
|
||||
|
||||
任务配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "reimbursement",
|
||||
"scenario": "daily_risk_scan",
|
||||
"intent": "monitor",
|
||||
"entities": [],
|
||||
"time_range": {
|
||||
"raw": "昨日"
|
||||
},
|
||||
"constraints": {
|
||||
"risk_level": ["medium", "high"]
|
||||
},
|
||||
"risk_signals": [
|
||||
"duplicate_invoice",
|
||||
"missing_attachment",
|
||||
"policy_overrun"
|
||||
],
|
||||
"next_step": "run_rule"
|
||||
}
|
||||
```
|
||||
|
||||
BIN
server/rules/finance-rules/公司通信费报销规则.xlsx
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
29
server/rules/risk-rules/risk.expense.reason_too_brief.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
30
server/rules/risk-rules/risk.invoice.cross_year_invoice.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
29
server/rules/risk-rules/risk.invoice.duplicate_invoice.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
28
server/scripts/sync_platform_risk_rules.py
Normal 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()
|
||||
13
server/scripts/test_rule_json_api.py
Normal 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"))
|
||||
@@ -17,11 +17,12 @@ def get_db() -> Generator[Session, None, None]:
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CurrentUserContext:
|
||||
username: str
|
||||
name: str
|
||||
role_codes: list[str]
|
||||
is_admin: bool
|
||||
class CurrentUserContext:
|
||||
username: str
|
||||
name: str
|
||||
role_codes: list[str]
|
||||
is_admin: bool
|
||||
department_name: str = ""
|
||||
|
||||
|
||||
def get_current_user(
|
||||
@@ -41,6 +42,10 @@ def get_current_user(
|
||||
str | None,
|
||||
Header(description="是否管理员,支持 `true/false/1/0`。"),
|
||||
] = None,
|
||||
x_auth_department: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的所属部门。"),
|
||||
] = None,
|
||||
) -> CurrentUserContext:
|
||||
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"}
|
||||
@@ -56,10 +61,11 @@ def get_current_user(
|
||||
|
||||
return CurrentUserContext(
|
||||
username=username or name,
|
||||
name=name or username,
|
||||
role_codes=role_codes,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
name=name or username,
|
||||
role_codes=role_codes,
|
||||
is_admin=is_admin,
|
||||
department_name=(x_auth_department or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def require_admin_user(
|
||||
|
||||
@@ -23,7 +23,9 @@ from app.schemas.agent_asset import (
|
||||
AgentAssetRead,
|
||||
AgentAssetReviewCreate,
|
||||
AgentAssetReviewRead,
|
||||
AgentAssetVersionCompareRead,
|
||||
AgentAssetRuleJsonRead,
|
||||
AgentAssetRuleJsonWrite,
|
||||
AgentAssetSpreadsheetChangeRecordRead,
|
||||
AgentAssetUpdate,
|
||||
AgentAssetVersionCreate,
|
||||
AgentAssetVersionRead,
|
||||
@@ -49,7 +51,7 @@ RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_u
|
||||
|
||||
|
||||
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
|
||||
if isinstance(exc, PermissionError):
|
||||
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
|
||||
|
||||
|
||||
@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(
|
||||
"/{asset_id}/spreadsheet/onlyoffice-config",
|
||||
response_model=AgentAssetOnlyOfficeConfigRead,
|
||||
@@ -122,7 +166,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
|
||||
db: DbSession,
|
||||
version: Annotated[
|
||||
str | None,
|
||||
Query(description="可选的规则版本号;不传时默认当前版本。"),
|
||||
Query(description="兼容旧前端的可选参数;表格规则始终打开当前规则表。"),
|
||||
] = None,
|
||||
) -> AgentAssetOnlyOfficeConfigRead:
|
||||
try:
|
||||
@@ -139,7 +183,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
|
||||
"/{asset_id}/spreadsheet/content",
|
||||
response_class=FileResponse,
|
||||
summary="下载或预览规则 Excel 文件",
|
||||
description="按版本返回规则的 Excel 快照,用于浏览器预览或下载。",
|
||||
description="返回当前规则 Excel 文件,用于浏览器预览或下载。",
|
||||
)
|
||||
def get_agent_asset_spreadsheet_content(
|
||||
asset_id: str,
|
||||
@@ -147,7 +191,7 @@ def get_agent_asset_spreadsheet_content(
|
||||
db: DbSession,
|
||||
version: Annotated[
|
||||
str | None,
|
||||
Query(description="可选的规则版本号;不传时默认当前版本。"),
|
||||
Query(description="兼容旧前端的可选参数;不传时返回当前规则表。"),
|
||||
] = None,
|
||||
) -> FileResponse:
|
||||
try:
|
||||
@@ -170,18 +214,18 @@ def get_agent_asset_spreadsheet_content(
|
||||
def get_agent_asset_spreadsheet_onlyoffice_content(
|
||||
asset_id: str,
|
||||
db: DbSession,
|
||||
version: Annotated[
|
||||
str,
|
||||
Query(min_length=1, description="规则版本号。"),
|
||||
],
|
||||
access_token: Annotated[
|
||||
str,
|
||||
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
|
||||
],
|
||||
version: Annotated[
|
||||
str | None,
|
||||
Query(description="兼容旧 ONLYOFFICE URL;当前表格模式不再使用。"),
|
||||
] = None,
|
||||
) -> FileResponse:
|
||||
try:
|
||||
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(
|
||||
asset_id,
|
||||
version=version,
|
||||
@@ -201,7 +245,7 @@ def get_agent_asset_spreadsheet_onlyoffice_content(
|
||||
response_model=AgentAssetRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="上传规则 Excel 文件",
|
||||
description="为指定规则上传新的 Excel 快照,并自动生成新规则版本。",
|
||||
description="为指定规则上传新的 Excel 文件,并记录本次表格修改。",
|
||||
)
|
||||
def upload_agent_asset_spreadsheet(
|
||||
asset_id: str,
|
||||
@@ -266,22 +310,27 @@ def import_agent_asset_spreadsheet_content(
|
||||
"/{asset_id}/spreadsheet/onlyoffice/callback",
|
||||
response_model=AgentAssetOnlyOfficeCallbackRead,
|
||||
summary="接收规则 Excel 的 ONLYOFFICE 回调",
|
||||
description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本。",
|
||||
description="接收 ONLYOFFICE 回写内容,并记录本次表格修改。",
|
||||
)
|
||||
def handle_agent_asset_spreadsheet_onlyoffice_callback(
|
||||
asset_id: str,
|
||||
payload: AgentAssetOnlyOfficeCallbackWrite,
|
||||
db: DbSession,
|
||||
version: Annotated[
|
||||
str,
|
||||
Query(min_length=1, description="打开编辑器时对应的规则版本号。"),
|
||||
],
|
||||
str | None,
|
||||
Query(description="兼容旧 ONLYOFFICE 回调;当前表格模式不再使用。"),
|
||||
] = None,
|
||||
actor_name: Annotated[
|
||||
str | None,
|
||||
Query(description="发起编辑的用户显示名。"),
|
||||
] = None,
|
||||
) -> AgentAssetOnlyOfficeCallbackRead:
|
||||
try:
|
||||
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
|
||||
asset_id,
|
||||
version=version,
|
||||
payload=payload.model_dump(),
|
||||
actor_name=actor_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
@@ -289,6 +338,24 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
|
||||
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(
|
||||
"",
|
||||
response_model=AgentAssetRead,
|
||||
@@ -533,25 +600,3 @@ def get_agent_asset_version_timeline(
|
||||
except Exception as 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)
|
||||
|
||||
@@ -2,12 +2,19 @@ from __future__ import annotations
|
||||
|
||||
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 app.api.deps import get_db
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
@@ -44,6 +51,67 @@ def list_employees(
|
||||
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(
|
||||
"",
|
||||
response_model=EmployeeRead,
|
||||
|
||||
@@ -12,15 +12,21 @@ from app.schemas.reimbursement import (
|
||||
ExpenseClaimAttachmentActionResponse,
|
||||
ExpenseClaimActionResponse,
|
||||
ExpenseClaimAttachmentRead,
|
||||
ExpenseClaimApprovalPayload,
|
||||
ExpenseClaimItemCreate,
|
||||
ExpenseClaimItemActionResponse,
|
||||
ExpenseClaimItemUpdate,
|
||||
ExpenseClaimRead,
|
||||
ExpenseClaimReturnPayload,
|
||||
ExpenseClaimUpdate,
|
||||
ReimbursementCreate,
|
||||
ReimbursementRead,
|
||||
TravelReimbursementCalculatorRequest,
|
||||
TravelReimbursementCalculatorResponse,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.reimbursement import ReimbursementService
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
|
||||
router = APIRouter()
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -48,6 +54,29 @@ def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> Reimbur
|
||||
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(
|
||||
"/claims",
|
||||
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)
|
||||
|
||||
|
||||
@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(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimRead,
|
||||
@@ -77,6 +116,43 @@ def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -
|
||||
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(
|
||||
"/claims/{claim_id}/items/{item_id}",
|
||||
response_model=ExpenseClaimRead,
|
||||
@@ -415,11 +491,11 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
return claim
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除个人报销草稿",
|
||||
description="删除当前登录用户可见的草稿报销单。",
|
||||
@router.post(
|
||||
"/claims/{claim_id}/return",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="退回报销单",
|
||||
description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
@@ -427,7 +503,73 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"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")
|
||||
|
||||
return ExpenseClaimActionResponse(
|
||||
message=f"{claim.claim_no} 草稿已删除。",
|
||||
message=f"{claim.claim_no} 报销单已删除。",
|
||||
claim_id=claim.id,
|
||||
status="deleted",
|
||||
)
|
||||
|
||||
@@ -93,6 +93,10 @@ class ExpenseClaimItem(Base):
|
||||
|
||||
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):
|
||||
__tablename__ = "accounts_receivable"
|
||||
|
||||
@@ -56,6 +56,17 @@ class AgentAssetRepository:
|
||||
stmt = stmt.limit(limit)
|
||||
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:
|
||||
stmt = select(AgentAssetVersion).where(
|
||||
AgentAssetVersion.asset_id == asset_id,
|
||||
|
||||
@@ -28,6 +28,28 @@ class AuditLogRepository:
|
||||
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
|
||||
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:
|
||||
self.db.add(log)
|
||||
self.db.commit()
|
||||
|
||||
@@ -93,6 +93,22 @@ class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
|
||||
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):
|
||||
event_type: str
|
||||
version: str
|
||||
@@ -117,15 +133,15 @@ class AgentAssetSpreadsheetDiffSheetRead(BaseModel):
|
||||
change_type: str
|
||||
|
||||
|
||||
class AgentAssetVersionCompareRead(BaseModel):
|
||||
base_version: str
|
||||
target_version: str
|
||||
added_sheet_count: int = 0
|
||||
removed_sheet_count: int = 0
|
||||
changed_sheet_count: int = 0
|
||||
changed_cell_count: int = 0
|
||||
class AgentAssetSpreadsheetChangeRecordRead(BaseModel):
|
||||
id: str
|
||||
actor: str
|
||||
changed_at: datetime
|
||||
summary: str
|
||||
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = 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):
|
||||
@@ -162,6 +178,8 @@ class AgentAssetListItem(BaseModel):
|
||||
published_version: str | None
|
||||
working_version: str | None
|
||||
config_json: dict[str, Any]
|
||||
change_count: int = 0
|
||||
modified_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
@@ -12,8 +14,16 @@ class AuthUserRead(BaseModel):
|
||||
username: str
|
||||
name: str
|
||||
role: str
|
||||
department: str = ""
|
||||
departmentName: str = ""
|
||||
position: 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)
|
||||
email: EmailStr | str
|
||||
avatar: str
|
||||
|
||||
@@ -50,6 +50,7 @@ class EmployeeMetaRead(BaseModel):
|
||||
totalEmployees: int
|
||||
statusSummary: list[EmployeeStatusSummaryRead]
|
||||
roleOptions: list[EmployeeRoleOptionRead]
|
||||
organizationOptions: list[EmployeeOrganizationRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployeeRead(BaseModel):
|
||||
@@ -63,6 +64,7 @@ class EmployeeRead(BaseModel):
|
||||
position: str
|
||||
grade: str
|
||||
manager: str
|
||||
managerEmployeeNo: str | None = None
|
||||
financeOwner: str
|
||||
roles: 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, "入职日期")
|
||||
|
||||
|
||||
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):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
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)
|
||||
cost_center: str | None = Field(default=None, max_length=50)
|
||||
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
|
||||
password: str | None = Field(default=None, min_length=5, max_length=128)
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class ExpenseClaimItemRead(BaseModel):
|
||||
item_location: str
|
||||
item_amount: Decimal
|
||||
invoice_id: str | None
|
||||
is_system_generated: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -51,6 +52,7 @@ class ExpenseClaimAttachmentAnalysisRead(BaseModel):
|
||||
headline: str
|
||||
summary: str
|
||||
points: list[str] = Field(default_factory=list)
|
||||
rule_basis: list[str] = Field(default_factory=list)
|
||||
suggestion: str = ""
|
||||
|
||||
|
||||
@@ -112,6 +114,10 @@ class ExpenseClaimItemCreate(BaseModel):
|
||||
invoice_id: str | None = None
|
||||
|
||||
|
||||
class ExpenseClaimUpdate(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ExpenseClaimRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -148,11 +154,54 @@ class ExpenseClaimActionResponse(BaseModel):
|
||||
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):
|
||||
message: str
|
||||
claim_id: str
|
||||
item_id: str
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class UserAgentSuggestedAction(BaseModel):
|
||||
label: str = Field(description="建议动作文案。")
|
||||
action_type: str = Field(description="动作类型,例如 open_detail / create_draft。")
|
||||
description: str = Field(default="", description="动作说明。")
|
||||
payload: dict[str, Any] = Field(default_factory=dict, description="动作携带的结构化参数。")
|
||||
|
||||
|
||||
class UserAgentDraftPayload(BaseModel):
|
||||
@@ -85,6 +86,8 @@ class UserAgentReviewRiskBrief(BaseModel):
|
||||
title: str = Field(description="风险或注意事项标题。")
|
||||
level: str = Field(default="info", description="级别,例如 info / warning / high。")
|
||||
content: str = Field(description="面向用户展示的摘要说明。")
|
||||
detail: str = Field(default="", description="点击风险项后展示的详细解释。")
|
||||
suggestion: str = Field(default="", description="面向用户的处理建议。")
|
||||
|
||||
|
||||
class UserAgentReviewSlotCard(BaseModel):
|
||||
|
||||
84
server/src/app/services/agent_asset_rule_library.py
Normal 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())
|
||||
@@ -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_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
|
||||
FINANCE_RULES_LIBRARY = "finance-rules"
|
||||
RISK_RULES_LIBRARY = "risk-rules"
|
||||
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
||||
@@ -67,26 +69,13 @@ class AgentAssetSpreadsheetManager:
|
||||
actor_name: str,
|
||||
source: str = "upload",
|
||||
) -> RuleSpreadsheetMeta:
|
||||
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("规则表文件名不能为空。")
|
||||
if not content:
|
||||
raise ValueError("规则表文件内容不能为空。")
|
||||
|
||||
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_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",
|
||||
return self.store_rule_library_spreadsheet_snapshot(
|
||||
library=FINANCE_RULES_LIBRARY,
|
||||
asset_id=asset_id,
|
||||
version=version,
|
||||
file_name=file_name,
|
||||
content=content,
|
||||
actor_name=actor_name,
|
||||
source=source,
|
||||
)
|
||||
|
||||
@@ -115,7 +104,74 @@ class AgentAssetSpreadsheetManager:
|
||||
try:
|
||||
target_path.relative_to(self.rule_root)
|
||||
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.write_bytes(content)
|
||||
@@ -147,7 +203,7 @@ class AgentAssetSpreadsheetManager:
|
||||
try:
|
||||
resolved.relative_to(allowed_root)
|
||||
except ValueError:
|
||||
raise FileNotFoundError("规则表文件不存在。")
|
||||
raise FileNotFoundError("规则表文件不存在。") from None
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
@@ -228,11 +284,46 @@ class AgentAssetSpreadsheetManager:
|
||||
def build_company_travel_rule_template() -> bytes:
|
||||
standard_rows = [
|
||||
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
||||
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"],
|
||||
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"],
|
||||
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"],
|
||||
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"],
|
||||
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"],
|
||||
[
|
||||
"长途交通",
|
||||
"飞机、高铁、火车等跨城出行",
|
||||
"行程单、车票、发票",
|
||||
"据实报销",
|
||||
"超预算需直属领导审批",
|
||||
"优先选择公共交通",
|
||||
],
|
||||
[
|
||||
"住宿费",
|
||||
"出差住宿",
|
||||
"酒店发票、入住清单",
|
||||
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
|
||||
"超标需总监审批",
|
||||
"协议酒店优先",
|
||||
],
|
||||
[
|
||||
"市内交通",
|
||||
"出租车、网约车、地铁、公交",
|
||||
"发票或电子行程单",
|
||||
"150/天",
|
||||
"超限需补充说明",
|
||||
"夜间或无公共交通场景可豁免",
|
||||
],
|
||||
[
|
||||
"餐补",
|
||||
"出差期间日常补助",
|
||||
"无需票据",
|
||||
"120/天",
|
||||
"系统自动核定",
|
||||
"当天往返默认不享受",
|
||||
],
|
||||
[
|
||||
"招待餐费",
|
||||
"客户接待或项目宴请",
|
||||
"餐饮发票、参与人清单",
|
||||
"300/人",
|
||||
"需业务负责人审批",
|
||||
"需关联客户或项目",
|
||||
],
|
||||
]
|
||||
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:
|
||||
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="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
|
||||
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
|
||||
(
|
||||
'<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="/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(
|
||||
[
|
||||
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)
|
||||
]
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<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"/>'
|
||||
f'{"".join(overrides)}'
|
||||
"</Types>"
|
||||
@@ -331,9 +442,15 @@ def _build_root_rels_xml() -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/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"/>'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
|
||||
'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>"
|
||||
)
|
||||
|
||||
@@ -345,11 +462,16 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
sheet_count = len(sheets)
|
||||
return (
|
||||
'<?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">'
|
||||
'<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>"
|
||||
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>"
|
||||
'<HeadingPairs><vt:vector size="2" baseType="variant">'
|
||||
"<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>"
|
||||
)
|
||||
|
||||
@@ -357,7 +479,8 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
def _build_core_xml(created_at: str) -> str:
|
||||
return (
|
||||
'<?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:dcterms="http://purl.org/dc/terms/" '
|
||||
'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:
|
||||
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)
|
||||
]
|
||||
)
|
||||
@@ -412,10 +539,15 @@ def _build_styles_xml() -> str:
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
'<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>'
|
||||
'<cellStyleXfs count="1"><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>'
|
||||
'<cellStyleXfs count="1">'
|
||||
'<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>'
|
||||
'</styleSheet>'
|
||||
)
|
||||
|
||||
@@ -18,6 +18,50 @@ STATEFUL_CONTEXT_KEYS = (
|
||||
"attachment_count",
|
||||
"ocr_summary",
|
||||
"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
|
||||
|
||||
@@ -39,6 +83,7 @@ class AgentConversationService:
|
||||
normalized_id = str(conversation_id or "").strip()
|
||||
normalized_user_id = str(user_id or "").strip() or None
|
||||
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
|
||||
if conversation is not None and conversation.user_id != normalized_user_id:
|
||||
normalized_id = ""
|
||||
@@ -56,6 +101,7 @@ class AgentConversationService:
|
||||
source=source,
|
||||
entry_source=str(context_json.get("entry_source") or "").strip() or None,
|
||||
title=self._resolve_title(context_json),
|
||||
draft_claim_id=incoming_draft_claim_id or None,
|
||||
state_json=self._extract_state_json(context_json),
|
||||
)
|
||||
self.db.add(conversation)
|
||||
@@ -69,6 +115,8 @@ class AgentConversationService:
|
||||
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
|
||||
if not conversation.title:
|
||||
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._extract_state_json(context_json),
|
||||
@@ -86,7 +134,11 @@ class AgentConversationService:
|
||||
resolved_retention_days = retention_days or self._resolve_retention_days()
|
||||
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
|
||||
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:
|
||||
return 0
|
||||
|
||||
@@ -96,6 +148,13 @@ class AgentConversationService:
|
||||
self.db.commit()
|
||||
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:
|
||||
try:
|
||||
settings_row, _ = SettingsService(self.db).ensure_settings_ready()
|
||||
@@ -178,10 +237,18 @@ class AgentConversationService:
|
||||
*,
|
||||
conversation: AgentConversation,
|
||||
context_json: dict[str, Any],
|
||||
message: str | None = None,
|
||||
history_limit: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
merged = dict(context_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_history"] = self.list_message_history(
|
||||
@@ -192,16 +259,58 @@ class AgentConversationService:
|
||||
merged.setdefault("conversation_scenario", conversation.last_scenario)
|
||||
if 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["conversation_state"] = state_json
|
||||
|
||||
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)):
|
||||
merged[key] = state_json.get(key)
|
||||
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -354,6 +463,38 @@ class AgentConversationService:
|
||||
self.db.commit()
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -478,11 +619,28 @@ class AgentConversationService:
|
||||
continue
|
||||
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:
|
||||
state_json["draft_claim_id"] = draft_claim_id
|
||||
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
|
||||
def _merge_state_json(
|
||||
current_state: dict[str, Any] | None,
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
from app.schemas.audit_log import AuditLogRead
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
|
||||
logger = get_logger("app.services.audit")
|
||||
|
||||
|
||||
class AuditLogService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.repository = AuditLogRepository(db)
|
||||
|
||||
def list_logs(
|
||||
self,
|
||||
*,
|
||||
resource_type: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
action: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[AuditLogRead]:
|
||||
self._ensure_ready()
|
||||
items = self.repository.list(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
action=action,
|
||||
limit=limit,
|
||||
)
|
||||
return [AuditLogRead.model_validate(item) for item in items]
|
||||
|
||||
def log_action(
|
||||
self,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
before_json: dict[str, Any] | None = None,
|
||||
after_json: dict[str, Any] | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> AuditLog:
|
||||
log = AuditLog(
|
||||
actor=actor,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
before_json=before_json,
|
||||
after_json=after_json,
|
||||
request_id=request_id or uuid.uuid4().hex,
|
||||
)
|
||||
created = self.repository.create(log)
|
||||
logger.info(
|
||||
"Created audit log id=%s action=%s resource=%s:%s",
|
||||
created.id,
|
||||
created.action,
|
||||
created.resource_type,
|
||||
created.resource_id,
|
||||
)
|
||||
return created
|
||||
|
||||
def _ensure_ready(self) -> None:
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
from app.schemas.audit_log import AuditLogRead
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
|
||||
logger = get_logger("app.services.audit")
|
||||
|
||||
|
||||
class AuditLogService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.repository = AuditLogRepository(db)
|
||||
|
||||
def list_logs(
|
||||
self,
|
||||
*,
|
||||
resource_type: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
action: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[AuditLogRead]:
|
||||
self._ensure_ready()
|
||||
items = self.repository.list(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
action=action,
|
||||
limit=limit,
|
||||
)
|
||||
return [AuditLogRead.model_validate(item) for item in items]
|
||||
|
||||
def log_action(
|
||||
self,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
before_json: dict[str, Any] | None = None,
|
||||
after_json: dict[str, Any] | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> AuditLog:
|
||||
log = AuditLog(
|
||||
actor=actor,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
before_json=before_json,
|
||||
after_json=after_json,
|
||||
request_id=request_id or uuid.uuid4().hex,
|
||||
)
|
||||
created = self.repository.create(log)
|
||||
logger.info(
|
||||
"Created audit log id=%s action=%s resource=%s:%s",
|
||||
created.id,
|
||||
created.action,
|
||||
created.resource_type,
|
||||
created.resource_id,
|
||||
)
|
||||
return created
|
||||
|
||||
def _ensure_ready(self) -> None:
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import verify_password
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
||||
from app.services.employee import EmployeeService
|
||||
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
||||
@@ -31,8 +34,15 @@ class AuthenticatedUser:
|
||||
username: str
|
||||
name: str
|
||||
role: str
|
||||
department: str
|
||||
position: 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]
|
||||
email: str
|
||||
avatar: str
|
||||
@@ -78,8 +88,15 @@ class AuthService:
|
||||
username=admin_username or admin_email,
|
||||
name=display_name,
|
||||
role="管理员",
|
||||
department="",
|
||||
position="系统管理员",
|
||||
grade="",
|
||||
employee_no="",
|
||||
manager_name="",
|
||||
location="",
|
||||
cost_center="",
|
||||
finance_owner_name="",
|
||||
risk_profile={},
|
||||
role_codes=["manager"],
|
||||
email=admin_email or f"{admin_username}@local",
|
||||
avatar=display_name[:1].upper(),
|
||||
@@ -94,7 +111,11 @@ class AuthService:
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.roles))
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
)
|
||||
.where(func.lower(Employee.email) == identifier.lower())
|
||||
)
|
||||
employee = self.db.execute(stmt).scalars().first()
|
||||
@@ -115,27 +136,91 @@ class AuthService:
|
||||
)
|
||||
role_codes = [role.role_code for role in sorted_roles]
|
||||
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(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
||||
department=department,
|
||||
position=employee.position,
|
||||
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"],
|
||||
email=employee.email,
|
||||
avatar=(employee.name or "?")[:1].upper(),
|
||||
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
|
||||
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
|
||||
return AuthUserRead(
|
||||
username=user.username,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
department=user.department,
|
||||
departmentName=user.department,
|
||||
position=user.position,
|
||||
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,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
|
||||
@@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
||||
scene_code="travel",
|
||||
scene_label="差旅票据",
|
||||
expense_type="travel",
|
||||
keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"),
|
||||
keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"),
|
||||
score_bias=0.32,
|
||||
),
|
||||
DocumentRule(
|
||||
@@ -104,7 +104,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
||||
scene_code="transport",
|
||||
scene_label="交通票据",
|
||||
expense_type="transport",
|
||||
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
|
||||
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "乘车", "用车", "叫车", "车费", "车资", "的士", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
|
||||
score_bias=0.38,
|
||||
),
|
||||
DocumentRule(
|
||||
@@ -177,13 +177,14 @@ SUPPORTED_DOCUMENT_TYPES = tuple(DOCUMENT_TYPE_RULE_MAP.keys()) + ("other",)
|
||||
|
||||
AMOUNT_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)"
|
||||
r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
||||
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*元"),
|
||||
)
|
||||
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_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})")
|
||||
@@ -192,6 +193,58 @@ MERCHANT_PATTERNS = (
|
||||
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[::\s]*([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:
|
||||
@@ -212,7 +265,10 @@ class DocumentIntelligenceService:
|
||||
compact = re.sub(r"\s+", "", raw_text).lower()
|
||||
rule_match = _match_document_rule(compact)
|
||||
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(
|
||||
document_type=base_rule.document_type,
|
||||
document_type_label=base_rule.document_type_label,
|
||||
@@ -275,7 +331,10 @@ class DocumentIntelligenceService:
|
||||
for item in parsed.evidence
|
||||
if str(item or "").strip()
|
||||
][: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(
|
||||
document_type=normalized_type,
|
||||
@@ -312,7 +371,10 @@ class DocumentIntelligenceService:
|
||||
scene_code=rule_insight.scene_code,
|
||||
scene_label=rule_insight.scene_label,
|
||||
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_confidence=rule_insight.classification_confidence,
|
||||
evidence=rule_insight.evidence,
|
||||
@@ -337,7 +399,10 @@ class DocumentIntelligenceService:
|
||||
scene_code=rule_insight.scene_code,
|
||||
scene_label=rule_insight.scene_label,
|
||||
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_confidence=rule_insight.classification_confidence,
|
||||
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_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,
|
||||
fields=merged_fields,
|
||||
fields=_apply_document_type_field_labels(merged_fields, rule.document_type),
|
||||
classification_source=source,
|
||||
classification_confidence=max(parsed.confidence, rule_insight.classification_confidence),
|
||||
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 ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
|
||||
):
|
||||
return "amount"
|
||||
if compact_key in {"date", "time", "issued_at", "invoice_date"} or any(
|
||||
token in compact_label for token in ("日期", "时间", "开票日期", "发生时间")
|
||||
if compact_key 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"
|
||||
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 ""
|
||||
text_value = format(candidate.quantize(Decimal("0.01")), "f").rstrip("0").rstrip(".")
|
||||
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)
|
||||
if key == "route":
|
||||
return _extract_route(raw_value) or _clean_field_value(
|
||||
@@ -517,6 +623,8 @@ def _llm_document_field_label(key: str) -> str:
|
||||
return {
|
||||
"amount": "金额",
|
||||
"date": "日期",
|
||||
"invoice_date": "开票日期",
|
||||
"trip_date": "行程日期",
|
||||
"merchant_name": "商户",
|
||||
"invoice_number": "票据号码",
|
||||
"invoice_code": "发票代码",
|
||||
@@ -525,6 +633,35 @@ def _llm_document_field_label(key: str) -> str:
|
||||
}.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(
|
||||
base_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)
|
||||
|
||||
|
||||
def _extract_document_fields(text: str) -> list[DocumentField]:
|
||||
def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
|
||||
fields: list[DocumentField] = []
|
||||
amount = _extract_amount(text)
|
||||
if 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:
|
||||
fields.append(DocumentField(key="date", label="日期", value=date_value))
|
||||
|
||||
@@ -584,6 +721,8 @@ def _extract_amount(text: str) -> str:
|
||||
continue
|
||||
if candidate <= Decimal("0.00"):
|
||||
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:
|
||||
best_value = candidate
|
||||
|
||||
@@ -594,10 +733,49 @@ def _extract_amount(text: str) -> str:
|
||||
return f"{text_value}元"
|
||||
|
||||
|
||||
def _extract_date(text: str) -> str:
|
||||
match = DATE_PATTERN.search(text)
|
||||
if not match:
|
||||
def _is_amount_match_date_fragment(amount: Decimal, text: str, start: int, end: int) -> bool:
|
||||
if start < 0 or end < 0:
|
||||
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 ""
|
||||
|
||||
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()
|
||||
normalized = raw_value.replace("年", "-").replace("月", "-").replace("日", "")
|
||||
normalized = normalized.replace("/", "-").replace(".", "-")
|
||||
@@ -605,7 +783,60 @@ def _extract_date(text: str) -> str:
|
||||
if len(parts) != 3:
|
||||
return raw_value
|
||||
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:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
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 app.core.config import get_settings
|
||||
@@ -20,6 +21,9 @@ from app.repositories.employee import EmployeeRepository
|
||||
from app.schemas.employee import (
|
||||
EmployeeCreate,
|
||||
EmployeeHistoryRead,
|
||||
EmployeeImportErrorRead,
|
||||
EmployeeImportResultRead,
|
||||
EmployeeImportSummaryRead,
|
||||
EmployeeMetaRead,
|
||||
EmployeeOrganizationRead,
|
||||
EmployeeRead,
|
||||
@@ -27,8 +31,16 @@ from app.schemas.employee import (
|
||||
EmployeeStatusSummaryRead,
|
||||
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 (
|
||||
EMPLOYEE_DEFINITIONS,
|
||||
EMPLOYEE_PROFILE_REPAIRS,
|
||||
ORGANIZATION_DEFINITIONS,
|
||||
ROLE_DEFINITIONS,
|
||||
ROLE_DISPLAY_ORDER,
|
||||
@@ -37,6 +49,8 @@ from app.services.employee_seed import (
|
||||
|
||||
logger = get_logger("app.services.employee")
|
||||
DEFAULT_EMPLOYEE_PASSWORD = "123456"
|
||||
MAX_EMPLOYEE_CHANGE_LOGS = 5
|
||||
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
STATUS_TONE_MAP = {
|
||||
"在职": "success",
|
||||
@@ -57,7 +71,9 @@ def prepare_employee_directory() -> None:
|
||||
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
EmployeeService(db).ensure_directory_ready()
|
||||
service = EmployeeService(db)
|
||||
service.ensure_directory_ready()
|
||||
service.apply_profile_repairs()
|
||||
|
||||
|
||||
class EmployeeService:
|
||||
@@ -120,10 +136,27 @@ class EmployeeService:
|
||||
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(
|
||||
totalEmployees=len(employees),
|
||||
statusSummary=status_summary,
|
||||
roleOptions=role_options,
|
||||
organizationOptions=organization_options,
|
||||
)
|
||||
|
||||
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
|
||||
@@ -152,7 +185,7 @@ class EmployeeService:
|
||||
sync_state=payload.sync_state,
|
||||
spotlight=payload.spotlight,
|
||||
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||
last_sync_at=datetime.now(),
|
||||
last_sync_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
if payload.organization_unit_code:
|
||||
@@ -261,6 +294,43 @@ class EmployeeService:
|
||||
employee.finance_owner_name = finance_owner_name
|
||||
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:
|
||||
requested_codes = list(dict.fromkeys(payload.role_codes))
|
||||
roles: list[Role] = []
|
||||
@@ -280,7 +350,7 @@ class EmployeeService:
|
||||
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
|
||||
if next_role_codes != current_role_codes:
|
||||
employee.roles = sorted_roles
|
||||
changed_fields.append("系统角色")
|
||||
role_changed = True
|
||||
|
||||
if "password" in payload.model_fields_set and payload.password:
|
||||
password = payload.password.strip()
|
||||
@@ -289,10 +359,10 @@ class EmployeeService:
|
||||
employee.password_hash = hash_password(password)
|
||||
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)
|
||||
|
||||
now = datetime.now()
|
||||
now = datetime.now(UTC)
|
||||
employee.last_sync_at = now
|
||||
employee.sync_state = "已同步"
|
||||
|
||||
@@ -303,13 +373,25 @@ class EmployeeService:
|
||||
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:
|
||||
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
|
||||
|
||||
saved = self.repository.save(employee)
|
||||
hydrated = self.repository.get(saved.id)
|
||||
logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields))
|
||||
return self._serialize_employee(hydrated or saved)
|
||||
hydrated = self._save_employee_and_reload(employee)
|
||||
logger.info(
|
||||
"Updated employee id=%s fields=%s role_changed=%s",
|
||||
employee.id,
|
||||
",".join(changed_fields),
|
||||
role_changed,
|
||||
)
|
||||
return self._serialize_employee(hydrated)
|
||||
|
||||
def disable_employee(self, employee_id: str) -> EmployeeRead:
|
||||
self.ensure_directory_ready()
|
||||
@@ -321,17 +403,16 @@ class EmployeeService:
|
||||
if employee.employment_status == "停用":
|
||||
return self._serialize_employee(employee)
|
||||
|
||||
now = datetime.now()
|
||||
now = datetime.now(UTC)
|
||||
employee.employment_status = "停用"
|
||||
employee.sync_state = "已同步"
|
||||
employee.last_sync_at = now
|
||||
employee.spotlight = False
|
||||
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
|
||||
|
||||
saved = self.repository.save(employee)
|
||||
hydrated = self.repository.get(saved.id)
|
||||
hydrated = self._save_employee_and_reload(employee)
|
||||
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:
|
||||
self.ensure_directory_ready()
|
||||
@@ -343,16 +424,305 @@ class EmployeeService:
|
||||
if employee.employment_status != "停用":
|
||||
return self._serialize_employee(employee)
|
||||
|
||||
now = datetime.now()
|
||||
now = datetime.now(UTC)
|
||||
employee.employment_status = "在职"
|
||||
employee.sync_state = "已同步"
|
||||
employee.last_sync_at = now
|
||||
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
|
||||
|
||||
saved = self.repository.save(employee)
|
||||
hydrated = self.repository.get(saved.id)
|
||||
hydrated = self._save_employee_and_reload(employee)
|
||||
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:
|
||||
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||
@@ -471,6 +841,69 @@ class EmployeeService:
|
||||
|
||||
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:
|
||||
if not EXTRA_SEED_EMPLOYEE_NOS:
|
||||
return
|
||||
@@ -530,6 +963,12 @@ class EmployeeService:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
employee: Employee,
|
||||
@@ -542,10 +981,30 @@ class EmployeeService:
|
||||
employee=employee,
|
||||
action=action,
|
||||
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:
|
||||
organization = employee.organization_unit
|
||||
roles = self._sorted_roles(list(employee.roles))
|
||||
@@ -556,10 +1015,10 @@ class EmployeeService:
|
||||
EmployeeHistoryRead(
|
||||
action=item.action,
|
||||
owner=item.owner,
|
||||
time=self._format_datetime(item.occurred_at) or "",
|
||||
occurredAt=self._format_datetime(item.occurred_at) or "",
|
||||
time=self._format_history_datetime(item.occurred_at),
|
||||
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(
|
||||
@@ -571,6 +1030,7 @@ class EmployeeService:
|
||||
position=employee.position,
|
||||
grade=employee.grade,
|
||||
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 "",
|
||||
roles=role_labels,
|
||||
roleCodes=role_codes,
|
||||
@@ -648,11 +1108,30 @@ class EmployeeService:
|
||||
return None
|
||||
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
|
||||
def _format_datetime(value: datetime | None) -> str | None:
|
||||
if value is 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
|
||||
def _calculate_age(birth_date: date | None) -> int | None:
|
||||
|
||||
@@ -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_no": "E10018",
|
||||
|
||||
368
server/src/app/services/employee_spreadsheet.py
Normal 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)
|
||||
206
server/src/app/services/expense_amounts.py
Normal 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
|
||||
@@ -6,12 +6,17 @@ from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
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)
|
||||
|
||||
@@ -351,6 +356,11 @@ class TravelPolicyConfig(BaseModel):
|
||||
band_labels: 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_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)
|
||||
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||
train_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||
@@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService:
|
||||
).all()
|
||||
)
|
||||
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:
|
||||
version = self._get_current_version(asset)
|
||||
if version is None:
|
||||
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(
|
||||
markdown_content=str(version.content or ""),
|
||||
config_json=asset.config_json,
|
||||
)
|
||||
if not isinstance(runtime_payload, dict):
|
||||
spreadsheet_assets.append((asset, version))
|
||||
continue
|
||||
self._apply_runtime_payload(
|
||||
catalog,
|
||||
@@ -594,6 +622,15 @@ class ExpenseRuleRuntimeService:
|
||||
asset=asset,
|
||||
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
|
||||
|
||||
@@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService:
|
||||
)
|
||||
except ValidationError:
|
||||
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)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
@@ -27,6 +28,7 @@ class PreparedOcrInput:
|
||||
page_index: int | None = None
|
||||
preview_kind: str = ""
|
||||
preview_data_url: str = ""
|
||||
text_layer: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -38,6 +40,7 @@ class AggregatedOcrDocument:
|
||||
model: str = "PP-OCRv5_mobile"
|
||||
summary_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)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
lines: list[OcrRecognizeLineRead] = field(default_factory=list)
|
||||
@@ -112,12 +115,14 @@ class OcrService:
|
||||
|
||||
if suffix == ".pdf":
|
||||
try:
|
||||
text_layer = self._extract_pdf_text_layer(temp_path)
|
||||
prepared_inputs.extend(
|
||||
self._prepare_pdf_inputs(
|
||||
pdf_path=temp_path,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
@@ -261,6 +266,7 @@ class OcrService:
|
||||
filename: str,
|
||||
media_type: str,
|
||||
cleanup_paths: list[Path],
|
||||
text_layer: str = "",
|
||||
) -> list[PreparedOcrInput]:
|
||||
output_dir = pdf_path.with_suffix("")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -283,10 +289,33 @@ class OcrService:
|
||||
page_index=page_index,
|
||||
preview_kind="image" 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
|
||||
|
||||
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]:
|
||||
prefix = output_dir / "page"
|
||||
completed = subprocess.run(
|
||||
@@ -367,6 +396,8 @@ class OcrService:
|
||||
aggregated.preview_kind = descriptor.preview_kind
|
||||
if descriptor.preview_data_url and not aggregated.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()
|
||||
if page_summary:
|
||||
@@ -401,6 +432,20 @@ class OcrService:
|
||||
aggregated = aggregated_by_source.get(source_key)
|
||||
if aggregated is None:
|
||||
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(
|
||||
OcrRecognizeDocumentRead(
|
||||
filename=first_descriptor.filename,
|
||||
@@ -416,6 +461,13 @@ class OcrService:
|
||||
|
||||
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
|
||||
def _build_lines(
|
||||
items: list[dict],
|
||||
@@ -451,13 +503,26 @@ class OcrService:
|
||||
return summary
|
||||
|
||||
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)
|
||||
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(
|
||||
filename=aggregated.filename,
|
||||
summary=summary,
|
||||
text=full_text,
|
||||
preview_data_url=aggregated.preview_data_url,
|
||||
preview_data_url=preview_data_url,
|
||||
)
|
||||
warnings = list(aggregated.warnings)
|
||||
for warning in insight.warnings:
|
||||
@@ -493,8 +558,8 @@ class OcrService:
|
||||
)
|
||||
for field in insight.fields
|
||||
],
|
||||
preview_kind=aggregated.preview_kind,
|
||||
preview_data_url=aggregated.preview_data_url,
|
||||
preview_kind=preview_kind,
|
||||
preview_data_url=preview_data_url,
|
||||
warnings=warnings,
|
||||
lines=sorted(
|
||||
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
|
||||
def _cleanup_temp_paths(paths: list[Path]) -> None:
|
||||
for path in reversed(paths):
|
||||
|
||||
@@ -119,10 +119,11 @@ class OrchestratorService:
|
||||
context_json=context_json,
|
||||
)
|
||||
conversation_id = conversation.conversation_id
|
||||
context_json = self.conversation_service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json=context_json,
|
||||
)
|
||||
context_json = self.conversation_service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json=context_json,
|
||||
message=payload.message,
|
||||
)
|
||||
|
||||
route_json: dict[str, Any] = {
|
||||
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
||||
@@ -173,9 +174,11 @@ class OrchestratorService:
|
||||
task_asset=task_asset,
|
||||
)
|
||||
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
||||
requires_confirmation = (
|
||||
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||
)
|
||||
is_expense_review_action = self._is_expense_review_action(context_json)
|
||||
requires_confirmation = (
|
||||
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||
and not is_expense_review_action
|
||||
)
|
||||
|
||||
route_json = {
|
||||
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
||||
@@ -526,7 +529,11 @@ class OrchestratorService:
|
||||
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":
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
@@ -657,25 +664,38 @@ class OrchestratorService:
|
||||
),
|
||||
"draft_only": True,
|
||||
}
|
||||
fallback_factory = lambda exc: {
|
||||
"message": f"草稿生成暂时不可用,请稍后再试:{exc}",
|
||||
"degraded": True,
|
||||
}
|
||||
fallback_factory = lambda exc: {
|
||||
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
if ontology.scenario == "expense":
|
||||
tool_type = AgentToolType.DATABASE.value
|
||||
tool_name = "database.expense_claims.save_or_submit"
|
||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
fallback_factory = lambda exc: {
|
||||
"message": f"报销草稿落库失败,请稍后再试:{exc}",
|
||||
"degraded": True,
|
||||
}
|
||||
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||
is_persistence_action = self._is_expense_persistence_action(context_json)
|
||||
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(
|
||||
run_id=run_id,
|
||||
user_id=payload.user_id,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
fallback_factory = lambda exc: {
|
||||
"message": (
|
||||
f"报销草稿落库失败,请稍后再试:{exc}"
|
||||
if is_persistence_action
|
||||
else f"报销内容预览生成失败,请稍后再试:{exc}"
|
||||
),
|
||||
"degraded": True,
|
||||
}
|
||||
|
||||
tool_payload, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
@@ -781,10 +801,17 @@ class OrchestratorService:
|
||||
failed_tool_count=failed_tool_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_next_step(ontology: OntologyParseResult, source: str) -> str:
|
||||
if ontology.clarification_required:
|
||||
return "ask_clarification"
|
||||
@staticmethod
|
||||
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:
|
||||
return "ask_clarification"
|
||||
if ontology.intent == "draft":
|
||||
return "create_draft"
|
||||
if ontology.scenario == "knowledge" or ontology.intent == "explain":
|
||||
@@ -793,7 +820,28 @@ class OrchestratorService:
|
||||
return "run_rule"
|
||||
if ontology.intent in {"query", "compare"}:
|
||||
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
|
||||
def _flatten_capability_codes(
|
||||
@@ -1140,16 +1188,18 @@ class OrchestratorService:
|
||||
if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip()
|
||||
)
|
||||
)
|
||||
expense_types = list(
|
||||
dict.fromkeys(
|
||||
str(item.normalized_value or item.value or "").strip()
|
||||
for item in ontology.entities
|
||||
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
|
||||
)
|
||||
)
|
||||
status_values = list(
|
||||
dict.fromkeys(
|
||||
str(item.value).strip()
|
||||
expense_types = list(
|
||||
dict.fromkeys(
|
||||
str(item.normalized_value or item.value or "").strip()
|
||||
for item in ontology.entities
|
||||
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(
|
||||
dict.fromkeys(
|
||||
str(item.value).strip()
|
||||
for item in ontology.constraints
|
||||
if item.field == "status" and item.operator == "=" and str(item.value).strip()
|
||||
)
|
||||
@@ -1164,10 +1214,24 @@ class OrchestratorService:
|
||||
|
||||
if expense_claim_nos:
|
||||
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
|
||||
if expense_types:
|
||||
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
||||
if status_values:
|
||||
conditions.append(ExpenseClaim.status.in_(status_values))
|
||||
if expense_types:
|
||||
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
||||
if 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:
|
||||
amount_value = float(item.value)
|
||||
@@ -1226,11 +1290,31 @@ class OrchestratorService:
|
||||
scoped_to_current_user = True
|
||||
else:
|
||||
scope_label = "全部报销单"
|
||||
|
||||
return conditions, scope_label, scoped_to_current_user
|
||||
|
||||
def _build_current_user_claim_conditions(
|
||||
self,
|
||||
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None,
|
||||
context_json: dict[str, Any],
|
||||
|
||||
77
server/src/app/services/risk_ontology_bridge.py
Normal 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()
|
||||
593
server/src/app/services/travel_reimbursement_calculator.py
Normal 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'))}"
|
||||
@@ -1,139 +1,139 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/app/__init__.py
|
||||
src/app/main.py
|
||||
src/app/api/__init__.py
|
||||
src/app/api/deps.py
|
||||
src/app/api/router.py
|
||||
src/app/api/v1/__init__.py
|
||||
src/app/api/v1/router.py
|
||||
src/app/api/v1/endpoints/__init__.py
|
||||
src/app/api/v1/endpoints/agent_assets.py
|
||||
src/app/api/v1/endpoints/agent_runs.py
|
||||
src/app/api/v1/endpoints/audit_logs.py
|
||||
src/app/api/v1/endpoints/auth.py
|
||||
src/app/api/v1/endpoints/bootstrap.py
|
||||
src/app/api/v1/endpoints/employees.py
|
||||
src/app/api/v1/endpoints/health.py
|
||||
src/app/api/v1/endpoints/knowledge.py
|
||||
src/app/api/v1/endpoints/ocr.py
|
||||
src/app/api/v1/endpoints/ontology.py
|
||||
src/app/api/v1/endpoints/orchestrator.py
|
||||
src/app/api/v1/endpoints/reimbursements.py
|
||||
src/app/api/v1/endpoints/settings.py
|
||||
src/app/api/v1/endpoints/system_logs.py
|
||||
src/app/core/__init__.py
|
||||
src/app/core/admin_secret.py
|
||||
src/app/core/agent_enums.py
|
||||
src/app/core/bootstrap.py
|
||||
src/app/core/config.py
|
||||
src/app/core/logging.py
|
||||
src/app/core/openapi.py
|
||||
src/app/core/secret_box.py
|
||||
src/app/core/security.py
|
||||
src/app/db/__init__.py
|
||||
src/app/db/base.py
|
||||
src/app/db/base_class.py
|
||||
src/app/db/session.py
|
||||
src/app/middleware/__init__.py
|
||||
src/app/middleware/logging.py
|
||||
src/app/models/__init__.py
|
||||
src/app/models/agent_asset.py
|
||||
src/app/models/agent_conversation.py
|
||||
src/app/models/agent_run.py
|
||||
src/app/models/approval.py
|
||||
src/app/models/audit_log.py
|
||||
src/app/models/employee.py
|
||||
src/app/models/employee_change_log.py
|
||||
src/app/models/financial_record.py
|
||||
src/app/models/organization.py
|
||||
src/app/models/reimbursement.py
|
||||
src/app/models/role.py
|
||||
src/app/models/system_model_setting.py
|
||||
src/app/models/system_setting.py
|
||||
src/app/models/system_setting_secret.py
|
||||
src/app/repositories/__init__.py
|
||||
src/app/repositories/agent_asset.py
|
||||
src/app/repositories/agent_run.py
|
||||
src/app/repositories/audit_log.py
|
||||
src/app/repositories/employee.py
|
||||
src/app/repositories/reimbursement.py
|
||||
src/app/repositories/settings.py
|
||||
src/app/schemas/__init__.py
|
||||
src/app/schemas/agent_asset.py
|
||||
src/app/schemas/agent_run.py
|
||||
src/app/schemas/audit_log.py
|
||||
src/app/schemas/auth.py
|
||||
src/app/schemas/bootstrap.py
|
||||
src/app/schemas/common.py
|
||||
src/app/schemas/employee.py
|
||||
src/app/schemas/knowledge.py
|
||||
src/app/schemas/ocr.py
|
||||
src/app/schemas/ontology.py
|
||||
src/app/schemas/orchestrator.py
|
||||
src/app/schemas/reimbursement.py
|
||||
src/app/schemas/settings.py
|
||||
src/app/schemas/system_log.py
|
||||
src/app/schemas/user_agent.py
|
||||
src/app/services/__init__.py
|
||||
src/app/services/agent_asset_spreadsheet.py
|
||||
src/app/services/agent_assets.py
|
||||
src/app/services/agent_conversations.py
|
||||
src/app/services/agent_foundation.py
|
||||
src/app/services/agent_runs.py
|
||||
src/app/services/audit.py
|
||||
src/app/services/auth.py
|
||||
src/app/services/document_intelligence.py
|
||||
src/app/services/employee.py
|
||||
src/app/services/employee_seed.py
|
||||
src/app/services/expense_claims.py
|
||||
src/app/services/expense_rule_runtime.py
|
||||
src/app/services/hermes_sync.py
|
||||
src/app/services/knowledge.py
|
||||
src/app/services/knowledge_index_tasks.py
|
||||
src/app/services/knowledge_normalizer.py
|
||||
src/app/services/knowledge_rag.py
|
||||
src/app/services/knowledge_scheduler.py
|
||||
src/app/services/knowledge_sync.py
|
||||
src/app/services/model_connectivity.py
|
||||
src/app/services/ocr.py
|
||||
src/app/services/ontology.py
|
||||
src/app/services/orchestrator.py
|
||||
src/app/services/reimbursement.py
|
||||
src/app/services/runtime_chat.py
|
||||
src/app/services/settings.py
|
||||
src/app/services/system_hermes.py
|
||||
src/app/services/system_logs.py
|
||||
src/app/services/user_agent.py
|
||||
src/x_financial_server.egg-info/PKG-INFO
|
||||
src/x_financial_server.egg-info/SOURCES.txt
|
||||
src/x_financial_server.egg-info/dependency_links.txt
|
||||
src/x_financial_server.egg-info/requires.txt
|
||||
src/x_financial_server.egg-info/top_level.txt
|
||||
tests/test_agent_asset_onlyoffice_key.py
|
||||
tests/test_agent_asset_service.py
|
||||
tests/test_agent_asset_spreadsheet_import.py
|
||||
tests/test_agent_foundation_endpoints.py
|
||||
tests/test_agent_runs_service.py
|
||||
tests/test_auth_service.py
|
||||
tests/test_config_settings_reload.py
|
||||
tests/test_document_intelligence.py
|
||||
tests/test_employee_service.py
|
||||
tests/test_env_file_precedence.py
|
||||
tests/test_expense_claim_service.py
|
||||
tests/test_imports.py
|
||||
tests/test_knowledge_normalizer.py
|
||||
tests/test_knowledge_onlyoffice_config.py
|
||||
tests/test_knowledge_rag_service.py
|
||||
tests/test_knowledge_service.py
|
||||
tests/test_ocr_endpoints.py
|
||||
tests/test_ocr_service.py
|
||||
tests/test_ontology_service.py
|
||||
tests/test_openapi_schema.py
|
||||
tests/test_reimbursement_endpoints.py
|
||||
tests/test_runtime_chat_service.py
|
||||
tests/test_server_start_dependencies.py
|
||||
tests/test_settings_persistence.py
|
||||
tests/test_settings_service.py
|
||||
tests/test_system_logs_service.py
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/app/__init__.py
|
||||
src/app/main.py
|
||||
src/app/api/__init__.py
|
||||
src/app/api/deps.py
|
||||
src/app/api/router.py
|
||||
src/app/api/v1/__init__.py
|
||||
src/app/api/v1/router.py
|
||||
src/app/api/v1/endpoints/__init__.py
|
||||
src/app/api/v1/endpoints/agent_assets.py
|
||||
src/app/api/v1/endpoints/agent_runs.py
|
||||
src/app/api/v1/endpoints/audit_logs.py
|
||||
src/app/api/v1/endpoints/auth.py
|
||||
src/app/api/v1/endpoints/bootstrap.py
|
||||
src/app/api/v1/endpoints/employees.py
|
||||
src/app/api/v1/endpoints/health.py
|
||||
src/app/api/v1/endpoints/knowledge.py
|
||||
src/app/api/v1/endpoints/ocr.py
|
||||
src/app/api/v1/endpoints/ontology.py
|
||||
src/app/api/v1/endpoints/orchestrator.py
|
||||
src/app/api/v1/endpoints/reimbursements.py
|
||||
src/app/api/v1/endpoints/settings.py
|
||||
src/app/api/v1/endpoints/system_logs.py
|
||||
src/app/core/__init__.py
|
||||
src/app/core/admin_secret.py
|
||||
src/app/core/agent_enums.py
|
||||
src/app/core/bootstrap.py
|
||||
src/app/core/config.py
|
||||
src/app/core/logging.py
|
||||
src/app/core/openapi.py
|
||||
src/app/core/secret_box.py
|
||||
src/app/core/security.py
|
||||
src/app/db/__init__.py
|
||||
src/app/db/base.py
|
||||
src/app/db/base_class.py
|
||||
src/app/db/session.py
|
||||
src/app/middleware/__init__.py
|
||||
src/app/middleware/logging.py
|
||||
src/app/models/__init__.py
|
||||
src/app/models/agent_asset.py
|
||||
src/app/models/agent_conversation.py
|
||||
src/app/models/agent_run.py
|
||||
src/app/models/approval.py
|
||||
src/app/models/audit_log.py
|
||||
src/app/models/employee.py
|
||||
src/app/models/employee_change_log.py
|
||||
src/app/models/financial_record.py
|
||||
src/app/models/organization.py
|
||||
src/app/models/reimbursement.py
|
||||
src/app/models/role.py
|
||||
src/app/models/system_model_setting.py
|
||||
src/app/models/system_setting.py
|
||||
src/app/models/system_setting_secret.py
|
||||
src/app/repositories/__init__.py
|
||||
src/app/repositories/agent_asset.py
|
||||
src/app/repositories/agent_run.py
|
||||
src/app/repositories/audit_log.py
|
||||
src/app/repositories/employee.py
|
||||
src/app/repositories/reimbursement.py
|
||||
src/app/repositories/settings.py
|
||||
src/app/schemas/__init__.py
|
||||
src/app/schemas/agent_asset.py
|
||||
src/app/schemas/agent_run.py
|
||||
src/app/schemas/audit_log.py
|
||||
src/app/schemas/auth.py
|
||||
src/app/schemas/bootstrap.py
|
||||
src/app/schemas/common.py
|
||||
src/app/schemas/employee.py
|
||||
src/app/schemas/knowledge.py
|
||||
src/app/schemas/ocr.py
|
||||
src/app/schemas/ontology.py
|
||||
src/app/schemas/orchestrator.py
|
||||
src/app/schemas/reimbursement.py
|
||||
src/app/schemas/settings.py
|
||||
src/app/schemas/system_log.py
|
||||
src/app/schemas/user_agent.py
|
||||
src/app/services/__init__.py
|
||||
src/app/services/agent_asset_spreadsheet.py
|
||||
src/app/services/agent_assets.py
|
||||
src/app/services/agent_conversations.py
|
||||
src/app/services/agent_foundation.py
|
||||
src/app/services/agent_runs.py
|
||||
src/app/services/audit.py
|
||||
src/app/services/auth.py
|
||||
src/app/services/document_intelligence.py
|
||||
src/app/services/employee.py
|
||||
src/app/services/employee_seed.py
|
||||
src/app/services/expense_claims.py
|
||||
src/app/services/expense_rule_runtime.py
|
||||
src/app/services/hermes_sync.py
|
||||
src/app/services/knowledge.py
|
||||
src/app/services/knowledge_index_tasks.py
|
||||
src/app/services/knowledge_normalizer.py
|
||||
src/app/services/knowledge_rag.py
|
||||
src/app/services/knowledge_scheduler.py
|
||||
src/app/services/knowledge_sync.py
|
||||
src/app/services/model_connectivity.py
|
||||
src/app/services/ocr.py
|
||||
src/app/services/ontology.py
|
||||
src/app/services/orchestrator.py
|
||||
src/app/services/reimbursement.py
|
||||
src/app/services/runtime_chat.py
|
||||
src/app/services/settings.py
|
||||
src/app/services/system_hermes.py
|
||||
src/app/services/system_logs.py
|
||||
src/app/services/user_agent.py
|
||||
src/x_financial_server.egg-info/PKG-INFO
|
||||
src/x_financial_server.egg-info/SOURCES.txt
|
||||
src/x_financial_server.egg-info/dependency_links.txt
|
||||
src/x_financial_server.egg-info/requires.txt
|
||||
src/x_financial_server.egg-info/top_level.txt
|
||||
tests/test_agent_asset_onlyoffice_key.py
|
||||
tests/test_agent_asset_service.py
|
||||
tests/test_agent_asset_spreadsheet_import.py
|
||||
tests/test_agent_foundation_endpoints.py
|
||||
tests/test_agent_runs_service.py
|
||||
tests/test_auth_service.py
|
||||
tests/test_config_settings_reload.py
|
||||
tests/test_document_intelligence.py
|
||||
tests/test_employee_service.py
|
||||
tests/test_env_file_precedence.py
|
||||
tests/test_expense_claim_service.py
|
||||
tests/test_imports.py
|
||||
tests/test_knowledge_normalizer.py
|
||||
tests/test_knowledge_onlyoffice_config.py
|
||||
tests/test_knowledge_rag_service.py
|
||||
tests/test_knowledge_service.py
|
||||
tests/test_ocr_endpoints.py
|
||||
tests/test_ocr_service.py
|
||||
tests/test_ontology_service.py
|
||||
tests/test_openapi_schema.py
|
||||
tests/test_reimbursement_endpoints.py
|
||||
tests/test_runtime_chat_service.py
|
||||
tests/test_server_start_dependencies.py
|
||||
tests/test_settings_persistence.py
|
||||
tests/test_settings_service.py
|
||||
tests/test_system_logs_service.py
|
||||
tests/test_user_agent_service.py
|
||||
@@ -1,84 +1,84 @@
|
||||
{
|
||||
"file_name": "行程单_2_鄂AX9877.pdf",
|
||||
"storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 32459,
|
||||
"uploaded_at": "2026-05-16T08:41:42.540134+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "行程单_2_鄂AX9877.preview.png",
|
||||
"analysis": {
|
||||
"severity": "pass",
|
||||
"label": "AI提示符合条件",
|
||||
"headline": "AI提示:附件符合基础校验条件",
|
||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||
"points": [
|
||||
"票据类型:已识别为出租车/网约车票据。",
|
||||
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
|
||||
"金额字段:已识别到与当前明细接近的金额 35.53 元。"
|
||||
],
|
||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "taxi_receipt",
|
||||
"document_type_label": "出租车/网约车票据",
|
||||
"scene_code": "transport",
|
||||
"scene_label": "交通票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "35.53元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-03-04"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "全季酒店"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "transport",
|
||||
"current_expense_type_label": "交通费",
|
||||
"allowed_scene_labels": [
|
||||
"交通"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"停车/通行费票据",
|
||||
"一般收据/凭证",
|
||||
"出租车/网约车票据",
|
||||
"增值税发票"
|
||||
],
|
||||
"recognized_scene_code": "transport",
|
||||
"recognized_scene_label": "交通票据",
|
||||
"recognized_document_type": "taxi_receipt",
|
||||
"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行程单\nAMAP ITINERARY\n1申请时间:2026-03-04\n【行程时间:2026-03-0407:05至2026-03-0407:33\n|行程人手机号:18602700270\n1共计1单行程,合计35.53元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-04\n1\n滴滴出行\n武汉市\n全季酒店武汉工程大学店\n武汉站\n35.53元\n07:05\n页码:1/1",
|
||||
"ocr_summary": "高德地图一打车;行程单;AMAP ITINERARY",
|
||||
"ocr_avg_score": 0.9819406509399414,
|
||||
"ocr_line_count": 25,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"滴滴出行",
|
||||
"滴滴",
|
||||
"打车",
|
||||
"上车"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
{
|
||||
"file_name": "行程单_2_鄂AX9877.pdf",
|
||||
"storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 32459,
|
||||
"uploaded_at": "2026-05-16T08:41:42.540134+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "行程单_2_鄂AX9877.preview.png",
|
||||
"analysis": {
|
||||
"severity": "pass",
|
||||
"label": "AI提示符合条件",
|
||||
"headline": "AI提示:附件符合基础校验条件",
|
||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||
"points": [
|
||||
"票据类型:已识别为出租车/网约车票据。",
|
||||
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
|
||||
"金额字段:已识别到与当前明细接近的金额 35.53 元。"
|
||||
],
|
||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "taxi_receipt",
|
||||
"document_type_label": "出租车/网约车票据",
|
||||
"scene_code": "transport",
|
||||
"scene_label": "交通票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "35.53元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-03-04"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "全季酒店"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "transport",
|
||||
"current_expense_type_label": "交通费",
|
||||
"allowed_scene_labels": [
|
||||
"交通"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"停车/通行费票据",
|
||||
"一般收据/凭证",
|
||||
"出租车/网约车票据",
|
||||
"增值税发票"
|
||||
],
|
||||
"recognized_scene_code": "transport",
|
||||
"recognized_scene_label": "交通票据",
|
||||
"recognized_document_type": "taxi_receipt",
|
||||
"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行程单\nAMAP ITINERARY\n1申请时间:2026-03-04\n【行程时间:2026-03-0407:05至2026-03-0407:33\n|行程人手机号:18602700270\n1共计1单行程,合计35.53元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-04\n1\n滴滴出行\n武汉市\n全季酒店武汉工程大学店\n武汉站\n35.53元\n07:05\n页码:1/1",
|
||||
"ocr_summary": "高德地图一打车;行程单;AMAP ITINERARY",
|
||||
"ocr_avg_score": 0.9819406509399414,
|
||||
"ocr_line_count": 25,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"滴滴出行",
|
||||
"滴滴",
|
||||
"打车",
|
||||
"上车"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
@@ -1,84 +1,84 @@
|
||||
{
|
||||
"file_name": "行程单_1_鄂A1S987.pdf",
|
||||
"storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 34880,
|
||||
"uploaded_at": "2026-05-16T08:17:53.656595+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "行程单_1_鄂A1S987.preview.png",
|
||||
"analysis": {
|
||||
"severity": "pass",
|
||||
"label": "AI提示符合条件",
|
||||
"headline": "AI提示:附件符合基础校验条件",
|
||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||
"points": [
|
||||
"票据类型:已识别为出租车/网约车票据。",
|
||||
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
|
||||
"金额字段:已识别到与当前明细接近的金额 10.30 元。"
|
||||
],
|
||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "taxi_receipt",
|
||||
"document_type_label": "出租车/网约车票据",
|
||||
"scene_code": "transport",
|
||||
"scene_label": "交通票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "10.3元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-03-01"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "全季酒店"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "transport",
|
||||
"current_expense_type_label": "交通费",
|
||||
"allowed_scene_labels": [
|
||||
"交通"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"停车/通行费票据",
|
||||
"一般收据/凭证",
|
||||
"出租车/网约车票据",
|
||||
"增值税发票"
|
||||
],
|
||||
"recognized_scene_code": "transport",
|
||||
"recognized_scene_label": "交通票据",
|
||||
"recognized_document_type": "taxi_receipt",
|
||||
"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行程单\nAMAP ITINERARY\n1申请时间:2026-03-01\n【行程时间:2026-03-0113:23至2026-03-0113:40\n行程人手机号:18602700270\n|共计1单行程,合计10.30元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-01\n1\n滴滴出行\n13:23\n武汉市\n金融港北地铁站\n全季酒店武汉工程大学店\n10.30元\n页码:1/1",
|
||||
"ocr_summary": "高德地图一打车;行程单;AMAP ITINERARY",
|
||||
"ocr_avg_score": 0.9844024634361267,
|
||||
"ocr_line_count": 25,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"滴滴出行",
|
||||
"滴滴",
|
||||
"打车",
|
||||
"上车"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
{
|
||||
"file_name": "行程单_1_鄂A1S987.pdf",
|
||||
"storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.pdf",
|
||||
"media_type": "application/pdf",
|
||||
"size_bytes": 34880,
|
||||
"uploaded_at": "2026-05-16T08:17:53.656595+00:00",
|
||||
"previewable": true,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.preview.png",
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": "行程单_1_鄂A1S987.preview.png",
|
||||
"analysis": {
|
||||
"severity": "pass",
|
||||
"label": "AI提示符合条件",
|
||||
"headline": "AI提示:附件符合基础校验条件",
|
||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||
"points": [
|
||||
"票据类型:已识别为出租车/网约车票据。",
|
||||
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
|
||||
"金额字段:已识别到与当前明细接近的金额 10.30 元。"
|
||||
],
|
||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||
},
|
||||
"document_info": {
|
||||
"document_type": "taxi_receipt",
|
||||
"document_type_label": "出租车/网约车票据",
|
||||
"scene_code": "transport",
|
||||
"scene_label": "交通票据",
|
||||
"fields": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "金额",
|
||||
"value": "10.3元"
|
||||
},
|
||||
{
|
||||
"key": "date",
|
||||
"label": "日期",
|
||||
"value": "2026-03-01"
|
||||
},
|
||||
{
|
||||
"key": "merchant_name",
|
||||
"label": "商户",
|
||||
"value": "全季酒店"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirement_check": {
|
||||
"matches": true,
|
||||
"current_expense_type": "transport",
|
||||
"current_expense_type_label": "交通费",
|
||||
"allowed_scene_labels": [
|
||||
"交通"
|
||||
],
|
||||
"allowed_document_type_labels": [
|
||||
"停车/通行费票据",
|
||||
"一般收据/凭证",
|
||||
"出租车/网约车票据",
|
||||
"增值税发票"
|
||||
],
|
||||
"recognized_scene_code": "transport",
|
||||
"recognized_scene_label": "交通票据",
|
||||
"recognized_document_type": "taxi_receipt",
|
||||
"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行程单\nAMAP ITINERARY\n1申请时间:2026-03-01\n【行程时间:2026-03-0113:23至2026-03-0113:40\n行程人手机号:18602700270\n|共计1单行程,合计10.30元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-01\n1\n滴滴出行\n13:23\n武汉市\n金融港北地铁站\n全季酒店武汉工程大学店\n10.30元\n页码:1/1",
|
||||
"ocr_summary": "高德地图一打车;行程单;AMAP ITINERARY",
|
||||
"ocr_avg_score": 0.9844024634361267,
|
||||
"ocr_line_count": 25,
|
||||
"ocr_classification_source": "rule",
|
||||
"ocr_classification_confidence": 0.88,
|
||||
"ocr_classification_evidence": [
|
||||
"滴滴出行",
|
||||
"滴滴",
|
||||
"打车",
|
||||
"上车"
|
||||
],
|
||||
"ocr_warnings": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 134 KiB |
@@ -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": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 133 KiB |
@@ -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": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 133 KiB |
BIN
server/storage/font-test-after-install.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
@@ -20,7 +20,7 @@
|
||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
||||
"ingest_agent_run_id": "run_8b0ead1e3c734a53"
|
||||
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||
},
|
||||
{
|
||||
"id": "a8f8465df08e455ebe133351721d49f8",
|
||||
@@ -35,13 +35,13 @@
|
||||
"updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1,
|
||||
"ingest_status": 1,
|
||||
"ingest_status_updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||
"ingest_status": 4,
|
||||
"ingest_status_updated_at": "2026-05-20T16:00:02.515903+00:00",
|
||||
"ingest_completed_at": "",
|
||||
"ingest_document_name": "",
|
||||
"ingest_document_updated_at": "",
|
||||
"ingest_document_sha256": "",
|
||||
"ingest_agent_run_id": ""
|
||||
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,5 +24,28 @@
|
||||
"processing_start_time": 1779011842,
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,268 +1,268 @@
|
||||
{
|
||||
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||
"entity_names": [
|
||||
"工会委员会",
|
||||
"Business Original Documents",
|
||||
"First Approver",
|
||||
"P8",
|
||||
"一级部门总经理",
|
||||
"组织人事部",
|
||||
"业务原始凭据",
|
||||
"营销中心",
|
||||
"保证金",
|
||||
"投标保证金",
|
||||
"餐补",
|
||||
"第十四条业务招待费",
|
||||
"Chief Engineer",
|
||||
"业务招待",
|
||||
"Employee Welfare",
|
||||
"经济舱",
|
||||
"2024年4月17日",
|
||||
"三等舱",
|
||||
"财务信息化系统",
|
||||
"分管领导",
|
||||
"重点支出管理规定",
|
||||
"备用金借款",
|
||||
"Financial Review",
|
||||
"第五章附则",
|
||||
"Company Leadership",
|
||||
"第十九条",
|
||||
"经办人",
|
||||
"预算内支出",
|
||||
"Current Account Payment",
|
||||
"Business Entertainment",
|
||||
"Tax Control System Details",
|
||||
"第二十一条",
|
||||
"成本中心归属",
|
||||
"岗位支出报销审批权限表",
|
||||
"工会经费管理办法",
|
||||
"商旅系统",
|
||||
"Special Subsidy",
|
||||
"中国银行外汇折算价",
|
||||
"因公借款",
|
||||
"资产采购",
|
||||
"广告费",
|
||||
"First-Level Department General Manager",
|
||||
"正式员工",
|
||||
"一万元",
|
||||
"公司员工教育培训管理办法",
|
||||
"责任原则",
|
||||
"第二章职责分工",
|
||||
"预算先行",
|
||||
"Planning and Finance Department",
|
||||
"Accommodation Cost Reimbursement",
|
||||
"Official Vehicle Subsidy",
|
||||
"第四条归口管理部门主要职责",
|
||||
"Personal Service Compensation",
|
||||
"邮递费",
|
||||
"附表3:支出归口管理部门与归口业务范围",
|
||||
"员工",
|
||||
"第二条目的",
|
||||
"Director",
|
||||
"支出归口管理部门与归口业务范围",
|
||||
"其他支出(员工)",
|
||||
"报销标准",
|
||||
"5000000 Yuan Approval Limit",
|
||||
"第十一条备用金借款",
|
||||
"会议费",
|
||||
"第十七条",
|
||||
"第七条各级管理人员主要职责",
|
||||
"50000 Yuan Approval Limit",
|
||||
"全资子公司",
|
||||
"涉外业务汇率标准",
|
||||
"总监",
|
||||
"第十三条差旅费",
|
||||
"审批权限表",
|
||||
"商旅订票规范",
|
||||
"Final Approval Position",
|
||||
"报销资格",
|
||||
"新增报销规定",
|
||||
"公司支出管理办法",
|
||||
"Institution General Manager",
|
||||
"房屋租金",
|
||||
"Staff Activities",
|
||||
"分包外包(内部单位)",
|
||||
"报销申请时限",
|
||||
"Financial Information System",
|
||||
"Expenditure Authorization Approval Scope",
|
||||
"直辖市",
|
||||
"培训费",
|
||||
"第十二条市内交通费",
|
||||
"第十五条",
|
||||
"终审岗",
|
||||
"Remote Work Housing",
|
||||
"Centralized Management department",
|
||||
"第二十条",
|
||||
"办公室(党委办公室)",
|
||||
"Three Flows Consistency Principle",
|
||||
"审批权限",
|
||||
"VAT Special Invoice",
|
||||
"后勤服务部",
|
||||
"员工支出报销审批权限表",
|
||||
"公司总裁",
|
||||
"出差补贴",
|
||||
"Basic Level Managers",
|
||||
"预付款项",
|
||||
"附表1:员工支出报销审批权限表",
|
||||
"经办部门",
|
||||
"信息管理部",
|
||||
"通信费",
|
||||
"第十六条",
|
||||
"增值税发票",
|
||||
"财务入账条件",
|
||||
"Hotel Accommodation Standards",
|
||||
"审批流转程序",
|
||||
"Self-Driving Travel Provisions",
|
||||
"交通费",
|
||||
"第九条支出报销审批",
|
||||
"薪酬福利支出分配计划",
|
||||
"产品规划设计部",
|
||||
"因公用车补贴",
|
||||
"Committee Chairpersons",
|
||||
"Business Division General Manager",
|
||||
"组织安排",
|
||||
"1 Yuan Per Person Per Kilometer Reimbursement",
|
||||
"Separation of Approval and Processing Principle",
|
||||
"第五条计划财务部主要职责",
|
||||
"200000 Yuan Approval Limit",
|
||||
"公司各部门",
|
||||
"第十四条",
|
||||
"Other Areas",
|
||||
"分支机构",
|
||||
"Departments And Units",
|
||||
"计划财务部",
|
||||
"Other Employees",
|
||||
"第二十三条",
|
||||
"公司团建管理办法",
|
||||
"火车硬席",
|
||||
"税控系统明细清单",
|
||||
"Trade Union Fund",
|
||||
"报销标准变化情况",
|
||||
"薪酬福利支出",
|
||||
"Hong Kong, Macau, And Taiwan Region",
|
||||
"对外捐赠支出",
|
||||
"Multi-Level Approval Rule",
|
||||
"Three Working Days Deadline",
|
||||
"Employee Remuneration",
|
||||
"销售退款",
|
||||
"股权投资、兼并收购",
|
||||
"控股子公司",
|
||||
"取消报销规定",
|
||||
"Procurement Management Regulations",
|
||||
"Middle Managers",
|
||||
"差旅费",
|
||||
"批办分离",
|
||||
"住宿费",
|
||||
"Travel Allowance Standards",
|
||||
"第二十三条本办法的归口与实施",
|
||||
"Senior Vice President",
|
||||
"供应商",
|
||||
"人事归口管理部门",
|
||||
"Management Personnel At All Levels",
|
||||
"效益优先",
|
||||
"Operating Department Individual",
|
||||
"Remote Work Housing Rental Expenses",
|
||||
"取消报销规定内容",
|
||||
"Company",
|
||||
"修订说明",
|
||||
"国网数科公司",
|
||||
"Vice President",
|
||||
"分级授权",
|
||||
"Expenditure Reimbursement Application",
|
||||
"第二十四条附件",
|
||||
"第二十二条",
|
||||
"出租车",
|
||||
"Night High-Speed Rail Provision",
|
||||
"各级管理人员",
|
||||
"受益原则",
|
||||
"公司员工因公通讯费用实施细则",
|
||||
"公司支出管理办法(2024)",
|
||||
"出差补贴标准",
|
||||
"Bid Security Deposit Approval Limits Table",
|
||||
"第二条范围",
|
||||
"Company Property Rental Management",
|
||||
"调动工作",
|
||||
"远光软件股份有限公司",
|
||||
"市内交通费",
|
||||
"交通工具等级标准",
|
||||
"Operator",
|
||||
"第八条支出报销申请",
|
||||
"Directly-Controlled Municipalities And Special Administrative Regions",
|
||||
"出差规定",
|
||||
"业务招待费",
|
||||
"Senior Managers",
|
||||
"逐级审批规则",
|
||||
"Company Business Travel System",
|
||||
"广告宣传费",
|
||||
"Transportation Cost Reimbursement",
|
||||
"财务",
|
||||
"第一章总则",
|
||||
"材料采购",
|
||||
"人力资源服务部",
|
||||
"证券与法律事务部",
|
||||
"Transportation Level Standards",
|
||||
"归口管理部门",
|
||||
"商旅客服",
|
||||
"第四章重点支出管理规定",
|
||||
"出差审批程序",
|
||||
"Business Trip Approval",
|
||||
"西藏",
|
||||
"附表2:岗位支出报销审批权限表",
|
||||
"第十八条",
|
||||
"第二十四条",
|
||||
"Company Hotel Accommodation Limit Standards",
|
||||
"办法",
|
||||
"DAP研发中心",
|
||||
"新增规定内容",
|
||||
"基本补助",
|
||||
"Travel Allowance",
|
||||
"异地挂职锻炼补贴标准",
|
||||
"部门负责人",
|
||||
"Provincial Capitals",
|
||||
"特区",
|
||||
"Transportation Tickets",
|
||||
"第三章支出报销申请与审批",
|
||||
"品牌及市场运营中心",
|
||||
"分包外包(外部单位)",
|
||||
"探亲路费",
|
||||
"President",
|
||||
"凭据报销",
|
||||
"基本出差补贴",
|
||||
"Taxi Usage Regulations",
|
||||
"Government Fees",
|
||||
"Commercial Travel System",
|
||||
"远光制度〔2024〕14号",
|
||||
"审批权限变化情况",
|
||||
"基建工程",
|
||||
"支出报销申请与审批",
|
||||
"中国外汇交易中心参考汇率",
|
||||
"Department Manager",
|
||||
"支出报销审批",
|
||||
"预算调整决策程序",
|
||||
"公司1号文",
|
||||
"External Conference Accommodation",
|
||||
"厉行节约",
|
||||
"Commercial Insurance",
|
||||
"公司",
|
||||
"第三条管理原则",
|
||||
"捐赠申请",
|
||||
"分类控制",
|
||||
"业务宣传费",
|
||||
"产业投资部",
|
||||
"公司员工探亲管理办法",
|
||||
"Subsequent Approver",
|
||||
"100000 Yuan Approval Limit",
|
||||
"Tax Authority Recognized Invoice",
|
||||
"国家电网公司",
|
||||
"业务佐证材料",
|
||||
"第六条经办部门(个人)主要职责",
|
||||
"结算起点",
|
||||
"第十条支出成本中心归属",
|
||||
"母公司"
|
||||
],
|
||||
"count": 258,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
|
||||
}
|
||||
{
|
||||
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||
"entity_names": [
|
||||
"工会委员会",
|
||||
"Business Original Documents",
|
||||
"First Approver",
|
||||
"P8",
|
||||
"一级部门总经理",
|
||||
"组织人事部",
|
||||
"业务原始凭据",
|
||||
"营销中心",
|
||||
"保证金",
|
||||
"投标保证金",
|
||||
"餐补",
|
||||
"第十四条业务招待费",
|
||||
"Chief Engineer",
|
||||
"业务招待",
|
||||
"Employee Welfare",
|
||||
"经济舱",
|
||||
"2024年4月17日",
|
||||
"三等舱",
|
||||
"财务信息化系统",
|
||||
"分管领导",
|
||||
"重点支出管理规定",
|
||||
"备用金借款",
|
||||
"Financial Review",
|
||||
"第五章附则",
|
||||
"Company Leadership",
|
||||
"第十九条",
|
||||
"经办人",
|
||||
"预算内支出",
|
||||
"Current Account Payment",
|
||||
"Business Entertainment",
|
||||
"Tax Control System Details",
|
||||
"第二十一条",
|
||||
"成本中心归属",
|
||||
"岗位支出报销审批权限表",
|
||||
"工会经费管理办法",
|
||||
"商旅系统",
|
||||
"Special Subsidy",
|
||||
"中国银行外汇折算价",
|
||||
"因公借款",
|
||||
"资产采购",
|
||||
"广告费",
|
||||
"First-Level Department General Manager",
|
||||
"正式员工",
|
||||
"一万元",
|
||||
"公司员工教育培训管理办法",
|
||||
"责任原则",
|
||||
"第二章职责分工",
|
||||
"预算先行",
|
||||
"Planning and Finance Department",
|
||||
"Accommodation Cost Reimbursement",
|
||||
"Official Vehicle Subsidy",
|
||||
"第四条归口管理部门主要职责",
|
||||
"Personal Service Compensation",
|
||||
"邮递费",
|
||||
"附表3:支出归口管理部门与归口业务范围",
|
||||
"员工",
|
||||
"第二条目的",
|
||||
"Director",
|
||||
"支出归口管理部门与归口业务范围",
|
||||
"其他支出(员工)",
|
||||
"报销标准",
|
||||
"5000000 Yuan Approval Limit",
|
||||
"第十一条备用金借款",
|
||||
"会议费",
|
||||
"第十七条",
|
||||
"第七条各级管理人员主要职责",
|
||||
"50000 Yuan Approval Limit",
|
||||
"全资子公司",
|
||||
"涉外业务汇率标准",
|
||||
"总监",
|
||||
"第十三条差旅费",
|
||||
"审批权限表",
|
||||
"商旅订票规范",
|
||||
"Final Approval Position",
|
||||
"报销资格",
|
||||
"新增报销规定",
|
||||
"公司支出管理办法",
|
||||
"Institution General Manager",
|
||||
"房屋租金",
|
||||
"Staff Activities",
|
||||
"分包外包(内部单位)",
|
||||
"报销申请时限",
|
||||
"Financial Information System",
|
||||
"Expenditure Authorization Approval Scope",
|
||||
"直辖市",
|
||||
"培训费",
|
||||
"第十二条市内交通费",
|
||||
"第十五条",
|
||||
"终审岗",
|
||||
"Remote Work Housing",
|
||||
"Centralized Management department",
|
||||
"第二十条",
|
||||
"办公室(党委办公室)",
|
||||
"Three Flows Consistency Principle",
|
||||
"审批权限",
|
||||
"VAT Special Invoice",
|
||||
"后勤服务部",
|
||||
"员工支出报销审批权限表",
|
||||
"公司总裁",
|
||||
"出差补贴",
|
||||
"Basic Level Managers",
|
||||
"预付款项",
|
||||
"附表1:员工支出报销审批权限表",
|
||||
"经办部门",
|
||||
"信息管理部",
|
||||
"通信费",
|
||||
"第十六条",
|
||||
"增值税发票",
|
||||
"财务入账条件",
|
||||
"Hotel Accommodation Standards",
|
||||
"审批流转程序",
|
||||
"Self-Driving Travel Provisions",
|
||||
"交通费",
|
||||
"第九条支出报销审批",
|
||||
"薪酬福利支出分配计划",
|
||||
"产品规划设计部",
|
||||
"因公用车补贴",
|
||||
"Committee Chairpersons",
|
||||
"Business Division General Manager",
|
||||
"组织安排",
|
||||
"1 Yuan Per Person Per Kilometer Reimbursement",
|
||||
"Separation of Approval and Processing Principle",
|
||||
"第五条计划财务部主要职责",
|
||||
"200000 Yuan Approval Limit",
|
||||
"公司各部门",
|
||||
"第十四条",
|
||||
"Other Areas",
|
||||
"分支机构",
|
||||
"Departments And Units",
|
||||
"计划财务部",
|
||||
"Other Employees",
|
||||
"第二十三条",
|
||||
"公司团建管理办法",
|
||||
"火车硬席",
|
||||
"税控系统明细清单",
|
||||
"Trade Union Fund",
|
||||
"报销标准变化情况",
|
||||
"薪酬福利支出",
|
||||
"Hong Kong, Macau, And Taiwan Region",
|
||||
"对外捐赠支出",
|
||||
"Multi-Level Approval Rule",
|
||||
"Three Working Days Deadline",
|
||||
"Employee Remuneration",
|
||||
"销售退款",
|
||||
"股权投资、兼并收购",
|
||||
"控股子公司",
|
||||
"取消报销规定",
|
||||
"Procurement Management Regulations",
|
||||
"Middle Managers",
|
||||
"差旅费",
|
||||
"批办分离",
|
||||
"住宿费",
|
||||
"Travel Allowance Standards",
|
||||
"第二十三条本办法的归口与实施",
|
||||
"Senior Vice President",
|
||||
"供应商",
|
||||
"人事归口管理部门",
|
||||
"Management Personnel At All Levels",
|
||||
"效益优先",
|
||||
"Operating Department Individual",
|
||||
"Remote Work Housing Rental Expenses",
|
||||
"取消报销规定内容",
|
||||
"Company",
|
||||
"修订说明",
|
||||
"国网数科公司",
|
||||
"Vice President",
|
||||
"分级授权",
|
||||
"Expenditure Reimbursement Application",
|
||||
"第二十四条附件",
|
||||
"第二十二条",
|
||||
"出租车",
|
||||
"Night High-Speed Rail Provision",
|
||||
"各级管理人员",
|
||||
"受益原则",
|
||||
"公司员工因公通讯费用实施细则",
|
||||
"公司支出管理办法(2024)",
|
||||
"出差补贴标准",
|
||||
"Bid Security Deposit Approval Limits Table",
|
||||
"第二条范围",
|
||||
"Company Property Rental Management",
|
||||
"调动工作",
|
||||
"远光软件股份有限公司",
|
||||
"市内交通费",
|
||||
"交通工具等级标准",
|
||||
"Operator",
|
||||
"第八条支出报销申请",
|
||||
"Directly-Controlled Municipalities And Special Administrative Regions",
|
||||
"出差规定",
|
||||
"业务招待费",
|
||||
"Senior Managers",
|
||||
"逐级审批规则",
|
||||
"Company Business Travel System",
|
||||
"广告宣传费",
|
||||
"Transportation Cost Reimbursement",
|
||||
"财务",
|
||||
"第一章总则",
|
||||
"材料采购",
|
||||
"人力资源服务部",
|
||||
"证券与法律事务部",
|
||||
"Transportation Level Standards",
|
||||
"归口管理部门",
|
||||
"商旅客服",
|
||||
"第四章重点支出管理规定",
|
||||
"出差审批程序",
|
||||
"Business Trip Approval",
|
||||
"西藏",
|
||||
"附表2:岗位支出报销审批权限表",
|
||||
"第十八条",
|
||||
"第二十四条",
|
||||
"Company Hotel Accommodation Limit Standards",
|
||||
"办法",
|
||||
"DAP研发中心",
|
||||
"新增规定内容",
|
||||
"基本补助",
|
||||
"Travel Allowance",
|
||||
"异地挂职锻炼补贴标准",
|
||||
"部门负责人",
|
||||
"Provincial Capitals",
|
||||
"特区",
|
||||
"Transportation Tickets",
|
||||
"第三章支出报销申请与审批",
|
||||
"品牌及市场运营中心",
|
||||
"分包外包(外部单位)",
|
||||
"探亲路费",
|
||||
"President",
|
||||
"凭据报销",
|
||||
"基本出差补贴",
|
||||
"Taxi Usage Regulations",
|
||||
"Government Fees",
|
||||
"Commercial Travel System",
|
||||
"远光制度〔2024〕14号",
|
||||
"审批权限变化情况",
|
||||
"基建工程",
|
||||
"支出报销申请与审批",
|
||||
"中国外汇交易中心参考汇率",
|
||||
"Department Manager",
|
||||
"支出报销审批",
|
||||
"预算调整决策程序",
|
||||
"公司1号文",
|
||||
"External Conference Accommodation",
|
||||
"厉行节约",
|
||||
"Commercial Insurance",
|
||||
"公司",
|
||||
"第三条管理原则",
|
||||
"捐赠申请",
|
||||
"分类控制",
|
||||
"业务宣传费",
|
||||
"产业投资部",
|
||||
"公司员工探亲管理办法",
|
||||
"Subsequent Approver",
|
||||
"100000 Yuan Approval Limit",
|
||||
"Tax Authority Recognized Invoice",
|
||||
"国家电网公司",
|
||||
"业务佐证材料",
|
||||
"第六条经办部门(个人)主要职责",
|
||||
"结算起点",
|
||||
"第十条支出成本中心归属",
|
||||
"母公司"
|
||||
],
|
||||
"count": 258,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
|
||||
}
|
||||
}
|
||||
@@ -1,166 +1,166 @@
|
||||
{
|
||||
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||
"relation_pairs": [
|
||||
[
|
||||
"Departments And Units",
|
||||
"Taxi Usage Regulations"
|
||||
],
|
||||
[
|
||||
"取消报销规定内容",
|
||||
"报销标准变化情况"
|
||||
],
|
||||
[
|
||||
"业务招待费",
|
||||
"第十四条"
|
||||
],
|
||||
[
|
||||
"控股子公司",
|
||||
"计划财务部"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"工会委员会"
|
||||
],
|
||||
[
|
||||
"第一章总则",
|
||||
"第三条管理原则"
|
||||
],
|
||||
[
|
||||
"广告宣传费",
|
||||
"第十六条"
|
||||
],
|
||||
[
|
||||
"Tax Control System Details",
|
||||
"VAT Special Invoice"
|
||||
],
|
||||
[
|
||||
"Expenditure Reimbursement Application",
|
||||
"Tax Authority Recognized Invoice"
|
||||
],
|
||||
[
|
||||
"远光制度〔2024〕14号",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"Financial Review",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"Operating Department Individual",
|
||||
"Procurement Management Regulations"
|
||||
],
|
||||
[
|
||||
"会议费",
|
||||
"第十五条"
|
||||
],
|
||||
[
|
||||
"Company",
|
||||
"Management Personnel At All Levels"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第十七条"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第十八条"
|
||||
],
|
||||
[
|
||||
"Operator",
|
||||
"Three Working Days Deadline"
|
||||
],
|
||||
[
|
||||
"第十一条备用金借款",
|
||||
"第四章重点支出管理规定"
|
||||
],
|
||||
[
|
||||
"Expenditure Reimbursement Application",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"业务招待费",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第二十一条"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法(2024)",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"第四条归口管理部门主要职责",
|
||||
"计划财务部"
|
||||
],
|
||||
[
|
||||
"会议费",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"Company",
|
||||
"Operating Department Individual"
|
||||
],
|
||||
[
|
||||
"商旅系统",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"会议费",
|
||||
"公司总裁"
|
||||
],
|
||||
[
|
||||
"计划财务部",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第十九条"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第二十条"
|
||||
],
|
||||
[
|
||||
"Company",
|
||||
"Planning and Finance Department"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"营销中心"
|
||||
],
|
||||
[
|
||||
"Business Original Documents",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"办公室(党委办公室)"
|
||||
],
|
||||
[
|
||||
"Departments And Units",
|
||||
"Night High-Speed Rail Provision"
|
||||
],
|
||||
[
|
||||
"Centralized Management department",
|
||||
"Company"
|
||||
],
|
||||
[
|
||||
"组织人事部",
|
||||
"调动工作"
|
||||
],
|
||||
[
|
||||
"报销标准变化情况",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"第一章总则",
|
||||
"远光软件股份有限公司"
|
||||
]
|
||||
],
|
||||
"count": 39,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
|
||||
}
|
||||
{
|
||||
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||
"relation_pairs": [
|
||||
[
|
||||
"Departments And Units",
|
||||
"Taxi Usage Regulations"
|
||||
],
|
||||
[
|
||||
"取消报销规定内容",
|
||||
"报销标准变化情况"
|
||||
],
|
||||
[
|
||||
"业务招待费",
|
||||
"第十四条"
|
||||
],
|
||||
[
|
||||
"控股子公司",
|
||||
"计划财务部"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"工会委员会"
|
||||
],
|
||||
[
|
||||
"第一章总则",
|
||||
"第三条管理原则"
|
||||
],
|
||||
[
|
||||
"广告宣传费",
|
||||
"第十六条"
|
||||
],
|
||||
[
|
||||
"Tax Control System Details",
|
||||
"VAT Special Invoice"
|
||||
],
|
||||
[
|
||||
"Expenditure Reimbursement Application",
|
||||
"Tax Authority Recognized Invoice"
|
||||
],
|
||||
[
|
||||
"远光制度〔2024〕14号",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"Financial Review",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"Operating Department Individual",
|
||||
"Procurement Management Regulations"
|
||||
],
|
||||
[
|
||||
"会议费",
|
||||
"第十五条"
|
||||
],
|
||||
[
|
||||
"Company",
|
||||
"Management Personnel At All Levels"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第十七条"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第十八条"
|
||||
],
|
||||
[
|
||||
"Operator",
|
||||
"Three Working Days Deadline"
|
||||
],
|
||||
[
|
||||
"第十一条备用金借款",
|
||||
"第四章重点支出管理规定"
|
||||
],
|
||||
[
|
||||
"Expenditure Reimbursement Application",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"业务招待费",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第二十一条"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法(2024)",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"第四条归口管理部门主要职责",
|
||||
"计划财务部"
|
||||
],
|
||||
[
|
||||
"会议费",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"Company",
|
||||
"Operating Department Individual"
|
||||
],
|
||||
[
|
||||
"商旅系统",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"会议费",
|
||||
"公司总裁"
|
||||
],
|
||||
[
|
||||
"计划财务部",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第十九条"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第二十条"
|
||||
],
|
||||
[
|
||||
"Company",
|
||||
"Planning and Finance Department"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"营销中心"
|
||||
],
|
||||
[
|
||||
"Business Original Documents",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"办公室(党委办公室)"
|
||||
],
|
||||
[
|
||||
"Departments And Units",
|
||||
"Night High-Speed Rail Provision"
|
||||
],
|
||||
[
|
||||
"Centralized Management department",
|
||||
"Company"
|
||||
],
|
||||
[
|
||||
"组织人事部",
|
||||
"调动工作"
|
||||
],
|
||||
[
|
||||
"报销标准变化情况",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"第一章总则",
|
||||
"远光软件股份有限公司"
|
||||
]
|
||||
],
|
||||
"count": 39,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
|
||||
}
|
||||
}
|
||||
@@ -1,353 +1,353 @@
|
||||
{
|
||||
"第一章总则<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "第一章总则<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"第十一条备用金借款<SEP>第四章重点支出管理规定": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "第十一条备用金借款<SEP>第四章重点支出管理规定"
|
||||
},
|
||||
"公司支出管理办法<SEP>办公室(党委办公室)": {
|
||||
"chunk_ids": [
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "公司支出管理办法<SEP>办公室(党委办公室)"
|
||||
},
|
||||
"计划财务部<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "计划财务部<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"第一章总则<SEP>第三条管理原则": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "第一章总则<SEP>第三条管理原则"
|
||||
},
|
||||
"Company<SEP>Management Personnel At All Levels": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "Company<SEP>Management Personnel At All Levels"
|
||||
},
|
||||
"Centralized Management department<SEP>Company": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012077,
|
||||
"update_time": 1779012077,
|
||||
"_id": "Centralized Management department<SEP>Company"
|
||||
},
|
||||
"Company<SEP>Planning and Finance Department": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012077,
|
||||
"update_time": 1779012077,
|
||||
"_id": "Company<SEP>Planning and Finance Department"
|
||||
},
|
||||
"Company<SEP>Operating Department Individual": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012078,
|
||||
"update_time": 1779012078,
|
||||
"_id": "Company<SEP>Operating Department Individual"
|
||||
},
|
||||
"公司支出管理办法<SEP>工会委员会": {
|
||||
"chunk_ids": [
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "公司支出管理办法<SEP>工会委员会"
|
||||
},
|
||||
"Expenditure Reimbursement Application<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "Expenditure Reimbursement Application<SEP>Operator"
|
||||
},
|
||||
"公司支出管理办法<SEP>营销中心": {
|
||||
"chunk_ids": [
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "公司支出管理办法<SEP>营销中心"
|
||||
},
|
||||
"第四条归口管理部门主要职责<SEP>计划财务部": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "第四条归口管理部门主要职责<SEP>计划财务部"
|
||||
},
|
||||
"Tax Control System Details<SEP>VAT Special Invoice": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "Tax Control System Details<SEP>VAT Special Invoice"
|
||||
},
|
||||
"Operating Department Individual<SEP>Procurement Management Regulations": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012081,
|
||||
"update_time": 1779012081,
|
||||
"_id": "Operating Department Individual<SEP>Procurement Management Regulations"
|
||||
},
|
||||
"Business Original Documents<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "Business Original Documents<SEP>Operator"
|
||||
},
|
||||
"Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice"
|
||||
},
|
||||
"公司<SEP>第十七条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "公司<SEP>第十七条"
|
||||
},
|
||||
"Operator<SEP>Three Working Days Deadline": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012083,
|
||||
"update_time": 1779012083,
|
||||
"_id": "Operator<SEP>Three Working Days Deadline"
|
||||
},
|
||||
"Departments And Units<SEP>Night High-Speed Rail Provision": {
|
||||
"chunk_ids": [
|
||||
"chunk-613d6dfd4c5e9c807229a3147f96b584"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "Departments And Units<SEP>Night High-Speed Rail Provision"
|
||||
},
|
||||
"公司<SEP>第十八条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "公司<SEP>第十八条"
|
||||
},
|
||||
"公司<SEP>第十九条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "公司<SEP>第十九条"
|
||||
},
|
||||
"报销标准变化情况<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-18d968b78afe916b419c1b5973421ebe"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "报销标准变化情况<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"取消报销规定内容<SEP>报销标准变化情况": {
|
||||
"chunk_ids": [
|
||||
"chunk-18d968b78afe916b419c1b5973421ebe"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "取消报销规定内容<SEP>报销标准变化情况"
|
||||
},
|
||||
"Financial Review<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "Financial Review<SEP>Operator"
|
||||
},
|
||||
"公司支出管理办法(2024)<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "公司支出管理办法(2024)<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"远光制度〔2024〕14号<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "远光制度〔2024〕14号<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"Departments And Units<SEP>Taxi Usage Regulations": {
|
||||
"chunk_ids": [
|
||||
"chunk-613d6dfd4c5e9c807229a3147f96b584"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012099,
|
||||
"update_time": 1779012099,
|
||||
"_id": "Departments And Units<SEP>Taxi Usage Regulations"
|
||||
},
|
||||
"控股子公司<SEP>计划财务部": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012099,
|
||||
"update_time": 1779012099,
|
||||
"_id": "控股子公司<SEP>计划财务部"
|
||||
},
|
||||
"公司<SEP>第二十条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "公司<SEP>第二十条"
|
||||
},
|
||||
"商旅系统<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "商旅系统<SEP>差旅费"
|
||||
},
|
||||
"业务招待费<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "业务招待费<SEP>差旅费"
|
||||
},
|
||||
"公司<SEP>第二十一条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "公司<SEP>第二十一条"
|
||||
},
|
||||
"广告宣传费<SEP>第十六条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "广告宣传费<SEP>第十六条"
|
||||
},
|
||||
"组织人事部<SEP>调动工作": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012090,
|
||||
"update_time": 1779012090,
|
||||
"_id": "组织人事部<SEP>调动工作"
|
||||
},
|
||||
"会议费<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "会议费<SEP>差旅费"
|
||||
},
|
||||
"业务招待费<SEP>第十四条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "业务招待费<SEP>第十四条"
|
||||
},
|
||||
"会议费<SEP>第十五条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "会议费<SEP>第十五条"
|
||||
},
|
||||
"会议费<SEP>公司总裁": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "会议费<SEP>公司总裁"
|
||||
}
|
||||
{
|
||||
"第一章总则<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "第一章总则<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"第十一条备用金借款<SEP>第四章重点支出管理规定": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "第十一条备用金借款<SEP>第四章重点支出管理规定"
|
||||
},
|
||||
"公司支出管理办法<SEP>办公室(党委办公室)": {
|
||||
"chunk_ids": [
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "公司支出管理办法<SEP>办公室(党委办公室)"
|
||||
},
|
||||
"计划财务部<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "计划财务部<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"第一章总则<SEP>第三条管理原则": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "第一章总则<SEP>第三条管理原则"
|
||||
},
|
||||
"Company<SEP>Management Personnel At All Levels": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "Company<SEP>Management Personnel At All Levels"
|
||||
},
|
||||
"Centralized Management department<SEP>Company": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012077,
|
||||
"update_time": 1779012077,
|
||||
"_id": "Centralized Management department<SEP>Company"
|
||||
},
|
||||
"Company<SEP>Planning and Finance Department": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012077,
|
||||
"update_time": 1779012077,
|
||||
"_id": "Company<SEP>Planning and Finance Department"
|
||||
},
|
||||
"Company<SEP>Operating Department Individual": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012078,
|
||||
"update_time": 1779012078,
|
||||
"_id": "Company<SEP>Operating Department Individual"
|
||||
},
|
||||
"公司支出管理办法<SEP>工会委员会": {
|
||||
"chunk_ids": [
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "公司支出管理办法<SEP>工会委员会"
|
||||
},
|
||||
"Expenditure Reimbursement Application<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "Expenditure Reimbursement Application<SEP>Operator"
|
||||
},
|
||||
"公司支出管理办法<SEP>营销中心": {
|
||||
"chunk_ids": [
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "公司支出管理办法<SEP>营销中心"
|
||||
},
|
||||
"第四条归口管理部门主要职责<SEP>计划财务部": {
|
||||
"chunk_ids": [
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "第四条归口管理部门主要职责<SEP>计划财务部"
|
||||
},
|
||||
"Tax Control System Details<SEP>VAT Special Invoice": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "Tax Control System Details<SEP>VAT Special Invoice"
|
||||
},
|
||||
"Operating Department Individual<SEP>Procurement Management Regulations": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012081,
|
||||
"update_time": 1779012081,
|
||||
"_id": "Operating Department Individual<SEP>Procurement Management Regulations"
|
||||
},
|
||||
"Business Original Documents<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "Business Original Documents<SEP>Operator"
|
||||
},
|
||||
"Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice"
|
||||
},
|
||||
"公司<SEP>第十七条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "公司<SEP>第十七条"
|
||||
},
|
||||
"Operator<SEP>Three Working Days Deadline": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012083,
|
||||
"update_time": 1779012083,
|
||||
"_id": "Operator<SEP>Three Working Days Deadline"
|
||||
},
|
||||
"Departments And Units<SEP>Night High-Speed Rail Provision": {
|
||||
"chunk_ids": [
|
||||
"chunk-613d6dfd4c5e9c807229a3147f96b584"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "Departments And Units<SEP>Night High-Speed Rail Provision"
|
||||
},
|
||||
"公司<SEP>第十八条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "公司<SEP>第十八条"
|
||||
},
|
||||
"公司<SEP>第十九条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "公司<SEP>第十九条"
|
||||
},
|
||||
"报销标准变化情况<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-18d968b78afe916b419c1b5973421ebe"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "报销标准变化情况<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"取消报销规定内容<SEP>报销标准变化情况": {
|
||||
"chunk_ids": [
|
||||
"chunk-18d968b78afe916b419c1b5973421ebe"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "取消报销规定内容<SEP>报销标准变化情况"
|
||||
},
|
||||
"Financial Review<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "Financial Review<SEP>Operator"
|
||||
},
|
||||
"公司支出管理办法(2024)<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "公司支出管理办法(2024)<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"远光制度〔2024〕14号<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "远光制度〔2024〕14号<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"Departments And Units<SEP>Taxi Usage Regulations": {
|
||||
"chunk_ids": [
|
||||
"chunk-613d6dfd4c5e9c807229a3147f96b584"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012099,
|
||||
"update_time": 1779012099,
|
||||
"_id": "Departments And Units<SEP>Taxi Usage Regulations"
|
||||
},
|
||||
"控股子公司<SEP>计划财务部": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012099,
|
||||
"update_time": 1779012099,
|
||||
"_id": "控股子公司<SEP>计划财务部"
|
||||
},
|
||||
"公司<SEP>第二十条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "公司<SEP>第二十条"
|
||||
},
|
||||
"商旅系统<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "商旅系统<SEP>差旅费"
|
||||
},
|
||||
"业务招待费<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "业务招待费<SEP>差旅费"
|
||||
},
|
||||
"公司<SEP>第二十一条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "公司<SEP>第二十一条"
|
||||
},
|
||||
"广告宣传费<SEP>第十六条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "广告宣传费<SEP>第十六条"
|
||||
},
|
||||
"组织人事部<SEP>调动工作": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012090,
|
||||
"update_time": 1779012090,
|
||||
"_id": "组织人事部<SEP>调动工作"
|
||||
},
|
||||
"会议费<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "会议费<SEP>差旅费"
|
||||
},
|
||||
"业务招待费<SEP>第十四条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "业务招待费<SEP>第十四条"
|
||||
},
|
||||
"会议费<SEP>第十五条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "会议费<SEP>第十五条"
|
||||
},
|
||||
"会议费<SEP>公司总裁": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "会议费<SEP>公司总裁"
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,8 @@ def test_rule_spreadsheet_onlyoffice_key_uses_safe_characters() -> None:
|
||||
|
||||
key = AgentAssetService._build_onlyoffice_document_key(
|
||||
"asset:id",
|
||||
"v1.0.0",
|
||||
metadata,
|
||||
)
|
||||
|
||||
assert key == "asset_id-v1.0.0-abc123"
|
||||
assert key == "asset_id-abc123"
|
||||
assert ":" not in key
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
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.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import (
|
||||
AgentAssetContentType,
|
||||
AgentAssetDomain,
|
||||
@@ -19,16 +22,61 @@ from app.core.agent_enums import (
|
||||
AgentRunSource,
|
||||
AgentRunStatus,
|
||||
)
|
||||
from app.core.config import SERVER_DIR
|
||||
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 (
|
||||
AgentAssetCreate,
|
||||
AgentAssetReviewCreate,
|
||||
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_runs import AgentRunService
|
||||
from app.services.audit import AuditLogService
|
||||
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:
|
||||
@@ -53,6 +101,19 @@ def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则
|
||||
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:
|
||||
with build_session() as 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)
|
||||
assert len(rules) >= 3
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
with build_session() as 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
|
||||
|
||||
|
||||
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:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
@@ -186,7 +298,10 @@ def test_expense_rule_runtime_uses_published_version_instead_of_working_version(
|
||||
rule.id,
|
||||
AgentAssetVersionCreate(
|
||||
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,
|
||||
change_note="未上线草稿",
|
||||
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 恢复生成工作稿"
|
||||
|
||||
|
||||
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:
|
||||
service = AgentAssetService(db)
|
||||
rule = next(
|
||||
@@ -233,31 +348,33 @@ def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
|
||||
service.upload_rule_spreadsheet(
|
||||
rule.id,
|
||||
filename="公司差旅费报销规则.xlsx",
|
||||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||||
actor="finance_user",
|
||||
)
|
||||
base_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
|
||||
service.upload_rule_spreadsheet(
|
||||
rule.id,
|
||||
filename="公司差旅费报销规则.xlsx",
|
||||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
|
||||
actor="finance_user",
|
||||
)
|
||||
target_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
|
||||
|
||||
diff = service.compare_spreadsheet_versions(
|
||||
rule.id,
|
||||
base_version=base_version or "",
|
||||
target_version=target_version or "",
|
||||
records = service.list_spreadsheet_change_records(rule.id)
|
||||
latest = records[0]
|
||||
|
||||
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 diff.changed_sheet_count == 1
|
||||
assert diff.changed_cell_count == 3
|
||||
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 any(
|
||||
item.cell == "A3" and item.change_type == "added"
|
||||
for item in latest.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:
|
||||
service = AgentAssetService(db)
|
||||
rule = next(
|
||||
@@ -274,26 +391,143 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
|
||||
)
|
||||
detail = service.get_asset(rule.id)
|
||||
assert detail is not None
|
||||
working_version = detail.working_version or ""
|
||||
|
||||
current_asset = service.repository.get(rule.id)
|
||||
assert current_asset is not None
|
||||
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)
|
||||
original_live_bytes = live_path.read_bytes()
|
||||
try:
|
||||
live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]]))
|
||||
assert not service.spreadsheet_manager.asset_root.exists()
|
||||
|
||||
snapshot_path, _, _ = service.get_rule_spreadsheet_content(
|
||||
rule.id,
|
||||
version=working_version,
|
||||
)
|
||||
current_path, _, _ = service.get_rule_spreadsheet_content(rule.id)
|
||||
|
||||
assert snapshot_path != live_path
|
||||
workbook = load_workbook(snapshot_path, data_only=False)
|
||||
assert workbook.active["B2"].value == 500
|
||||
finally:
|
||||
live_path.write_bytes(original_live_bytes)
|
||||
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,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
records = service.list_spreadsheet_change_records(rule.id)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0].actor == "manager_user"
|
||||
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:
|
||||
@@ -388,6 +622,126 @@ def test_agent_asset_service_returns_travel_policy_rule_detail() -> None:
|
||||
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:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.agent_enums import AgentAssetStatus
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
|
||||
|
||||
def build_client() -> tuple[TestClient, sessionmaker[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)
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return TestClient(app), session_factory
|
||||
|
||||
|
||||
def test_list_agent_assets_endpoint_returns_seeded_items() -> None:
|
||||
client, _ = build_client()
|
||||
|
||||
response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload
|
||||
assert all(item["asset_type"] == "rule" for item in payload)
|
||||
assert any(item["code"] == "rule.expense.travel_risk_control_standard" for item in payload)
|
||||
|
||||
|
||||
def test_get_agent_asset_detail_endpoint_returns_version_history() -> None:
|
||||
client, _ = build_client()
|
||||
|
||||
list_response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
|
||||
asset_id = next(
|
||||
item["id"]
|
||||
for item in list_response.json()
|
||||
if item["code"] == "rule.expense.travel_risk_control_standard"
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1/agent-assets/{asset_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["recent_versions"]
|
||||
assert payload["current_version_content_type"] == "markdown"
|
||||
assert payload["current_version"] == "v1.1.0"
|
||||
assert "行程闭环" in payload["current_version_content"]
|
||||
|
||||
|
||||
def test_activate_pending_rule_endpoint_is_blocked() -> None:
|
||||
client, session_factory = build_client()
|
||||
|
||||
with session_factory() as db:
|
||||
pending_rule = next(
|
||||
item
|
||||
for item in AgentAssetService(db).list_assets(asset_type="rule")
|
||||
if item.status == AgentAssetStatus.REVIEW.value
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/agent-assets/{pending_rule.id}/activate",
|
||||
headers={"x-actor": "pytest"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "审核" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_list_audit_logs_endpoint_returns_seeded_logs() -> None:
|
||||
client, _ = build_client()
|
||||
|
||||
response = client.get("/api/v1/audit-logs")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload
|
||||
assert any(item["action"] == "review_rule" for item in payload)
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.agent_enums import AgentAssetStatus
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
|
||||
|
||||
def build_client() -> tuple[TestClient, sessionmaker[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)
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return TestClient(app), session_factory
|
||||
|
||||
|
||||
def test_list_agent_assets_endpoint_returns_seeded_items() -> None:
|
||||
client, _ = build_client()
|
||||
|
||||
response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload
|
||||
assert all(item["asset_type"] == "rule" for item in payload)
|
||||
assert any(item["code"] == "rule.expense.travel_risk_control_standard" for item in payload)
|
||||
|
||||
|
||||
def test_get_agent_asset_detail_endpoint_returns_version_history() -> None:
|
||||
client, _ = build_client()
|
||||
|
||||
list_response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
|
||||
asset_id = next(
|
||||
item["id"]
|
||||
for item in list_response.json()
|
||||
if item["code"] == "rule.expense.travel_risk_control_standard"
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1/agent-assets/{asset_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["recent_versions"]
|
||||
assert payload["current_version_content_type"] == "markdown"
|
||||
assert payload["current_version"] == "v1.1.0"
|
||||
assert "行程闭环" in payload["current_version_content"]
|
||||
|
||||
|
||||
def test_activate_pending_rule_endpoint_is_blocked() -> None:
|
||||
client, session_factory = build_client()
|
||||
|
||||
with session_factory() as db:
|
||||
pending_rule = next(
|
||||
item
|
||||
for item in AgentAssetService(db).list_assets(asset_type="rule")
|
||||
if item.status == AgentAssetStatus.REVIEW.value
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/agent-assets/{pending_rule.id}/activate",
|
||||
headers={"x-actor": "pytest"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "审核" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_list_audit_logs_endpoint_returns_seeded_logs() -> None:
|
||||
client, _ = build_client()
|
||||
|
||||
response = client.get("/api/v1/audit-logs")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload
|
||||
assert any(item["action"] == "review_rule" for item in payload)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, func, select
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
|
||||
153
server/tests/test_employee_spreadsheet_import.py
Normal 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"
|
||||
@@ -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 recognized.lines[0].page_index == 0
|
||||
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 == ""
|
||||
|
||||
78
server/tests/test_onlyoffice_callback_summary.py
Normal 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__])
|
||||
399
server/tests/test_orchestrator_review_flow.py
Normal 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]] == ["差旅费", "交通费", "住宿费"]
|
||||
@@ -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"]["requirement_check"]["matches"] is True
|
||||
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(
|
||||
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"])
|
||||
|
||||
|
||||
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:
|
||||
preview_bytes = b"fake-preview-png"
|
||||
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
||||
|
||||
BIN
web/UI/流程输入.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
|
||||
<title>ReimburseOps - 企业报销智能运营台</title>
|
||||
|
||||
5
web/public/assets/favicon.svg
Normal 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 |
@@ -103,3 +103,39 @@ h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; fon
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::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);
|
||||
}
|
||||
}
|
||||
|
||||
336
web/src/assets/styles/views/approval-center-view-part2.css
Normal 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; }
|
||||
}
|
||||
@@ -1477,339 +1477,3 @@ tbody tr:last-child td { border-bottom: 0; }
|
||||
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; }
|
||||
}
|
||||
|
||||
1255
web/src/assets/styles/views/audit-view-part2.css
Normal file
@@ -142,6 +142,8 @@
|
||||
|
||||
.picker-trigger,
|
||||
.ghost-filter-btn,
|
||||
.template-btn,
|
||||
.export-btn,
|
||||
.create-btn,
|
||||
.row-action {
|
||||
min-height: 38px;
|
||||
@@ -282,6 +284,24 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -293,6 +313,50 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -333,13 +397,14 @@
|
||||
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.table-wrap table {
|
||||
width: 100%;
|
||||
align-self: flex-start;
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
@@ -503,7 +568,7 @@
|
||||
}
|
||||
|
||||
table {
|
||||
height: 100%;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
min-width: 1180px;
|
||||
border-collapse: collapse;
|
||||
@@ -659,9 +724,12 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.role-stack {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
@@ -770,6 +838,7 @@ tbody tr:last-child td {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.detail-main,
|
||||
@@ -777,6 +846,7 @@ tbody tr:last-child td {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.detail-card,
|
||||
@@ -821,6 +891,7 @@ tbody tr:last-child td {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -850,6 +921,118 @@ tbody tr:last-child td {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -911,10 +1094,10 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 128px 112px;
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
@@ -925,19 +1108,45 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.history-row strong {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-row span,
|
||||
.history-row small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
.history-row-owner,
|
||||
.history-row-time {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
margin-top: 0;
|
||||
color: #64748b;
|
||||
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 {
|
||||
@@ -1087,4 +1296,18 @@ tbody tr:last-child td {
|
||||
.role-grid {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,6 +707,15 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inline-empty.is-loading {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.inline-empty.is-loading > .table-loading {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.inspector-empty {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
|
||||
@@ -418,12 +418,20 @@ th {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
.empty-row {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-loading-row {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-loading-row > .table-loading {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
|
||||