Compare commits
27 Commits
68f663f2f4
...
codex/opti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f787ff38 | ||
|
|
2908dda024 | ||
|
|
e701fa01da | ||
|
|
f28d7e6d16 | ||
|
|
b183b0bd5e | ||
|
|
8f65661809 | ||
|
|
002bf4f756 | ||
|
|
f8b25a7ccc | ||
|
|
d7e98a58b9 | ||
|
|
57957d11a0 | ||
|
|
2574bc81d1 | ||
|
|
54ffef66d3 | ||
|
|
d460ee0fe7 | ||
|
|
9472813739 | ||
|
|
dc007f948a | ||
|
|
9db663e81f | ||
|
|
813ac81950 | ||
|
|
9902a3b968 | ||
|
|
29df4eee3b | ||
|
|
5106d286a1 | ||
|
|
64ec27949f | ||
|
|
8814fe7dfa | ||
|
|
9b97f456cf | ||
|
|
9d90bf5299 | ||
|
|
35a3783481 | ||
|
|
4414ffb34c | ||
|
|
55e0591a5e |
@@ -30,10 +30,24 @@ services:
|
|||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -lc
|
- -lc
|
||||||
- >
|
- >
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
||||||
python3 python3-pip python3-venv &&
|
python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra &&
|
||||||
mkdir -p /run/sshd && /usr/sbin/sshd &&
|
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 &&
|
printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh &&
|
||||||
chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&
|
chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&
|
||||||
touch /root/.bashrc /root/.profile &&
|
touch /root/.bashrc /root/.profile &&
|
||||||
|
|||||||
@@ -1,457 +1,457 @@
|
|||||||
# 语义本体协议设计
|
# 语义本体协议设计
|
||||||
|
|
||||||
## 1. 定位
|
## 1. 定位
|
||||||
|
|
||||||
语义本体协议是用户问题、定时任务、规则中心、MCP、数据库查询和 Agent 之间的统一中间层。
|
语义本体协议是用户问题、定时任务、规则中心、MCP、数据库查询和 Agent 之间的统一中间层。
|
||||||
|
|
||||||
它解决的问题是:
|
它解决的问题是:
|
||||||
|
|
||||||
- 用户到底在问哪个业务域?
|
- 用户到底在问哪个业务域?
|
||||||
- 这属于什么场景?
|
- 这属于什么场景?
|
||||||
- 用户想做什么?
|
- 用户想做什么?
|
||||||
- 问题中涉及哪些对象?
|
- 问题中涉及哪些对象?
|
||||||
- 有没有时间、金额、状态、部门等过滤条件?
|
- 有没有时间、金额、状态、部门等过滤条件?
|
||||||
- 是否涉及风险?
|
- 是否涉及风险?
|
||||||
- 下一步应该查知识库、查数据库、跑规则、调 MCP,还是追问?
|
- 下一步应该查知识库、查数据库、跑规则、调 MCP,还是追问?
|
||||||
|
|
||||||
## 2. 第一版核心字段
|
## 2. 第一版核心字段
|
||||||
|
|
||||||
第一版建议只强制落 8 个字段。
|
第一版建议只强制落 8 个字段。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"domain": "",
|
"domain": "",
|
||||||
"scenario": "",
|
"scenario": "",
|
||||||
"intent": "",
|
"intent": "",
|
||||||
"entities": [],
|
"entities": [],
|
||||||
"time_range": {},
|
"time_range": {},
|
||||||
"constraints": {},
|
"constraints": {},
|
||||||
"risk_signals": [],
|
"risk_signals": [],
|
||||||
"next_step": ""
|
"next_step": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.1 domain
|
### 2.1 domain
|
||||||
|
|
||||||
一级业务域。
|
一级业务域。
|
||||||
|
|
||||||
建议枚举:
|
建议枚举:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
reimbursement
|
reimbursement
|
||||||
accounts_receivable
|
accounts_receivable
|
||||||
accounts_payable
|
accounts_payable
|
||||||
general_finance
|
general_finance
|
||||||
system_operation
|
system_operation
|
||||||
```
|
```
|
||||||
|
|
||||||
含义:
|
含义:
|
||||||
|
|
||||||
- `reimbursement`:报销、差旅、发票、补件。
|
- `reimbursement`:报销、差旅、发票、补件。
|
||||||
- `accounts_receivable`:应收账款、客户开票、收款、账龄。
|
- `accounts_receivable`:应收账款、客户开票、收款、账龄。
|
||||||
- `accounts_payable`:应付账款、供应商发票、付款、对账。
|
- `accounts_payable`:应付账款、供应商发票、付款、对账。
|
||||||
- `general_finance`:通用财务知识、制度、统计。
|
- `general_finance`:通用财务知识、制度、统计。
|
||||||
- `system_operation`:系统巡检、任务运行、规则维护、MCP 健康检查。
|
- `system_operation`:系统巡检、任务运行、规则维护、MCP 健康检查。
|
||||||
|
|
||||||
### 2.2 scenario
|
### 2.2 scenario
|
||||||
|
|
||||||
细分场景。
|
细分场景。
|
||||||
|
|
||||||
报销:
|
报销:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
travel_reimbursement
|
travel_reimbursement
|
||||||
daily_expense
|
daily_expense
|
||||||
invoice_validation
|
invoice_validation
|
||||||
attachment_review
|
attachment_review
|
||||||
policy_overrun
|
policy_overrun
|
||||||
reimbursement_audit
|
reimbursement_audit
|
||||||
```
|
```
|
||||||
|
|
||||||
应收:
|
应收:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
customer_invoice
|
customer_invoice
|
||||||
collection_followup
|
collection_followup
|
||||||
receivable_aging
|
receivable_aging
|
||||||
payment_matching
|
payment_matching
|
||||||
bad_debt_risk
|
bad_debt_risk
|
||||||
contract_receivable
|
contract_receivable
|
||||||
```
|
```
|
||||||
|
|
||||||
应付:
|
应付:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
vendor_invoice
|
vendor_invoice
|
||||||
payment_request
|
payment_request
|
||||||
payable_aging
|
payable_aging
|
||||||
vendor_reconciliation
|
vendor_reconciliation
|
||||||
invoice_matching
|
invoice_matching
|
||||||
cash_outflow_forecast
|
cash_outflow_forecast
|
||||||
```
|
```
|
||||||
|
|
||||||
系统运营:
|
系统运营:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
daily_risk_scan
|
daily_risk_scan
|
||||||
daily_finance_statistics
|
daily_finance_statistics
|
||||||
knowledge_accumulation
|
knowledge_accumulation
|
||||||
mcp_health_check
|
mcp_health_check
|
||||||
rule_quality_review
|
rule_quality_review
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.3 intent
|
### 2.3 intent
|
||||||
|
|
||||||
用户或任务的意图。
|
用户或任务的意图。
|
||||||
|
|
||||||
建议枚举:
|
建议枚举:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
query
|
query
|
||||||
explain
|
explain
|
||||||
create
|
create
|
||||||
validate
|
validate
|
||||||
summarize
|
summarize
|
||||||
reconcile
|
reconcile
|
||||||
monitor
|
monitor
|
||||||
predict
|
predict
|
||||||
remind
|
remind
|
||||||
generate
|
generate
|
||||||
optimize
|
optimize
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.4 entities
|
### 2.4 entities
|
||||||
|
|
||||||
识别出的业务对象。
|
识别出的业务对象。
|
||||||
|
|
||||||
统一结构:
|
统一结构:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "invoice",
|
"type": "invoice",
|
||||||
"value": "INV-202605001",
|
"value": "INV-202605001",
|
||||||
"normalized_value": "INV-202605001",
|
"normalized_value": "INV-202605001",
|
||||||
"role": "target",
|
"role": "target",
|
||||||
"confidence": 0.92
|
"confidence": 0.92
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
常见实体:
|
常见实体:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
employee
|
employee
|
||||||
department
|
department
|
||||||
customer
|
customer
|
||||||
vendor
|
vendor
|
||||||
invoice
|
invoice
|
||||||
contract
|
contract
|
||||||
reimbursement_request
|
reimbursement_request
|
||||||
payment_order
|
payment_order
|
||||||
receipt
|
receipt
|
||||||
bank_transaction
|
bank_transaction
|
||||||
cost_center
|
cost_center
|
||||||
project
|
project
|
||||||
policy
|
policy
|
||||||
approval_node
|
approval_node
|
||||||
rule
|
rule
|
||||||
task
|
task
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.5 time_range
|
### 2.5 time_range
|
||||||
|
|
||||||
统一描述时间。
|
统一描述时间。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"raw": "上个月",
|
"raw": "上个月",
|
||||||
"start": "2026-04-01",
|
"start": "2026-04-01",
|
||||||
"end": "2026-04-30",
|
"end": "2026-04-30",
|
||||||
"granularity": "month"
|
"granularity": "month"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Hermes 定时任务也使用同一字段。
|
Hermes 定时任务也使用同一字段。
|
||||||
|
|
||||||
例如每日风险巡检:
|
例如每日风险巡检:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"raw": "昨日",
|
"raw": "昨日",
|
||||||
"start": "2026-05-09",
|
"start": "2026-05-09",
|
||||||
"end": "2026-05-09",
|
"end": "2026-05-09",
|
||||||
"granularity": "day"
|
"granularity": "day"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.6 constraints
|
### 2.6 constraints
|
||||||
|
|
||||||
查询、判断或执行条件。
|
查询、判断或执行条件。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "overdue",
|
"status": "overdue",
|
||||||
"aging_days": ">30",
|
"aging_days": ">30",
|
||||||
"amount": {
|
"amount": {
|
||||||
"operator": ">",
|
"operator": ">",
|
||||||
"value": 50000,
|
"value": 50000,
|
||||||
"currency": "CNY"
|
"currency": "CNY"
|
||||||
},
|
},
|
||||||
"department": "销售部",
|
"department": "销售部",
|
||||||
"risk_level": ["medium", "high"]
|
"risk_level": ["medium", "high"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.7 risk_signals
|
### 2.7 risk_signals
|
||||||
|
|
||||||
风险信号。
|
风险信号。
|
||||||
|
|
||||||
建议枚举:
|
建议枚举:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
duplicate_invoice
|
duplicate_invoice
|
||||||
missing_attachment
|
missing_attachment
|
||||||
policy_overrun
|
policy_overrun
|
||||||
over_budget
|
over_budget
|
||||||
overdue_receivable
|
overdue_receivable
|
||||||
bad_debt_risk
|
bad_debt_risk
|
||||||
vendor_payment_risk
|
vendor_payment_risk
|
||||||
payment_mismatch
|
payment_mismatch
|
||||||
contract_mismatch
|
contract_mismatch
|
||||||
cashflow_pressure
|
cashflow_pressure
|
||||||
mcp_unavailable
|
mcp_unavailable
|
||||||
rule_quality_issue
|
rule_quality_issue
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.8 next_step
|
### 2.8 next_step
|
||||||
|
|
||||||
下一步动作。
|
下一步动作。
|
||||||
|
|
||||||
建议枚举:
|
建议枚举:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
answer
|
answer
|
||||||
ask_clarification
|
ask_clarification
|
||||||
query_database
|
query_database
|
||||||
run_rule
|
run_rule
|
||||||
call_mcp
|
call_mcp
|
||||||
search_knowledge
|
search_knowledge
|
||||||
create_draft
|
create_draft
|
||||||
create_task
|
create_task
|
||||||
generate_report
|
generate_report
|
||||||
notify_user
|
notify_user
|
||||||
escalate_to_human
|
escalate_to_human
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 扩展字段
|
## 3. 扩展字段
|
||||||
|
|
||||||
后续可以增加:
|
后续可以增加:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schema_version": "1.1",
|
"schema_version": "1.1",
|
||||||
"confidence": 0.86,
|
"confidence": 0.86,
|
||||||
"ambiguity": [],
|
"ambiguity": [],
|
||||||
"missing_slots": [],
|
"missing_slots": [],
|
||||||
"required_capabilities": [],
|
"required_capabilities": [],
|
||||||
"normalized_query": "",
|
"normalized_query": "",
|
||||||
"permission_scope": {},
|
"permission_scope": {},
|
||||||
"audit_tags": []
|
"audit_tags": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. 混合语义解析架构
|
## 4. 混合语义解析架构
|
||||||
|
|
||||||
第一版可上线实现不应只依赖关键词和正则。
|
第一版可上线实现不应只依赖关键词和正则。
|
||||||
|
|
||||||
推荐采用:
|
推荐采用:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
输入上下文装配
|
输入上下文装配
|
||||||
用户文本 + 页面上下文 + 附件名称 + OCR/VLM 摘要
|
用户文本 + 页面上下文 + 附件名称 + OCR/VLM 摘要
|
||||||
↓
|
↓
|
||||||
预抽取
|
预抽取
|
||||||
时间、金额、单号、显式对象
|
时间、金额、单号、显式对象
|
||||||
↓
|
↓
|
||||||
LLM 结构化解析
|
LLM 结构化解析
|
||||||
输出 scenario / intent / entities / missing_slots / ambiguity
|
输出 scenario / intent / entities / missing_slots / ambiguity
|
||||||
↓
|
↓
|
||||||
Schema 校验
|
Schema 校验
|
||||||
JSON 解析、字段枚举、必填校验、类型归一化
|
JSON 解析、字段枚举、必填校验、类型归一化
|
||||||
↓
|
↓
|
||||||
规则兜底
|
规则兜底
|
||||||
模型失败、低置信度或字段缺失时回退到规则解析
|
模型失败、低置信度或字段缺失时回退到规则解析
|
||||||
↓
|
↓
|
||||||
澄清追问
|
澄清追问
|
||||||
低置信度、歧义、缺槽位时不允许直接查库
|
低置信度、歧义、缺槽位时不允许直接查库
|
||||||
```
|
```
|
||||||
|
|
||||||
设计原则:
|
设计原则:
|
||||||
|
|
||||||
- 模型优先负责“理解意图和场景”。
|
- 模型优先负责“理解意图和场景”。
|
||||||
- 规则优先负责“校验、补全和兜底”。
|
- 规则优先负责“校验、补全和兜底”。
|
||||||
- 附件名称、OCR、VLM 结果只能作为证据,不等于已确认事实。
|
- 附件名称、OCR、VLM 结果只能作为证据,不等于已确认事实。
|
||||||
- 所有语义输出都必须标记置信度和来源。
|
- 所有语义输出都必须标记置信度和来源。
|
||||||
|
|
||||||
## 5. 推荐新增字段
|
## 5. 推荐新增字段
|
||||||
|
|
||||||
为支持模型优先解析,建议在扩展字段中至少增加:
|
为支持模型优先解析,建议在扩展字段中至少增加:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"missing_slots": [],
|
"missing_slots": [],
|
||||||
"ambiguity": [],
|
"ambiguity": [],
|
||||||
"field_confidence": {},
|
"field_confidence": {},
|
||||||
"field_source": {},
|
"field_source": {},
|
||||||
"attachment_context": [],
|
"attachment_context": [],
|
||||||
"parse_strategy": "llm_primary_with_rule_fallback"
|
"parse_strategy": "llm_primary_with_rule_fallback"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
字段说明:
|
字段说明:
|
||||||
|
|
||||||
- `missing_slots`:还缺哪些关键字段,例如费用类型、单据号、客户单位。
|
- `missing_slots`:还缺哪些关键字段,例如费用类型、单据号、客户单位。
|
||||||
- `ambiguity`:当前可能混淆的理解结果。
|
- `ambiguity`:当前可能混淆的理解结果。
|
||||||
- `field_confidence`:字段级置信度,而不是只给整体分数。
|
- `field_confidence`:字段级置信度,而不是只给整体分数。
|
||||||
- `field_source`:字段来自 `llm`、`rule`、`ocr`、`vlm` 还是 `user_context`。
|
- `field_source`:字段来自 `llm`、`rule`、`ocr`、`vlm` 还是 `user_context`。
|
||||||
- `attachment_context`:本次可供语义解析使用的附件摘要。
|
- `attachment_context`:本次可供语义解析使用的附件摘要。
|
||||||
- `parse_strategy`:标记本次是模型主解析还是规则回退。
|
- `parse_strategy`:标记本次是模型主解析还是规则回退。
|
||||||
|
|
||||||
## 6. 叙述型财务输入
|
## 6. 叙述型财务输入
|
||||||
|
|
||||||
语义层必须支持“不是查询句”的自然叙述。
|
语义层必须支持“不是查询句”的自然叙述。
|
||||||
|
|
||||||
典型样例:
|
典型样例:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
我今天去客户现场,招待了客户,花销了1000元
|
我今天去客户现场,招待了客户,花销了1000元
|
||||||
我垫付了打车费和餐费,帮我看看怎么报
|
我垫付了打车费和餐费,帮我看看怎么报
|
||||||
上传了三张票,帮我整理成报销草稿
|
上传了三张票,帮我整理成报销草稿
|
||||||
```
|
```
|
||||||
|
|
||||||
这类输入不能默认识别成 `query`。
|
这类输入不能默认识别成 `query`。
|
||||||
|
|
||||||
建议默认策略:
|
建议默认策略:
|
||||||
|
|
||||||
- 优先识别为 `reimbursement` 域。
|
- 优先识别为 `reimbursement` 域。
|
||||||
- 场景优先落到 `daily_expense`、`travel_reimbursement` 或 `attachment_review`。
|
- 场景优先落到 `daily_expense`、`travel_reimbursement` 或 `attachment_review`。
|
||||||
- 意图优先落到 `create`、`generate` 或 `validate`。
|
- 意图优先落到 `create`、`generate` 或 `validate`。
|
||||||
- 缺失关键字段时返回 `ask_clarification`,而不是直接查数据库。
|
- 缺失关键字段时返回 `ask_clarification`,而不是直接查数据库。
|
||||||
|
|
||||||
## 7. 模糊短句与澄清规则
|
## 7. 模糊短句与澄清规则
|
||||||
|
|
||||||
以下输入应优先追问:
|
以下输入应优先追问:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
我要报销
|
我要报销
|
||||||
这个为什么还没处理
|
这个为什么还没处理
|
||||||
帮我看一下这个
|
帮我看一下这个
|
||||||
上传好了,下一步呢
|
上传好了,下一步呢
|
||||||
```
|
```
|
||||||
|
|
||||||
处理原则:
|
处理原则:
|
||||||
|
|
||||||
- 不允许直接执行工具。
|
- 不允许直接执行工具。
|
||||||
- 不允许直接落到应收、应付查询。
|
- 不允许直接落到应收、应付查询。
|
||||||
- 必须生成澄清问题。
|
- 必须生成澄清问题。
|
||||||
- 必须在审计中记录触发追问的原因。
|
- 必须在审计中记录触发追问的原因。
|
||||||
|
|
||||||
扩展原则:
|
扩展原则:
|
||||||
|
|
||||||
- 先不要把所有字段都做成数据库列。
|
- 先不要把所有字段都做成数据库列。
|
||||||
- 语义结果建议存 JSONB。
|
- 语义结果建议存 JSONB。
|
||||||
- 使用 `schema_version` 管理版本。
|
- 使用 `schema_version` 管理版本。
|
||||||
- Orchestrator 只依赖稳定字段。
|
- Orchestrator 只依赖稳定字段。
|
||||||
- 新字段以可选方式加入,不影响老任务。
|
- 新字段以可选方式加入,不影响老任务。
|
||||||
|
|
||||||
## 4. 示例
|
## 4. 示例
|
||||||
|
|
||||||
### 4.1 用户查询应收账龄
|
### 4.1 用户查询应收账龄
|
||||||
|
|
||||||
用户问:
|
用户问:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
上个月哪些客户应收逾期超过 30 天?
|
上个月哪些客户应收逾期超过 30 天?
|
||||||
```
|
```
|
||||||
|
|
||||||
解析:
|
解析:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"domain": "accounts_receivable",
|
"domain": "accounts_receivable",
|
||||||
"scenario": "receivable_aging",
|
"scenario": "receivable_aging",
|
||||||
"intent": "query",
|
"intent": "query",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"type": "customer",
|
"type": "customer",
|
||||||
"value": "客户",
|
"value": "客户",
|
||||||
"role": "group_by"
|
"role": "group_by"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time_range": {
|
"time_range": {
|
||||||
"raw": "上个月",
|
"raw": "上个月",
|
||||||
"start": "2026-04-01",
|
"start": "2026-04-01",
|
||||||
"end": "2026-04-30",
|
"end": "2026-04-30",
|
||||||
"granularity": "month"
|
"granularity": "month"
|
||||||
},
|
},
|
||||||
"constraints": {
|
"constraints": {
|
||||||
"aging_days": ">30",
|
"aging_days": ">30",
|
||||||
"status": "overdue"
|
"status": "overdue"
|
||||||
},
|
},
|
||||||
"risk_signals": ["overdue_receivable"],
|
"risk_signals": ["overdue_receivable"],
|
||||||
"next_step": "query_database"
|
"next_step": "query_database"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 用户解释发票拦截
|
### 4.2 用户解释发票拦截
|
||||||
|
|
||||||
用户问:
|
用户问:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
这张发票为什么报销被拦截?
|
这张发票为什么报销被拦截?
|
||||||
```
|
```
|
||||||
|
|
||||||
解析:
|
解析:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"domain": "reimbursement",
|
"domain": "reimbursement",
|
||||||
"scenario": "invoice_validation",
|
"scenario": "invoice_validation",
|
||||||
"intent": "explain",
|
"intent": "explain",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"type": "invoice",
|
"type": "invoice",
|
||||||
"value": "这张发票",
|
"value": "这张发票",
|
||||||
"role": "target"
|
"role": "target"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time_range": {},
|
"time_range": {},
|
||||||
"constraints": {},
|
"constraints": {},
|
||||||
"risk_signals": ["unknown"],
|
"risk_signals": ["unknown"],
|
||||||
"next_step": "run_rule"
|
"next_step": "run_rule"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.3 Hermes 每日风险巡检
|
### 4.3 Hermes 每日风险巡检
|
||||||
|
|
||||||
任务配置:
|
任务配置:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"domain": "reimbursement",
|
"domain": "reimbursement",
|
||||||
"scenario": "daily_risk_scan",
|
"scenario": "daily_risk_scan",
|
||||||
"intent": "monitor",
|
"intent": "monitor",
|
||||||
"entities": [],
|
"entities": [],
|
||||||
"time_range": {
|
"time_range": {
|
||||||
"raw": "昨日"
|
"raw": "昨日"
|
||||||
},
|
},
|
||||||
"constraints": {
|
"constraints": {
|
||||||
"risk_level": ["medium", "high"]
|
"risk_level": ["medium", "high"]
|
||||||
},
|
},
|
||||||
"risk_signals": [
|
"risk_signals": [
|
||||||
"duplicate_invoice",
|
"duplicate_invoice",
|
||||||
"missing_attachment",
|
"missing_attachment",
|
||||||
"policy_overrun"
|
"policy_overrun"
|
||||||
],
|
],
|
||||||
"next_step": "run_rule"
|
"next_step": "run_rule"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
453
document/development/rules/rule-version-center-ui-plan.md
Normal file
453
document/development/rules/rule-version-center-ui-plan.md
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
# 规则版本中心 UI 方案
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
当前“任务规则中心 > 财务规则 > 公司差旅费报销规则”已经具备:
|
||||||
|
|
||||||
|
- 在线 Excel 编辑
|
||||||
|
- 工作版本 / 线上版本分离
|
||||||
|
- 最近 5 个版本展示
|
||||||
|
- 审核、上线、恢复能力
|
||||||
|
|
||||||
|
但页面仍然存在一个明显问题:
|
||||||
|
**版本治理能力已经有了,用户却很难第一眼看见。**
|
||||||
|
|
||||||
|
当前版本列表更像“历史记录”,不是一个明确的“版本操作中心”。
|
||||||
|
用户无法快速判断:
|
||||||
|
|
||||||
|
1. 当前真正生效的是哪个版本
|
||||||
|
2. 当前正在编辑的是哪个版本
|
||||||
|
3. 从哪里进入版本切换
|
||||||
|
4. 从哪里发起版本对比
|
||||||
|
5. 某个版本经历了哪些流转动作
|
||||||
|
|
||||||
|
因此,需要把现有“版本列表”升级为一个真正可用的 **版本中心**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设计目标
|
||||||
|
|
||||||
|
### 2.1 用户一眼能看懂
|
||||||
|
|
||||||
|
进入规则详情页后,用户无需点击就能立即识别:
|
||||||
|
|
||||||
|
- 当前线上版本
|
||||||
|
- 当前工作版本
|
||||||
|
- 是否存在未上线工作稿
|
||||||
|
- 最近版本是否处于待审 / 已通过 / 已驳回状态
|
||||||
|
|
||||||
|
### 2.2 关键操作显性化
|
||||||
|
|
||||||
|
以下操作不能再隐藏在不明显的位置:
|
||||||
|
|
||||||
|
- 切换查看版本
|
||||||
|
- 与线上版本对比
|
||||||
|
- 查看完整流转
|
||||||
|
- 从历史版本恢复
|
||||||
|
|
||||||
|
### 2.3 保持 OnlyOffice 是主角
|
||||||
|
|
||||||
|
该页面的核心仍然是 Excel 规则表。
|
||||||
|
版本中心必须增强治理能力,但不能把主表格压缩成附属内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 推荐方案
|
||||||
|
|
||||||
|
采用:
|
||||||
|
|
||||||
|
> **左侧 OnlyOffice 主工作区 + 右侧版本中心 + 顶部显性入口 + 抽屉式详情**
|
||||||
|
|
||||||
|
这是比“单独开二级页签”更适合当前页面的方案,因为用户经常需要:
|
||||||
|
|
||||||
|
- 一边看表
|
||||||
|
- 一边知道自己看的是什么版本
|
||||||
|
- 一边进入版本对比或恢复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 页面整体布局
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 标题区:公司差旅费报销规则 │
|
||||||
|
│ 线上版本 v1.0.5 已上线 工作版本 v1.0.6 待审核 │
|
||||||
|
│ [下载 Excel] [上传表格] [版本对比] [查看流转] │
|
||||||
|
├───────────────────────────────────────────────┬────────────────────┤
|
||||||
|
│ │ 版本中心 │
|
||||||
|
│ │ │
|
||||||
|
│ │ ┌──────────────┐ │
|
||||||
|
│ │ │ 线上版本 │ │
|
||||||
|
│ │ │ v1.0.5 │ │
|
||||||
|
│ │ └──────────────┘ │
|
||||||
|
│ OnlyOffice │ ┌──────────────┐ │
|
||||||
|
│ 规则表主工作区 │ │ 工作版本 │ │
|
||||||
|
│ │ │ v1.0.6 │ │
|
||||||
|
│ │ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ 最近版本 │
|
||||||
|
│ │ v1.0.6 待审核 │
|
||||||
|
│ │ v1.0.5 已上线 │
|
||||||
|
│ │ v1.0.4 历史版本 │
|
||||||
|
│ │ │
|
||||||
|
│ │ 最近流转 │
|
||||||
|
│ │ [查看完整流转] │
|
||||||
|
└───────────────────────────────────────────────┴────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 顶部操作区设计
|
||||||
|
|
||||||
|
顶部必须保留并强化四个动作:
|
||||||
|
|
||||||
|
| 按钮 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| 下载 Excel | 下载当前预览版本 |
|
||||||
|
| 上传表格 | 导入内容生成新工作稿 |
|
||||||
|
| 版本对比 | 打开对比抽屉 |
|
||||||
|
| 查看流转 | 打开流转抽屉 |
|
||||||
|
|
||||||
|
### 5.1 版本对比按钮
|
||||||
|
|
||||||
|
这是一级入口,不能只藏在版本列表里。
|
||||||
|
默认行为:
|
||||||
|
|
||||||
|
- 基准版本:当前线上版本
|
||||||
|
- 对比版本:当前工作版本
|
||||||
|
|
||||||
|
如果两者相同,则按钮仍可用,但进入后提示:
|
||||||
|
|
||||||
|
> 当前工作版本与线上版本一致,可选择其他历史版本进行比较。
|
||||||
|
|
||||||
|
### 5.2 查看流转按钮
|
||||||
|
|
||||||
|
用于进入当前规则的完整生命周期视图。
|
||||||
|
不应只展示审计日志,而要展示“版本业务履历”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 右侧版本中心设计
|
||||||
|
|
||||||
|
### 6.1 顶部双版本卡片
|
||||||
|
|
||||||
|
```text
|
||||||
|
线上版本
|
||||||
|
v1.0.5
|
||||||
|
已上线
|
||||||
|
|
||||||
|
工作版本
|
||||||
|
v1.0.6
|
||||||
|
待审核
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 设计目的
|
||||||
|
|
||||||
|
用户进入页面后,最先要知道的是:
|
||||||
|
|
||||||
|
- **谁在线上**
|
||||||
|
- **谁正在被编辑**
|
||||||
|
|
||||||
|
而不是先看一个无上下文的历史列表。
|
||||||
|
|
||||||
|
### 6.2 最近版本列表
|
||||||
|
|
||||||
|
每个版本项包含:
|
||||||
|
|
||||||
|
- 版本号
|
||||||
|
- 生命周期状态
|
||||||
|
- 创建时间
|
||||||
|
- 变更说明
|
||||||
|
- 操作入口
|
||||||
|
|
||||||
|
建议样式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
v1.0.6 待审核
|
||||||
|
2026-05-18 09:12
|
||||||
|
补充出差补助标准
|
||||||
|
[查看] [与线上比]
|
||||||
|
|
||||||
|
v1.0.5 已上线
|
||||||
|
2026-05-18 08:40
|
||||||
|
新增补助页签
|
||||||
|
[查看]
|
||||||
|
|
||||||
|
v1.0.4 历史版本
|
||||||
|
2026-05-17 17:20
|
||||||
|
修正住宿标准
|
||||||
|
[查看] [恢复]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 规则
|
||||||
|
|
||||||
|
- `查看`:切换当前预览版本
|
||||||
|
- `与线上比`:直接以线上版本为基准进入对比
|
||||||
|
- `恢复`:仅高级管理人员可见
|
||||||
|
- 当前 `working_version` 不显示“恢复”
|
||||||
|
|
||||||
|
### 6.3 最近流转摘要
|
||||||
|
|
||||||
|
右侧版本中心底部展示最近 3 条流转:
|
||||||
|
|
||||||
|
```text
|
||||||
|
最近流转
|
||||||
|
09:12 曹笑竹 保存工作稿
|
||||||
|
09:25 曹笑竹 提交审核
|
||||||
|
10:08 顾承宇 审核通过
|
||||||
|
[查看完整流转]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 版本流转时间线设计
|
||||||
|
|
||||||
|
## 7.1 入口
|
||||||
|
|
||||||
|
两个入口:
|
||||||
|
|
||||||
|
1. 顶部 `查看流转`
|
||||||
|
2. 右侧版本中心底部 `查看完整流转`
|
||||||
|
|
||||||
|
## 7.2 容器
|
||||||
|
|
||||||
|
使用右侧宽抽屉,不使用小弹窗。
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 时间线内容会逐步增长
|
||||||
|
- 审核意见需要足够宽度展示
|
||||||
|
- 后续可能接入版本说明、操作人、来源版本
|
||||||
|
|
||||||
|
## 7.3 时间线内容
|
||||||
|
|
||||||
|
时间线按时间倒序或正序展示,推荐默认正序:
|
||||||
|
|
||||||
|
```text
|
||||||
|
● 2026-05-18 09:12
|
||||||
|
v1.0.6 工作稿创建
|
||||||
|
曹笑竹 保存工作稿
|
||||||
|
变更说明:补充出差补助标准
|
||||||
|
|
||||||
|
● 2026-05-18 09:25
|
||||||
|
提交审核
|
||||||
|
曹笑竹 提交当前工作版本
|
||||||
|
|
||||||
|
● 2026-05-18 10:08
|
||||||
|
审核通过
|
||||||
|
顾承宇:口径已核对,可上线
|
||||||
|
|
||||||
|
○ 待正式上线
|
||||||
|
```
|
||||||
|
|
||||||
|
如果版本来自恢复:
|
||||||
|
|
||||||
|
```text
|
||||||
|
● 基于 v1.0.3 恢复生成 v1.0.7
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7.4 时间线事件类型
|
||||||
|
|
||||||
|
| 事件类型 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `created` | 创建版本 |
|
||||||
|
| `submitted` | 提交审核 |
|
||||||
|
| `approved` | 审核通过 |
|
||||||
|
| `rejected` | 驳回 |
|
||||||
|
| `published` | 正式上线 |
|
||||||
|
| `restored` | 基于历史版本恢复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 版本差异对比设计
|
||||||
|
|
||||||
|
## 8.1 入口
|
||||||
|
|
||||||
|
版本对比必须有两个入口:
|
||||||
|
|
||||||
|
1. 顶部一级按钮:`版本对比`
|
||||||
|
2. 每个历史版本行内操作:`与线上比`
|
||||||
|
|
||||||
|
这样既满足“主动进入”,也满足“看到某个版本就顺手比较”。
|
||||||
|
|
||||||
|
## 8.2 容器
|
||||||
|
|
||||||
|
使用宽抽屉,推荐宽度:
|
||||||
|
|
||||||
|
- 桌面:页面宽度的 70% ~ 80%
|
||||||
|
- 小屏:全屏
|
||||||
|
|
||||||
|
不建议用普通弹窗,因为:
|
||||||
|
|
||||||
|
- Excel 差异需要足够展示宽度
|
||||||
|
- 版本选择器、摘要、表格都要共存
|
||||||
|
|
||||||
|
## 8.3 顶部区域
|
||||||
|
|
||||||
|
```text
|
||||||
|
版本对比
|
||||||
|
|
||||||
|
基准版本 [v1.0.5 已上线 ▼]
|
||||||
|
对比版本 [v1.0.6 待审核 ▼]
|
||||||
|
```
|
||||||
|
|
||||||
|
默认值:
|
||||||
|
|
||||||
|
- `baseVersion = published_version`
|
||||||
|
- `targetVersion = working_version`
|
||||||
|
|
||||||
|
## 8.4 差异摘要
|
||||||
|
|
||||||
|
优先先给决策信息,再给底层明细。
|
||||||
|
|
||||||
|
```text
|
||||||
|
差异摘要
|
||||||
|
- 修改 2 个工作表
|
||||||
|
- 新增 1 个工作表
|
||||||
|
- 修改 12 个单元格
|
||||||
|
- 删除 2 行
|
||||||
|
```
|
||||||
|
|
||||||
|
如果无差异:
|
||||||
|
|
||||||
|
```text
|
||||||
|
两个版本内容一致,没有发现表格差异。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8.5 差异详情
|
||||||
|
|
||||||
|
第一阶段优先支持 Excel 规则表:
|
||||||
|
|
||||||
|
| 工作表 | 位置 | 旧值 | 新值 | 类型 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 出差补助标准 | B4 | 75 | 90 | 修改 |
|
||||||
|
| 差旅住宿费标准 | A106 | - | 新增城市 | 新增 |
|
||||||
|
|
||||||
|
后续可扩展:
|
||||||
|
|
||||||
|
- 仅看新增
|
||||||
|
- 仅看删除
|
||||||
|
- 仅看数值变化
|
||||||
|
- 按工作表筛选
|
||||||
|
|
||||||
|
## 8.6 对比结果的业务语气
|
||||||
|
|
||||||
|
不要把页面做成“程序员 diff 工具”。
|
||||||
|
它应该像制度审核页面:
|
||||||
|
|
||||||
|
- 先讲影响
|
||||||
|
- 再讲位置
|
||||||
|
- 最后给证据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 数据接口设计
|
||||||
|
|
||||||
|
## 9.1 时间线接口
|
||||||
|
|
||||||
|
建议新增:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /agent-assets/{asset_id}/version-timeline
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
- 版本号
|
||||||
|
- 事件类型
|
||||||
|
- 操作人
|
||||||
|
- 操作时间
|
||||||
|
- 审核意见
|
||||||
|
- 来源版本(如有)
|
||||||
|
|
||||||
|
## 9.2 对比接口
|
||||||
|
|
||||||
|
建议新增:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /agent-assets/{asset_id}/versions/compare?base_version=v1.0.5&target_version=v1.0.6
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
- 基准版本
|
||||||
|
- 对比版本
|
||||||
|
- 工作表差异摘要
|
||||||
|
- 单元格级差异明细
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 视觉规范
|
||||||
|
|
||||||
|
### 10.1 颜色
|
||||||
|
|
||||||
|
沿用当前系统已有色彩,不引入新风格:
|
||||||
|
|
||||||
|
| 状态 | 建议色 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已上线 | 绿色 |
|
||||||
|
| 工作稿 | 蓝色 |
|
||||||
|
| 待审核 | 橙色 |
|
||||||
|
| 已驳回 | 红色 |
|
||||||
|
| 历史版本 | 灰色 |
|
||||||
|
|
||||||
|
### 10.2 密度
|
||||||
|
|
||||||
|
- 右侧版本中心应为紧凑型信息面板
|
||||||
|
- 不要使用过大的卡片间距
|
||||||
|
- 不能明显压缩 OnlyOffice 主区域
|
||||||
|
|
||||||
|
### 10.3 交互反馈
|
||||||
|
|
||||||
|
- 可点击元素必须有 hover
|
||||||
|
- 当前预览版本必须有 active 高亮
|
||||||
|
- 抽屉打开后保留明确关闭按钮
|
||||||
|
- 恢复操作必须二次确认
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 推荐实施顺序
|
||||||
|
|
||||||
|
### 第一阶段
|
||||||
|
|
||||||
|
1. 顶部新增 `版本对比`、`查看流转`
|
||||||
|
2. Excel 详情页改成:
|
||||||
|
- 左侧 OnlyOffice
|
||||||
|
- 右侧版本中心
|
||||||
|
3. 右侧展示:
|
||||||
|
- 线上版本
|
||||||
|
- 工作版本
|
||||||
|
- 最近 5 个版本
|
||||||
|
- 最近 3 条流转
|
||||||
|
|
||||||
|
### 第二阶段
|
||||||
|
|
||||||
|
1. 实现版本流转抽屉
|
||||||
|
2. 实现版本对比抽屉
|
||||||
|
3. 补齐真实后端接口
|
||||||
|
|
||||||
|
### 第三阶段
|
||||||
|
|
||||||
|
1. 增加更细的工作表筛选
|
||||||
|
2. 增加更多 diff 维度
|
||||||
|
3. 增加版本差异导出能力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 本次开发目标
|
||||||
|
|
||||||
|
本次开发直接完成以下内容:
|
||||||
|
|
||||||
|
1. 规则详情页出现明确的版本中心
|
||||||
|
2. 页面上出现明确的:
|
||||||
|
- `版本对比`
|
||||||
|
- `查看流转`
|
||||||
|
3. 最近版本列表增加:
|
||||||
|
- `查看`
|
||||||
|
- `与线上比`
|
||||||
|
- `恢复为工作稿`
|
||||||
|
4. 版本流转抽屉可用
|
||||||
|
5. 版本对比抽屉可用
|
||||||
|
6. 对比结果至少支持 Excel 表格的:
|
||||||
|
- 工作表新增 / 删除
|
||||||
|
- 单元格新增 / 删除 / 修改
|
||||||
|
|
||||||
237
document/development/rules/rule-version-governance-plan.md
Normal file
237
document/development/rules/rule-version-governance-plan.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# 规则版本治理方案
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
当前“任务规则中心”的规则资产只有一个 `current_version` 指针。
|
||||||
|
它同时承担了三种含义:
|
||||||
|
|
||||||
|
1. 财务人员正在编辑的版本
|
||||||
|
2. 审核中的候选版本
|
||||||
|
3. 系统运行时真正生效的线上版本
|
||||||
|
|
||||||
|
这会直接带来三个问题:
|
||||||
|
|
||||||
|
- 财务人员一旦修改 Excel,最新内容就会立刻成为 `current_version`,容易被误解为已经正式生效
|
||||||
|
- 审核、上线、回滚都围绕同一个指针转,权限边界不清晰
|
||||||
|
- 如果误上线,虽然能切换历史版本,但缺少“线上版本”和“工作版本”分离后的安全缓冲
|
||||||
|
|
||||||
|
## 2. 设计目标
|
||||||
|
|
||||||
|
这次改造要解决的不是“多存几个历史版本”,而是建立一套可长期使用的规则治理机制:
|
||||||
|
|
||||||
|
1. 财务人员可以编辑规则,但编辑结果默认只是草稿
|
||||||
|
2. 只有高级管理人员审核通过后,规则才能成为正式线上版本
|
||||||
|
3. 系统运行时只能读取正式线上版本,不能读取草稿
|
||||||
|
4. 前台要能清楚区分:
|
||||||
|
- 当前线上版本
|
||||||
|
- 当前工作版本
|
||||||
|
- 最近 5 个历史版本
|
||||||
|
5. 如果误操作上线,可以快速恢复,但恢复动作仍然要留下完整审计链
|
||||||
|
|
||||||
|
## 3. 核心模型
|
||||||
|
|
||||||
|
### 3.1 双指针版本模型
|
||||||
|
|
||||||
|
在规则资产上新增两个版本指针:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `published_version` | 当前正式在线上生效的版本 |
|
||||||
|
| `working_version` | 当前最新的工作稿 / 待审稿 |
|
||||||
|
|
||||||
|
兼容策略:
|
||||||
|
|
||||||
|
- 现有 `current_version` 暂时保留,用于兼容历史代码
|
||||||
|
- 对规则资产来说,后续它只承担“当前工作版本”的兼容语义
|
||||||
|
- 运行时逻辑不再读取 `current_version`,而是优先读取 `published_version`
|
||||||
|
|
||||||
|
### 3.2 版本状态
|
||||||
|
|
||||||
|
不额外在版本表中硬存一套容易失真的状态,而是根据“版本指针 + 最新审核记录”动态推导:
|
||||||
|
|
||||||
|
| 条件 | 版本状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| `version == published_version` | 已上线 |
|
||||||
|
| `version == working_version` 且无审核记录 | 草稿 |
|
||||||
|
| `version == working_version` 且最新审核为 `pending` | 待审核 |
|
||||||
|
| `version == working_version` 且最新审核为 `approved` | 已通过待上线 |
|
||||||
|
| `version == working_version` 且最新审核为 `rejected` | 已驳回 |
|
||||||
|
| 其他历史版本 | 历史版本 |
|
||||||
|
|
||||||
|
这样可以避免“版本状态”和“审核记录”两套数据互相打架。
|
||||||
|
|
||||||
|
## 4. 权限边界
|
||||||
|
|
||||||
|
| 角色 | 能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| 财务人员 `finance` | 编辑工作稿、上传/导入 Excel、提交审核 |
|
||||||
|
| 高级管理人员 `manager` / `admin` | 审核通过、驳回、正式发布、恢复历史版本 |
|
||||||
|
| 其他普通员工 | 只读 |
|
||||||
|
|
||||||
|
### 4.1 财务人员
|
||||||
|
|
||||||
|
- 可以直接编辑当前 `working_version`
|
||||||
|
- 每次保存自动生成新版本,并把它设为新的 `working_version`
|
||||||
|
- 不能把草稿直接变成 `published_version`
|
||||||
|
|
||||||
|
### 4.2 高级管理人员
|
||||||
|
|
||||||
|
- 可以对 `working_version` 发起:
|
||||||
|
- 审核通过
|
||||||
|
- 驳回
|
||||||
|
- 正式发布
|
||||||
|
- 只有 `approved` 的工作版本才能发布
|
||||||
|
|
||||||
|
## 5. 发布与回滚流程
|
||||||
|
|
||||||
|
### 5.1 正常发布
|
||||||
|
|
||||||
|
1. 财务人员编辑并保存
|
||||||
|
2. 系统生成新版本,例如 `v1.0.6`
|
||||||
|
3. `working_version = v1.0.6`
|
||||||
|
4. 财务人员提交审核
|
||||||
|
5. 高级管理人员审核通过
|
||||||
|
6. 高级管理人员点击“正式上线”
|
||||||
|
7. `published_version = v1.0.6`
|
||||||
|
8. 系统运行时切换到新版本
|
||||||
|
|
||||||
|
### 5.2 驳回
|
||||||
|
|
||||||
|
1. 财务人员提交审核
|
||||||
|
2. 高级管理人员驳回
|
||||||
|
3. 当前工作版本保留,但状态显示为“已驳回”
|
||||||
|
4. 财务人员继续编辑,形成新的工作版本
|
||||||
|
|
||||||
|
### 5.3 恢复历史版本
|
||||||
|
|
||||||
|
不直接把 `published_version` 指回旧版本,而是采用“复制恢复”的方式:
|
||||||
|
|
||||||
|
1. 管理员在最近 5 个版本中选择一个历史版本
|
||||||
|
2. 系统基于该历史版本内容生成一个新的恢复版本,例如 `v1.0.7`
|
||||||
|
3. 新版本写入 `working_version`
|
||||||
|
4. 审核通过后再正式发布
|
||||||
|
|
||||||
|
这么做的好处:
|
||||||
|
|
||||||
|
- 不会破坏历史链路
|
||||||
|
- 每一次恢复都有明确的责任人与时间
|
||||||
|
- 既能快速回滚,又保留审计闭环
|
||||||
|
|
||||||
|
## 6. 版本保留策略
|
||||||
|
|
||||||
|
### 6.1 前台展示
|
||||||
|
|
||||||
|
- 详情页固定展示最近 5 个版本
|
||||||
|
- 每个版本显示:
|
||||||
|
- 版本号
|
||||||
|
- 状态
|
||||||
|
- 创建人
|
||||||
|
- 创建时间
|
||||||
|
- 变更说明
|
||||||
|
|
||||||
|
### 6.2 后台保存
|
||||||
|
|
||||||
|
后台不能机械地“只保留 5 个版本”,否则可能把关键线上版本挤掉。
|
||||||
|
建议策略:
|
||||||
|
|
||||||
|
1. 始终保留当前 `published_version`
|
||||||
|
2. 始终保留当前 `working_version`
|
||||||
|
3. 额外保留最近 5 个历史版本
|
||||||
|
|
||||||
|
这样既满足前台简洁,也能避免误删关键版本。
|
||||||
|
|
||||||
|
## 7. 前端交互
|
||||||
|
|
||||||
|
### 7.1 规则详情页顶部
|
||||||
|
|
||||||
|
展示两个醒目的版本标签:
|
||||||
|
|
||||||
|
- 当前线上版本
|
||||||
|
- 当前工作版本
|
||||||
|
|
||||||
|
如果两者不同,需要明确提示:
|
||||||
|
|
||||||
|
> 当前存在尚未上线的工作稿,系统运行仍以线上版本为准。
|
||||||
|
|
||||||
|
### 7.2 编辑区
|
||||||
|
|
||||||
|
- 财务人员看到“可编辑工作稿”
|
||||||
|
- 普通用户只读
|
||||||
|
- 管理员可编辑,但主要职责仍是审核与发布
|
||||||
|
|
||||||
|
### 7.3 版本区
|
||||||
|
|
||||||
|
最近 5 个版本中每条都显示状态:
|
||||||
|
|
||||||
|
- 已上线
|
||||||
|
- 草稿
|
||||||
|
- 待审核
|
||||||
|
- 已通过待上线
|
||||||
|
- 已驳回
|
||||||
|
- 历史版本
|
||||||
|
|
||||||
|
可执行操作:
|
||||||
|
|
||||||
|
- 查看
|
||||||
|
- 基于该版本恢复
|
||||||
|
- 对当前工作版本提交审核 / 审核 / 发布
|
||||||
|
|
||||||
|
## 8. 后端改造清单
|
||||||
|
|
||||||
|
1. `agent_assets`
|
||||||
|
- 新增 `published_version`
|
||||||
|
- 新增 `working_version`
|
||||||
|
2. 兼容旧数据
|
||||||
|
- 历史规则资产初始化时:
|
||||||
|
- `published_version = current_version`
|
||||||
|
- `working_version = current_version`
|
||||||
|
3. 版本保存
|
||||||
|
- 保存新版本后:
|
||||||
|
- 只更新 `working_version`
|
||||||
|
- `current_version` 同步为 `working_version` 以兼容旧逻辑
|
||||||
|
4. 审核
|
||||||
|
- 审核只针对 `working_version`
|
||||||
|
5. 发布
|
||||||
|
- 只允许把已审核通过的 `working_version` 推到 `published_version`
|
||||||
|
6. 运行时
|
||||||
|
- 只读取 `published_version`
|
||||||
|
7. 回滚
|
||||||
|
- 新增“基于历史版本恢复为新工作稿”的接口
|
||||||
|
|
||||||
|
## 9. 前端改造清单
|
||||||
|
|
||||||
|
1. 资产详情模型增加:
|
||||||
|
- `publishedVersion`
|
||||||
|
- `workingVersion`
|
||||||
|
- 每个历史版本的派生状态
|
||||||
|
2. 规则详情页展示:
|
||||||
|
- 当前线上版本
|
||||||
|
- 当前工作版本
|
||||||
|
- 最近 5 个版本
|
||||||
|
3. 操作权限拆分:
|
||||||
|
- finance:编辑、上传、提交审核
|
||||||
|
- manager/admin:审核、上线、恢复
|
||||||
|
4. OnlyOffice 编辑逻辑:
|
||||||
|
- 默认编辑工作版本
|
||||||
|
- 历史版本只读
|
||||||
|
5. 正式上线按钮:
|
||||||
|
- 只有工作版本已审核通过时可用
|
||||||
|
|
||||||
|
## 10. 本次实现边界
|
||||||
|
|
||||||
|
本轮优先完成以下能力:
|
||||||
|
|
||||||
|
1. 规则版本双指针
|
||||||
|
2. 财务角色可编辑工作稿
|
||||||
|
3. 正式上线只切换 `published_version`
|
||||||
|
4. 运行时只读取 `published_version`
|
||||||
|
5. 最近 5 个版本展示
|
||||||
|
6. 基于历史版本快速恢复为新工作稿
|
||||||
|
|
||||||
|
后续如需要,再继续补:
|
||||||
|
|
||||||
|
- 版本差异对比
|
||||||
|
- 审核意见流转面板
|
||||||
|
- 发布说明 / 审批单号
|
||||||
|
- 定时生效
|
||||||
|
|
||||||
@@ -18,8 +18,9 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.6.0,<3.0.0",
|
"pydantic-settings>=2.6.0,<3.0.0",
|
||||||
"python-dotenv>=1.0.1,<2.0.0",
|
"python-dotenv>=1.0.1,<2.0.0",
|
||||||
"email-validator>=2.2.0,<3.0.0",
|
"email-validator>=2.2.0,<3.0.0",
|
||||||
"python-multipart>=0.0.20,<1.0.0",
|
"python-multipart>=0.0.20,<1.0.0",
|
||||||
"lightrag-hku>=1.4.16,<1.5.0",
|
"openpyxl>=3.1.5,<4.0.0",
|
||||||
|
"lightrag-hku>=1.4.16,<1.5.0",
|
||||||
"qdrant-client>=1.18.0,<2.0.0",
|
"qdrant-client>=1.18.0,<2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
BIN
server/rules/finance-rules/公司差旅费报销规则.xlsx
Normal file
BIN
server/rules/finance-rules/公司差旅费报销规则.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/公司通信费报销规则.xlsx
Normal file
BIN
server/rules/finance-rules/公司通信费报销规则.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/远光软件2026费用报销说明手册.pdf
Normal file
BIN
server/rules/finance-rules/远光软件2026费用报销说明手册.pdf
Normal file
Binary file not shown.
@@ -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
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
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
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
259
server/scripts/build_company_travel_default_workbook.py
Normal file
259
server/scripts/build_company_travel_default_workbook.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
OUTPUT_PATH = Path(__file__).resolve().parents[1] / "rules" / "finance-rules" / "公司差旅费报销规则.xlsx"
|
||||||
|
|
||||||
|
ROWS = [
|
||||||
|
(1, "北京", "北京", "", "", 500, 450, 450, 500, ""),
|
||||||
|
(2, "天津", "6 个中心城区、滨海新区、东丽区、西青区、津南区、北辰区、武清区、宝坻区、静海区、蓟县", "", "", 380, 360, 350, 380, ""),
|
||||||
|
(2, "天津", "宁河区", "", "", 320, 300, 280, 320, ""),
|
||||||
|
(3, "河北", "石家庄", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(3, "河北", "张家口、秦皇岛、廊坊、承德、保定", "张家口市;秦皇岛市;承德市", "张家口市:7-9 月、11-3 月;秦皇岛市:7-8 月;承德市:7-9 月", 350, 300, 250, 350, 420),
|
||||||
|
(3, "河北", "雄安新区(不含雄县、安新县、容城县)", "", "", 450, 400, 350, 450, ""),
|
||||||
|
(3, "河北", "其他地区", "", "", 310, 290, 250, 310, ""),
|
||||||
|
(4, "山西", "太原", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(4, "山西", "大同、晋城", "", "", 350, 300, 250, 350, ""),
|
||||||
|
(4, "山西", "临汾", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(4, "山西", "阳泉、长治、晋中", "", "", 310, 290, 250, 310, ""),
|
||||||
|
(4, "山西", "其他地区", "", "", 240, 220, 200, 240, ""),
|
||||||
|
(5, "内蒙古", "呼和浩特", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(5, "内蒙古", "海拉尔市、满洲里市、阿尔山市", "海拉尔市、满洲里市、阿尔山市", "7-9 月", 320, 300, 250, 320, 380),
|
||||||
|
(5, "内蒙古", "二连浩特市", "二连浩特市", "7-9 月", 320, 300, 250, 320, 380),
|
||||||
|
(5, "内蒙古", "额济纳市", "额济纳市", "9-10 月", 320, 300, 250, 320, 380),
|
||||||
|
(5, "内蒙古", "其他地区", "", "", 320, 300, 250, 320, ""),
|
||||||
|
(6, "辽宁", "沈阳", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(6, "辽宁", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(7, "大连", "大连", "大连", "7-9 月", 350, 300, 300, 350, 420),
|
||||||
|
(8, "吉林", "长春", "长春", "7-9 月", 350, 330, 300, 350, 420),
|
||||||
|
(8, "吉林", "吉林、延边州、长白山管理区", "吉林、延边州、长白山管理区", "7-9 月", 350, 300, 250, 350, 420),
|
||||||
|
(8, "吉林", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(9, "黑龙江", "哈尔滨", "哈尔滨", "7-9 月", 350, 330, 300, 350, 420),
|
||||||
|
(9, "黑龙江", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "6-8 月", 300, 280, 250, 300, 360),
|
||||||
|
(9, "黑龙江", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(10, "上海", "上海", "", "", 500, 450, 450, 500, ""),
|
||||||
|
(11, "江苏", "南京", "", "", 380, 350, 350, 380, ""),
|
||||||
|
(11, "江苏", "苏州、无锡、常州、镇江", "", "", 350, 300, 250, 380, ""),
|
||||||
|
(11, "江苏", "其他地区", "", "", 350, 300, 250, 360, ""),
|
||||||
|
(12, "浙江", "杭州", "", "", 400, 350, 350, 400, ""),
|
||||||
|
(12, "浙江", "其他地区", "", "", 340, 300, 250, 340, ""),
|
||||||
|
(13, "宁波", "宁波", "", "", 350, 300, 250, 350, ""),
|
||||||
|
(14, "安徽", "合肥", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(14, "安徽", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||||
|
(15, "福建", "福州", "", "", 380, 350, 300, 380, ""),
|
||||||
|
(15, "福建", "泉州、平潭综合实验区", "", "", 350, 300, 250, 380, ""),
|
||||||
|
(15, "福建", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||||
|
(16, "厦门", "厦门", "", "", 400, 380, 350, 400, ""),
|
||||||
|
(17, "江西", "南昌", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(17, "江西", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||||
|
(18, "山东", "济南", "", "", 380, 350, 300, 380, ""),
|
||||||
|
(18, "山东", "烟台、威海、日照", "烟台、威海、日照", "7-9 月", 350, 300, 250, 380, 450),
|
||||||
|
(18, "山东", "淄博、枣庄、东营、潍坊、济宁、泰安", "", "", 350, 300, 250, 380, ""),
|
||||||
|
(18, "山东", "其他地区", "", "", 350, 300, 250, 360, ""),
|
||||||
|
(19, "青岛", "青岛", "青岛", "7-9 月", 350, 300, 250, 380, 450),
|
||||||
|
(20, "河南", "郑州", "", "", 380, 350, 300, 380, ""),
|
||||||
|
(20, "河南", "洛阳", "洛阳", "4-5 月上旬", 330, 300, 250, 330, 390),
|
||||||
|
(20, "河南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(21, "湖北", "武汉", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(21, "湖北", "其他地区", "", "", 320, 300, 250, 320, ""),
|
||||||
|
(22, "湖南", "长沙", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(22, "湖南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(23, "广东", "广州", "", "", 450, 400, 400, 450, ""),
|
||||||
|
(23, "广东", "珠海", "", "", 450, 400, 350, 450, ""),
|
||||||
|
(23, "广东", "佛山、东莞、中山、江门", "", "", 350, 300, 250, 450, ""),
|
||||||
|
(23, "广东", "其他地区", "", "", 350, 300, 250, 420, ""),
|
||||||
|
(24, "深圳", "深圳", "", "", 450, 400, 400, 450, ""),
|
||||||
|
(25, "广西", "南宁", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(25, "广西", "桂林、北海", "桂林、北海", "1-2 月、7-9 月", 330, 300, 250, 330, 390),
|
||||||
|
(25, "广西", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(26, "海南", "海口、文昌、澄迈县", "海口、文昌、澄迈县", "11-2 月", 350, 330, 310, 350, 420),
|
||||||
|
(26, "海南", "琼海、万宁、陵水县、保亭县", "琼海、万宁、陵水县、保亭县", "11-3 月", 350, 330, 310, 350, 420),
|
||||||
|
(26, "海南", "三沙、儋州、五指山、东方、安定县、屯昌县、临高县、白沙县、昌江县、乐东县、琼中县、洋浦开发区", "", "", 350, 330, 310, 350, ""),
|
||||||
|
(26, "海南", "三亚", "三亚", "10-4 月", 400, 380, 350, 400, 480),
|
||||||
|
(27, "重庆", "9 个中心城区、北部新区", "", "", 370, 350, 330, 370, ""),
|
||||||
|
(27, "重庆", "其他地区", "", "", 300, 280, 260, 300, ""),
|
||||||
|
(28, "四川", "成都", "", "", 370, 350, 330, 370, ""),
|
||||||
|
(28, "四川", "阿坝州、甘孜州", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(28, "四川", "绵阳、乐山、雅安", "", "", 320, 300, 250, 320, ""),
|
||||||
|
(28, "四川", "宜宾", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(28, "四川", "凉山州", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(28, "四川", "德阳、遂宁、巴中", "", "", 310, 290, 250, 310, ""),
|
||||||
|
(28, "四川", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(29, "贵州", "贵阳", "", "", 370, 350, 300, 370, ""),
|
||||||
|
(29, "贵州", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(30, "云南", "昆明", "", "", 380, 350, 300, 380, ""),
|
||||||
|
(30, "云南", "大理州、丽江市、迪庆州、西双版纳州", "", "", 350, 300, 250, 380, ""),
|
||||||
|
(30, "云南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(31, "西藏", "拉萨", "拉萨", "5-10 月", 350, 330, 300, 350, 420),
|
||||||
|
(31, "西藏", "其他地区", "其他地区", "5-10 月", 300, 280, 250, 300, 360),
|
||||||
|
(32, "陕西", "西安", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(32, "陕西", "榆林、延安", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(32, "陕西", "杨凌区", "", "", 260, 240, 220, 260, ""),
|
||||||
|
(32, "陕西", "咸阳、宝鸡", "", "", 260, 240, 220, 260, ""),
|
||||||
|
(32, "陕西", "渭南、韩城", "", "", 260, 240, 220, 260, ""),
|
||||||
|
(32, "陕西", "其他地区", "", "", 230, 210, 200, 230, ""),
|
||||||
|
(33, "甘肃", "兰州", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(33, "甘肃", "其他地区", "", "", 310, 290, 250, 310, ""),
|
||||||
|
(34, "青海", "西宁", "西宁", "6-9 月", 350, 330, 300, 350, 420),
|
||||||
|
(34, "青海", "玉树州", "玉树州", "5-9 月", 300, 280, 250, 300, 360),
|
||||||
|
(34, "青海", "果洛州", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(34, "青海", "海北州、黄南州", "海北州、黄南州", "5-9 月", 250, 230, 210, 250, 300),
|
||||||
|
(34, "青海", "海东、海南州", "海东、海南州", "5-9 月", 250, 230, 210, 250, 300),
|
||||||
|
(34, "青海", "海西州", "海西州", "5-9 月", 200, 200, 200, 200, 240),
|
||||||
|
(35, "宁夏", "银川", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(35, "宁夏", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||||
|
(36, "新疆", "乌鲁木齐", "", "", 350, 330, 300, 350, ""),
|
||||||
|
(36, "新疆", "石河子、克拉玛依、昌吉州、伊犁州、阿勒泰地区、博州、吐鲁番、哈密地区、巴州、和田地区", "", "", 340, 300, 250, 340, ""),
|
||||||
|
(36, "新疆", "克州", "", "", 320, 300, 250, 320, ""),
|
||||||
|
(36, "新疆", "喀什地区", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(36, "新疆", "阿克苏地区", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(36, "新疆", "塔城地区", "", "", 300, 280, 250, 300, ""),
|
||||||
|
(37, "港澳台", "香港、澳门、台湾", "", "", 450, 400, 350, 500, ""),
|
||||||
|
(38, "国外", "国外", "", "", 700, 600, 500, 700, ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_workbook() -> Workbook:
|
||||||
|
workbook = Workbook()
|
||||||
|
worksheet = workbook.active
|
||||||
|
worksheet.title = "差旅住宿费标准"
|
||||||
|
headers = [
|
||||||
|
"序号",
|
||||||
|
"地区",
|
||||||
|
"地区(城市)",
|
||||||
|
"旺季地区",
|
||||||
|
"旺季期间",
|
||||||
|
"公司级管理人员、高层经理(P7及以上)",
|
||||||
|
"中层经理、基层经理(P4-P6、外聘专家)",
|
||||||
|
"其他员工",
|
||||||
|
"超标限额",
|
||||||
|
"旺季超标限额",
|
||||||
|
]
|
||||||
|
|
||||||
|
worksheet.append(["差旅住宿费标准"])
|
||||||
|
worksheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
|
||||||
|
worksheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
|
||||||
|
worksheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
|
||||||
|
worksheet["A1"].alignment = Alignment(horizontal="center")
|
||||||
|
worksheet.append(headers)
|
||||||
|
for row in ROWS:
|
||||||
|
worksheet.append(row)
|
||||||
|
|
||||||
|
header_fill = PatternFill("solid", fgColor="D9EAF7")
|
||||||
|
thin = Side(style="thin", color="B7C9D6")
|
||||||
|
for cell in worksheet[2]:
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
for row in worksheet.iter_rows(min_row=3, max_row=worksheet.max_row):
|
||||||
|
for cell in row:
|
||||||
|
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||||
|
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
for cell in row[5:]:
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
|
||||||
|
worksheet.freeze_panes = "A3"
|
||||||
|
worksheet.auto_filter.ref = f"A2:J{worksheet.max_row}"
|
||||||
|
widths = [8, 12, 42, 28, 28, 22, 26, 12, 12, 14]
|
||||||
|
for index, width in enumerate(widths, start=1):
|
||||||
|
worksheet.column_dimensions[get_column_letter(index)].width = width
|
||||||
|
worksheet.row_dimensions[1].height = 26
|
||||||
|
worksheet.row_dimensions[2].height = 42
|
||||||
|
for index in range(3, worksheet.max_row + 1):
|
||||||
|
worksheet.row_dimensions[index].height = 36
|
||||||
|
|
||||||
|
subsidy_sheet = workbook.create_sheet("出差补助标准")
|
||||||
|
subsidy_headers = [
|
||||||
|
"补助类型",
|
||||||
|
"项目",
|
||||||
|
"港澳台",
|
||||||
|
"直辖市/特区",
|
||||||
|
"西藏",
|
||||||
|
"新疆-乌鲁木齐",
|
||||||
|
"新疆-其他",
|
||||||
|
"其他地区",
|
||||||
|
"国外",
|
||||||
|
]
|
||||||
|
subsidy_rows = [
|
||||||
|
("伙食补助", "自行解决餐食", 75, 65, 65, 55, 55, 55, 140),
|
||||||
|
("基本补助", "基本出差补贴", 35, 35, 105, 75, 135, 35, 35),
|
||||||
|
("合计", "", 110, 100, 170, 130, 190, 90, 175),
|
||||||
|
]
|
||||||
|
subsidy_sheet.append(["出差补助标准"])
|
||||||
|
subsidy_sheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(subsidy_headers))
|
||||||
|
subsidy_sheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
|
||||||
|
subsidy_sheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
|
||||||
|
subsidy_sheet["A1"].alignment = Alignment(horizontal="center")
|
||||||
|
subsidy_sheet.append(subsidy_headers)
|
||||||
|
for row in subsidy_rows:
|
||||||
|
subsidy_sheet.append(row)
|
||||||
|
subsidy_sheet.append(["备注", "注 1:新疆分公司同事出差至乌鲁木齐外的其他新疆地区,基本补助标准为 95 元。"])
|
||||||
|
subsidy_sheet.append(["备注", "注 2:西藏分公司同事出差至拉萨市外的其他西藏地区,基本补助标准为 35 元。"])
|
||||||
|
subsidy_sheet.merge_cells(start_row=6, start_column=2, end_row=6, end_column=len(subsidy_headers))
|
||||||
|
subsidy_sheet.merge_cells(start_row=7, start_column=2, end_row=7, end_column=len(subsidy_headers))
|
||||||
|
for cell in subsidy_sheet[2]:
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
for row in subsidy_sheet.iter_rows(min_row=3, max_row=7):
|
||||||
|
for cell in row:
|
||||||
|
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||||
|
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
for cell in subsidy_sheet[5]:
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = PatternFill("solid", fgColor="E2F0D9")
|
||||||
|
subsidy_sheet.freeze_panes = "A3"
|
||||||
|
subsidy_sheet.auto_filter.ref = "A2:I5"
|
||||||
|
subsidy_widths = [14, 18, 12, 16, 12, 18, 16, 14, 12]
|
||||||
|
for index, width in enumerate(subsidy_widths, start=1):
|
||||||
|
subsidy_sheet.column_dimensions[get_column_letter(index)].width = width
|
||||||
|
subsidy_sheet.row_dimensions[1].height = 26
|
||||||
|
subsidy_sheet.row_dimensions[2].height = 36
|
||||||
|
for index in range(3, 8):
|
||||||
|
subsidy_sheet.row_dimensions[index].height = 28
|
||||||
|
|
||||||
|
source_sheet = workbook.create_sheet("来源说明")
|
||||||
|
source_sheet.append(["来源文件", "页码", "说明"])
|
||||||
|
source_sheet.append(
|
||||||
|
[
|
||||||
|
"远光软件2026费用报销说明手册.pdf",
|
||||||
|
"第 13-19 页",
|
||||||
|
"依据 PDF 附件 3《差旅住宿费标准》整理为默认支撑表。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
source_sheet.append(
|
||||||
|
[
|
||||||
|
"远光软件2026费用报销说明手册.pdf",
|
||||||
|
"第 20 页",
|
||||||
|
"依据 PDF 附件 4《出差补助标准》整理为默认支撑表。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for row in source_sheet.iter_rows():
|
||||||
|
for cell in row:
|
||||||
|
cell.alignment = Alignment(wrap_text=True, vertical="center")
|
||||||
|
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
for cell in source_sheet[1]:
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = header_fill
|
||||||
|
source_sheet.column_dimensions["A"].width = 34
|
||||||
|
source_sheet.column_dimensions["B"].width = 14
|
||||||
|
source_sheet.column_dimensions["C"].width = 56
|
||||||
|
return workbook
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
workbook = build_workbook()
|
||||||
|
workbook.save(OUTPUT_PATH)
|
||||||
|
print(OUTPUT_PATH)
|
||||||
|
print(f"rows={len(ROWS)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
28
server/scripts/sync_platform_risk_rules.py
Normal file
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
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"))
|
||||||
@@ -240,7 +240,7 @@ run_bootstrap_python() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies_ready() {
|
dependencies_ready() {
|
||||||
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
|
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, openpyxl, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
pip_ready() {
|
pip_ready() {
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ def get_db() -> Generator[Session, None, None]:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class CurrentUserContext:
|
class CurrentUserContext:
|
||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
role_codes: list[str]
|
role_codes: list[str]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
department_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
@@ -41,6 +42,10 @@ def get_current_user(
|
|||||||
str | None,
|
str | None,
|
||||||
Header(description="是否管理员,支持 `true/false/1/0`。"),
|
Header(description="是否管理员,支持 `true/false/1/0`。"),
|
||||||
] = None,
|
] = None,
|
||||||
|
x_auth_department: Annotated[
|
||||||
|
str | None,
|
||||||
|
Header(description="当前登录人的所属部门。"),
|
||||||
|
] = None,
|
||||||
) -> CurrentUserContext:
|
) -> CurrentUserContext:
|
||||||
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
||||||
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
@@ -56,19 +61,46 @@ def get_current_user(
|
|||||||
|
|
||||||
return CurrentUserContext(
|
return CurrentUserContext(
|
||||||
username=username or name,
|
username=username or name,
|
||||||
name=name or username,
|
name=name or username,
|
||||||
role_codes=role_codes,
|
role_codes=role_codes,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
)
|
department_name=(x_auth_department or "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def require_admin_user(
|
def require_admin_user(
|
||||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
) -> CurrentUserContext:
|
) -> CurrentUserContext:
|
||||||
if current_user.is_admin or "manager" in current_user.role_codes:
|
if current_user.is_admin or "manager" in current_user.role_codes:
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="只有管理员可以上传、删除或修改知识库文件。",
|
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_rule_editor_user(
|
||||||
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
|
) -> CurrentUserContext:
|
||||||
|
role_codes = {item.strip() for item in current_user.role_codes}
|
||||||
|
if current_user.is_admin or "manager" in role_codes or "finance" in role_codes:
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只有财务人员或高级管理人员可以编辑规则草稿。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_rule_reviewer_user(
|
||||||
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
|
) -> CurrentUserContext:
|
||||||
|
role_codes = {item.strip() for item in current_user.role_codes}
|
||||||
|
if current_user.is_admin or "manager" in role_codes:
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只有高级管理人员可以审核、发布或恢复正式规则。",
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,19 +2,34 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db
|
from app.api.deps import (
|
||||||
|
CurrentUserContext,
|
||||||
|
get_current_user,
|
||||||
|
get_db,
|
||||||
|
require_admin_user,
|
||||||
|
require_rule_editor_user,
|
||||||
|
require_rule_reviewer_user,
|
||||||
|
)
|
||||||
from app.schemas.agent_asset import (
|
from app.schemas.agent_asset import (
|
||||||
AgentAssetCreate,
|
AgentAssetCreate,
|
||||||
AgentAssetListItem,
|
AgentAssetListItem,
|
||||||
|
AgentAssetOnlyOfficeCallbackRead,
|
||||||
|
AgentAssetOnlyOfficeCallbackWrite,
|
||||||
|
AgentAssetOnlyOfficeConfigRead,
|
||||||
AgentAssetRead,
|
AgentAssetRead,
|
||||||
AgentAssetReviewCreate,
|
AgentAssetReviewCreate,
|
||||||
AgentAssetReviewRead,
|
AgentAssetReviewRead,
|
||||||
|
AgentAssetRuleJsonRead,
|
||||||
|
AgentAssetRuleJsonWrite,
|
||||||
|
AgentAssetSpreadsheetChangeRecordRead,
|
||||||
AgentAssetUpdate,
|
AgentAssetUpdate,
|
||||||
AgentAssetVersionCreate,
|
AgentAssetVersionCreate,
|
||||||
AgentAssetVersionRead,
|
AgentAssetVersionRead,
|
||||||
|
AgentAssetVersionTimelineItemRead,
|
||||||
)
|
)
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse
|
||||||
from app.services.agent_assets import AgentAssetService
|
from app.services.agent_assets import AgentAssetService
|
||||||
@@ -29,10 +44,14 @@ RequestIdHeader = Annotated[
|
|||||||
str | None,
|
str | None,
|
||||||
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
||||||
]
|
]
|
||||||
|
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||||
|
AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)]
|
||||||
|
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
||||||
|
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
|
||||||
|
|
||||||
|
|
||||||
def _handle_asset_error(exc: Exception) -> None:
|
def _handle_asset_error(exc: Exception) -> None:
|
||||||
if isinstance(exc, LookupError):
|
if isinstance(exc, (LookupError, FileNotFoundError)):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
if isinstance(exc, PermissionError):
|
if isinstance(exc, PermissionError):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
@@ -93,6 +112,250 @@ def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
|
|||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{asset_id}/rule-json",
|
||||||
|
response_model=AgentAssetRuleJsonRead,
|
||||||
|
summary="读取风险规则 JSON",
|
||||||
|
description="读取 JSON 风险规则资产绑定的规则文件内容。",
|
||||||
|
)
|
||||||
|
def get_agent_asset_rule_json(
|
||||||
|
asset_id: str,
|
||||||
|
_: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> AgentAssetRuleJsonRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).read_rule_json(asset_id)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{asset_id}/rule-json",
|
||||||
|
response_model=AgentAssetRuleJsonRead,
|
||||||
|
summary="保存风险规则 JSON",
|
||||||
|
description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。",
|
||||||
|
)
|
||||||
|
def save_agent_asset_rule_json(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetRuleJsonWrite,
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRuleJsonRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).write_rule_json(
|
||||||
|
asset_id,
|
||||||
|
body=payload,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{asset_id}/spreadsheet/onlyoffice-config",
|
||||||
|
response_model=AgentAssetOnlyOfficeConfigRead,
|
||||||
|
summary="读取规则 Excel 的 ONLYOFFICE 配置",
|
||||||
|
description="为规则详情页中的 Excel 规则表生成 ONLYOFFICE 配置。",
|
||||||
|
)
|
||||||
|
def get_agent_asset_spreadsheet_onlyoffice_config(
|
||||||
|
asset_id: str,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
version: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(description="兼容旧前端的可选参数;表格规则始终打开当前规则表。"),
|
||||||
|
] = None,
|
||||||
|
) -> AgentAssetOnlyOfficeConfigRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).build_rule_spreadsheet_onlyoffice_config(
|
||||||
|
asset_id,
|
||||||
|
current_user,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{asset_id}/spreadsheet/content",
|
||||||
|
response_class=FileResponse,
|
||||||
|
summary="下载或预览规则 Excel 文件",
|
||||||
|
description="返回当前规则 Excel 文件,用于浏览器预览或下载。",
|
||||||
|
)
|
||||||
|
def get_agent_asset_spreadsheet_content(
|
||||||
|
asset_id: str,
|
||||||
|
_: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
version: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(description="兼容旧前端的可选参数;不传时返回当前规则表。"),
|
||||||
|
] = None,
|
||||||
|
) -> FileResponse:
|
||||||
|
try:
|
||||||
|
file_path, media_type, filename = AgentAssetService(db).get_rule_spreadsheet_content(
|
||||||
|
asset_id,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{asset_id}/spreadsheet/onlyoffice/content",
|
||||||
|
response_class=FileResponse,
|
||||||
|
summary="供 ONLYOFFICE 读取规则 Excel 源文件",
|
||||||
|
description="使用短时令牌供 ONLYOFFICE 拉取规则表源文件。",
|
||||||
|
)
|
||||||
|
def get_agent_asset_spreadsheet_onlyoffice_content(
|
||||||
|
asset_id: str,
|
||||||
|
db: DbSession,
|
||||||
|
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, access_token)
|
||||||
|
file_path, media_type, filename = service.get_rule_spreadsheet_content(
|
||||||
|
asset_id,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/spreadsheet/upload",
|
||||||
|
response_model=AgentAssetRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="上传规则 Excel 文件",
|
||||||
|
description="为指定规则上传新的 Excel 文件,并记录本次表格修改。",
|
||||||
|
)
|
||||||
|
def upload_agent_asset_spreadsheet(
|
||||||
|
asset_id: str,
|
||||||
|
content: Annotated[
|
||||||
|
bytes,
|
||||||
|
Body(
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
description="待上传的 Excel 文件二进制内容。",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
filename: Annotated[str, Query(min_length=1, description="原始文件名。")],
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).upload_rule_spreadsheet(
|
||||||
|
asset_id,
|
||||||
|
filename=filename,
|
||||||
|
content=content,
|
||||||
|
actor=current_user.name,
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/spreadsheet/import-content",
|
||||||
|
response_model=AgentAssetRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="导入规则 Excel 表格内容",
|
||||||
|
description="读取上传 Excel 中的工作表内容,写回当前规则表;保留当前规则文件名与规则身份。",
|
||||||
|
)
|
||||||
|
def import_agent_asset_spreadsheet_content(
|
||||||
|
asset_id: str,
|
||||||
|
content: Annotated[
|
||||||
|
bytes,
|
||||||
|
Body(
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
description="待导入的 Excel 文件二进制内容。",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
filename: Annotated[str, Query(min_length=1, description="上传文件原始文件名。")],
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).import_rule_spreadsheet_content(
|
||||||
|
asset_id,
|
||||||
|
filename=filename,
|
||||||
|
content=content,
|
||||||
|
actor=current_user.name,
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/spreadsheet/onlyoffice/callback",
|
||||||
|
response_model=AgentAssetOnlyOfficeCallbackRead,
|
||||||
|
summary="接收规则 Excel 的 ONLYOFFICE 回调",
|
||||||
|
description="接收 ONLYOFFICE 回写内容,并记录本次表格修改。",
|
||||||
|
)
|
||||||
|
def handle_agent_asset_spreadsheet_onlyoffice_callback(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetOnlyOfficeCallbackWrite,
|
||||||
|
db: DbSession,
|
||||||
|
version: Annotated[
|
||||||
|
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)
|
||||||
|
|
||||||
|
return AgentAssetOnlyOfficeCallbackRead()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{asset_id}/spreadsheet/change-records",
|
||||||
|
response_model=list[AgentAssetSpreadsheetChangeRecordRead],
|
||||||
|
summary="读取规则表最近修改记录",
|
||||||
|
description="返回最近 30 次 ONLYOFFICE 保存级修改记录,用于展示操作者、时间和具体差异。",
|
||||||
|
)
|
||||||
|
def list_agent_asset_spreadsheet_change_records(
|
||||||
|
asset_id: str,
|
||||||
|
_: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=30, description="返回条数,最多 30 条。")] = 30,
|
||||||
|
) -> list[AgentAssetSpreadsheetChangeRecordRead]:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).list_spreadsheet_change_records(asset_id, limit=limit)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=AgentAssetRead,
|
response_model=AgentAssetRead,
|
||||||
@@ -237,11 +500,22 @@ def create_agent_asset_version(
|
|||||||
def create_agent_asset_review(
|
def create_agent_asset_review(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
payload: AgentAssetReviewCreate,
|
payload: AgentAssetReviewCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
) -> AgentAssetReviewRead:
|
) -> AgentAssetReviewRead:
|
||||||
try:
|
try:
|
||||||
|
role_codes = {item.strip() for item in current_user.role_codes}
|
||||||
|
if payload.review_status.value == "pending":
|
||||||
|
if not (
|
||||||
|
current_user.is_admin
|
||||||
|
or "manager" in role_codes
|
||||||
|
or "finance" in role_codes
|
||||||
|
):
|
||||||
|
raise PermissionError("只有财务人员或高级管理人员可以提交审核。")
|
||||||
|
elif not (current_user.is_admin or "manager" in role_codes):
|
||||||
|
raise PermissionError("只有高级管理人员可以审核规则。")
|
||||||
return AgentAssetService(db).create_review(
|
return AgentAssetService(db).create_review(
|
||||||
asset_id,
|
asset_id,
|
||||||
payload,
|
payload,
|
||||||
@@ -270,6 +544,7 @@ def create_agent_asset_review(
|
|||||||
)
|
)
|
||||||
def activate_agent_asset(
|
def activate_agent_asset(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
|
_: RuleReviewerUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
@@ -282,3 +557,46 @@ def activate_agent_asset(
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_handle_asset_error(exc)
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{asset_id}/versions/{version}/restore",
|
||||||
|
response_model=AgentAssetRead,
|
||||||
|
summary="基于历史版本恢复工作稿",
|
||||||
|
description="复制指定历史版本内容生成新的工作版本,用于误上线后的快速恢复与重新审核。",
|
||||||
|
)
|
||||||
|
def restore_agent_asset_version(
|
||||||
|
asset_id: str,
|
||||||
|
version: str,
|
||||||
|
current_user: RuleReviewerUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).restore_version_as_working_copy(
|
||||||
|
asset_id,
|
||||||
|
version,
|
||||||
|
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}/version-timeline",
|
||||||
|
response_model=list[AgentAssetVersionTimelineItemRead],
|
||||||
|
summary="读取规则版本流转时间线",
|
||||||
|
description="返回规则版本创建、提交审核、审核结果和正式上线等流转事件。",
|
||||||
|
)
|
||||||
|
def get_agent_asset_version_timeline(
|
||||||
|
asset_id: str,
|
||||||
|
_: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> list[AgentAssetVersionTimelineItemRead]:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).list_version_timeline(asset_id)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse
|
||||||
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate
|
from app.schemas.employee import (
|
||||||
|
EmployeeCreate,
|
||||||
|
EmployeeImportResultRead,
|
||||||
|
EmployeeMetaRead,
|
||||||
|
EmployeeRead,
|
||||||
|
EmployeeUpdate,
|
||||||
|
)
|
||||||
from app.services.employee import EmployeeService
|
from app.services.employee import EmployeeService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -44,6 +51,67 @@ def list_employees(
|
|||||||
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/import-template",
|
||||||
|
summary="下载员工导入模板",
|
||||||
|
description="下载固定格式的员工 Excel 导入模板。",
|
||||||
|
)
|
||||||
|
def download_employee_import_template(db: DbSession) -> Response:
|
||||||
|
content = EmployeeService(db).build_import_template()
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": 'attachment; filename="employee-import-template.xlsx"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/export",
|
||||||
|
summary="导出员工 Excel",
|
||||||
|
description="按筛选条件导出员工目录 Excel 文件。",
|
||||||
|
)
|
||||||
|
def export_employees(
|
||||||
|
db: DbSession,
|
||||||
|
status_filter: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(alias="status", description="员工状态筛选值。"),
|
||||||
|
] = None,
|
||||||
|
keyword: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
|
||||||
|
] = None,
|
||||||
|
) -> Response:
|
||||||
|
content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="employee-export.xlsx"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/import",
|
||||||
|
response_model=EmployeeImportResultRead,
|
||||||
|
summary="导入员工 Excel",
|
||||||
|
description="按模板批量导入员工。全部校验通过后才写入数据库,任一行有错则整批不导入。",
|
||||||
|
)
|
||||||
|
async def import_employees(
|
||||||
|
db: DbSession,
|
||||||
|
file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
|
||||||
|
) -> EmployeeImportResultRead:
|
||||||
|
filename = (file.filename or "").lower()
|
||||||
|
if not filename.endswith(".xlsx"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="当前仅支持上传 .xlsx 格式的员工表格。",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
return EmployeeService(db).import_employees(content)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=EmployeeRead,
|
response_model=EmployeeRead,
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ from app.schemas.reimbursement import (
|
|||||||
ExpenseClaimAttachmentActionResponse,
|
ExpenseClaimAttachmentActionResponse,
|
||||||
ExpenseClaimActionResponse,
|
ExpenseClaimActionResponse,
|
||||||
ExpenseClaimAttachmentRead,
|
ExpenseClaimAttachmentRead,
|
||||||
|
ExpenseClaimApprovalPayload,
|
||||||
ExpenseClaimItemCreate,
|
ExpenseClaimItemCreate,
|
||||||
ExpenseClaimItemActionResponse,
|
ExpenseClaimItemActionResponse,
|
||||||
ExpenseClaimItemUpdate,
|
ExpenseClaimItemUpdate,
|
||||||
ExpenseClaimRead,
|
ExpenseClaimRead,
|
||||||
|
ExpenseClaimReturnPayload,
|
||||||
|
ExpenseClaimUpdate,
|
||||||
ReimbursementCreate,
|
ReimbursementCreate,
|
||||||
ReimbursementRead,
|
ReimbursementRead,
|
||||||
|
TravelReimbursementCalculatorRequest,
|
||||||
|
TravelReimbursementCalculatorResponse,
|
||||||
)
|
)
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.reimbursement import ReimbursementService
|
from app.services.reimbursement import ReimbursementService
|
||||||
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
@@ -48,6 +54,29 @@ def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> Reimbur
|
|||||||
return ReimbursementService(db).create_reimbursement(payload)
|
return ReimbursementService(db).create_reimbursement(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/travel-calculator",
|
||||||
|
response_model=TravelReimbursementCalculatorResponse,
|
||||||
|
summary="差旅报销标准测算",
|
||||||
|
description="根据规则中心的差旅报销表、当前员工职级、出差天数与地点测算住宿和补贴参考金额。",
|
||||||
|
responses={
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "测算入参或规则匹配失败。",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def calculate_travel_reimbursement(
|
||||||
|
payload: TravelReimbursementCalculatorRequest,
|
||||||
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> TravelReimbursementCalculatorResponse:
|
||||||
|
try:
|
||||||
|
return TravelReimbursementCalculatorService(db).calculate(payload, current_user)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/claims",
|
"/claims",
|
||||||
response_model=list[ExpenseClaimRead],
|
response_model=list[ExpenseClaimRead],
|
||||||
@@ -58,6 +87,16 @@ def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[Expens
|
|||||||
return ExpenseClaimService(db).list_claims(current_user)
|
return ExpenseClaimService(db).list_claims(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/claims/approvals",
|
||||||
|
response_model=list[ExpenseClaimRead],
|
||||||
|
summary="查询当前用户审批待办报销单列表",
|
||||||
|
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
|
||||||
|
)
|
||||||
|
def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
|
||||||
|
return ExpenseClaimService(db).list_approval_claims(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/claims/{claim_id}",
|
"/claims/{claim_id}",
|
||||||
response_model=ExpenseClaimRead,
|
response_model=ExpenseClaimRead,
|
||||||
@@ -77,6 +116,43 @@ def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -
|
|||||||
return claim
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/claims/{claim_id}",
|
||||||
|
response_model=ExpenseClaimRead,
|
||||||
|
summary="更新草稿报销单",
|
||||||
|
description="更新草稿待提交报销单的主说明等草稿字段。",
|
||||||
|
responses={
|
||||||
|
status.HTTP_404_NOT_FOUND: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "报销单不存在。",
|
||||||
|
},
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "报销单状态不允许更新。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def update_expense_claim(
|
||||||
|
claim_id: str,
|
||||||
|
payload: ExpenseClaimUpdate,
|
||||||
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ExpenseClaimRead:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
try:
|
||||||
|
claim = service.update_claim(
|
||||||
|
claim_id=claim_id,
|
||||||
|
payload=payload,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
return claim
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/claims/{claim_id}/items/{item_id}",
|
"/claims/{claim_id}/items/{item_id}",
|
||||||
response_model=ExpenseClaimRead,
|
response_model=ExpenseClaimRead,
|
||||||
@@ -415,11 +491,11 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
|||||||
return claim
|
return claim
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.post(
|
||||||
"/claims/{claim_id}",
|
"/claims/{claim_id}/return",
|
||||||
response_model=ExpenseClaimActionResponse,
|
response_model=ExpenseClaimRead,
|
||||||
summary="删除个人报销草稿",
|
summary="退回报销单",
|
||||||
description="删除当前登录用户可见的草稿报销单。",
|
description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_404_NOT_FOUND: {
|
status.HTTP_404_NOT_FOUND: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
@@ -427,7 +503,73 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
|||||||
},
|
},
|
||||||
status.HTTP_400_BAD_REQUEST: {
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
"description": "仅草稿状态允许删除。",
|
"description": "当前用户或单据状态不允许退回。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def return_expense_claim(
|
||||||
|
claim_id: str,
|
||||||
|
payload: ExpenseClaimReturnPayload,
|
||||||
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ExpenseClaimRead:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
try:
|
||||||
|
claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/claims/{claim_id}/approve",
|
||||||
|
response_model=ExpenseClaimRead,
|
||||||
|
summary="审批通过报销单",
|
||||||
|
description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。",
|
||||||
|
responses={
|
||||||
|
status.HTTP_404_NOT_FOUND: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "报销单不存在。",
|
||||||
|
},
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "当前用户或单据状态不允许审批通过。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def approve_expense_claim(
|
||||||
|
claim_id: str,
|
||||||
|
payload: ExpenseClaimApprovalPayload,
|
||||||
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ExpenseClaimRead:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
try:
|
||||||
|
claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/claims/{claim_id}",
|
||||||
|
response_model=ExpenseClaimActionResponse,
|
||||||
|
summary="删除报销单",
|
||||||
|
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限。",
|
||||||
|
responses={
|
||||||
|
status.HTTP_404_NOT_FOUND: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "报销单不存在。",
|
||||||
|
},
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "当前用户或单据状态不允许删除。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -442,7 +584,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
|
||||||
return ExpenseClaimActionResponse(
|
return ExpenseClaimActionResponse(
|
||||||
message=f"{claim.claim_no} 草稿已删除。",
|
message=f"{claim.claim_no} 报销单已删除。",
|
||||||
claim_id=claim.id,
|
claim_id=claim.id,
|
||||||
status="deleted",
|
status="deleted",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class AgentAsset(Base):
|
|||||||
reviewer: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
reviewer: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(20), index=True, default="draft")
|
status: Mapped[str] = mapped_column(String(20), index=True, default="draft")
|
||||||
current_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
current_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
published_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
working_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ class ExpenseClaimItem(Base):
|
|||||||
|
|
||||||
claim = relationship("ExpenseClaim", back_populates="items")
|
claim = relationship("ExpenseClaim", back_populates="items")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_system_generated(self) -> bool:
|
||||||
|
return str(self.item_type or "").strip().lower() in {"travel_allowance"}
|
||||||
|
|
||||||
|
|
||||||
class AccountsReceivableRecord(Base):
|
class AccountsReceivableRecord(Base):
|
||||||
__tablename__ = "accounts_receivable"
|
__tablename__ = "accounts_receivable"
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ class AgentAssetRepository:
|
|||||||
stmt = stmt.limit(limit)
|
stmt = stmt.limit(limit)
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
def list_versions_for_assets(self, asset_ids: list[str]) -> list[AgentAssetVersion]:
|
||||||
|
if not asset_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(AgentAssetVersion)
|
||||||
|
.where(AgentAssetVersion.asset_id.in_(asset_ids))
|
||||||
|
.order_by(AgentAssetVersion.asset_id, AgentAssetVersion.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
|
def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
|
||||||
stmt = select(AgentAssetVersion).where(
|
stmt = select(AgentAssetVersion).where(
|
||||||
AgentAssetVersion.asset_id == asset_id,
|
AgentAssetVersion.asset_id == asset_id,
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ class AgentRunRepository:
|
|||||||
self.db.refresh(tool_call)
|
self.db.refresh(tool_call)
|
||||||
return tool_call
|
return tool_call
|
||||||
|
|
||||||
|
def get_tool_call(self, tool_call_id: str) -> AgentToolCall | None:
|
||||||
|
stmt = select(AgentToolCall).where(AgentToolCall.id == tool_call_id)
|
||||||
|
return self.db.scalar(stmt)
|
||||||
|
|
||||||
|
def save_tool_call(self, tool_call: AgentToolCall) -> AgentToolCall:
|
||||||
|
self.db.add(tool_call)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(tool_call)
|
||||||
|
return tool_call
|
||||||
|
|
||||||
def create_semantic_parse(self, semantic_parse: SemanticParseLog) -> SemanticParseLog:
|
def create_semantic_parse(self, semantic_parse: SemanticParseLog) -> SemanticParseLog:
|
||||||
self.db.add(semantic_parse)
|
self.db.add(semantic_parse)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ class AuditLogRepository:
|
|||||||
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
|
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
def list_for_resources(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_ids: list[str],
|
||||||
|
action: str | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list[AuditLog]:
|
||||||
|
if not resource_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
stmt = select(AuditLog).where(
|
||||||
|
AuditLog.resource_type == resource_type,
|
||||||
|
AuditLog.resource_id.in_(resource_ids),
|
||||||
|
)
|
||||||
|
if action:
|
||||||
|
stmt = stmt.where(AuditLog.action == action)
|
||||||
|
stmt = stmt.order_by(AuditLog.created_at.desc())
|
||||||
|
if limit is not None:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
def create(self, log: AuditLog) -> AuditLog:
|
def create(self, log: AuditLog) -> AuditLog:
|
||||||
self.db.add(log)
|
self.db.add(log)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class AgentAssetUpdate(BaseModel):
|
|||||||
reviewer: str | None = Field(default=None, max_length=100)
|
reviewer: str | None = Field(default=None, max_length=100)
|
||||||
status: AgentAssetStatus | None = None
|
status: AgentAssetStatus | None = None
|
||||||
current_version: str | None = Field(default=None, max_length=30)
|
current_version: str | None = Field(default=None, max_length=30)
|
||||||
|
published_version: str | None = Field(default=None, max_length=30)
|
||||||
|
working_version: str | None = Field(default=None, max_length=30)
|
||||||
config_json: dict[str, Any] | None = None
|
config_json: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +76,74 @@ class AgentAssetReviewRead(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetOnlyOfficeConfigRead(BaseModel):
|
||||||
|
documentServerUrl: str
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetOnlyOfficeCallbackRead(BaseModel):
|
||||||
|
error: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
status: int = Field(description="ONLYOFFICE 回调状态码。")
|
||||||
|
url: str | None = Field(default=None, description="文档下载地址,状态为 2 或 6 时使用。")
|
||||||
|
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
|
||||||
|
actor: str
|
||||||
|
event_time: datetime
|
||||||
|
title: str
|
||||||
|
description: str = ""
|
||||||
|
note: str | None = None
|
||||||
|
source_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetSpreadsheetDiffCellRead(BaseModel):
|
||||||
|
sheet_name: str
|
||||||
|
cell: str
|
||||||
|
change_type: str
|
||||||
|
before_value: Any | None = None
|
||||||
|
after_value: Any | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetSpreadsheetDiffSheetRead(BaseModel):
|
||||||
|
sheet_name: str
|
||||||
|
change_type: str
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class AgentAssetVersionRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -86,6 +156,9 @@ class AgentAssetVersionRead(BaseModel):
|
|||||||
created_by: str
|
created_by: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
is_current: bool = False
|
is_current: bool = False
|
||||||
|
is_published: bool = False
|
||||||
|
is_working: bool = False
|
||||||
|
lifecycle_state: str = "history"
|
||||||
|
|
||||||
|
|
||||||
class AgentAssetListItem(BaseModel):
|
class AgentAssetListItem(BaseModel):
|
||||||
@@ -102,7 +175,11 @@ class AgentAssetListItem(BaseModel):
|
|||||||
reviewer: str | None
|
reviewer: str | None
|
||||||
status: str
|
status: str
|
||||||
current_version: str | None
|
current_version: str | None
|
||||||
|
published_version: str | None
|
||||||
|
working_version: str | None
|
||||||
config_json: dict[str, Any]
|
config_json: dict[str, Any]
|
||||||
|
change_count: int = 0
|
||||||
|
modified_by: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -12,8 +14,16 @@ class AuthUserRead(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
department: str = ""
|
||||||
|
departmentName: str = ""
|
||||||
position: str = ""
|
position: str = ""
|
||||||
grade: str = ""
|
grade: str = ""
|
||||||
|
employeeNo: str = ""
|
||||||
|
managerName: str = ""
|
||||||
|
location: str = ""
|
||||||
|
costCenter: str = ""
|
||||||
|
financeOwnerName: str = ""
|
||||||
|
riskProfile: dict[str, Any] = Field(default_factory=dict)
|
||||||
roleCodes: list[str] = Field(default_factory=list)
|
roleCodes: list[str] = Field(default_factory=list)
|
||||||
email: EmailStr | str
|
email: EmailStr | str
|
||||||
avatar: str
|
avatar: str
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class EmployeeMetaRead(BaseModel):
|
|||||||
totalEmployees: int
|
totalEmployees: int
|
||||||
statusSummary: list[EmployeeStatusSummaryRead]
|
statusSummary: list[EmployeeStatusSummaryRead]
|
||||||
roleOptions: list[EmployeeRoleOptionRead]
|
roleOptions: list[EmployeeRoleOptionRead]
|
||||||
|
organizationOptions: list[EmployeeOrganizationRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class EmployeeRead(BaseModel):
|
class EmployeeRead(BaseModel):
|
||||||
@@ -63,6 +64,7 @@ class EmployeeRead(BaseModel):
|
|||||||
position: str
|
position: str
|
||||||
grade: str
|
grade: str
|
||||||
manager: str
|
manager: str
|
||||||
|
managerEmployeeNo: str | None = None
|
||||||
financeOwner: str
|
financeOwner: str
|
||||||
roles: list[str] = Field(default_factory=list)
|
roles: list[str] = Field(default_factory=list)
|
||||||
roleCodes: list[str] = Field(default_factory=list)
|
roleCodes: list[str] = Field(default_factory=list)
|
||||||
@@ -112,6 +114,28 @@ class EmployeeCreate(BaseModel):
|
|||||||
return _parse_optional_date(self.join_date, "入职日期")
|
return _parse_optional_date(self.join_date, "入职日期")
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeImportErrorRead(BaseModel):
|
||||||
|
row: int
|
||||||
|
column: str
|
||||||
|
employeeNo: str = ""
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeImportSummaryRead(BaseModel):
|
||||||
|
totalRows: int = 0
|
||||||
|
created: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
errorCount: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeImportResultRead(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
summary: EmployeeImportSummaryRead
|
||||||
|
errors: list[EmployeeImportErrorRead] = Field(default_factory=list)
|
||||||
|
importedAt: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EmployeeUpdate(BaseModel):
|
class EmployeeUpdate(BaseModel):
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
gender: str | None = Field(default=None, max_length=20)
|
gender: str | None = Field(default=None, max_length=20)
|
||||||
@@ -124,6 +148,8 @@ class EmployeeUpdate(BaseModel):
|
|||||||
grade: str | None = Field(default=None, min_length=1, max_length=20)
|
grade: str | None = Field(default=None, min_length=1, max_length=20)
|
||||||
cost_center: str | None = Field(default=None, max_length=50)
|
cost_center: str | None = Field(default=None, max_length=50)
|
||||||
finance_owner_name: str | None = Field(default=None, max_length=100)
|
finance_owner_name: str | None = Field(default=None, max_length=100)
|
||||||
|
organization_unit_code: str | None = Field(default=None, max_length=50)
|
||||||
|
manager_employee_no: str | None = Field(default=None, max_length=50)
|
||||||
role_codes: list[str] | None = None
|
role_codes: list[str] | None = None
|
||||||
password: str | None = Field(default=None, min_length=5, max_length=128)
|
password: str | None = Field(default=None, min_length=5, max_length=128)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class ExpenseClaimItemRead(BaseModel):
|
|||||||
item_location: str
|
item_location: str
|
||||||
item_amount: Decimal
|
item_amount: Decimal
|
||||||
invoice_id: str | None
|
invoice_id: str | None
|
||||||
|
is_system_generated: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ class ExpenseClaimAttachmentAnalysisRead(BaseModel):
|
|||||||
headline: str
|
headline: str
|
||||||
summary: str
|
summary: str
|
||||||
points: list[str] = Field(default_factory=list)
|
points: list[str] = Field(default_factory=list)
|
||||||
|
rule_basis: list[str] = Field(default_factory=list)
|
||||||
suggestion: str = ""
|
suggestion: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -112,6 +114,10 @@ class ExpenseClaimItemCreate(BaseModel):
|
|||||||
invoice_id: str | None = None
|
invoice_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimUpdate(BaseModel):
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimRead(BaseModel):
|
class ExpenseClaimRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -148,11 +154,54 @@ class ExpenseClaimActionResponse(BaseModel):
|
|||||||
status: str | None = None
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimReturnPayload(BaseModel):
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
reason_codes: list[str] = Field(default_factory=list, max_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimApprovalPayload(BaseModel):
|
||||||
|
opinion: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class TravelReimbursementCalculatorRequest(BaseModel):
|
||||||
|
days: int = Field(ge=1, le=365)
|
||||||
|
location: str = Field(min_length=1, max_length=120)
|
||||||
|
grade: str | None = Field(default=None, max_length=30)
|
||||||
|
|
||||||
|
|
||||||
|
class TravelReimbursementCalculatorResponse(BaseModel):
|
||||||
|
days: int
|
||||||
|
location: str
|
||||||
|
matched_city: str
|
||||||
|
city_tier: str
|
||||||
|
grade: str
|
||||||
|
grade_band: str
|
||||||
|
grade_band_label: str
|
||||||
|
hotel_rate: Decimal
|
||||||
|
hotel_amount: Decimal
|
||||||
|
allowance_region: str
|
||||||
|
meal_allowance_rate: Decimal
|
||||||
|
basic_allowance_rate: Decimal
|
||||||
|
total_allowance_rate: Decimal
|
||||||
|
allowance_amount: Decimal
|
||||||
|
total_amount: Decimal
|
||||||
|
rule_name: str
|
||||||
|
rule_version: str
|
||||||
|
formula_text: str
|
||||||
|
summary_text: str
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimAttachmentActionResponse(BaseModel):
|
class ExpenseClaimAttachmentActionResponse(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
claim_id: str
|
claim_id: str
|
||||||
item_id: str
|
item_id: str
|
||||||
invoice_id: str | None = None
|
invoice_id: str | None = None
|
||||||
|
item_date: date | None = None
|
||||||
|
item_type: str | None = None
|
||||||
|
item_reason: str | None = None
|
||||||
|
item_location: str | None = None
|
||||||
|
item_amount: Decimal | None = None
|
||||||
|
claim_amount: Decimal | None = None
|
||||||
attachment: ExpenseClaimAttachmentRead | None = None
|
attachment: ExpenseClaimAttachmentRead | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class UserAgentSuggestedAction(BaseModel):
|
|||||||
label: str = Field(description="建议动作文案。")
|
label: str = Field(description="建议动作文案。")
|
||||||
action_type: str = Field(description="动作类型,例如 open_detail / create_draft。")
|
action_type: str = Field(description="动作类型,例如 open_detail / create_draft。")
|
||||||
description: str = Field(default="", description="动作说明。")
|
description: str = Field(default="", description="动作说明。")
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict, description="动作携带的结构化参数。")
|
||||||
|
|
||||||
|
|
||||||
class UserAgentDraftPayload(BaseModel):
|
class UserAgentDraftPayload(BaseModel):
|
||||||
@@ -85,6 +86,8 @@ class UserAgentReviewRiskBrief(BaseModel):
|
|||||||
title: str = Field(description="风险或注意事项标题。")
|
title: str = Field(description="风险或注意事项标题。")
|
||||||
level: str = Field(default="info", description="级别,例如 info / warning / high。")
|
level: str = Field(default="info", description="级别,例如 info / warning / high。")
|
||||||
content: str = Field(description="面向用户展示的摘要说明。")
|
content: str = Field(description="面向用户展示的摘要说明。")
|
||||||
|
detail: str = Field(default="", description="点击风险项后展示的详细解释。")
|
||||||
|
suggestion: str = Field(default="", description="面向用户的处理建议。")
|
||||||
|
|
||||||
|
|
||||||
class UserAgentReviewSlotCard(BaseModel):
|
class UserAgentReviewSlotCard(BaseModel):
|
||||||
|
|||||||
84
server/src/app/services/agent_asset_rule_library.py
Normal file
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())
|
||||||
610
server/src/app/services/agent_asset_spreadsheet.py
Normal file
610
server/src/app/services/agent_asset_spreadsheet.py
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
from app.core.config import SERVER_DIR, get_settings
|
||||||
|
|
||||||
|
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
||||||
|
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
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}
|
||||||
|
SPREADSHEET_MIME_TYPE = (
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RuleSpreadsheetMeta:
|
||||||
|
file_name: str
|
||||||
|
storage_key: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
checksum: str
|
||||||
|
updated_at: str
|
||||||
|
updated_by: str
|
||||||
|
source: str = "upload"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetSpreadsheetManager:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
storage_root: Path | None = None,
|
||||||
|
rule_root: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.storage_root = Path(storage_root or settings.resolved_storage_root_dir).resolve()
|
||||||
|
self.asset_root = (self.storage_root / "agent_assets").resolve()
|
||||||
|
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 store_spreadsheet(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
asset_id: str,
|
||||||
|
version: str,
|
||||||
|
file_name: str,
|
||||||
|
content: bytes,
|
||||||
|
actor_name: str,
|
||||||
|
source: str = "upload",
|
||||||
|
) -> RuleSpreadsheetMeta:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
def store_rule_library_spreadsheet(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
library: str,
|
||||||
|
file_name: str,
|
||||||
|
content: bytes,
|
||||||
|
actor_name: str,
|
||||||
|
source: str = "rule-library",
|
||||||
|
) -> RuleSpreadsheetMeta:
|
||||||
|
normalized_library = str(library or "").strip()
|
||||||
|
if normalized_library not in RULE_LIBRARY_NAMES:
|
||||||
|
raise ValueError("规则库目录不合法。")
|
||||||
|
|
||||||
|
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
||||||
|
if not normalized_name:
|
||||||
|
raise ValueError("规则表文件名不能为空。")
|
||||||
|
if not content:
|
||||||
|
raise ValueError("规则表文件内容不能为空。")
|
||||||
|
|
||||||
|
self.ensure_rule_library_dirs()
|
||||||
|
relative_path = Path("rules") / normalized_library / 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 resolve_storage_path(self, storage_key: str) -> Path:
|
||||||
|
normalized = Path(str(storage_key or "").strip())
|
||||||
|
if not normalized.parts:
|
||||||
|
raise FileNotFoundError("规则表文件不存在。")
|
||||||
|
|
||||||
|
if normalized.parts[0] == "rules":
|
||||||
|
resolved = (SERVER_DIR / normalized).resolve()
|
||||||
|
allowed_root = self.rule_root
|
||||||
|
else:
|
||||||
|
resolved = (self.storage_root / normalized).resolve()
|
||||||
|
allowed_root = self.storage_root
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved.relative_to(allowed_root)
|
||||||
|
except ValueError:
|
||||||
|
raise FileNotFoundError("规则表文件不存在。") from None
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_version_markdown(markdown: str) -> RuleSpreadsheetMeta | None:
|
||||||
|
match = RULE_SPREADSHEET_BLOCK_PATTERN.search(str(markdown or ""))
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return RuleSpreadsheetMeta(
|
||||||
|
file_name=str(payload.get("file_name") or "").strip(),
|
||||||
|
storage_key=str(payload.get("storage_key") or "").strip(),
|
||||||
|
mime_type=str(payload.get("mime_type") or SPREADSHEET_MIME_TYPE).strip()
|
||||||
|
or SPREADSHEET_MIME_TYPE,
|
||||||
|
size_bytes=int(payload.get("size_bytes") or 0),
|
||||||
|
checksum=str(payload.get("checksum") or "").strip(),
|
||||||
|
updated_at=str(payload.get("updated_at") or "").strip(),
|
||||||
|
updated_by=str(payload.get("updated_by") or "system").strip() or "system",
|
||||||
|
source=str(payload.get("source") or "upload").strip() or "upload",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_version_markdown(
|
||||||
|
*,
|
||||||
|
rule_name: str,
|
||||||
|
version: str,
|
||||||
|
metadata: RuleSpreadsheetMeta,
|
||||||
|
) -> str:
|
||||||
|
sections = [
|
||||||
|
f"# {rule_name}",
|
||||||
|
"",
|
||||||
|
"## 规则载体",
|
||||||
|
"",
|
||||||
|
"- 详情类型:Excel 表格",
|
||||||
|
f"- 当前规则版本:`{version}`",
|
||||||
|
f"- 表格文件:`{metadata.file_name}`",
|
||||||
|
f"- 最近更新人:{metadata.updated_by}",
|
||||||
|
f"- 最近更新时间:{metadata.updated_at}",
|
||||||
|
"",
|
||||||
|
"## 使用说明",
|
||||||
|
"",
|
||||||
|
"- 管理员可直接在规则中心内联编辑 Excel 表格,并通过 ONLYOFFICE 回写新版本。",
|
||||||
|
"- 上传新的 Excel 文件后,会自动生成新的规则版本快照。",
|
||||||
|
"- 切换到历史版本时仅提供预览,不允许直接覆盖历史快照。",
|
||||||
|
"",
|
||||||
|
"```rule-spreadsheet",
|
||||||
|
json.dumps(asdict(metadata), ensure_ascii=False, indent=2),
|
||||||
|
"```",
|
||||||
|
]
|
||||||
|
return "\n".join(sections)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_rule_document_config(
|
||||||
|
metadata: RuleSpreadsheetMeta,
|
||||||
|
*,
|
||||||
|
asset_version: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"kind": "spreadsheet",
|
||||||
|
"file_name": metadata.file_name,
|
||||||
|
"mime_type": metadata.mime_type,
|
||||||
|
"size_bytes": metadata.size_bytes,
|
||||||
|
"checksum": metadata.checksum,
|
||||||
|
"updated_at": metadata.updated_at,
|
||||||
|
"updated_by": metadata.updated_by,
|
||||||
|
"source": metadata.source,
|
||||||
|
"asset_version": asset_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_company_travel_rule_template() -> bytes:
|
||||||
|
standard_rows = [
|
||||||
|
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
||||||
|
[
|
||||||
|
"长途交通",
|
||||||
|
"飞机、高铁、火车等跨城出行",
|
||||||
|
"行程单、车票、发票",
|
||||||
|
"据实报销",
|
||||||
|
"超预算需直属领导审批",
|
||||||
|
"优先选择公共交通",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"住宿费",
|
||||||
|
"出差住宿",
|
||||||
|
"酒店发票、入住清单",
|
||||||
|
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
|
||||||
|
"超标需总监审批",
|
||||||
|
"协议酒店优先",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"市内交通",
|
||||||
|
"出租车、网约车、地铁、公交",
|
||||||
|
"发票或电子行程单",
|
||||||
|
"150/天",
|
||||||
|
"超限需补充说明",
|
||||||
|
"夜间或无公共交通场景可豁免",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"餐补",
|
||||||
|
"出差期间日常补助",
|
||||||
|
"无需票据",
|
||||||
|
"120/天",
|
||||||
|
"系统自动核定",
|
||||||
|
"当天往返默认不享受",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"招待餐费",
|
||||||
|
"客户接待或项目宴请",
|
||||||
|
"餐饮发票、参与人清单",
|
||||||
|
"300/人",
|
||||||
|
"需业务负责人审批",
|
||||||
|
"需关联客户或项目",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
instruction_rows = [
|
||||||
|
["字段", "填写说明"],
|
||||||
|
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
|
||||||
|
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
|
||||||
|
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
|
||||||
|
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
|
||||||
|
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
|
||||||
|
["备注", "记录豁免条件、灰度口径或制度来源。"],
|
||||||
|
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
|
||||||
|
]
|
||||||
|
return _build_xlsx_bytes(
|
||||||
|
[
|
||||||
|
("差旅报销标准", standard_rows),
|
||||||
|
("填表说明", instruction_rows),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
|
||||||
|
return _build_xlsx_bytes([(sheet_name, [[""]])])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rebuild_from_uploaded_content(content: bytes) -> bytes:
|
||||||
|
if not content:
|
||||||
|
raise ValueError("待导入的表格内容不能为空。")
|
||||||
|
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(
|
||||||
|
filename=BytesIO(content),
|
||||||
|
read_only=True,
|
||||||
|
data_only=False,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise ValueError("无法解析上传的 Excel 表格。") from exc
|
||||||
|
|
||||||
|
sheets: list[tuple[str, list[list[object]]]] = []
|
||||||
|
for worksheet in workbook.worksheets:
|
||||||
|
rows = [
|
||||||
|
list(row)
|
||||||
|
for row in worksheet.iter_rows(values_only=True)
|
||||||
|
]
|
||||||
|
sheets.append((worksheet.title, _trim_empty_table(rows)))
|
||||||
|
|
||||||
|
if not sheets:
|
||||||
|
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
|
||||||
|
return _build_xlsx_bytes(sheets)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
||||||
|
created_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
workbook_buffer = BytesIO()
|
||||||
|
|
||||||
|
with ZipFile(workbook_buffer, "w", ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("[Content_Types].xml", _build_content_types_xml(sheets))
|
||||||
|
archive.writestr("_rels/.rels", _build_root_rels_xml())
|
||||||
|
archive.writestr("docProps/app.xml", _build_app_xml(sheets))
|
||||||
|
archive.writestr("docProps/core.xml", _build_core_xml(created_at))
|
||||||
|
archive.writestr("xl/workbook.xml", _build_workbook_xml(sheets))
|
||||||
|
archive.writestr("xl/_rels/workbook.xml.rels", _build_workbook_rels_xml(sheets))
|
||||||
|
archive.writestr("xl/styles.xml", _build_styles_xml())
|
||||||
|
|
||||||
|
for index, (_, rows) in enumerate(sheets, start=1):
|
||||||
|
archive.writestr(
|
||||||
|
f"xl/worksheets/sheet{index}.xml",
|
||||||
|
_build_sheet_xml(rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
return workbook_buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
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"/>'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
overrides.extend(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
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="xml" ContentType="application/xml"/>'
|
||||||
|
f'{"".join(overrides)}'
|
||||||
|
"</Types>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"/>'
|
||||||
|
"</Relationships>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||||
|
titles = "".join(
|
||||||
|
[f'<vt:lpstr>{escape(name)}</vt:lpstr>' for name, _ in sheets]
|
||||||
|
)
|
||||||
|
sheet_count = len(sheets)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/'
|
||||||
|
'extended-properties" '
|
||||||
|
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
||||||
|
'<Application>Microsoft Excel</Application>'
|
||||||
|
'<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>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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" '
|
||||||
|
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
||||||
|
'xmlns:dcterms="http://purl.org/dc/terms/" '
|
||||||
|
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
|
||||||
|
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||||||
|
"<dc:creator>X-Financial</dc:creator>"
|
||||||
|
"<cp:lastModifiedBy>X-Financial</cp:lastModifiedBy>"
|
||||||
|
f'<dcterms:created xsi:type="dcterms:W3CDTF">{created_at}</dcterms:created>'
|
||||||
|
f'<dcterms:modified xsi:type="dcterms:W3CDTF">{created_at}</dcterms:modified>'
|
||||||
|
"</cp:coreProperties>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||||
|
sheet_items = "".join(
|
||||||
|
[
|
||||||
|
f'<sheet name="{escape(name)}" sheetId="{index}" r:id="rId{index}"/>'
|
||||||
|
for index, (name, _) in enumerate(sheets, start=1)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||||
|
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||||
|
"<bookViews><workbookView/></bookViews>"
|
||||||
|
f"<sheets>{sheet_items}</sheets>"
|
||||||
|
"</workbook>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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/'
|
||||||
|
f'relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
|
||||||
|
)
|
||||||
|
for index, _ in enumerate(sheets, start=1)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
relationships += (
|
||||||
|
f'<Relationship Id="rId{len(sheets) + 1}" '
|
||||||
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
|
||||||
|
'Target="styles.xml"/>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
f"{relationships}"
|
||||||
|
"</Relationships>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_styles_xml() -> str:
|
||||||
|
return (
|
||||||
|
'<?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>'
|
||||||
|
'<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>"
|
||||||
|
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
||||||
|
'</styleSheet>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sheet_xml(rows: list[list[object]]) -> str:
|
||||||
|
normalized_rows = rows or [[""]]
|
||||||
|
max_column_count = max((len(row) for row in normalized_rows), default=1)
|
||||||
|
worksheet_rows: list[str] = []
|
||||||
|
|
||||||
|
for row_index, row in enumerate(normalized_rows, start=1):
|
||||||
|
cells: list[str] = []
|
||||||
|
for column_index, cell in enumerate(row, start=1):
|
||||||
|
ref = f"{_column_letter(column_index)}{row_index}"
|
||||||
|
text = "" if cell is None else str(cell)
|
||||||
|
preserve = ' xml:space="preserve"' if text.strip() != text or "\n" in text else ""
|
||||||
|
cells.append(
|
||||||
|
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>'
|
||||||
|
)
|
||||||
|
worksheet_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
|
||||||
|
|
||||||
|
dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||||
|
f'<dimension ref="{dimension}"/>'
|
||||||
|
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>"
|
||||||
|
"<sheetFormatPr defaultRowHeight=\"18\"/>"
|
||||||
|
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
|
||||||
|
"</worksheet>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_letter(index: int) -> str:
|
||||||
|
value = max(1, int(index))
|
||||||
|
result = ""
|
||||||
|
while value > 0:
|
||||||
|
value, remainder = divmod(value - 1, 26)
|
||||||
|
result = f"{chr(65 + remainder)}{result}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
|
||||||
|
normalized_rows = [list(row) for row in rows]
|
||||||
|
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):
|
||||||
|
normalized_rows.pop()
|
||||||
|
|
||||||
|
if not normalized_rows:
|
||||||
|
return [[""]]
|
||||||
|
|
||||||
|
max_column = 0
|
||||||
|
for row in normalized_rows:
|
||||||
|
for index, cell in enumerate(row, start=1):
|
||||||
|
if cell not in (None, ""):
|
||||||
|
max_column = max(max_column, index)
|
||||||
|
|
||||||
|
if max_column <= 0:
|
||||||
|
return [[""]]
|
||||||
|
|
||||||
|
return [row[:max_column] for row in normalized_rows]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,50 @@ STATEFUL_CONTEXT_KEYS = (
|
|||||||
"attachment_count",
|
"attachment_count",
|
||||||
"ocr_summary",
|
"ocr_summary",
|
||||||
"ocr_documents",
|
"ocr_documents",
|
||||||
|
"review_form_values",
|
||||||
|
"business_time_context",
|
||||||
|
)
|
||||||
|
REVIEW_FLOW_CONTEXT_KEYS = {
|
||||||
|
"draft_claim_id",
|
||||||
|
"draft_claim_no",
|
||||||
|
"draft_status",
|
||||||
|
"request_context",
|
||||||
|
"attachment_names",
|
||||||
|
"attachment_count",
|
||||||
|
"ocr_summary",
|
||||||
|
"ocr_documents",
|
||||||
|
"review_form_values",
|
||||||
|
"business_time_context",
|
||||||
|
}
|
||||||
|
REVIEW_FLOW_CONTINUATION_KEYWORDS = (
|
||||||
|
"补充",
|
||||||
|
"继续",
|
||||||
|
"继续上传",
|
||||||
|
"当前",
|
||||||
|
"这张",
|
||||||
|
"这个",
|
||||||
|
"该单据",
|
||||||
|
"现有",
|
||||||
|
"已有",
|
||||||
|
"关联",
|
||||||
|
"合并",
|
||||||
|
"修改",
|
||||||
|
"更正",
|
||||||
|
"改成",
|
||||||
|
"调整",
|
||||||
|
"下一步",
|
||||||
|
"保存草稿",
|
||||||
|
)
|
||||||
|
NEW_EXPENSE_PROMPT_KEYWORDS = (
|
||||||
|
"申请报销",
|
||||||
|
"我要报销",
|
||||||
|
"我想报销",
|
||||||
|
"帮我报销",
|
||||||
|
"发起报销",
|
||||||
|
"提交报销",
|
||||||
|
"生成报销",
|
||||||
|
"创建报销",
|
||||||
|
"新建报销",
|
||||||
)
|
)
|
||||||
DEFAULT_CONVERSATION_RETENTION_DAYS = 3
|
DEFAULT_CONVERSATION_RETENTION_DAYS = 3
|
||||||
|
|
||||||
@@ -39,6 +83,7 @@ class AgentConversationService:
|
|||||||
normalized_id = str(conversation_id or "").strip()
|
normalized_id = str(conversation_id or "").strip()
|
||||||
normalized_user_id = str(user_id or "").strip() or None
|
normalized_user_id = str(user_id or "").strip() or None
|
||||||
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense"
|
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense"
|
||||||
|
incoming_draft_claim_id = self._resolve_draft_claim_id(context_json)
|
||||||
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
||||||
if conversation is not None and conversation.user_id != normalized_user_id:
|
if conversation is not None and conversation.user_id != normalized_user_id:
|
||||||
normalized_id = ""
|
normalized_id = ""
|
||||||
@@ -56,6 +101,7 @@ class AgentConversationService:
|
|||||||
source=source,
|
source=source,
|
||||||
entry_source=str(context_json.get("entry_source") or "").strip() or None,
|
entry_source=str(context_json.get("entry_source") or "").strip() or None,
|
||||||
title=self._resolve_title(context_json),
|
title=self._resolve_title(context_json),
|
||||||
|
draft_claim_id=incoming_draft_claim_id or None,
|
||||||
state_json=self._extract_state_json(context_json),
|
state_json=self._extract_state_json(context_json),
|
||||||
)
|
)
|
||||||
self.db.add(conversation)
|
self.db.add(conversation)
|
||||||
@@ -69,6 +115,8 @@ class AgentConversationService:
|
|||||||
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
|
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
|
||||||
if not conversation.title:
|
if not conversation.title:
|
||||||
conversation.title = self._resolve_title(context_json)
|
conversation.title = self._resolve_title(context_json)
|
||||||
|
if incoming_draft_claim_id:
|
||||||
|
conversation.draft_claim_id = incoming_draft_claim_id
|
||||||
conversation.state_json = self._merge_state_json(
|
conversation.state_json = self._merge_state_json(
|
||||||
conversation.state_json,
|
conversation.state_json,
|
||||||
self._extract_state_json(context_json),
|
self._extract_state_json(context_json),
|
||||||
@@ -86,7 +134,11 @@ class AgentConversationService:
|
|||||||
resolved_retention_days = retention_days or self._resolve_retention_days()
|
resolved_retention_days = retention_days or self._resolve_retention_days()
|
||||||
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
|
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
|
||||||
stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff)
|
stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff)
|
||||||
expired_conversations = list(self.db.scalars(stmt).all())
|
expired_conversations = [
|
||||||
|
conversation
|
||||||
|
for conversation in self.db.scalars(stmt).all()
|
||||||
|
if not self._is_saved_conversation(conversation)
|
||||||
|
]
|
||||||
if not expired_conversations:
|
if not expired_conversations:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -96,6 +148,13 @@ class AgentConversationService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return len(expired_conversations)
|
return len(expired_conversations)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_saved_conversation(conversation: AgentConversation) -> bool:
|
||||||
|
if str(conversation.draft_claim_id or "").strip():
|
||||||
|
return True
|
||||||
|
state_json = dict(conversation.state_json or {})
|
||||||
|
return bool(str(state_json.get("draft_claim_id") or "").strip())
|
||||||
|
|
||||||
def _resolve_retention_days(self) -> int:
|
def _resolve_retention_days(self) -> int:
|
||||||
try:
|
try:
|
||||||
settings_row, _ = SettingsService(self.db).ensure_settings_ready()
|
settings_row, _ = SettingsService(self.db).ensure_settings_ready()
|
||||||
@@ -178,10 +237,18 @@ class AgentConversationService:
|
|||||||
*,
|
*,
|
||||||
conversation: AgentConversation,
|
conversation: AgentConversation,
|
||||||
context_json: dict[str, Any],
|
context_json: dict[str, Any],
|
||||||
|
message: str | None = None,
|
||||||
history_limit: int = 8,
|
history_limit: int = 8,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
merged = dict(context_json or {})
|
merged = dict(context_json or {})
|
||||||
state_json = dict(conversation.state_json or {})
|
state_json = dict(conversation.state_json or {})
|
||||||
|
should_hydrate_review_flow = self._should_hydrate_review_flow_context(
|
||||||
|
context_json=merged,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
if not should_hydrate_review_flow:
|
||||||
|
for key in REVIEW_FLOW_CONTEXT_KEYS:
|
||||||
|
merged.pop(key, None)
|
||||||
|
|
||||||
merged["conversation_id"] = conversation.conversation_id
|
merged["conversation_id"] = conversation.conversation_id
|
||||||
merged["conversation_history"] = self.list_message_history(
|
merged["conversation_history"] = self.list_message_history(
|
||||||
@@ -192,16 +259,58 @@ class AgentConversationService:
|
|||||||
merged.setdefault("conversation_scenario", conversation.last_scenario)
|
merged.setdefault("conversation_scenario", conversation.last_scenario)
|
||||||
if conversation.last_intent:
|
if conversation.last_intent:
|
||||||
merged.setdefault("conversation_intent", conversation.last_intent)
|
merged.setdefault("conversation_intent", conversation.last_intent)
|
||||||
if conversation.draft_claim_id and not str(merged.get("draft_claim_id") or "").strip():
|
if (
|
||||||
|
should_hydrate_review_flow
|
||||||
|
and conversation.draft_claim_id
|
||||||
|
and not str(merged.get("draft_claim_id") or "").strip()
|
||||||
|
):
|
||||||
merged["draft_claim_id"] = conversation.draft_claim_id
|
merged["draft_claim_id"] = conversation.draft_claim_id
|
||||||
merged["conversation_state"] = state_json
|
merged["conversation_state"] = state_json
|
||||||
|
|
||||||
for key in STATEFUL_CONTEXT_KEYS:
|
for key in STATEFUL_CONTEXT_KEYS:
|
||||||
|
if key in REVIEW_FLOW_CONTEXT_KEYS and not should_hydrate_review_flow:
|
||||||
|
continue
|
||||||
if self._is_empty_value(merged.get(key)) and not self._is_empty_value(state_json.get(key)):
|
if self._is_empty_value(merged.get(key)) and not self._is_empty_value(state_json.get(key)):
|
||||||
merged[key] = state_json.get(key)
|
merged[key] = state_json.get(key)
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_hydrate_review_flow_context(
|
||||||
|
*,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
message: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if isinstance(context_json.get("expense_scene_selection"), dict):
|
||||||
|
return True
|
||||||
|
if AgentConversationService._resolve_draft_claim_id(context_json):
|
||||||
|
compact_message = str(message or "").replace(" ", "")
|
||||||
|
if compact_message and any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
if str(context_json.get("review_action") or "").strip():
|
||||||
|
return True
|
||||||
|
if str(context_json.get("entry_source") or "").strip() == "detail":
|
||||||
|
return True
|
||||||
|
if not AgentConversationService._is_empty_value(context_json.get("attachment_names")):
|
||||||
|
return True
|
||||||
|
if not AgentConversationService._is_empty_value(context_json.get("ocr_documents")):
|
||||||
|
return True
|
||||||
|
if str(context_json.get("ocr_summary") or "").strip():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
if int(context_json.get("attachment_count") or 0) > 0:
|
||||||
|
return True
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
compact_message = str(message or "").replace(" ", "")
|
||||||
|
if not compact_message:
|
||||||
|
return False
|
||||||
|
if any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
|
||||||
|
return False
|
||||||
|
return any(keyword in compact_message for keyword in REVIEW_FLOW_CONTINUATION_KEYWORDS)
|
||||||
|
|
||||||
def append_message(
|
def append_message(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -354,6 +463,38 @@ class AgentConversationService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return len(conversations)
|
return len(conversations)
|
||||||
|
|
||||||
|
def delete_conversations_for_draft_claim(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
claim_id: str | None,
|
||||||
|
source: str | None = "user_message",
|
||||||
|
session_type: str | None = "expense",
|
||||||
|
) -> int:
|
||||||
|
normalized_claim_id = str(claim_id or "").strip()
|
||||||
|
if not normalized_claim_id:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
stmt = select(AgentConversation).where(AgentConversation.draft_claim_id == normalized_claim_id)
|
||||||
|
if source:
|
||||||
|
stmt = stmt.where(AgentConversation.source == source)
|
||||||
|
conversations = list(self.db.scalars(stmt).all())
|
||||||
|
normalized_session_type = str(session_type or "").strip()
|
||||||
|
if normalized_session_type:
|
||||||
|
conversations = [
|
||||||
|
conversation
|
||||||
|
for conversation in conversations
|
||||||
|
if (str((conversation.state_json or {}).get("session_type") or "").strip() or "expense")
|
||||||
|
== normalized_session_type
|
||||||
|
]
|
||||||
|
if not conversations:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for conversation in conversations:
|
||||||
|
self.db.delete(conversation)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
return len(conversations)
|
||||||
|
|
||||||
def delete_conversation(
|
def delete_conversation(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -478,11 +619,28 @@ class AgentConversationService:
|
|||||||
continue
|
continue
|
||||||
state_json[key] = value
|
state_json[key] = value
|
||||||
|
|
||||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
draft_claim_id = AgentConversationService._resolve_draft_claim_id(context_json)
|
||||||
if draft_claim_id:
|
if draft_claim_id:
|
||||||
state_json["draft_claim_id"] = draft_claim_id
|
state_json["draft_claim_id"] = draft_claim_id
|
||||||
return state_json
|
return state_json
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_draft_claim_id(context_json: dict[str, Any]) -> str:
|
||||||
|
draft_claim_id = str((context_json or {}).get("draft_claim_id") or "").strip()
|
||||||
|
if draft_claim_id:
|
||||||
|
return draft_claim_id
|
||||||
|
|
||||||
|
request_context = (context_json or {}).get("request_context")
|
||||||
|
if isinstance(request_context, dict):
|
||||||
|
return str(
|
||||||
|
request_context.get("claim_id")
|
||||||
|
or request_context.get("claimId")
|
||||||
|
or request_context.get("draft_claim_id")
|
||||||
|
or request_context.get("draftClaimId")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_state_json(
|
def _merge_state_json(
|
||||||
current_state: dict[str, Any] | None,
|
current_state: dict[str, Any] | None,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import inspect, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.agent_enums import (
|
from app.core.agent_enums import (
|
||||||
@@ -32,6 +34,20 @@ from app.models.financial_record import (
|
|||||||
ExpenseClaim,
|
ExpenseClaim,
|
||||||
ExpenseClaimItem,
|
ExpenseClaimItem,
|
||||||
)
|
)
|
||||||
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
|
from app.services.agent_asset_spreadsheet import (
|
||||||
|
AgentAssetSpreadsheetManager,
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
|
FINANCE_RULES_LIBRARY,
|
||||||
|
RISK_RULES_LIBRARY,
|
||||||
|
RuleSpreadsheetMeta,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLATFORM_DESTINATION_LOCATION_RULE_CODE = "risk.travel.destination_receipt_location"
|
||||||
|
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME = "risk.travel.destination_receipt_location.json"
|
||||||
from app.services.expense_rule_runtime import (
|
from app.services.expense_rule_runtime import (
|
||||||
build_scene_submission_standard_markdown,
|
build_scene_submission_standard_markdown,
|
||||||
build_travel_risk_control_standard_markdown,
|
build_travel_risk_control_standard_markdown,
|
||||||
@@ -77,7 +93,11 @@ LEGACY_RULE_CODES = (
|
|||||||
"rule.ap.payment_dual_review",
|
"rule.ap.payment_dual_review",
|
||||||
)
|
)
|
||||||
|
|
||||||
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
|
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
|
||||||
|
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
||||||
|
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
|
||||||
|
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",)
|
||||||
|
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("费用科目",)
|
||||||
|
|
||||||
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
||||||
"kind": "policy_rule_draft",
|
"kind": "policy_rule_draft",
|
||||||
@@ -159,6 +179,7 @@ class AgentFoundationService:
|
|||||||
def ensure_foundation_ready(self) -> None:
|
def ensure_foundation_ready(self) -> None:
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(bind=self.db.get_bind())
|
Base.metadata.create_all(bind=self.db.get_bind())
|
||||||
|
self._ensure_agent_asset_schema()
|
||||||
self._seed_agent_assets()
|
self._seed_agent_assets()
|
||||||
self._sync_demo_financial_records()
|
self._sync_demo_financial_records()
|
||||||
self._seed_runs_and_logs()
|
self._seed_runs_and_logs()
|
||||||
@@ -191,6 +212,8 @@ class AgentFoundationService:
|
|||||||
reviewer="高嘉禾",
|
reviewer="高嘉禾",
|
||||||
status=AgentAssetStatus.REVIEW.value,
|
status=AgentAssetStatus.REVIEW.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version=None,
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={
|
config_json={
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
@@ -211,6 +234,8 @@ class AgentFoundationService:
|
|||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={
|
config_json={
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -229,6 +254,8 @@ class AgentFoundationService:
|
|||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.1.0",
|
current_version="v1.1.0",
|
||||||
|
published_version="v1.1.0",
|
||||||
|
working_version="v1.1.0",
|
||||||
config_json={
|
config_json={
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -240,6 +267,55 @@ class AgentFoundationService:
|
|||||||
"rule_template_label": "差旅标准模板",
|
"rule_template_label": "差旅标准模板",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
company_travel_rule = AgentAsset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
name="公司差旅费报销规则",
|
||||||
|
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
|
||||||
|
owner="财务制度管理组",
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
published_version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
working_version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
config_json={
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
platform_risk_assets = self._build_platform_risk_seed_assets()
|
||||||
|
company_communication_rule = AgentAsset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
|
name="公司通信费报销规则",
|
||||||
|
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
|
||||||
|
owner="财务制度管理组",
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
published_version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
working_version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
config_json={
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
|
},
|
||||||
|
)
|
||||||
skill_expense_asset = AgentAsset(
|
skill_expense_asset = AgentAsset(
|
||||||
asset_type=AgentAssetType.SKILL.value,
|
asset_type=AgentAssetType.SKILL.value,
|
||||||
code="skill.expense.summary_lookup",
|
code="skill.expense.summary_lookup",
|
||||||
@@ -251,6 +327,8 @@ class AgentFoundationService:
|
|||||||
reviewer="陈硕",
|
reviewer="陈硕",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={"input_schema": ["time_range", "employee", "department"]},
|
config_json={"input_schema": ["time_range", "employee", "department"]},
|
||||||
)
|
)
|
||||||
skill_ar_asset = AgentAsset(
|
skill_ar_asset = AgentAsset(
|
||||||
@@ -264,6 +342,8 @@ class AgentFoundationService:
|
|||||||
reviewer="陈硕",
|
reviewer="陈硕",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={"input_schema": ["customer", "aging_bucket", "status"]},
|
config_json={"input_schema": ["customer", "aging_bucket", "status"]},
|
||||||
)
|
)
|
||||||
invoice_mcp_asset = AgentAsset(
|
invoice_mcp_asset = AgentAsset(
|
||||||
@@ -277,6 +357,8 @@ class AgentFoundationService:
|
|||||||
reviewer="周悦宁",
|
reviewer="周悦宁",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={"endpoint": "mock://invoice/verify", "timeout_ms": 1200},
|
config_json={"endpoint": "mock://invoice/verify", "timeout_ms": 1200},
|
||||||
)
|
)
|
||||||
ledger_mcp_asset = AgentAsset(
|
ledger_mcp_asset = AgentAsset(
|
||||||
@@ -290,6 +372,8 @@ class AgentFoundationService:
|
|||||||
reviewer="周悦宁",
|
reviewer="周悦宁",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500},
|
config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500},
|
||||||
)
|
)
|
||||||
task_asset = AgentAsset(
|
task_asset = AgentAsset(
|
||||||
@@ -303,6 +387,8 @@ class AgentFoundationService:
|
|||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={"cron": "0 9 * * *", "agent": AgentName.HERMES.value},
|
config_json={"cron": "0 9 * * *", "agent": AgentName.HERMES.value},
|
||||||
)
|
)
|
||||||
ar_summary_task = AgentAsset(
|
ar_summary_task = AgentAsset(
|
||||||
@@ -316,6 +402,8 @@ class AgentFoundationService:
|
|||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={"cron": "0 10 * * 1", "agent": AgentName.HERMES.value},
|
config_json={"cron": "0 10 * * 1", "agent": AgentName.HERMES.value},
|
||||||
)
|
)
|
||||||
rule_digest_task = AgentAsset(
|
rule_digest_task = AgentAsset(
|
||||||
@@ -329,6 +417,8 @@ class AgentFoundationService:
|
|||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value},
|
config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value},
|
||||||
)
|
)
|
||||||
knowledge_index_task = AgentAsset(
|
knowledge_index_task = AgentAsset(
|
||||||
@@ -342,7 +432,9 @@ class AgentFoundationService:
|
|||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value},
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
|
config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.db.add_all(
|
self.db.add_all(
|
||||||
@@ -350,6 +442,9 @@ class AgentFoundationService:
|
|||||||
attachment_rule,
|
attachment_rule,
|
||||||
scene_submission_rule,
|
scene_submission_rule,
|
||||||
travel_policy_rule,
|
travel_policy_rule,
|
||||||
|
*platform_risk_assets,
|
||||||
|
company_travel_rule,
|
||||||
|
company_communication_rule,
|
||||||
skill_expense_asset,
|
skill_expense_asset,
|
||||||
skill_ar_asset,
|
skill_ar_asset,
|
||||||
invoice_mcp_asset,
|
invoice_mcp_asset,
|
||||||
@@ -362,6 +457,17 @@ class AgentFoundationService:
|
|||||||
)
|
)
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
|
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
||||||
|
company_travel_rule,
|
||||||
|
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
actor_name="系统初始化",
|
||||||
|
)
|
||||||
|
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
|
||||||
|
company_communication_rule,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
actor_name="系统初始化",
|
||||||
|
)
|
||||||
|
|
||||||
self.db.add_all(
|
self.db.add_all(
|
||||||
[
|
[
|
||||||
AgentAssetVersion(
|
AgentAssetVersion(
|
||||||
@@ -410,6 +516,41 @@ class AgentFoundationService:
|
|||||||
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
|
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
|
||||||
created_by="系统初始化",
|
created_by="系统初始化",
|
||||||
),
|
),
|
||||||
|
*[
|
||||||
|
AgentAssetVersion(
|
||||||
|
asset=asset,
|
||||||
|
version="v1.0.0",
|
||||||
|
content=self._platform_risk_rule_markdown(asset),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note=f"平台通用风险规则:{asset.name}",
|
||||||
|
created_by="系统初始化",
|
||||||
|
)
|
||||||
|
for asset in platform_risk_assets
|
||||||
|
],
|
||||||
|
AgentAssetVersion(
|
||||||
|
asset=company_travel_rule,
|
||||||
|
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||||
|
rule_name=company_travel_rule.name,
|
||||||
|
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
metadata=company_travel_rule_meta,
|
||||||
|
),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note="初始化差旅费报销 Excel 规则表。",
|
||||||
|
created_by="系统初始化",
|
||||||
|
),
|
||||||
|
AgentAssetVersion(
|
||||||
|
asset=company_communication_rule,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||||
|
rule_name=company_communication_rule.name,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
metadata=company_communication_rule_meta,
|
||||||
|
),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note="初始化通信费报销 Excel 规则表。",
|
||||||
|
created_by="系统初始化",
|
||||||
|
),
|
||||||
AgentAssetVersion(
|
AgentAssetVersion(
|
||||||
asset=skill_expense_asset,
|
asset=skill_expense_asset,
|
||||||
version="v1.0.0",
|
version="v1.0.0",
|
||||||
@@ -514,7 +655,7 @@ class AgentFoundationService:
|
|||||||
content=self._json_content(
|
content=self._json_content(
|
||||||
{
|
{
|
||||||
"task_type": "knowledge_index_sync",
|
"task_type": "knowledge_index_sync",
|
||||||
"schedule": "0 0 * * *",
|
"schedule": "0 0 * * *",
|
||||||
"target_agent": AgentName.HERMES.value,
|
"target_agent": AgentName.HERMES.value,
|
||||||
"folder": "报销制度",
|
"folder": "报销制度",
|
||||||
"changed_only": True,
|
"changed_only": True,
|
||||||
@@ -553,6 +694,22 @@ class AgentFoundationService:
|
|||||||
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
|
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
|
||||||
reviewed_at=datetime.now(UTC),
|
reviewed_at=datetime.now(UTC),
|
||||||
),
|
),
|
||||||
|
AgentAssetReview(
|
||||||
|
asset=company_travel_rule,
|
||||||
|
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
reviewer="顾承宇",
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
),
|
||||||
|
AgentAssetReview(
|
||||||
|
asset=company_communication_rule,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
reviewer="顾承宇",
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -916,6 +1073,12 @@ class AgentFoundationService:
|
|||||||
travel_policy_rule = self.db.scalar(
|
travel_policy_rule = self.db.scalar(
|
||||||
select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard")
|
select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard")
|
||||||
)
|
)
|
||||||
|
company_travel_rule = self.db.scalar(
|
||||||
|
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
|
)
|
||||||
|
company_communication_rule = self.db.scalar(
|
||||||
|
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
|
||||||
|
)
|
||||||
|
|
||||||
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
|
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
|
||||||
attachment_rule = self._create_seed_asset(
|
attachment_rule = self._create_seed_asset(
|
||||||
@@ -940,8 +1103,11 @@ class AgentFoundationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if attachment_rule is not None:
|
if attachment_rule is not None:
|
||||||
attachment_rule.current_version = "v1.0.0"
|
if not str(attachment_rule.current_version or "").strip():
|
||||||
attachment_rule.status = AgentAssetStatus.REVIEW.value
|
attachment_rule.current_version = "v1.0.0"
|
||||||
|
if not str(attachment_rule.working_version or "").strip():
|
||||||
|
attachment_rule.working_version = attachment_rule.current_version
|
||||||
|
attachment_rule.status = attachment_rule.status or AgentAssetStatus.REVIEW.value
|
||||||
attachment_rule.description = "统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。"
|
attachment_rule.description = "统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。"
|
||||||
attachment_rule.config_json = {
|
attachment_rule.config_json = {
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
@@ -1003,8 +1169,13 @@ class AgentFoundationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if scene_submission_rule is not None:
|
if scene_submission_rule is not None:
|
||||||
scene_submission_rule.current_version = "v1.0.0"
|
if not str(scene_submission_rule.current_version or "").strip():
|
||||||
scene_submission_rule.status = AgentAssetStatus.ACTIVE.value
|
scene_submission_rule.current_version = "v1.0.0"
|
||||||
|
if not str(scene_submission_rule.working_version or "").strip():
|
||||||
|
scene_submission_rule.working_version = scene_submission_rule.current_version
|
||||||
|
if not str(scene_submission_rule.published_version or "").strip():
|
||||||
|
scene_submission_rule.published_version = scene_submission_rule.current_version
|
||||||
|
scene_submission_rule.status = scene_submission_rule.status or AgentAssetStatus.ACTIVE.value
|
||||||
scene_submission_rule.description = "统一定义各报销场景的必填字段、附件类型要求和金额阈值。"
|
scene_submission_rule.description = "统一定义各报销场景的必填字段、附件类型要求和金额阈值。"
|
||||||
scene_submission_rule.config_json = {
|
scene_submission_rule.config_json = {
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
@@ -1054,8 +1225,13 @@ class AgentFoundationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if travel_policy_rule is not None:
|
if travel_policy_rule is not None:
|
||||||
travel_policy_rule.current_version = "v1.1.0"
|
if not str(travel_policy_rule.current_version or "").strip():
|
||||||
travel_policy_rule.status = AgentAssetStatus.ACTIVE.value
|
travel_policy_rule.current_version = "v1.1.0"
|
||||||
|
if not str(travel_policy_rule.working_version or "").strip():
|
||||||
|
travel_policy_rule.working_version = travel_policy_rule.current_version
|
||||||
|
if not str(travel_policy_rule.published_version or "").strip():
|
||||||
|
travel_policy_rule.published_version = travel_policy_rule.current_version
|
||||||
|
travel_policy_rule.status = travel_policy_rule.status or AgentAssetStatus.ACTIVE.value
|
||||||
travel_policy_rule.config_json = {
|
travel_policy_rule.config_json = {
|
||||||
"severity": "high",
|
"severity": "high",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -1091,6 +1267,151 @@ class AgentFoundationService:
|
|||||||
reviewed_at=datetime.now(UTC),
|
reviewed_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.sync_platform_risk_rules_from_library()
|
||||||
|
|
||||||
|
if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes:
|
||||||
|
company_travel_rule = self._create_seed_asset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
name="公司差旅费报销规则",
|
||||||
|
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
|
||||||
|
owner="财务制度管理组",
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
config_json={
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if COMPANY_COMMUNICATION_EXPENSE_RULE_CODE not in existing_codes:
|
||||||
|
company_communication_rule = self._create_seed_asset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
|
name="公司通信费报销规则",
|
||||||
|
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
|
||||||
|
owner="财务制度管理组",
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
config_json={
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if company_travel_rule is not None:
|
||||||
|
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
|
||||||
|
if not str(company_travel_rule.current_version or "").strip():
|
||||||
|
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
|
||||||
|
if not str(company_travel_rule.working_version or "").strip():
|
||||||
|
company_travel_rule.working_version = company_travel_rule.current_version
|
||||||
|
if not str(company_travel_rule.published_version or "").strip():
|
||||||
|
company_travel_rule.published_version = company_travel_rule.current_version
|
||||||
|
if not str(company_travel_rule.status or "").strip():
|
||||||
|
company_travel_rule.status = AgentAssetStatus.ACTIVE.value
|
||||||
|
company_travel_rule.description = "通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。"
|
||||||
|
company_travel_rule.config_json = {
|
||||||
|
**(company_travel_rule.config_json or {}),
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
|
}
|
||||||
|
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
||||||
|
company_travel_rule,
|
||||||
|
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
|
||||||
|
actor_name="系统初始化",
|
||||||
|
)
|
||||||
|
self._ensure_asset_version(
|
||||||
|
company_travel_rule,
|
||||||
|
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
|
||||||
|
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||||
|
rule_name=company_travel_rule.name,
|
||||||
|
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
|
||||||
|
metadata=company_travel_rule_meta,
|
||||||
|
),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note="初始化差旅费报销 Excel 规则表。",
|
||||||
|
created_by="系统初始化",
|
||||||
|
)
|
||||||
|
if str(company_travel_rule.current_version or "").strip() == COMPANY_TRAVEL_RULE_VERSION:
|
||||||
|
self._ensure_asset_review(
|
||||||
|
company_travel_rule,
|
||||||
|
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
|
reviewer="顾承宇",
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
if company_communication_rule is not None:
|
||||||
|
company_communication_rule.scenario_json = list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON)
|
||||||
|
if not str(company_communication_rule.current_version or "").strip():
|
||||||
|
company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION
|
||||||
|
if not str(company_communication_rule.working_version or "").strip():
|
||||||
|
company_communication_rule.working_version = company_communication_rule.current_version
|
||||||
|
if not str(company_communication_rule.published_version or "").strip():
|
||||||
|
company_communication_rule.published_version = company_communication_rule.current_version
|
||||||
|
if not str(company_communication_rule.status or "").strip():
|
||||||
|
company_communication_rule.status = AgentAssetStatus.ACTIVE.value
|
||||||
|
company_communication_rule.description = "通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。"
|
||||||
|
company_communication_rule.config_json = {
|
||||||
|
**(company_communication_rule.config_json or {}),
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
|
}
|
||||||
|
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
|
||||||
|
company_communication_rule,
|
||||||
|
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
|
||||||
|
actor_name="系统初始化",
|
||||||
|
)
|
||||||
|
self._ensure_asset_version(
|
||||||
|
company_communication_rule,
|
||||||
|
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
|
||||||
|
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||||
|
rule_name=company_communication_rule.name,
|
||||||
|
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
|
||||||
|
metadata=company_communication_rule_meta,
|
||||||
|
),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note="初始化通信费报销 Excel 规则表。",
|
||||||
|
created_by="系统初始化",
|
||||||
|
)
|
||||||
|
if str(company_communication_rule.current_version or "").strip() == COMPANY_COMMUNICATION_RULE_VERSION:
|
||||||
|
self._ensure_asset_review(
|
||||||
|
company_communication_rule,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
reviewer="顾承宇",
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
if "skill.ar.aging_summary" not in existing_codes:
|
if "skill.ar.aging_summary" not in existing_codes:
|
||||||
asset = self._create_seed_asset(
|
asset = self._create_seed_asset(
|
||||||
asset_type=AgentAssetType.SKILL.value,
|
asset_type=AgentAssetType.SKILL.value,
|
||||||
@@ -1219,7 +1540,7 @@ class AgentFoundationService:
|
|||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
current_version="v1.0.0",
|
current_version="v1.0.0",
|
||||||
config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value},
|
config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value},
|
||||||
)
|
)
|
||||||
self._ensure_asset_version(
|
self._ensure_asset_version(
|
||||||
asset,
|
asset,
|
||||||
@@ -1227,7 +1548,7 @@ class AgentFoundationService:
|
|||||||
content=self._json_content(
|
content=self._json_content(
|
||||||
{
|
{
|
||||||
"task_type": "knowledge_index_sync",
|
"task_type": "knowledge_index_sync",
|
||||||
"schedule": "0 0 * * *",
|
"schedule": "0 0 * * *",
|
||||||
"target_agent": AgentName.HERMES.value,
|
"target_agent": AgentName.HERMES.value,
|
||||||
"folder": "报销制度",
|
"folder": "报销制度",
|
||||||
"changed_only": True,
|
"changed_only": True,
|
||||||
@@ -1238,6 +1559,182 @@ class AgentFoundationService:
|
|||||||
created_by="系统初始化",
|
created_by="系统初始化",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _ensure_company_travel_rule_spreadsheet_seed(
|
||||||
|
self,
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
actor_name: str,
|
||||||
|
):
|
||||||
|
manager = AgentAssetSpreadsheetManager()
|
||||||
|
manager.ensure_rule_library_dirs()
|
||||||
|
live_document = manager.store_rule_library_spreadsheet(
|
||||||
|
library=FINANCE_RULES_LIBRARY,
|
||||||
|
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
|
content=self._read_or_build_company_travel_rule_file(manager),
|
||||||
|
actor_name=actor_name,
|
||||||
|
source="rule-library",
|
||||||
|
)
|
||||||
|
existing_document = (
|
||||||
|
asset.config_json.get("rule_document")
|
||||||
|
if isinstance(asset.config_json, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
storage_key = (
|
||||||
|
str(existing_document.get("storage_key") or "").strip()
|
||||||
|
if isinstance(existing_document, dict)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
if storage_key:
|
||||||
|
try:
|
||||||
|
existing_path = manager.resolve_storage_path(storage_key)
|
||||||
|
except FileNotFoundError:
|
||||||
|
existing_path = None
|
||||||
|
if existing_path is not None and existing_path.exists():
|
||||||
|
asset.config_json = {
|
||||||
|
**(asset.config_json or {}),
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"tag": "财务规则",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"rule_document": {
|
||||||
|
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||||
|
live_document,
|
||||||
|
asset_version=version,
|
||||||
|
),
|
||||||
|
"storage_key": live_document.storage_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return live_document
|
||||||
|
|
||||||
|
asset.config_json = {
|
||||||
|
**(asset.config_json or {}),
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"tag": "财务规则",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"rule_document": {
|
||||||
|
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||||
|
live_document,
|
||||||
|
asset_version=version,
|
||||||
|
),
|
||||||
|
"storage_key": live_document.storage_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return live_document
|
||||||
|
|
||||||
|
def _ensure_company_communication_rule_spreadsheet_seed(
|
||||||
|
self,
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
actor_name: str,
|
||||||
|
):
|
||||||
|
return self._ensure_finance_rule_spreadsheet_seed(
|
||||||
|
asset,
|
||||||
|
version=version,
|
||||||
|
actor_name=actor_name,
|
||||||
|
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
|
fallback_sheet_name="通信费报销规则",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_or_build_company_travel_rule_file(
|
||||||
|
manager: AgentAssetSpreadsheetManager,
|
||||||
|
) -> bytes:
|
||||||
|
live_key = (
|
||||||
|
Path("rules")
|
||||||
|
/ FINANCE_RULES_LIBRARY
|
||||||
|
/ COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
|
||||||
|
).as_posix()
|
||||||
|
live_path = manager.resolve_storage_path(live_key)
|
||||||
|
if live_path.exists():
|
||||||
|
return live_path.read_bytes()
|
||||||
|
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
|
||||||
|
|
||||||
|
def _ensure_finance_rule_spreadsheet_seed(
|
||||||
|
self,
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
actor_name: str,
|
||||||
|
file_name: str,
|
||||||
|
fallback_sheet_name: str,
|
||||||
|
):
|
||||||
|
manager = AgentAssetSpreadsheetManager()
|
||||||
|
manager.ensure_rule_library_dirs()
|
||||||
|
live_document = manager.store_rule_library_spreadsheet(
|
||||||
|
library=FINANCE_RULES_LIBRARY,
|
||||||
|
file_name=file_name,
|
||||||
|
content=self._read_or_build_finance_rule_file(
|
||||||
|
manager,
|
||||||
|
file_name=file_name,
|
||||||
|
fallback_sheet_name=fallback_sheet_name,
|
||||||
|
),
|
||||||
|
actor_name=actor_name,
|
||||||
|
source="rule-library",
|
||||||
|
)
|
||||||
|
existing_document = (
|
||||||
|
asset.config_json.get("rule_document")
|
||||||
|
if isinstance(asset.config_json, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
storage_key = (
|
||||||
|
str(existing_document.get("storage_key") or "").strip()
|
||||||
|
if isinstance(existing_document, dict)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
if storage_key:
|
||||||
|
try:
|
||||||
|
existing_path = manager.resolve_storage_path(storage_key)
|
||||||
|
except FileNotFoundError:
|
||||||
|
existing_path = None
|
||||||
|
if existing_path is not None and existing_path.exists():
|
||||||
|
asset.config_json = {
|
||||||
|
**(asset.config_json or {}),
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"tag": "财务规则",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"rule_document": {
|
||||||
|
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||||
|
live_document,
|
||||||
|
asset_version=version,
|
||||||
|
),
|
||||||
|
"storage_key": live_document.storage_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return live_document
|
||||||
|
|
||||||
|
asset.config_json = {
|
||||||
|
**(asset.config_json or {}),
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"tag": "财务规则",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"rule_document": {
|
||||||
|
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||||
|
live_document,
|
||||||
|
asset_version=version,
|
||||||
|
),
|
||||||
|
"storage_key": live_document.storage_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return live_document
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_or_build_finance_rule_file(
|
||||||
|
manager: AgentAssetSpreadsheetManager,
|
||||||
|
*,
|
||||||
|
file_name: str,
|
||||||
|
fallback_sheet_name: str,
|
||||||
|
) -> bytes:
|
||||||
|
live_key = (
|
||||||
|
Path("rules")
|
||||||
|
/ FINANCE_RULES_LIBRARY
|
||||||
|
/ file_name
|
||||||
|
).as_posix()
|
||||||
|
live_path = manager.resolve_storage_path(live_key)
|
||||||
|
if live_path.exists():
|
||||||
|
return live_path.read_bytes()
|
||||||
|
return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name)
|
||||||
|
|
||||||
def _create_seed_asset(
|
def _create_seed_asset(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1264,6 +1761,8 @@ class AgentFoundationService:
|
|||||||
reviewer=reviewer,
|
reviewer=reviewer,
|
||||||
status=status,
|
status=status,
|
||||||
current_version=current_version,
|
current_version=current_version,
|
||||||
|
published_version=current_version if status == AgentAssetStatus.ACTIVE.value else None,
|
||||||
|
working_version=current_version,
|
||||||
config_json=config_json,
|
config_json=config_json,
|
||||||
)
|
)
|
||||||
self.db.add(asset)
|
self.db.add(asset)
|
||||||
@@ -1348,6 +1847,36 @@ class AgentFoundationService:
|
|||||||
for log in obsolete_logs:
|
for log in obsolete_logs:
|
||||||
self.db.delete(log)
|
self.db.delete(log)
|
||||||
|
|
||||||
|
def _ensure_agent_asset_schema(self) -> None:
|
||||||
|
bind = self.db.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
if "agent_assets" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
|
||||||
|
column_names = {column["name"] for column in inspector.get_columns("agent_assets")}
|
||||||
|
migration_statements: list[str] = []
|
||||||
|
if "published_version" not in column_names:
|
||||||
|
migration_statements.append("ALTER TABLE agent_assets ADD COLUMN published_version VARCHAR(30)")
|
||||||
|
if "working_version" not in column_names:
|
||||||
|
migration_statements.append("ALTER TABLE agent_assets ADD COLUMN working_version VARCHAR(30)")
|
||||||
|
|
||||||
|
for statement in migration_statements:
|
||||||
|
self.db.execute(text(statement))
|
||||||
|
|
||||||
|
self.db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE agent_assets "
|
||||||
|
"SET working_version = COALESCE(working_version, current_version), "
|
||||||
|
"published_version = CASE "
|
||||||
|
"WHEN published_version IS NOT NULL THEN published_version "
|
||||||
|
"WHEN status = 'active' THEN current_version "
|
||||||
|
"ELSE published_version END"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if migration_statements:
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
def _attachment_submission_requirement_markdown(
|
def _attachment_submission_requirement_markdown(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1415,6 +1944,229 @@ class AgentFoundationService:
|
|||||||
def _travel_risk_control_standard_markdown(self, *, version: str = "v1.1.0") -> str:
|
def _travel_risk_control_standard_markdown(self, *, version: str = "v1.1.0") -> str:
|
||||||
return self._markdown_content(build_travel_risk_control_standard_markdown())
|
return self._markdown_content(build_travel_risk_control_standard_markdown())
|
||||||
|
|
||||||
|
def _iter_platform_risk_manifests(self) -> list[tuple[str, dict[str, object]]]:
|
||||||
|
manager = AgentAssetRuleLibraryManager()
|
||||||
|
manifests: list[tuple[str, dict[str, object]]] = []
|
||||||
|
for file_name in sorted(manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)):
|
||||||
|
payload = manager.read_rule_library_json(library=RISK_RULES_LIBRARY, file_name=file_name)
|
||||||
|
if payload.get("enabled") is False:
|
||||||
|
continue
|
||||||
|
manifests.append((file_name, payload))
|
||||||
|
return manifests
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_platform_risk_category(manifest: dict[str, object]) -> str:
|
||||||
|
explicit = str(manifest.get("risk_category") or "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
rule_code = str(manifest.get("rule_code") or "").strip().lower()
|
||||||
|
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||||
|
domains = {str(item or "").strip().lower() for item in applies_to.get("domains") or []}
|
||||||
|
expense_types = {
|
||||||
|
str(item or "").strip().lower() for item in applies_to.get("expense_types") or []
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule_code.startswith("risk.invoice."):
|
||||||
|
return "发票"
|
||||||
|
if "meal" in domains or "entertainment" in expense_types:
|
||||||
|
return "餐饮招待"
|
||||||
|
if "transport" in expense_types or "consecutive_transport" in rule_code:
|
||||||
|
return "交通出行"
|
||||||
|
if "office" in expense_types:
|
||||||
|
return "办公物料"
|
||||||
|
if "travel" in domains or rule_code.startswith("risk.travel."):
|
||||||
|
return "差旅"
|
||||||
|
if rule_code.startswith("risk.expense."):
|
||||||
|
return "费用科目"
|
||||||
|
return "通用"
|
||||||
|
|
||||||
|
def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]:
|
||||||
|
category = self._resolve_platform_risk_category(manifest)
|
||||||
|
return [category] if category else ["通用"]
|
||||||
|
|
||||||
|
def _platform_risk_config_json(self, file_name: str, manifest: dict[str, object]) -> dict[str, object]:
|
||||||
|
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
|
||||||
|
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||||
|
risk_category = self._resolve_platform_risk_category(manifest)
|
||||||
|
return {
|
||||||
|
"severity": str(fail_outcome.get("severity") or "medium"),
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "风险规则",
|
||||||
|
"detail_mode": "json_risk",
|
||||||
|
"risk_category": risk_category,
|
||||||
|
"rule_library": RISK_RULES_LIBRARY,
|
||||||
|
"rule_document": {
|
||||||
|
"file_name": file_name,
|
||||||
|
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||||
|
},
|
||||||
|
"ontology_signal": str(manifest.get("ontology_signal") or "").strip(),
|
||||||
|
"evaluator": str(manifest.get("evaluator") or "").strip(),
|
||||||
|
"source_ref": (
|
||||||
|
(manifest.get("metadata") or {}).get("source_ref")
|
||||||
|
if isinstance(manifest.get("metadata"), dict)
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_platform_risk_seed_assets(self) -> list[AgentAsset]:
|
||||||
|
assets: list[AgentAsset] = []
|
||||||
|
for file_name, manifest in self._iter_platform_risk_manifests():
|
||||||
|
rule_code = str(manifest.get("rule_code") or "").strip()
|
||||||
|
if not rule_code:
|
||||||
|
continue
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||||
|
rule_description = str(manifest.get("description") or "").strip()
|
||||||
|
assets.append(
|
||||||
|
AgentAsset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=rule_code,
|
||||||
|
name=str(manifest.get("name") or rule_code),
|
||||||
|
description=rule_description
|
||||||
|
or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=self._platform_risk_scenario_json(manifest),
|
||||||
|
owner=str(metadata.get("owner") or "风控与审计部"),
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
|
config_json=self._platform_risk_config_json(file_name, manifest),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return assets
|
||||||
|
|
||||||
|
def sync_platform_risk_rules_from_library(self) -> int:
|
||||||
|
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
|
||||||
|
before_count = len(existing_codes)
|
||||||
|
self._ensure_platform_risk_rules_from_library(existing_codes)
|
||||||
|
self.db.flush()
|
||||||
|
after_codes = set(self.db.scalars(select(AgentAsset.code)).all())
|
||||||
|
synced = max(len(after_codes) - before_count, 0)
|
||||||
|
manifest_count = len(self._iter_platform_risk_manifests())
|
||||||
|
logger.info(
|
||||||
|
"Platform risk rules synced from library",
|
||||||
|
extra={"manifest_count": manifest_count, "created_count": synced, "total": len(after_codes)},
|
||||||
|
)
|
||||||
|
return manifest_count
|
||||||
|
|
||||||
|
def _ensure_platform_risk_rules_from_library(self, existing_codes: set[str]) -> None:
|
||||||
|
for file_name, manifest in self._iter_platform_risk_manifests():
|
||||||
|
rule_code = str(manifest.get("rule_code") or "").strip()
|
||||||
|
if not rule_code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||||
|
rule_description = str(manifest.get("description") or "").strip()
|
||||||
|
config_json = self._platform_risk_config_json(file_name, manifest)
|
||||||
|
scenario_json = self._platform_risk_scenario_json(manifest)
|
||||||
|
|
||||||
|
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == rule_code))
|
||||||
|
if asset is None and rule_code not in existing_codes:
|
||||||
|
asset = self._create_seed_asset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=rule_code,
|
||||||
|
name=str(manifest.get("name") or rule_code),
|
||||||
|
description=rule_description
|
||||||
|
or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=scenario_json,
|
||||||
|
owner=str(metadata.get("owner") or "风控与审计部"),
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version="v1.0.0",
|
||||||
|
config_json=config_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
if asset is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not str(asset.current_version or "").strip():
|
||||||
|
asset.current_version = "v1.0.0"
|
||||||
|
if not str(asset.working_version or "").strip():
|
||||||
|
asset.working_version = asset.current_version
|
||||||
|
if not str(asset.published_version or "").strip():
|
||||||
|
asset.published_version = asset.current_version
|
||||||
|
asset.status = asset.status or AgentAssetStatus.ACTIVE.value
|
||||||
|
asset.name = str(manifest.get("name") or asset.name or rule_code)
|
||||||
|
if rule_description:
|
||||||
|
asset.description = rule_description
|
||||||
|
asset.config_json = config_json
|
||||||
|
asset.scenario_json = scenario_json
|
||||||
|
|
||||||
|
self._ensure_asset_version(
|
||||||
|
asset,
|
||||||
|
version="v1.0.0",
|
||||||
|
content=self._platform_risk_rule_markdown(asset, manifest=manifest, file_name=file_name),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note=f"平台通用风险规则:{asset.name}",
|
||||||
|
created_by="系统初始化",
|
||||||
|
)
|
||||||
|
self._ensure_asset_review(
|
||||||
|
asset,
|
||||||
|
version="v1.0.0",
|
||||||
|
reviewer="顾承宇",
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="平台内置风险规则,供提交验审与风险问答共用。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _platform_risk_rule_markdown(
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
manifest: dict[str, object] | None = None,
|
||||||
|
file_name: str = "",
|
||||||
|
) -> str:
|
||||||
|
config = asset.config_json if isinstance(asset.config_json, dict) else {}
|
||||||
|
rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
|
||||||
|
resolved_file_name = file_name or str(rule_document.get("file_name") or "").strip()
|
||||||
|
evaluator = str(config.get("evaluator") or (manifest or {}).get("evaluator") or "").strip()
|
||||||
|
ontology_signal = str(config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or "").strip()
|
||||||
|
source_ref = str(config.get("source_ref") or "").strip()
|
||||||
|
if not source_ref and isinstance(manifest, dict):
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# {asset.name}",
|
||||||
|
"",
|
||||||
|
"## 规则类型",
|
||||||
|
"",
|
||||||
|
"- 平台内置通用风险规则(`json_risk`)",
|
||||||
|
]
|
||||||
|
if evaluator:
|
||||||
|
lines.append(f"- 检查器:`{evaluator}`")
|
||||||
|
if ontology_signal:
|
||||||
|
lines.append(f"- 本体信号:`{ontology_signal}`")
|
||||||
|
if source_ref:
|
||||||
|
lines.extend(["", "## 来源", "", f"- {source_ref}"])
|
||||||
|
if resolved_file_name:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## 配置文件",
|
||||||
|
"",
|
||||||
|
f"- `rules/{RISK_RULES_LIBRARY}/{resolved_file_name}`",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _platform_destination_location_risk_markdown() -> str:
|
||||||
|
return AgentFoundationService._platform_risk_rule_markdown(
|
||||||
|
AgentAsset(name="申报地点与票据地点一致", config_json={"evaluator": "location_consistency"}),
|
||||||
|
manifest={
|
||||||
|
"evaluator": "location_consistency",
|
||||||
|
"ontology_signal": "location_mismatch",
|
||||||
|
"metadata": {"source_ref": "常用risk.txt / 一、出差类 / 行程不符"},
|
||||||
|
},
|
||||||
|
file_name=PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _markdown_content(content: str) -> str:
|
def _markdown_content(content: str) -> str:
|
||||||
return content
|
return content
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.agent_enums import AgentPermissionLevel, AgentRunStatus
|
from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunStatus
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
||||||
from app.repositories.agent_run import AgentRunRepository
|
from app.repositories.agent_run import AgentRunRepository
|
||||||
@@ -15,6 +15,8 @@ from app.services.agent_foundation import AgentFoundationService
|
|||||||
|
|
||||||
logger = get_logger("app.services.agent_runs")
|
logger = get_logger("app.services.agent_runs")
|
||||||
|
|
||||||
|
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
|
||||||
|
|
||||||
|
|
||||||
class AgentRunService:
|
class AgentRunService:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
@@ -30,11 +32,13 @@ class AgentRunService:
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
) -> list[AgentRunRead]:
|
) -> list[AgentRunRead]:
|
||||||
self._ensure_ready()
|
self._ensure_ready()
|
||||||
|
self._reconcile_stale_knowledge_index_runs()
|
||||||
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
|
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
|
||||||
return [self._serialize_run(item) for item in runs]
|
return [self._serialize_run(item) for item in runs]
|
||||||
|
|
||||||
def get_run(self, run_id: str) -> AgentRunRead | None:
|
def get_run(self, run_id: str) -> AgentRunRead | None:
|
||||||
self._ensure_ready()
|
self._ensure_ready()
|
||||||
|
self._reconcile_stale_knowledge_index_runs(target_run_id=run_id)
|
||||||
run = self.repository.get_by_run_id(run_id)
|
run = self.repository.get_by_run_id(run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
return None
|
return None
|
||||||
@@ -174,6 +178,35 @@ class AgentRunService:
|
|||||||
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
|
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
|
||||||
return AgentToolCallRead.model_validate(created)
|
return AgentToolCallRead.model_validate(created)
|
||||||
|
|
||||||
|
def update_tool_call(
|
||||||
|
self,
|
||||||
|
tool_call_id: str,
|
||||||
|
*,
|
||||||
|
request_json: dict[str, Any] | None = None,
|
||||||
|
response_json: dict[str, Any] | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
duration_ms: int | None = None,
|
||||||
|
error_message: str | None = None,
|
||||||
|
) -> AgentToolCallRead:
|
||||||
|
self._ensure_ready()
|
||||||
|
tool_call = self.repository.get_tool_call(tool_call_id)
|
||||||
|
if tool_call is None:
|
||||||
|
raise LookupError("Tool call not found")
|
||||||
|
|
||||||
|
if request_json is not None:
|
||||||
|
tool_call.request_json = request_json
|
||||||
|
if response_json is not None:
|
||||||
|
tool_call.response_json = response_json
|
||||||
|
if status is not None:
|
||||||
|
tool_call.status = status
|
||||||
|
if duration_ms is not None:
|
||||||
|
tool_call.duration_ms = duration_ms
|
||||||
|
tool_call.error_message = error_message
|
||||||
|
|
||||||
|
updated = self.repository.save_tool_call(tool_call)
|
||||||
|
logger.info("Updated tool call id=%s status=%s", updated.id, updated.status)
|
||||||
|
return AgentToolCallRead.model_validate(updated)
|
||||||
|
|
||||||
def record_semantic_parse(
|
def record_semantic_parse(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -214,6 +247,73 @@ class AgentRunService:
|
|||||||
def _ensure_ready(self) -> None:
|
def _ensure_ready(self) -> None:
|
||||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||||
|
|
||||||
|
def _reconcile_stale_knowledge_index_runs(self, *, target_run_id: str | None = None) -> None:
|
||||||
|
runs = self.repository.list(
|
||||||
|
agent=AgentName.HERMES.value,
|
||||||
|
status=AgentRunStatus.RUNNING.value,
|
||||||
|
limit=200,
|
||||||
|
)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
for run in runs:
|
||||||
|
if target_run_id is not None and run.run_id != target_run_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
route_json = dict(run.route_json or {})
|
||||||
|
if str(route_json.get("job_type") or "").strip() != "knowledge_index_sync":
|
||||||
|
continue
|
||||||
|
|
||||||
|
heartbeat_at = self._parse_heartbeat_time(
|
||||||
|
str(route_json.get("heartbeat_at") or "").strip()
|
||||||
|
)
|
||||||
|
last_seen_at = heartbeat_at or run.started_at
|
||||||
|
if last_seen_at.tzinfo is None:
|
||||||
|
last_seen_at = last_seen_at.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
if now - last_seen_at <= KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stale_document_ids = [
|
||||||
|
str(document_id).strip()
|
||||||
|
for document_id in list(route_json.get("requested_document_ids") or [])
|
||||||
|
if str(document_id).strip()
|
||||||
|
]
|
||||||
|
if stale_document_ids:
|
||||||
|
from app.services.knowledge import (
|
||||||
|
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||||
|
KnowledgeService,
|
||||||
|
)
|
||||||
|
|
||||||
|
KnowledgeService(db=self.db).set_document_ingest_statuses(
|
||||||
|
stale_document_ids,
|
||||||
|
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||||
|
agent_run_id=run.run_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
route_json.update(
|
||||||
|
{
|
||||||
|
"phase": "stale_failed",
|
||||||
|
"heartbeat_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
run.route_json = route_json
|
||||||
|
run.status = AgentRunStatus.FAILED.value
|
||||||
|
run.result_summary = "知识归纳任务长时间无心跳,系统已自动标记失败。"
|
||||||
|
run.error_message = "Knowledge index heartbeat timed out."
|
||||||
|
run.finished_at = now
|
||||||
|
self.repository.save_run(run)
|
||||||
|
logger.warning("Marked stale knowledge index run as failed run_id=%s", run.run_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_heartbeat_time(raw_value: str) -> datetime | None:
|
||||||
|
normalized = str(raw_value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_run(run: AgentRun) -> AgentRunRead:
|
def _serialize_run(run: AgentRun) -> AgentRunRead:
|
||||||
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None
|
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None
|
||||||
|
|||||||
@@ -1,70 +1,70 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.repositories.audit_log import AuditLogRepository
|
from app.repositories.audit_log import AuditLogRepository
|
||||||
from app.schemas.audit_log import AuditLogRead
|
from app.schemas.audit_log import AuditLogRead
|
||||||
from app.services.agent_foundation import AgentFoundationService
|
from app.services.agent_foundation import AgentFoundationService
|
||||||
|
|
||||||
logger = get_logger("app.services.audit")
|
logger = get_logger("app.services.audit")
|
||||||
|
|
||||||
|
|
||||||
class AuditLogService:
|
class AuditLogService:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.repository = AuditLogRepository(db)
|
self.repository = AuditLogRepository(db)
|
||||||
|
|
||||||
def list_logs(
|
def list_logs(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
resource_type: str | None = None,
|
resource_type: str | None = None,
|
||||||
resource_id: str | None = None,
|
resource_id: str | None = None,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[AuditLogRead]:
|
) -> list[AuditLogRead]:
|
||||||
self._ensure_ready()
|
self._ensure_ready()
|
||||||
items = self.repository.list(
|
items = self.repository.list(
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
action=action,
|
action=action,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
return [AuditLogRead.model_validate(item) for item in items]
|
return [AuditLogRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
def log_action(
|
def log_action(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
actor: str,
|
actor: str,
|
||||||
action: str,
|
action: str,
|
||||||
resource_type: str,
|
resource_type: str,
|
||||||
resource_id: str,
|
resource_id: str,
|
||||||
before_json: dict[str, Any] | None = None,
|
before_json: dict[str, Any] | None = None,
|
||||||
after_json: dict[str, Any] | None = None,
|
after_json: dict[str, Any] | None = None,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
) -> AuditLog:
|
) -> AuditLog:
|
||||||
log = AuditLog(
|
log = AuditLog(
|
||||||
actor=actor,
|
actor=actor,
|
||||||
action=action,
|
action=action,
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
before_json=before_json,
|
before_json=before_json,
|
||||||
after_json=after_json,
|
after_json=after_json,
|
||||||
request_id=request_id or uuid.uuid4().hex,
|
request_id=request_id or uuid.uuid4().hex,
|
||||||
)
|
)
|
||||||
created = self.repository.create(log)
|
created = self.repository.create(log)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Created audit log id=%s action=%s resource=%s:%s",
|
"Created audit log id=%s action=%s resource=%s:%s",
|
||||||
created.id,
|
created.id,
|
||||||
created.action,
|
created.action,
|
||||||
created.resource_type,
|
created.resource_type,
|
||||||
created.resource_id,
|
created.resource_id,
|
||||||
)
|
)
|
||||||
return created
|
return created
|
||||||
|
|
||||||
def _ensure_ready(self) -> None:
|
def _ensure_ready(self) -> None:
|
||||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.core.security import verify_password
|
from app.core.security import verify_password
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
||||||
from app.services.employee import EmployeeService
|
from app.services.employee import EmployeeService
|
||||||
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
||||||
@@ -31,8 +34,15 @@ class AuthenticatedUser:
|
|||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
department: str
|
||||||
position: str
|
position: str
|
||||||
grade: str
|
grade: str
|
||||||
|
employee_no: str
|
||||||
|
manager_name: str
|
||||||
|
location: str
|
||||||
|
cost_center: str
|
||||||
|
finance_owner_name: str
|
||||||
|
risk_profile: dict[str, Any]
|
||||||
role_codes: list[str]
|
role_codes: list[str]
|
||||||
email: str
|
email: str
|
||||||
avatar: str
|
avatar: str
|
||||||
@@ -78,8 +88,15 @@ class AuthService:
|
|||||||
username=admin_username or admin_email,
|
username=admin_username or admin_email,
|
||||||
name=display_name,
|
name=display_name,
|
||||||
role="管理员",
|
role="管理员",
|
||||||
|
department="",
|
||||||
position="系统管理员",
|
position="系统管理员",
|
||||||
grade="",
|
grade="",
|
||||||
|
employee_no="",
|
||||||
|
manager_name="",
|
||||||
|
location="",
|
||||||
|
cost_center="",
|
||||||
|
finance_owner_name="",
|
||||||
|
risk_profile={},
|
||||||
role_codes=["manager"],
|
role_codes=["manager"],
|
||||||
email=admin_email or f"{admin_username}@local",
|
email=admin_email or f"{admin_username}@local",
|
||||||
avatar=display_name[:1].upper(),
|
avatar=display_name[:1].upper(),
|
||||||
@@ -94,7 +111,11 @@ class AuthService:
|
|||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Employee)
|
select(Employee)
|
||||||
.options(selectinload(Employee.roles))
|
.options(
|
||||||
|
selectinload(Employee.organization_unit),
|
||||||
|
selectinload(Employee.manager),
|
||||||
|
selectinload(Employee.roles),
|
||||||
|
)
|
||||||
.where(func.lower(Employee.email) == identifier.lower())
|
.where(func.lower(Employee.email) == identifier.lower())
|
||||||
)
|
)
|
||||||
employee = self.db.execute(stmt).scalars().first()
|
employee = self.db.execute(stmt).scalars().first()
|
||||||
@@ -115,27 +136,91 @@ class AuthService:
|
|||||||
)
|
)
|
||||||
role_codes = [role.role_code for role in sorted_roles]
|
role_codes = [role.role_code for role in sorted_roles]
|
||||||
primary_role_code = role_codes[0] if role_codes else "user"
|
primary_role_code = role_codes[0] if role_codes else "user"
|
||||||
|
department = employee.organization_unit.name if employee.organization_unit is not None else ""
|
||||||
|
manager_name = self._resolve_manager_name(employee)
|
||||||
|
|
||||||
return AuthenticatedUser(
|
return AuthenticatedUser(
|
||||||
username=employee.email,
|
username=employee.email,
|
||||||
name=employee.name,
|
name=employee.name,
|
||||||
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
||||||
|
department=department,
|
||||||
position=employee.position,
|
position=employee.position,
|
||||||
grade=employee.grade,
|
grade=employee.grade,
|
||||||
|
employee_no=employee.employee_no,
|
||||||
|
manager_name=manager_name,
|
||||||
|
location=employee.location or "",
|
||||||
|
cost_center=employee.cost_center or "",
|
||||||
|
finance_owner_name=employee.finance_owner_name or "",
|
||||||
|
risk_profile=self._build_risk_profile(employee),
|
||||||
role_codes=role_codes or ["user"],
|
role_codes=role_codes or ["user"],
|
||||||
email=employee.email,
|
email=employee.email,
|
||||||
avatar=(employee.name or "?")[:1].upper(),
|
avatar=(employee.name or "?")[:1].upper(),
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_manager_name(employee: Employee) -> str:
|
||||||
|
if employee.manager is not None and employee.manager.name:
|
||||||
|
return str(employee.manager.name).strip()
|
||||||
|
if employee.organization_unit is not None and employee.organization_unit.manager_name:
|
||||||
|
return str(employee.organization_unit.manager_name).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _build_risk_profile(self, employee: Employee) -> dict[str, Any]:
|
||||||
|
since = datetime.now(UTC) - timedelta(days=90)
|
||||||
|
identity_values = [
|
||||||
|
str(employee.name or "").strip(),
|
||||||
|
str(employee.email or "").strip(),
|
||||||
|
str(employee.employee_no or "").strip(),
|
||||||
|
]
|
||||||
|
name_candidates = [item for item in dict.fromkeys(identity_values) if item]
|
||||||
|
conditions = [ExpenseClaim.employee_id == employee.id]
|
||||||
|
if name_candidates:
|
||||||
|
conditions.append(ExpenseClaim.employee_name.in_(name_candidates))
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.where(or_(*conditions), ExpenseClaim.occurred_at >= since)
|
||||||
|
.order_by(ExpenseClaim.occurred_at.desc())
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
claims = list(self.db.scalars(stmt).all())
|
||||||
|
recent_risk_flags: list[str] = []
|
||||||
|
for claim in claims:
|
||||||
|
for flag in claim.risk_flags_json or []:
|
||||||
|
normalized = str(flag or "").strip()
|
||||||
|
if normalized and normalized not in recent_risk_flags:
|
||||||
|
recent_risk_flags.append(normalized)
|
||||||
|
if len(recent_risk_flags) >= 6:
|
||||||
|
break
|
||||||
|
if len(recent_risk_flags) >= 6:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"windowDays": 90,
|
||||||
|
"totalClaimCount": len(claims),
|
||||||
|
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
|
||||||
|
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
|
||||||
|
"recentRiskFlags": recent_risk_flags,
|
||||||
|
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
|
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
|
||||||
return AuthUserRead(
|
return AuthUserRead(
|
||||||
username=user.username,
|
username=user.username,
|
||||||
name=user.name,
|
name=user.name,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
|
department=user.department,
|
||||||
|
departmentName=user.department,
|
||||||
position=user.position,
|
position=user.position,
|
||||||
grade=user.grade,
|
grade=user.grade,
|
||||||
|
employeeNo=user.employee_no,
|
||||||
|
managerName=user.manager_name,
|
||||||
|
location=user.location,
|
||||||
|
costCenter=user.cost_center,
|
||||||
|
financeOwnerName=user.finance_owner_name,
|
||||||
|
riskProfile=user.risk_profile,
|
||||||
roleCodes=user.role_codes,
|
roleCodes=user.role_codes,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
avatar=user.avatar,
|
avatar=user.avatar,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
|||||||
scene_code="travel",
|
scene_code="travel",
|
||||||
scene_label="差旅票据",
|
scene_label="差旅票据",
|
||||||
expense_type="travel",
|
expense_type="travel",
|
||||||
keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"),
|
keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"),
|
||||||
score_bias=0.32,
|
score_bias=0.32,
|
||||||
),
|
),
|
||||||
DocumentRule(
|
DocumentRule(
|
||||||
@@ -104,7 +104,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
|||||||
scene_code="transport",
|
scene_code="transport",
|
||||||
scene_label="交通票据",
|
scene_label="交通票据",
|
||||||
expense_type="transport",
|
expense_type="transport",
|
||||||
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
|
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "乘车", "用车", "叫车", "车费", "车资", "的士", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
|
||||||
score_bias=0.38,
|
score_bias=0.38,
|
||||||
),
|
),
|
||||||
DocumentRule(
|
DocumentRule(
|
||||||
@@ -177,13 +177,14 @@ SUPPORTED_DOCUMENT_TYPES = tuple(DOCUMENT_TYPE_RULE_MAP.keys()) + ("other",)
|
|||||||
|
|
||||||
AMOUNT_PATTERNS = (
|
AMOUNT_PATTERNS = (
|
||||||
re.compile(
|
re.compile(
|
||||||
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)"
|
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
|
||||||
r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
r"[::\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
||||||
),
|
),
|
||||||
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
|
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
|
||||||
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
|
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
|
||||||
)
|
)
|
||||||
DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
|
DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
|
||||||
|
TIME_PATTERN = re.compile(r"(?<!\d)([01]?\d|2[0-3])[::]([0-5]\d)(?!\d)")
|
||||||
INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[::\s]*([A-Za-z0-9-]{6,24})")
|
INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[::\s]*([A-Za-z0-9-]{6,24})")
|
||||||
INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[::\s]*([A-Za-z0-9-]{6,24})")
|
INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[::\s]*([A-Za-z0-9-]{6,24})")
|
||||||
TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[::\s]*([A-Za-z0-9]{2,12})")
|
TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[::\s]*([A-Za-z0-9]{2,12})")
|
||||||
@@ -192,6 +193,58 @@ MERCHANT_PATTERNS = (
|
|||||||
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[::\s]*([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40})"),
|
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[::\s]*([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40})"),
|
||||||
re.compile(r"([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40}(?:酒店|宾馆|饭店|酒楼|餐厅|航空|铁路|滴滴出行|停车场|服务区))"),
|
re.compile(r"([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40}(?:酒店|宾馆|饭店|酒楼|餐厅|航空|铁路|滴滴出行|停车场|服务区))"),
|
||||||
)
|
)
|
||||||
|
DATE_FIELD_KEYS = {
|
||||||
|
"date",
|
||||||
|
"time",
|
||||||
|
"issued_at",
|
||||||
|
"invoice_date",
|
||||||
|
"issue_date",
|
||||||
|
"travel_date",
|
||||||
|
"trip_date",
|
||||||
|
"journey_date",
|
||||||
|
"departure_date",
|
||||||
|
"departure_time",
|
||||||
|
"depart_date",
|
||||||
|
"depart_time",
|
||||||
|
"boarding_date",
|
||||||
|
"boarding_time",
|
||||||
|
"train_date",
|
||||||
|
"train_time",
|
||||||
|
"train_departure_time",
|
||||||
|
"scheduled_departure_time",
|
||||||
|
"flight_date",
|
||||||
|
"flight_time",
|
||||||
|
"ride_date",
|
||||||
|
"ride_time",
|
||||||
|
"pickup_time",
|
||||||
|
"start_time",
|
||||||
|
}
|
||||||
|
TRIP_DATE_LABEL_BY_DOCUMENT_TYPE = {
|
||||||
|
"train_ticket": "列车出发时间",
|
||||||
|
"flight_itinerary": "起飞日期",
|
||||||
|
"taxi_receipt": "乘车时间",
|
||||||
|
"transport_receipt": "乘车时间",
|
||||||
|
"parking_toll_receipt": "通行日期",
|
||||||
|
}
|
||||||
|
TRIP_DATE_FIELD_LABEL_TOKENS = (
|
||||||
|
"日期",
|
||||||
|
"时间",
|
||||||
|
"开票日期",
|
||||||
|
"发生时间",
|
||||||
|
"行程日期",
|
||||||
|
"出发日期",
|
||||||
|
"出发时间",
|
||||||
|
"列车出发时间",
|
||||||
|
"发车日期",
|
||||||
|
"发车时间",
|
||||||
|
"开车时间",
|
||||||
|
"乘车日期",
|
||||||
|
"乘车时间",
|
||||||
|
"起飞日期",
|
||||||
|
"航班日期",
|
||||||
|
"上车时间",
|
||||||
|
"用车时间",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DocumentIntelligenceService:
|
class DocumentIntelligenceService:
|
||||||
@@ -212,7 +265,10 @@ class DocumentIntelligenceService:
|
|||||||
compact = re.sub(r"\s+", "", raw_text).lower()
|
compact = re.sub(r"\s+", "", raw_text).lower()
|
||||||
rule_match = _match_document_rule(compact)
|
rule_match = _match_document_rule(compact)
|
||||||
base_rule = rule_match.rule or DEFAULT_RULE
|
base_rule = rule_match.rule or DEFAULT_RULE
|
||||||
fields = tuple(_extract_document_fields(raw_text))
|
fields = _apply_document_type_field_labels(
|
||||||
|
tuple(_extract_document_fields(raw_text, base_rule.document_type)),
|
||||||
|
base_rule.document_type,
|
||||||
|
)
|
||||||
rule_insight = DocumentInsight(
|
rule_insight = DocumentInsight(
|
||||||
document_type=base_rule.document_type,
|
document_type=base_rule.document_type,
|
||||||
document_type_label=base_rule.document_type_label,
|
document_type_label=base_rule.document_type_label,
|
||||||
@@ -275,7 +331,10 @@ class DocumentIntelligenceService:
|
|||||||
for item in parsed.evidence
|
for item in parsed.evidence
|
||||||
if str(item or "").strip()
|
if str(item or "").strip()
|
||||||
][:4]
|
][:4]
|
||||||
normalized_fields = _normalize_llm_document_fields(parsed.fields)
|
normalized_fields = _apply_document_type_field_labels(
|
||||||
|
tuple(_normalize_llm_document_fields(parsed.fields)),
|
||||||
|
normalized_type,
|
||||||
|
)
|
||||||
|
|
||||||
return LlmDocumentClassification(
|
return LlmDocumentClassification(
|
||||||
document_type=normalized_type,
|
document_type=normalized_type,
|
||||||
@@ -312,7 +371,10 @@ class DocumentIntelligenceService:
|
|||||||
scene_code=rule_insight.scene_code,
|
scene_code=rule_insight.scene_code,
|
||||||
scene_label=rule_insight.scene_label,
|
scene_label=rule_insight.scene_label,
|
||||||
expense_type=rule_insight.expense_type,
|
expense_type=rule_insight.expense_type,
|
||||||
fields=merged_fields,
|
fields=_apply_document_type_field_labels(
|
||||||
|
merged_fields,
|
||||||
|
rule_insight.document_type,
|
||||||
|
),
|
||||||
classification_source=rule_insight.classification_source,
|
classification_source=rule_insight.classification_source,
|
||||||
classification_confidence=rule_insight.classification_confidence,
|
classification_confidence=rule_insight.classification_confidence,
|
||||||
evidence=rule_insight.evidence,
|
evidence=rule_insight.evidence,
|
||||||
@@ -337,7 +399,10 @@ class DocumentIntelligenceService:
|
|||||||
scene_code=rule_insight.scene_code,
|
scene_code=rule_insight.scene_code,
|
||||||
scene_label=rule_insight.scene_label,
|
scene_label=rule_insight.scene_label,
|
||||||
expense_type=rule_insight.expense_type,
|
expense_type=rule_insight.expense_type,
|
||||||
fields=merged_fields,
|
fields=_apply_document_type_field_labels(
|
||||||
|
merged_fields,
|
||||||
|
rule_insight.document_type,
|
||||||
|
),
|
||||||
classification_source=rule_insight.classification_source,
|
classification_source=rule_insight.classification_source,
|
||||||
classification_confidence=rule_insight.classification_confidence,
|
classification_confidence=rule_insight.classification_confidence,
|
||||||
evidence=rule_insight.evidence,
|
evidence=rule_insight.evidence,
|
||||||
@@ -354,7 +419,7 @@ class DocumentIntelligenceService:
|
|||||||
scene_code=rule.scene_code if parsed.scene_code == "other" else parsed.scene_code,
|
scene_code=rule.scene_code if parsed.scene_code == "other" else parsed.scene_code,
|
||||||
scene_label=rule.scene_label if parsed.scene_label == "其他票据" else parsed.scene_label,
|
scene_label=rule.scene_label if parsed.scene_label == "其他票据" else parsed.scene_label,
|
||||||
expense_type=rule.expense_type if parsed.expense_type == "other" else parsed.expense_type,
|
expense_type=rule.expense_type if parsed.expense_type == "other" else parsed.expense_type,
|
||||||
fields=merged_fields,
|
fields=_apply_document_type_field_labels(merged_fields, rule.document_type),
|
||||||
classification_source=source,
|
classification_source=source,
|
||||||
classification_confidence=max(parsed.confidence, rule_insight.classification_confidence),
|
classification_confidence=max(parsed.confidence, rule_insight.classification_confidence),
|
||||||
evidence=tuple(parsed.evidence or rule_insight.evidence),
|
evidence=tuple(parsed.evidence or rule_insight.evidence),
|
||||||
@@ -464,8 +529,49 @@ def _normalize_llm_document_field_key(key: str, label: str) -> str:
|
|||||||
token in compact_label for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
|
token in compact_label for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
|
||||||
):
|
):
|
||||||
return "amount"
|
return "amount"
|
||||||
if compact_key in {"date", "time", "issued_at", "invoice_date"} or any(
|
if compact_key in {
|
||||||
token in compact_label for token in ("日期", "时间", "开票日期", "发生时间")
|
"travel_date",
|
||||||
|
"trip_date",
|
||||||
|
"journey_date",
|
||||||
|
"departure_date",
|
||||||
|
"departure_time",
|
||||||
|
"depart_date",
|
||||||
|
"depart_time",
|
||||||
|
"boarding_date",
|
||||||
|
"boarding_time",
|
||||||
|
"train_date",
|
||||||
|
"train_time",
|
||||||
|
"train_departure_time",
|
||||||
|
"scheduled_departure_time",
|
||||||
|
"flight_date",
|
||||||
|
"flight_time",
|
||||||
|
"ride_date",
|
||||||
|
"ride_time",
|
||||||
|
"pickup_time",
|
||||||
|
"start_time",
|
||||||
|
} or any(
|
||||||
|
token in compact_label
|
||||||
|
for token in (
|
||||||
|
"行程日期",
|
||||||
|
"出发日期",
|
||||||
|
"出发时间",
|
||||||
|
"列车出发时间",
|
||||||
|
"发车日期",
|
||||||
|
"发车时间",
|
||||||
|
"开车时间",
|
||||||
|
"乘车日期",
|
||||||
|
"乘车时间",
|
||||||
|
"起飞日期",
|
||||||
|
"航班日期",
|
||||||
|
"上车时间",
|
||||||
|
"用车时间",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return "trip_date"
|
||||||
|
if compact_key in {"issued_at", "issue_date", "invoice_date"} or "开票日期" in compact_label:
|
||||||
|
return "invoice_date"
|
||||||
|
if compact_key in {"date", "time"} or any(
|
||||||
|
token in compact_label for token in ("日期", "时间", "发生时间")
|
||||||
):
|
):
|
||||||
return "date"
|
return "date"
|
||||||
if compact_key in {"merchant_name", "merchant", "seller_name", "vendor_name"} or any(
|
if compact_key in {"merchant_name", "merchant", "seller_name", "vendor_name"} or any(
|
||||||
@@ -504,7 +610,7 @@ def _normalize_llm_document_field_value(key: str, value: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
text_value = format(candidate.quantize(Decimal("0.01")), "f").rstrip("0").rstrip(".")
|
text_value = format(candidate.quantize(Decimal("0.01")), "f").rstrip("0").rstrip(".")
|
||||||
return f"{text_value}元"
|
return f"{text_value}元"
|
||||||
if key == "date":
|
if key in {"date", "time", "invoice_date", "trip_date"}:
|
||||||
return _extract_date(raw_value) or _clean_field_value(raw_value)
|
return _extract_date(raw_value) or _clean_field_value(raw_value)
|
||||||
if key == "route":
|
if key == "route":
|
||||||
return _extract_route(raw_value) or _clean_field_value(
|
return _extract_route(raw_value) or _clean_field_value(
|
||||||
@@ -517,6 +623,8 @@ def _llm_document_field_label(key: str) -> str:
|
|||||||
return {
|
return {
|
||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
"date": "日期",
|
"date": "日期",
|
||||||
|
"invoice_date": "开票日期",
|
||||||
|
"trip_date": "行程日期",
|
||||||
"merchant_name": "商户",
|
"merchant_name": "商户",
|
||||||
"invoice_number": "票据号码",
|
"invoice_number": "票据号码",
|
||||||
"invoice_code": "发票代码",
|
"invoice_code": "发票代码",
|
||||||
@@ -525,6 +633,35 @@ def _llm_document_field_label(key: str) -> str:
|
|||||||
}.get(key, key)
|
}.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_document_type_field_labels(
|
||||||
|
fields: tuple[DocumentField, ...],
|
||||||
|
document_type: str,
|
||||||
|
) -> tuple[DocumentField, ...]:
|
||||||
|
date_label = TRIP_DATE_LABEL_BY_DOCUMENT_TYPE.get(
|
||||||
|
str(document_type or "").strip().lower()
|
||||||
|
)
|
||||||
|
if not date_label:
|
||||||
|
return fields
|
||||||
|
|
||||||
|
adjusted: list[DocumentField] = []
|
||||||
|
for field in fields:
|
||||||
|
compact_key = str(field.key or "").strip().lower()
|
||||||
|
compact_label = str(field.label or "").replace(" ", "")
|
||||||
|
if compact_key in {"issued_at", "issue_date", "invoice_date"} or any(
|
||||||
|
token in compact_label for token in ("开票日期", "发票日期")
|
||||||
|
):
|
||||||
|
adjusted.append(field)
|
||||||
|
continue
|
||||||
|
is_date_field = compact_key in DATE_FIELD_KEYS or any(
|
||||||
|
token in compact_label for token in TRIP_DATE_FIELD_LABEL_TOKENS
|
||||||
|
)
|
||||||
|
if is_date_field:
|
||||||
|
adjusted.append(DocumentField(key=field.key, label=date_label, value=field.value))
|
||||||
|
continue
|
||||||
|
adjusted.append(field)
|
||||||
|
return tuple(adjusted)
|
||||||
|
|
||||||
|
|
||||||
def _merge_document_fields(
|
def _merge_document_fields(
|
||||||
base_fields: tuple[DocumentField, ...],
|
base_fields: tuple[DocumentField, ...],
|
||||||
override_fields: tuple[DocumentField, ...],
|
override_fields: tuple[DocumentField, ...],
|
||||||
@@ -540,13 +677,13 @@ def _merge_document_fields(
|
|||||||
return tuple(merged[key] for key in order if key in merged)
|
return tuple(merged[key] for key in order if key in merged)
|
||||||
|
|
||||||
|
|
||||||
def _extract_document_fields(text: str) -> list[DocumentField]:
|
def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
|
||||||
fields: list[DocumentField] = []
|
fields: list[DocumentField] = []
|
||||||
amount = _extract_amount(text)
|
amount = _extract_amount(text)
|
||||||
if amount:
|
if amount:
|
||||||
fields.append(DocumentField(key="amount", label="金额", value=amount))
|
fields.append(DocumentField(key="amount", label="金额", value=amount))
|
||||||
|
|
||||||
date_value = _extract_date(text)
|
date_value = _extract_date(text, document_type=document_type)
|
||||||
if date_value:
|
if date_value:
|
||||||
fields.append(DocumentField(key="date", label="日期", value=date_value))
|
fields.append(DocumentField(key="date", label="日期", value=date_value))
|
||||||
|
|
||||||
@@ -584,6 +721,8 @@ def _extract_amount(text: str) -> str:
|
|||||||
continue
|
continue
|
||||||
if candidate <= Decimal("0.00"):
|
if candidate <= Decimal("0.00"):
|
||||||
continue
|
continue
|
||||||
|
if _is_amount_match_date_fragment(candidate, text, match.start(1), match.end(1)):
|
||||||
|
continue
|
||||||
if best_value is None or candidate > best_value:
|
if best_value is None or candidate > best_value:
|
||||||
best_value = candidate
|
best_value = candidate
|
||||||
|
|
||||||
@@ -594,10 +733,49 @@ def _extract_amount(text: str) -> str:
|
|||||||
return f"{text_value}元"
|
return f"{text_value}元"
|
||||||
|
|
||||||
|
|
||||||
def _extract_date(text: str) -> str:
|
def _is_amount_match_date_fragment(amount: Decimal, text: str, start: int, end: int) -> bool:
|
||||||
match = DATE_PATTERN.search(text)
|
if start < 0 or end < 0:
|
||||||
if not match:
|
return False
|
||||||
|
normalized = amount.quantize(Decimal("0.01"))
|
||||||
|
if normalized != normalized.to_integral_value() or normalized < Decimal("1900") or normalized > Decimal("2099"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
before = str(text or "")[max(0, start - 8):start]
|
||||||
|
after = str(text or "")[end:end + 10]
|
||||||
|
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
|
||||||
|
return True
|
||||||
|
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_date(text: str, *, document_type: str = "") -> str:
|
||||||
|
matches = list(DATE_PATTERN.finditer(text))
|
||||||
|
if not matches:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
normalized_type = str(document_type or "").strip().lower()
|
||||||
|
if normalized_type in TRIP_DATE_LABEL_BY_DOCUMENT_TYPE:
|
||||||
|
candidates: list[tuple[int, int, bool, str]] = []
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
value = _format_date_match_with_time(text, match)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
invoice_context = _is_invoice_date_context(text, match)
|
||||||
|
score = _score_trip_date_context(text, match, value, invoice_context)
|
||||||
|
candidates.append((score, index, invoice_context, value))
|
||||||
|
|
||||||
|
non_invoice_candidates = [candidate for candidate in candidates if not candidate[2]]
|
||||||
|
if non_invoice_candidates:
|
||||||
|
return max(non_invoice_candidates, key=lambda candidate: (candidate[0], -candidate[1]))[3]
|
||||||
|
if candidates:
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return _format_date_match_with_time(text, matches[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date_match_with_time(text: str, match: re.Match[str]) -> str:
|
||||||
raw_value = str(match.group(1) or "").strip()
|
raw_value = str(match.group(1) or "").strip()
|
||||||
normalized = raw_value.replace("年", "-").replace("月", "-").replace("日", "")
|
normalized = raw_value.replace("年", "-").replace("月", "-").replace("日", "")
|
||||||
normalized = normalized.replace("/", "-").replace(".", "-")
|
normalized = normalized.replace("/", "-").replace(".", "-")
|
||||||
@@ -605,7 +783,60 @@ def _extract_date(text: str) -> str:
|
|||||||
if len(parts) != 3:
|
if len(parts) != 3:
|
||||||
return raw_value
|
return raw_value
|
||||||
year, month, day = parts
|
year, month, day = parts
|
||||||
return f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
|
date_value = f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
|
||||||
|
surrounding = str(text or "")[max(0, match.start() - 18): match.end() + 24]
|
||||||
|
time_match = TIME_PATTERN.search(surrounding)
|
||||||
|
if time_match:
|
||||||
|
hour = str(time_match.group(1) or "").zfill(2)
|
||||||
|
minute = str(time_match.group(2) or "").zfill(2)
|
||||||
|
return f"{date_value} {hour}:{minute}"
|
||||||
|
return date_value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_invoice_date_context(text: str, match: re.Match[str]) -> bool:
|
||||||
|
window = str(text or "")[max(0, match.start() - 12): match.end() + 8]
|
||||||
|
compact = window.replace(" ", "")
|
||||||
|
return any(token in compact for token in ("开票日期", "发票日期", "开票时间", "开票"))
|
||||||
|
|
||||||
|
|
||||||
|
def _score_trip_date_context(
|
||||||
|
text: str,
|
||||||
|
match: re.Match[str],
|
||||||
|
value: str,
|
||||||
|
invoice_context: bool,
|
||||||
|
) -> int:
|
||||||
|
window = str(text or "")[max(0, match.start() - 32): match.end() + 32]
|
||||||
|
compact = window.replace(" ", "")
|
||||||
|
score = -20 if invoice_context else 0
|
||||||
|
if ":" in value or ":" in value:
|
||||||
|
score += 8
|
||||||
|
if any(
|
||||||
|
token in compact
|
||||||
|
for token in (
|
||||||
|
"行程日期",
|
||||||
|
"出发日期",
|
||||||
|
"出发时间",
|
||||||
|
"列车出发时间",
|
||||||
|
"发车日期",
|
||||||
|
"发车时间",
|
||||||
|
"开车时间",
|
||||||
|
"乘车日期",
|
||||||
|
"乘车时间",
|
||||||
|
"起飞日期",
|
||||||
|
"起飞时间",
|
||||||
|
"航班日期",
|
||||||
|
"上车时间",
|
||||||
|
"用车时间",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
score += 6
|
||||||
|
if any(token in compact for token in ("车次", "检票", "二等座", "一等座", "商务座", "软卧", "硬卧")):
|
||||||
|
score += 3
|
||||||
|
if re.search(r"[A-Z]\d{1,4}", compact):
|
||||||
|
score += 2
|
||||||
|
if re.search(r"[\u4e00-\u9fa5A-Za-z0-9()()·]{2,20}(?:至|到|→|->|—|–|-)[\u4e00-\u9fa5A-Za-z0-9()()·]{2,20}", compact):
|
||||||
|
score += 2
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
def _extract_merchant(text: str) -> str:
|
def _extract_merchant(text: str) -> str:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -20,6 +21,9 @@ from app.repositories.employee import EmployeeRepository
|
|||||||
from app.schemas.employee import (
|
from app.schemas.employee import (
|
||||||
EmployeeCreate,
|
EmployeeCreate,
|
||||||
EmployeeHistoryRead,
|
EmployeeHistoryRead,
|
||||||
|
EmployeeImportErrorRead,
|
||||||
|
EmployeeImportResultRead,
|
||||||
|
EmployeeImportSummaryRead,
|
||||||
EmployeeMetaRead,
|
EmployeeMetaRead,
|
||||||
EmployeeOrganizationRead,
|
EmployeeOrganizationRead,
|
||||||
EmployeeRead,
|
EmployeeRead,
|
||||||
@@ -27,8 +31,16 @@ from app.schemas.employee import (
|
|||||||
EmployeeStatusSummaryRead,
|
EmployeeStatusSummaryRead,
|
||||||
EmployeeUpdate,
|
EmployeeUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services.employee_spreadsheet import (
|
||||||
|
EmployeeImportRow,
|
||||||
|
EmployeeSpreadsheetError,
|
||||||
|
build_export_workbook_bytes,
|
||||||
|
build_import_template_bytes,
|
||||||
|
parse_employee_workbook,
|
||||||
|
)
|
||||||
from app.services.employee_seed import (
|
from app.services.employee_seed import (
|
||||||
EMPLOYEE_DEFINITIONS,
|
EMPLOYEE_DEFINITIONS,
|
||||||
|
EMPLOYEE_PROFILE_REPAIRS,
|
||||||
ORGANIZATION_DEFINITIONS,
|
ORGANIZATION_DEFINITIONS,
|
||||||
ROLE_DEFINITIONS,
|
ROLE_DEFINITIONS,
|
||||||
ROLE_DISPLAY_ORDER,
|
ROLE_DISPLAY_ORDER,
|
||||||
@@ -37,6 +49,8 @@ from app.services.employee_seed import (
|
|||||||
|
|
||||||
logger = get_logger("app.services.employee")
|
logger = get_logger("app.services.employee")
|
||||||
DEFAULT_EMPLOYEE_PASSWORD = "123456"
|
DEFAULT_EMPLOYEE_PASSWORD = "123456"
|
||||||
|
MAX_EMPLOYEE_CHANGE_LOGS = 5
|
||||||
|
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
STATUS_TONE_MAP = {
|
STATUS_TONE_MAP = {
|
||||||
"在职": "success",
|
"在职": "success",
|
||||||
@@ -57,7 +71,9 @@ def prepare_employee_directory() -> None:
|
|||||||
|
|
||||||
session_factory = get_session_factory()
|
session_factory = get_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
EmployeeService(db).ensure_directory_ready()
|
service = EmployeeService(db)
|
||||||
|
service.ensure_directory_ready()
|
||||||
|
service.apply_profile_repairs()
|
||||||
|
|
||||||
|
|
||||||
class EmployeeService:
|
class EmployeeService:
|
||||||
@@ -120,10 +136,27 @@ class EmployeeService:
|
|||||||
for role in self._sorted_roles(self.repository.list_roles())
|
for role in self._sorted_roles(self.repository.list_roles())
|
||||||
]
|
]
|
||||||
|
|
||||||
|
organization_options = [
|
||||||
|
EmployeeOrganizationRead(
|
||||||
|
id=unit.id,
|
||||||
|
code=unit.unit_code,
|
||||||
|
name=unit.name,
|
||||||
|
unitType=unit.unit_type,
|
||||||
|
costCenter=unit.cost_center,
|
||||||
|
location=unit.location,
|
||||||
|
managerName=unit.manager_name,
|
||||||
|
)
|
||||||
|
for unit in sorted(
|
||||||
|
self.repository.list_organization_units(),
|
||||||
|
key=lambda item: item.name,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
return EmployeeMetaRead(
|
return EmployeeMetaRead(
|
||||||
totalEmployees=len(employees),
|
totalEmployees=len(employees),
|
||||||
statusSummary=status_summary,
|
statusSummary=status_summary,
|
||||||
roleOptions=role_options,
|
roleOptions=role_options,
|
||||||
|
organizationOptions=organization_options,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
|
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
|
||||||
@@ -152,7 +185,7 @@ class EmployeeService:
|
|||||||
sync_state=payload.sync_state,
|
sync_state=payload.sync_state,
|
||||||
spotlight=payload.spotlight,
|
spotlight=payload.spotlight,
|
||||||
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||||
last_sync_at=datetime.now(),
|
last_sync_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload.organization_unit_code:
|
if payload.organization_unit_code:
|
||||||
@@ -261,6 +294,43 @@ class EmployeeService:
|
|||||||
employee.finance_owner_name = finance_owner_name
|
employee.finance_owner_name = finance_owner_name
|
||||||
changed_fields.append("财务归口")
|
changed_fields.append("财务归口")
|
||||||
|
|
||||||
|
if "organization_unit_code" in payload.model_fields_set:
|
||||||
|
organization_code = self._normalize_optional_text(payload.organization_unit_code)
|
||||||
|
current_code = (
|
||||||
|
employee.organization_unit.unit_code if employee.organization_unit else None
|
||||||
|
)
|
||||||
|
if organization_code != current_code:
|
||||||
|
if organization_code:
|
||||||
|
organization = self.repository.get_organization_by_code(organization_code)
|
||||||
|
if organization is None:
|
||||||
|
raise ValueError(f"部门编码 {organization_code} 不存在")
|
||||||
|
employee.organization_unit = organization
|
||||||
|
else:
|
||||||
|
employee.organization_unit = None
|
||||||
|
changed_fields.append("所属部门")
|
||||||
|
|
||||||
|
if "manager_employee_no" in payload.model_fields_set:
|
||||||
|
manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
|
||||||
|
current_manager_no = employee.manager.employee_no if employee.manager else None
|
||||||
|
|
||||||
|
if manager_employee_no:
|
||||||
|
if manager_employee_no == employee.employee_no:
|
||||||
|
raise ValueError("直属上级不能是员工本人")
|
||||||
|
|
||||||
|
manager = self.repository.get_by_employee_no(manager_employee_no)
|
||||||
|
if manager is None:
|
||||||
|
raise ValueError(f"直属上级工号 {manager_employee_no} 不存在")
|
||||||
|
|
||||||
|
if manager_employee_no != current_manager_no:
|
||||||
|
employee.manager = manager
|
||||||
|
changed_fields.append("直属上级")
|
||||||
|
elif current_manager_no is not None:
|
||||||
|
employee.manager = None
|
||||||
|
changed_fields.append("直属上级")
|
||||||
|
|
||||||
|
role_changed = False
|
||||||
|
sorted_roles: list[Role] = []
|
||||||
|
|
||||||
if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
|
if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
|
||||||
requested_codes = list(dict.fromkeys(payload.role_codes))
|
requested_codes = list(dict.fromkeys(payload.role_codes))
|
||||||
roles: list[Role] = []
|
roles: list[Role] = []
|
||||||
@@ -280,7 +350,7 @@ class EmployeeService:
|
|||||||
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
|
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
|
||||||
if next_role_codes != current_role_codes:
|
if next_role_codes != current_role_codes:
|
||||||
employee.roles = sorted_roles
|
employee.roles = sorted_roles
|
||||||
changed_fields.append("系统角色")
|
role_changed = True
|
||||||
|
|
||||||
if "password" in payload.model_fields_set and payload.password:
|
if "password" in payload.model_fields_set and payload.password:
|
||||||
password = payload.password.strip()
|
password = payload.password.strip()
|
||||||
@@ -289,10 +359,10 @@ class EmployeeService:
|
|||||||
employee.password_hash = hash_password(password)
|
employee.password_hash = hash_password(password)
|
||||||
password_changed = True
|
password_changed = True
|
||||||
|
|
||||||
if not changed_fields and not password_changed:
|
if not changed_fields and not password_changed and not role_changed:
|
||||||
return self._serialize_employee(employee)
|
return self._serialize_employee(employee)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now(UTC)
|
||||||
employee.last_sync_at = now
|
employee.last_sync_at = now
|
||||||
employee.sync_state = "已同步"
|
employee.sync_state = "已同步"
|
||||||
|
|
||||||
@@ -303,13 +373,25 @@ class EmployeeService:
|
|||||||
occurred_at=now,
|
occurred_at=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if role_changed:
|
||||||
|
role_labels = "、".join(role.name for role in sorted_roles)
|
||||||
|
self._append_change_log(
|
||||||
|
employee,
|
||||||
|
action=f"更新系统角色({role_labels})",
|
||||||
|
occurred_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
if password_changed:
|
if password_changed:
|
||||||
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
|
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
|
||||||
|
|
||||||
saved = self.repository.save(employee)
|
hydrated = self._save_employee_and_reload(employee)
|
||||||
hydrated = self.repository.get(saved.id)
|
logger.info(
|
||||||
logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields))
|
"Updated employee id=%s fields=%s role_changed=%s",
|
||||||
return self._serialize_employee(hydrated or saved)
|
employee.id,
|
||||||
|
",".join(changed_fields),
|
||||||
|
role_changed,
|
||||||
|
)
|
||||||
|
return self._serialize_employee(hydrated)
|
||||||
|
|
||||||
def disable_employee(self, employee_id: str) -> EmployeeRead:
|
def disable_employee(self, employee_id: str) -> EmployeeRead:
|
||||||
self.ensure_directory_ready()
|
self.ensure_directory_ready()
|
||||||
@@ -321,17 +403,16 @@ class EmployeeService:
|
|||||||
if employee.employment_status == "停用":
|
if employee.employment_status == "停用":
|
||||||
return self._serialize_employee(employee)
|
return self._serialize_employee(employee)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now(UTC)
|
||||||
employee.employment_status = "停用"
|
employee.employment_status = "停用"
|
||||||
employee.sync_state = "已同步"
|
employee.sync_state = "已同步"
|
||||||
employee.last_sync_at = now
|
employee.last_sync_at = now
|
||||||
employee.spotlight = False
|
employee.spotlight = False
|
||||||
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
|
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
|
||||||
|
|
||||||
saved = self.repository.save(employee)
|
hydrated = self._save_employee_and_reload(employee)
|
||||||
hydrated = self.repository.get(saved.id)
|
|
||||||
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
|
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
|
||||||
return self._serialize_employee(hydrated or saved)
|
return self._serialize_employee(hydrated)
|
||||||
|
|
||||||
def enable_employee(self, employee_id: str) -> EmployeeRead:
|
def enable_employee(self, employee_id: str) -> EmployeeRead:
|
||||||
self.ensure_directory_ready()
|
self.ensure_directory_ready()
|
||||||
@@ -343,16 +424,305 @@ class EmployeeService:
|
|||||||
if employee.employment_status != "停用":
|
if employee.employment_status != "停用":
|
||||||
return self._serialize_employee(employee)
|
return self._serialize_employee(employee)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now(UTC)
|
||||||
employee.employment_status = "在职"
|
employee.employment_status = "在职"
|
||||||
employee.sync_state = "已同步"
|
employee.sync_state = "已同步"
|
||||||
employee.last_sync_at = now
|
employee.last_sync_at = now
|
||||||
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
|
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
|
||||||
|
|
||||||
saved = self.repository.save(employee)
|
hydrated = self._save_employee_and_reload(employee)
|
||||||
hydrated = self.repository.get(saved.id)
|
|
||||||
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
|
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
|
||||||
return self._serialize_employee(hydrated or saved)
|
return self._serialize_employee(hydrated)
|
||||||
|
|
||||||
|
def build_import_template(self) -> bytes:
|
||||||
|
self.ensure_directory_ready()
|
||||||
|
return build_import_template_bytes()
|
||||||
|
|
||||||
|
def export_employees(self, status: str | None = None, keyword: str | None = None) -> bytes:
|
||||||
|
self.ensure_directory_ready()
|
||||||
|
employees = self.repository.list(status=status, keyword=keyword)
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
|
||||||
|
for employee in employees:
|
||||||
|
organization = employee.organization_unit
|
||||||
|
role_codes = ",".join(role.role_code for role in self._sorted_roles(list(employee.roles)))
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
employee.employee_no,
|
||||||
|
employee.name,
|
||||||
|
employee.email,
|
||||||
|
employee.gender or "",
|
||||||
|
self._format_date(employee.birth_date) or "",
|
||||||
|
employee.phone or "",
|
||||||
|
self._format_date(employee.join_date) or "",
|
||||||
|
employee.location or "",
|
||||||
|
employee.position,
|
||||||
|
employee.grade,
|
||||||
|
organization.unit_code if organization else "",
|
||||||
|
employee.manager.employee_no if employee.manager else "",
|
||||||
|
employee.finance_owner_name or "",
|
||||||
|
employee.cost_center or "",
|
||||||
|
employee.employment_status,
|
||||||
|
role_codes,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_export_workbook_bytes(rows)
|
||||||
|
|
||||||
|
def import_employees(self, content: bytes, actor: str = "系统管理员") -> EmployeeImportResultRead:
|
||||||
|
self.ensure_directory_ready()
|
||||||
|
parsed_rows, parse_errors = parse_employee_workbook(content)
|
||||||
|
if parse_errors:
|
||||||
|
return self._build_import_failure(parse_errors, total_rows=len(parsed_rows))
|
||||||
|
|
||||||
|
validation_errors = self._validate_import_rows(parsed_rows)
|
||||||
|
if validation_errors:
|
||||||
|
return self._build_import_failure(validation_errors, total_rows=len(parsed_rows))
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = self._apply_import_rows(parsed_rows, actor=actor)
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.exception("Employee import failed during database write")
|
||||||
|
raise
|
||||||
|
|
||||||
|
imported_at = self._format_datetime(datetime.now(UTC)) or ""
|
||||||
|
message = f"导入成功:新增 {summary['created']} 人,更新 {summary['updated']} 人。"
|
||||||
|
logger.info(
|
||||||
|
"Imported employees created=%d updated=%d total=%d",
|
||||||
|
summary["created"],
|
||||||
|
summary["updated"],
|
||||||
|
len(parsed_rows),
|
||||||
|
)
|
||||||
|
return EmployeeImportResultRead(
|
||||||
|
success=True,
|
||||||
|
message=message,
|
||||||
|
summary=EmployeeImportSummaryRead(
|
||||||
|
totalRows=len(parsed_rows),
|
||||||
|
created=summary["created"],
|
||||||
|
updated=summary["updated"],
|
||||||
|
errorCount=0,
|
||||||
|
),
|
||||||
|
errors=[],
|
||||||
|
importedAt=imported_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_import_rows(
|
||||||
|
self, rows: list[EmployeeImportRow]
|
||||||
|
) -> list[EmployeeSpreadsheetError]:
|
||||||
|
errors: list[EmployeeSpreadsheetError] = []
|
||||||
|
employee_nos_in_file: dict[str, int] = {}
|
||||||
|
emails_in_file: dict[str, int] = {}
|
||||||
|
|
||||||
|
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||||
|
organizations_by_code = {
|
||||||
|
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||||
|
}
|
||||||
|
employees_by_no = {
|
||||||
|
employee.employee_no: employee for employee in self.repository.list()
|
||||||
|
}
|
||||||
|
import_employee_nos = {row.employee_no for row in rows}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.employee_no in employee_nos_in_file:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="员工编号*",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"员工编号 {row.employee_no} 在文件中重复。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
employee_nos_in_file[row.employee_no] = row.row_number
|
||||||
|
|
||||||
|
if row.email in emails_in_file:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="邮箱*",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"邮箱 {row.email} 在文件中重复。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
emails_in_file[row.email] = row.row_number
|
||||||
|
|
||||||
|
existing_by_email = self.repository.get_by_email(row.email)
|
||||||
|
if existing_by_email is not None and existing_by_email.employee_no != row.employee_no:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="邮箱*",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=(
|
||||||
|
f"邮箱 {row.email} 已被员工 "
|
||||||
|
f"{existing_by_email.employee_no} 使用。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="部门编码",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"部门编码 {row.organization_unit_code} 不存在。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if row.manager_employee_no:
|
||||||
|
manager_exists = (
|
||||||
|
row.manager_employee_no in employees_by_no
|
||||||
|
or row.manager_employee_no in import_employee_nos
|
||||||
|
)
|
||||||
|
if not manager_exists:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="直属上级工号",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"直属上级工号 {row.manager_employee_no} 不存在。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row.manager_employee_no == row.employee_no:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="直属上级工号",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message="直属上级不能是员工本人。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid_role_codes = [
|
||||||
|
code for code in row.role_codes if code not in roles_by_code
|
||||||
|
]
|
||||||
|
if invalid_role_codes:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="角色编码",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"角色不存在:{'、'.join(invalid_role_codes)}。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def _apply_import_rows(
|
||||||
|
self,
|
||||||
|
rows: list[EmployeeImportRow],
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||||
|
organizations_by_code = {
|
||||||
|
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||||
|
}
|
||||||
|
employees_by_no = {
|
||||||
|
employee.employee_no: employee for employee in self.repository.list()
|
||||||
|
}
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for row in rows:
|
||||||
|
employee = employees_by_no.get(row.employee_no)
|
||||||
|
is_new = employee is None
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
name=row.name,
|
||||||
|
email=row.email,
|
||||||
|
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||||
|
)
|
||||||
|
self.db.add(employee)
|
||||||
|
employees_by_no[row.employee_no] = employee
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
employee.name = row.name
|
||||||
|
employee.email = row.email
|
||||||
|
employee.gender = row.gender
|
||||||
|
employee.birth_date = row.birth_date
|
||||||
|
employee.phone = row.phone
|
||||||
|
employee.join_date = row.join_date
|
||||||
|
employee.location = row.location
|
||||||
|
employee.position = row.position
|
||||||
|
employee.grade = row.grade
|
||||||
|
employee.finance_owner_name = row.finance_owner_name
|
||||||
|
employee.cost_center = row.cost_center
|
||||||
|
employee.employment_status = row.employment_status
|
||||||
|
employee.sync_state = "已同步"
|
||||||
|
employee.last_sync_at = now
|
||||||
|
|
||||||
|
if row.organization_unit_code:
|
||||||
|
employee.organization_unit = organizations_by_code[row.organization_unit_code]
|
||||||
|
else:
|
||||||
|
employee.organization_unit = None
|
||||||
|
|
||||||
|
employee.roles = self._sorted_roles(
|
||||||
|
[roles_by_code[code] for code in row.role_codes if code in roles_by_code]
|
||||||
|
)
|
||||||
|
|
||||||
|
action = (
|
||||||
|
"通过 Excel 导入新建员工档案"
|
||||||
|
if is_new
|
||||||
|
else "通过 Excel 导入更新员工档案"
|
||||||
|
)
|
||||||
|
self._append_change_log(employee, action=action, owner=actor, occurred_at=now)
|
||||||
|
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
employee = employees_by_no[row.employee_no]
|
||||||
|
if row.manager_employee_no:
|
||||||
|
employee.manager = employees_by_no.get(row.manager_employee_no)
|
||||||
|
else:
|
||||||
|
employee.manager = None
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return {"created": created, "updated": updated}
|
||||||
|
|
||||||
|
def _build_import_failure(
|
||||||
|
self,
|
||||||
|
errors: list[EmployeeSpreadsheetError],
|
||||||
|
*,
|
||||||
|
total_rows: int,
|
||||||
|
) -> EmployeeImportResultRead:
|
||||||
|
error_reads = [
|
||||||
|
EmployeeImportErrorRead(
|
||||||
|
row=item.row,
|
||||||
|
column=item.column,
|
||||||
|
employeeNo=item.employee_no,
|
||||||
|
message=item.message,
|
||||||
|
)
|
||||||
|
for item in errors
|
||||||
|
]
|
||||||
|
return EmployeeImportResultRead(
|
||||||
|
success=False,
|
||||||
|
message=(
|
||||||
|
f"导入未执行:共发现 {len(error_reads)} 处错误,请修正后重新导入。"
|
||||||
|
"原有员工数据未变更。"
|
||||||
|
),
|
||||||
|
summary=EmployeeImportSummaryRead(
|
||||||
|
totalRows=total_rows,
|
||||||
|
created=0,
|
||||||
|
updated=0,
|
||||||
|
errorCount=len(error_reads),
|
||||||
|
),
|
||||||
|
errors=error_reads,
|
||||||
|
importedAt=None,
|
||||||
|
)
|
||||||
|
|
||||||
def _seed_roles(self) -> None:
|
def _seed_roles(self) -> None:
|
||||||
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||||
@@ -471,6 +841,69 @@ class EmployeeService:
|
|||||||
|
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
|
def apply_profile_repairs(self) -> None:
|
||||||
|
"""Apply one-off demo profile repairs. Intended for startup/bootstrap only."""
|
||||||
|
try:
|
||||||
|
self._repair_employee_profiles()
|
||||||
|
self._trim_all_employee_change_logs()
|
||||||
|
self.db.commit()
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.exception("Failed to apply employee profile repairs")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _repair_employee_profiles(self) -> None:
|
||||||
|
if not EMPLOYEE_PROFILE_REPAIRS:
|
||||||
|
return
|
||||||
|
|
||||||
|
employees = self.repository.list()
|
||||||
|
employees_by_email = {employee.email.lower(): employee for employee in employees if employee.email}
|
||||||
|
employees_by_no = {employee.employee_no: employee for employee in employees if employee.employee_no}
|
||||||
|
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||||
|
organizations_by_code = {
|
||||||
|
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||||
|
}
|
||||||
|
|
||||||
|
for definition in EMPLOYEE_PROFILE_REPAIRS:
|
||||||
|
email = str(definition.get("email") or "").strip().lower()
|
||||||
|
employee_no = str(definition.get("employee_no") or "").strip()
|
||||||
|
employee = employees_by_email.get(email) or employees_by_no.get(employee_no)
|
||||||
|
if employee is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for field_name in (
|
||||||
|
"position",
|
||||||
|
"grade",
|
||||||
|
"location",
|
||||||
|
"cost_center",
|
||||||
|
"finance_owner_name",
|
||||||
|
"employment_status",
|
||||||
|
"sync_state",
|
||||||
|
):
|
||||||
|
value = definition.get(field_name)
|
||||||
|
if value:
|
||||||
|
setattr(employee, field_name, value)
|
||||||
|
|
||||||
|
organization_code = definition.get("organization_unit_code")
|
||||||
|
if organization_code:
|
||||||
|
employee.organization_unit = organizations_by_code.get(organization_code)
|
||||||
|
|
||||||
|
manager_employee_no = definition.get("manager_employee_no")
|
||||||
|
if manager_employee_no:
|
||||||
|
employee.manager = employees_by_no.get(manager_employee_no)
|
||||||
|
|
||||||
|
if not employee.password_hash:
|
||||||
|
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
|
||||||
|
|
||||||
|
role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code]
|
||||||
|
if role_codes:
|
||||||
|
merged_roles = {role.role_code: role for role in employee.roles}
|
||||||
|
for role_code in role_codes:
|
||||||
|
merged_roles[role_code] = roles_by_code[role_code]
|
||||||
|
employee.roles = self._sorted_roles(list(merged_roles.values()))
|
||||||
|
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
def _prune_extra_seed_employees(self) -> None:
|
def _prune_extra_seed_employees(self) -> None:
|
||||||
if not EXTRA_SEED_EMPLOYEE_NOS:
|
if not EXTRA_SEED_EMPLOYEE_NOS:
|
||||||
return
|
return
|
||||||
@@ -530,6 +963,12 @@ class EmployeeService:
|
|||||||
)
|
)
|
||||||
existing_keys.add(identity)
|
existing_keys.add(identity)
|
||||||
|
|
||||||
|
def _save_employee_and_reload(self, employee: Employee) -> Employee:
|
||||||
|
saved = self.repository.save(employee)
|
||||||
|
self._trim_employee_change_logs(saved.id)
|
||||||
|
self.db.commit()
|
||||||
|
return self.repository.get(saved.id) or saved
|
||||||
|
|
||||||
def _append_change_log(
|
def _append_change_log(
|
||||||
self,
|
self,
|
||||||
employee: Employee,
|
employee: Employee,
|
||||||
@@ -542,10 +981,30 @@ class EmployeeService:
|
|||||||
employee=employee,
|
employee=employee,
|
||||||
action=action,
|
action=action,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
occurred_at=occurred_at or datetime.now(),
|
occurred_at=occurred_at or datetime.now(UTC),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _trim_all_employee_change_logs(self) -> None:
|
||||||
|
for employee in self.repository.list():
|
||||||
|
self._trim_employee_change_logs(employee.id)
|
||||||
|
|
||||||
|
def _sorted_change_logs(self, employee: Employee) -> list[EmployeeChangeLog]:
|
||||||
|
return sorted(employee.change_logs, key=lambda item: item.occurred_at, reverse=True)
|
||||||
|
|
||||||
|
def _trim_employee_change_logs(self, employee_id: str) -> None:
|
||||||
|
stmt = (
|
||||||
|
select(EmployeeChangeLog)
|
||||||
|
.where(EmployeeChangeLog.employee_id == employee_id)
|
||||||
|
.order_by(EmployeeChangeLog.occurred_at.desc())
|
||||||
|
)
|
||||||
|
logs = list(self.db.execute(stmt).scalars().all())
|
||||||
|
if len(logs) <= MAX_EMPLOYEE_CHANGE_LOGS:
|
||||||
|
return
|
||||||
|
|
||||||
|
for stale in logs[MAX_EMPLOYEE_CHANGE_LOGS:]:
|
||||||
|
self.db.delete(stale)
|
||||||
|
|
||||||
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
|
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
|
||||||
organization = employee.organization_unit
|
organization = employee.organization_unit
|
||||||
roles = self._sorted_roles(list(employee.roles))
|
roles = self._sorted_roles(list(employee.roles))
|
||||||
@@ -556,10 +1015,10 @@ class EmployeeService:
|
|||||||
EmployeeHistoryRead(
|
EmployeeHistoryRead(
|
||||||
action=item.action,
|
action=item.action,
|
||||||
owner=item.owner,
|
owner=item.owner,
|
||||||
time=self._format_datetime(item.occurred_at) or "",
|
time=self._format_history_datetime(item.occurred_at),
|
||||||
occurredAt=self._format_datetime(item.occurred_at) or "",
|
occurredAt=self._format_history_datetime(item.occurred_at),
|
||||||
)
|
)
|
||||||
for item in employee.change_logs
|
for item in self._sorted_change_logs(employee)[:MAX_EMPLOYEE_CHANGE_LOGS]
|
||||||
]
|
]
|
||||||
|
|
||||||
return EmployeeRead(
|
return EmployeeRead(
|
||||||
@@ -571,6 +1030,7 @@ class EmployeeService:
|
|||||||
position=employee.position,
|
position=employee.position,
|
||||||
grade=employee.grade,
|
grade=employee.grade,
|
||||||
manager=employee.manager.name if employee.manager else "CEO",
|
manager=employee.manager.name if employee.manager else "CEO",
|
||||||
|
managerEmployeeNo=employee.manager.employee_no if employee.manager else None,
|
||||||
financeOwner=employee.finance_owner_name or "",
|
financeOwner=employee.finance_owner_name or "",
|
||||||
roles=role_labels,
|
roles=role_labels,
|
||||||
roleCodes=role_codes,
|
roleCodes=role_codes,
|
||||||
@@ -648,11 +1108,30 @@ class EmployeeService:
|
|||||||
return None
|
return None
|
||||||
return value.strftime("%Y-%m-%d")
|
return value.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_display_datetime(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
normalized = value.replace(tzinfo=UTC)
|
||||||
|
else:
|
||||||
|
normalized = value.astimezone(UTC)
|
||||||
|
return normalized.astimezone(DISPLAY_TIMEZONE)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_datetime(value: datetime | None) -> str | None:
|
def _format_datetime(value: datetime | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return value.strftime("%Y-%m-%d %H:%M")
|
local = EmployeeService._to_display_datetime(value)
|
||||||
|
return local.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_history_datetime(value: datetime | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
local = EmployeeService._to_display_datetime(value)
|
||||||
|
return (
|
||||||
|
f"{local.year}年{local.month}月{local.day}日"
|
||||||
|
f"{local.hour}时{local.minute}分"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _calculate_age(birth_date: date | None) -> int | None:
|
def _calculate_age(birth_date: date | None) -> int | None:
|
||||||
|
|||||||
@@ -144,6 +144,24 @@ ORGANIZATION_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
EMPLOYEE_PROFILE_REPAIRS = [
|
||||||
|
{
|
||||||
|
"employee_no": "E90919",
|
||||||
|
"name": "曹笑竹",
|
||||||
|
"email": "caoxiaozhu@xf.com",
|
||||||
|
"location": "武汉",
|
||||||
|
"position": "财务智能化产品经理",
|
||||||
|
"grade": "P5",
|
||||||
|
"organization_unit_code": "RND-CENTER",
|
||||||
|
"manager_employee_no": "E11745",
|
||||||
|
"finance_owner_name": "研发财务BP",
|
||||||
|
"cost_center": "CC-6112",
|
||||||
|
"employment_status": "在职",
|
||||||
|
"sync_state": "已同步",
|
||||||
|
"role_codes": ["user"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
EMPLOYEE_DEFINITIONS = [
|
EMPLOYEE_DEFINITIONS = [
|
||||||
{
|
{
|
||||||
"employee_no": "E10018",
|
"employee_no": "E10018",
|
||||||
|
|||||||
368
server/src/app/services/employee_spreadsheet.py
Normal file
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
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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,17 @@ from dataclasses import dataclass, field
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||||
|
from app.services.agent_asset_spreadsheet import (
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
AgentAssetSpreadsheetManager,
|
||||||
|
)
|
||||||
|
|
||||||
EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL)
|
EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL)
|
||||||
|
|
||||||
@@ -351,6 +356,11 @@ class TravelPolicyConfig(BaseModel):
|
|||||||
band_labels: dict[str, str] = Field(default_factory=dict)
|
band_labels: dict[str, str] = Field(default_factory=dict)
|
||||||
city_tiers: dict[str, str] = Field(default_factory=dict)
|
city_tiers: dict[str, str] = Field(default_factory=dict)
|
||||||
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||||
|
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||||
|
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||||
|
standard_rule_code: str = ""
|
||||||
|
standard_rule_name: str = ""
|
||||||
|
standard_rule_version: str = ""
|
||||||
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
|
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||||
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
|
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||||
train_classes: list[TravelClassConfig] = Field(default_factory=list)
|
train_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||||
@@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService:
|
|||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
if not assets:
|
if not assets:
|
||||||
return catalog
|
assets = []
|
||||||
|
|
||||||
|
asset_ids = {asset.id for asset in assets}
|
||||||
|
travel_spreadsheet_asset = self.db.scalar(
|
||||||
|
select(AgentAsset)
|
||||||
|
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
|
||||||
|
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
|
||||||
|
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
|
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids:
|
||||||
|
assets.append(travel_spreadsheet_asset)
|
||||||
|
|
||||||
|
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
version = self._get_current_version(asset)
|
version = self._get_current_version(asset)
|
||||||
if version is None:
|
if version is None:
|
||||||
continue
|
continue
|
||||||
|
is_travel_spreadsheet_asset = (
|
||||||
|
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||||
|
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
|
||||||
|
)
|
||||||
runtime_payload = self._extract_runtime_payload(
|
runtime_payload = self._extract_runtime_payload(
|
||||||
markdown_content=str(version.content or ""),
|
markdown_content=str(version.content or ""),
|
||||||
config_json=asset.config_json,
|
config_json=asset.config_json,
|
||||||
)
|
)
|
||||||
if not isinstance(runtime_payload, dict):
|
if not isinstance(runtime_payload, dict):
|
||||||
|
spreadsheet_assets.append((asset, version))
|
||||||
continue
|
continue
|
||||||
self._apply_runtime_payload(
|
self._apply_runtime_payload(
|
||||||
catalog,
|
catalog,
|
||||||
@@ -594,17 +622,26 @@ class ExpenseRuleRuntimeService:
|
|||||||
asset=asset,
|
asset=asset,
|
||||||
version=version,
|
version=version,
|
||||||
)
|
)
|
||||||
|
if is_travel_spreadsheet_asset:
|
||||||
|
spreadsheet_assets.append((asset, version))
|
||||||
|
|
||||||
|
for asset, version in spreadsheet_assets:
|
||||||
|
self._apply_spreadsheet_runtime_payload(
|
||||||
|
catalog,
|
||||||
|
asset=asset,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
|
||||||
return catalog
|
return catalog
|
||||||
|
|
||||||
def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None:
|
def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None:
|
||||||
current_version = str(asset.current_version or "").strip()
|
published_version = str(asset.published_version or asset.current_version or "").strip()
|
||||||
if not current_version:
|
if not published_version:
|
||||||
return None
|
return None
|
||||||
return self.db.scalar(
|
return self.db.scalar(
|
||||||
select(AgentAssetVersion).where(
|
select(AgentAssetVersion).where(
|
||||||
AgentAssetVersion.asset_id == asset.id,
|
AgentAssetVersion.asset_id == asset.id,
|
||||||
AgentAssetVersion.version == current_version,
|
AgentAssetVersion.version == published_version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService:
|
|||||||
)
|
)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _apply_spreadsheet_runtime_payload(
|
||||||
|
self,
|
||||||
|
catalog: ExpenseRuleCatalog,
|
||||||
|
*,
|
||||||
|
asset: AgentAsset,
|
||||||
|
version: AgentAssetVersion,
|
||||||
|
) -> None:
|
||||||
|
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||||
|
return
|
||||||
|
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
|
||||||
|
return
|
||||||
|
|
||||||
|
manager = AgentAssetSpreadsheetManager()
|
||||||
|
metadata = manager.parse_version_markdown(str(version.content or ""))
|
||||||
|
rule_document = (asset.config_json or {}).get("rule_document")
|
||||||
|
if not isinstance(rule_document, dict):
|
||||||
|
rule_document = {}
|
||||||
|
storage_key = str(metadata.storage_key if metadata is not None else "").strip()
|
||||||
|
if storage_key:
|
||||||
|
try:
|
||||||
|
workbook_path = manager.resolve_storage_path(storage_key)
|
||||||
|
except FileNotFoundError:
|
||||||
|
workbook_path = None
|
||||||
|
if workbook_path is not None and not workbook_path.exists():
|
||||||
|
workbook_path = None
|
||||||
|
else:
|
||||||
|
workbook_path = None
|
||||||
|
|
||||||
|
if workbook_path is None:
|
||||||
|
fallback_storage_key = str(rule_document.get("storage_key") or "").strip()
|
||||||
|
if not fallback_storage_key:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
workbook_path = manager.resolve_storage_path(fallback_storage_key)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
if not workbook_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(
|
||||||
|
workbook_path,
|
||||||
|
read_only=True,
|
||||||
|
data_only=True,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
standards = self._extract_travel_amount_standards_from_workbook(workbook)
|
||||||
|
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
|
||||||
|
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
|
||||||
|
transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
|
||||||
|
finally:
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
standard_rule_version = str(
|
||||||
|
rule_document.get("asset_version") or asset.current_version or version.version
|
||||||
|
).strip()
|
||||||
|
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None:
|
||||||
|
payload = catalog.travel_policy.model_dump()
|
||||||
|
payload["standard_rule_code"] = asset.code
|
||||||
|
payload["standard_rule_name"] = asset.name
|
||||||
|
payload["standard_rule_version"] = standard_rule_version
|
||||||
|
if hotel_city_limits:
|
||||||
|
payload["hotel_city_limits"] = {
|
||||||
|
**payload.get("hotel_city_limits", {}),
|
||||||
|
**hotel_city_limits,
|
||||||
|
}
|
||||||
|
if allowance_limits:
|
||||||
|
payload["allowance_limits"] = {
|
||||||
|
**payload.get("allowance_limits", {}),
|
||||||
|
**allowance_limits,
|
||||||
|
}
|
||||||
|
if transport_limits:
|
||||||
|
payload["transport_limits"] = {
|
||||||
|
**payload.get("transport_limits", {}),
|
||||||
|
**transport_limits,
|
||||||
|
}
|
||||||
|
catalog.travel_policy = RuntimeTravelPolicy(**payload)
|
||||||
|
|
||||||
|
for expense_type, amount in standards.items():
|
||||||
|
current = catalog.scene_policies.get(expense_type)
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
limit_attr = "item_amount_limit" if expense_type == "transport" else "claim_amount_limit"
|
||||||
|
base_limit = getattr(current, limit_attr, None)
|
||||||
|
next_limit = self._replace_amount_limit_warn_amount(
|
||||||
|
base_limit,
|
||||||
|
amount=amount,
|
||||||
|
metric_label=self._spreadsheet_metric_label(expense_type),
|
||||||
|
)
|
||||||
|
payload = current.model_dump()
|
||||||
|
payload["rule_code"] = asset.code
|
||||||
|
payload["rule_name"] = asset.name
|
||||||
|
payload["rule_version"] = standard_rule_version
|
||||||
|
payload[limit_attr] = next_limit.model_dump()
|
||||||
|
catalog.scene_policies[expense_type] = ExpenseScenePolicy(**payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_travel_amount_standards_from_workbook(workbook: Any) -> dict[str, Decimal]:
|
||||||
|
standards: dict[str, Decimal] = {}
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
header_index = -1
|
||||||
|
category_index = -1
|
||||||
|
standard_index = -1
|
||||||
|
for index, row in enumerate(rows[:8]):
|
||||||
|
values = [str(value or "").strip() for value in row]
|
||||||
|
if "费用分类" in values and "报销标准" in values:
|
||||||
|
header_index = index
|
||||||
|
category_index = values.index("费用分类")
|
||||||
|
standard_index = values.index("报销标准")
|
||||||
|
break
|
||||||
|
if header_index < 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
category = str(row[category_index] or "").strip() if len(row) > category_index else ""
|
||||||
|
standard_text = str(row[standard_index] or "").strip() if len(row) > standard_index else ""
|
||||||
|
amount = ExpenseRuleRuntimeService._extract_first_standard_amount(standard_text)
|
||||||
|
if not category or amount is None:
|
||||||
|
continue
|
||||||
|
normalized_type = ExpenseRuleRuntimeService._map_spreadsheet_category_to_expense_type(category)
|
||||||
|
if normalized_type:
|
||||||
|
standards[normalized_type] = amount
|
||||||
|
return standards
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_hotel_city_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||||
|
city_limits: dict[str, dict[str, Decimal]] = {}
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
header_index = -1
|
||||||
|
city_index = -1
|
||||||
|
band_indexes: dict[str, int] = {}
|
||||||
|
for index, row in enumerate(rows[:10]):
|
||||||
|
values = [str(value or "").strip() for value in row]
|
||||||
|
for candidate in ("地区(城市)", "城市", "地区"):
|
||||||
|
if candidate in values:
|
||||||
|
city_index = values.index(candidate)
|
||||||
|
break
|
||||||
|
if city_index < 0:
|
||||||
|
continue
|
||||||
|
for column_index, header in enumerate(values):
|
||||||
|
compact = re.sub(r"\s+", "", header)
|
||||||
|
if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
|
||||||
|
band_indexes["junior"] = column_index
|
||||||
|
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
|
||||||
|
band_indexes["mid"] = column_index
|
||||||
|
band_indexes["senior"] = column_index
|
||||||
|
if any(keyword in compact for keyword in ("P7", "高层经理", "公司级管理")):
|
||||||
|
band_indexes["manager"] = column_index
|
||||||
|
band_indexes["executive"] = column_index
|
||||||
|
if band_indexes:
|
||||||
|
header_index = index
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_index < 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
raw_city = str(row[city_index] or "").strip() if len(row) > city_index else ""
|
||||||
|
cities = ExpenseRuleRuntimeService._extract_city_names_from_cell(raw_city)
|
||||||
|
if not cities:
|
||||||
|
continue
|
||||||
|
for city in cities:
|
||||||
|
city_entry = city_limits.setdefault(city, {})
|
||||||
|
for band, column_index in band_indexes.items():
|
||||||
|
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||||
|
row[column_index] if len(row) > column_index else None
|
||||||
|
)
|
||||||
|
if amount is not None:
|
||||||
|
city_entry[band] = amount
|
||||||
|
return city_limits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||||
|
allowance_limits: dict[str, dict[str, Decimal]] = {}
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
header_index = -1
|
||||||
|
type_index = -1
|
||||||
|
region_indexes: dict[str, int] = {}
|
||||||
|
for index, row in enumerate(rows[:10]):
|
||||||
|
values = [str(value or "").strip() for value in row]
|
||||||
|
if "补助类型" not in values:
|
||||||
|
continue
|
||||||
|
header_index = index
|
||||||
|
type_index = values.index("补助类型")
|
||||||
|
for column_index, header in enumerate(values):
|
||||||
|
if column_index <= type_index:
|
||||||
|
continue
|
||||||
|
normalized = str(header or "").strip()
|
||||||
|
if not normalized or normalized == "项目":
|
||||||
|
continue
|
||||||
|
region_indexes[normalized] = column_index
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_index < 0 or type_index < 0 or not region_indexes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
raw_type = str(row[type_index] or "").strip() if len(row) > type_index else ""
|
||||||
|
allowance_key = ExpenseRuleRuntimeService._map_allowance_type_to_key(raw_type)
|
||||||
|
if not allowance_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry: dict[str, Decimal] = {}
|
||||||
|
for region_label, column_index in region_indexes.items():
|
||||||
|
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||||
|
row[column_index] if len(row) > column_index else None
|
||||||
|
)
|
||||||
|
if amount is not None:
|
||||||
|
entry[region_label] = amount
|
||||||
|
if entry:
|
||||||
|
allowance_limits[allowance_key] = entry
|
||||||
|
return allowance_limits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_allowance_type_to_key(value: str) -> str:
|
||||||
|
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||||
|
if "伙食" in normalized or "餐" in normalized:
|
||||||
|
return "meal"
|
||||||
|
if "基本" in normalized:
|
||||||
|
return "basic"
|
||||||
|
if "合计" in normalized or "总计" in normalized:
|
||||||
|
return "total"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_transport_class_limits_from_workbook(workbook: Any) -> dict[str, dict[str, int]]:
|
||||||
|
limits: dict[str, dict[str, int]] = {}
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
employee_index = -1
|
||||||
|
flight_index = -1
|
||||||
|
train_index = -1
|
||||||
|
for row_index, row in enumerate(rows[:10]):
|
||||||
|
values = [str(value or "").strip() for value in row]
|
||||||
|
if "员工职级" in values:
|
||||||
|
employee_index = values.index("员工职级")
|
||||||
|
for next_row in rows[row_index + 1 : row_index + 4]:
|
||||||
|
next_values = [str(value or "").strip() for value in next_row]
|
||||||
|
if "飞机" in next_values:
|
||||||
|
flight_index = next_values.index("飞机")
|
||||||
|
if "火车" in next_values:
|
||||||
|
train_index = next_values.index("火车")
|
||||||
|
if flight_index >= 0 and train_index >= 0:
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
|
if employee_index < 0 or (flight_index < 0 and train_index < 0):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
|
||||||
|
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text)
|
||||||
|
if not bands:
|
||||||
|
continue
|
||||||
|
flight_level = (
|
||||||
|
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||||
|
row[flight_index] if len(row) > flight_index else None,
|
||||||
|
kind="flight",
|
||||||
|
)
|
||||||
|
if flight_index >= 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
train_level = (
|
||||||
|
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||||
|
row[train_index] if len(row) > train_index else None,
|
||||||
|
kind="train",
|
||||||
|
)
|
||||||
|
if train_index >= 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
for band in bands:
|
||||||
|
entry = limits.setdefault(band, {})
|
||||||
|
if flight_level is not None:
|
||||||
|
entry["flight"] = flight_level
|
||||||
|
if train_level is not None:
|
||||||
|
entry["train"] = train_level
|
||||||
|
return limits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_transport_grade_row_to_bands(value: str) -> list[str]:
|
||||||
|
normalized = re.sub(r"\s+", "", str(value or "").upper())
|
||||||
|
if not normalized or normalized.startswith("注"):
|
||||||
|
return []
|
||||||
|
bands: list[str] = []
|
||||||
|
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")):
|
||||||
|
bands.extend(["junior", "mid"])
|
||||||
|
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")):
|
||||||
|
bands.extend(["mid", "senior", "manager", "executive"])
|
||||||
|
return list(dict.fromkeys(bands))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
|
||||||
|
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if kind == "flight":
|
||||||
|
if any(keyword in normalized for keyword in ("头等舱",)):
|
||||||
|
return 4
|
||||||
|
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
|
||||||
|
return 3
|
||||||
|
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
|
||||||
|
return 2
|
||||||
|
if "经济舱" in normalized:
|
||||||
|
return 1
|
||||||
|
if kind == "train":
|
||||||
|
if "商务座" in normalized:
|
||||||
|
return 3
|
||||||
|
if any(keyword in normalized for keyword in ("一等座", "软卧")):
|
||||||
|
return 2
|
||||||
|
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
|
||||||
|
return 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_city_names_from_cell(value: str) -> list[str]:
|
||||||
|
normalized = re.sub(r"[;;,,、/]+", "、", str(value or "").strip())
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
names: list[str] = []
|
||||||
|
for part in normalized.split("、"):
|
||||||
|
cleaned = re.sub(r"\s+", "", part)
|
||||||
|
cleaned = re.sub(r"[((].*?[))]", "", cleaned)
|
||||||
|
if not cleaned or any(keyword in cleaned for keyword in ("不含", "中心城区", "新区")):
|
||||||
|
continue
|
||||||
|
if len(cleaned) <= 12:
|
||||||
|
names.append(cleaned)
|
||||||
|
return list(dict.fromkeys(names))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_decimal_cell(value: Any) -> Decimal | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
|
||||||
|
except (ArithmeticError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_first_standard_amount(text: str) -> Decimal | None:
|
||||||
|
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)\s*/\s*(?:天|人|晚|次|笔)", str(text or ""))
|
||||||
|
if match is None:
|
||||||
|
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", str(text or ""))
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(match.group(1)).quantize(Decimal("0.01"))
|
||||||
|
except (ArithmeticError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_spreadsheet_category_to_expense_type(category: str) -> str:
|
||||||
|
normalized = re.sub(r"\s+", "", str(category or ""))
|
||||||
|
if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")):
|
||||||
|
return "transport"
|
||||||
|
if "招待" in normalized and "餐" in normalized:
|
||||||
|
return "entertainment"
|
||||||
|
if "餐补" in normalized or normalized == "餐费":
|
||||||
|
return "meal"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _spreadsheet_metric_label(expense_type: str) -> str:
|
||||||
|
return {
|
||||||
|
"transport": "单笔交通金额",
|
||||||
|
"meal": "差旅餐补金额",
|
||||||
|
"entertainment": "人均招待餐费",
|
||||||
|
}.get(expense_type, "金额")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _replace_amount_limit_warn_amount(
|
||||||
|
base_limit: AmountLimitConfig | None,
|
||||||
|
*,
|
||||||
|
amount: Decimal,
|
||||||
|
metric_label: str,
|
||||||
|
) -> AmountLimitConfig:
|
||||||
|
if base_limit is None:
|
||||||
|
return AmountLimitConfig(
|
||||||
|
warn_amount=amount,
|
||||||
|
block_amount=None,
|
||||||
|
metric_label=metric_label,
|
||||||
|
)
|
||||||
|
payload = base_limit.model_dump()
|
||||||
|
payload["warn_amount"] = amount
|
||||||
|
payload["metric_label"] = metric_label
|
||||||
|
return AmountLimitConfig(**payload)
|
||||||
|
|||||||
@@ -856,7 +856,13 @@ class KnowledgeService:
|
|||||||
|
|
||||||
status_payload = status_map.get(document_id) or {}
|
status_payload = status_map.get(document_id) or {}
|
||||||
rag_status = str(status_payload.get("status") or "").strip().lower()
|
rag_status = str(status_payload.get("status") or "").strip().lower()
|
||||||
if bool(status_payload.get("query_ready")):
|
linked_run_status = self._resolve_linked_ingest_run_status(entry)
|
||||||
|
if (
|
||||||
|
linked_run_status == AgentRunStatus.FAILED.value
|
||||||
|
and rag_status in {"pending", "processing", "preprocessed"}
|
||||||
|
):
|
||||||
|
desired_status = KNOWLEDGE_INGEST_STATUS_FAILED
|
||||||
|
elif bool(status_payload.get("query_ready")):
|
||||||
desired_status = KNOWLEDGE_INGEST_STATUS_INGESTED
|
desired_status = KNOWLEDGE_INGEST_STATUS_INGESTED
|
||||||
elif rag_status in {"pending", "processing", "preprocessed"}:
|
elif rag_status in {"pending", "processing", "preprocessed"}:
|
||||||
desired_status = KNOWLEDGE_INGEST_STATUS_SYNCING
|
desired_status = KNOWLEDGE_INGEST_STATUS_SYNCING
|
||||||
@@ -1007,12 +1013,22 @@ class KnowledgeService:
|
|||||||
probe_entry = {"ingest_status_updated_at": heartbeat_at}
|
probe_entry = {"ingest_status_updated_at": heartbeat_at}
|
||||||
return not self._is_syncing_status_stale(probe_entry)
|
return not self._is_syncing_status_stale(probe_entry)
|
||||||
|
|
||||||
return not self._is_syncing_status_stale(entry)
|
return not self._is_syncing_status_stale(entry)
|
||||||
|
|
||||||
def _require_entry(self, index: dict[str, Any], document_id: str) -> dict[str, Any]:
|
def _resolve_linked_ingest_run_status(self, entry: dict[str, Any]) -> str:
|
||||||
for entry in index["documents"]:
|
agent_run_id = str(entry.get("ingest_agent_run_id") or "").strip()
|
||||||
if entry["id"] == document_id:
|
if not agent_run_id or self.db is None:
|
||||||
return entry
|
return ""
|
||||||
|
|
||||||
|
run = self.db.scalar(select(AgentRun).where(AgentRun.run_id == agent_run_id))
|
||||||
|
if run is None:
|
||||||
|
return ""
|
||||||
|
return str(run.status or "").strip()
|
||||||
|
|
||||||
|
def _require_entry(self, index: dict[str, Any], document_id: str) -> dict[str, Any]:
|
||||||
|
for entry in index["documents"]:
|
||||||
|
if entry["id"] == document_id:
|
||||||
|
return entry
|
||||||
raise FileNotFoundError(document_id)
|
raise FileNotFoundError(document_id)
|
||||||
|
|
||||||
def _resolve_document_path(self, entry: dict[str, Any]) -> Path:
|
def _resolve_document_path(self, entry: dict[str, Any]) -> Path:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
@@ -18,6 +19,7 @@ from app.services.knowledge import (
|
|||||||
from app.services.knowledge_rag import KnowledgeRagService
|
from app.services.knowledge_rag import KnowledgeRagService
|
||||||
|
|
||||||
logger = get_logger("app.services.knowledge_index_tasks")
|
logger = get_logger("app.services.knowledge_index_tasks")
|
||||||
|
HEARTBEAT_INTERVAL_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeIndexTaskManager:
|
class KnowledgeIndexTaskManager:
|
||||||
@@ -58,6 +60,15 @@ class KnowledgeIndexTaskManager:
|
|||||||
session_factory = get_session_factory()
|
session_factory = get_session_factory()
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
started = perf_counter()
|
started = perf_counter()
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
heartbeat_thread: threading.Thread | None = None
|
||||||
|
tool_call_id = ""
|
||||||
|
tool_request_json = {
|
||||||
|
"agent": AgentName.HERMES.value,
|
||||||
|
"folder": folder,
|
||||||
|
"document_ids": document_ids,
|
||||||
|
"force": force,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_service = AgentRunService(db)
|
run_service = AgentRunService(db)
|
||||||
@@ -84,6 +95,44 @@ class KnowledgeIndexTaskManager:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
tool_call = run_service.record_tool_call(
|
||||||
|
run_id=agent_run_id,
|
||||||
|
tool_type=AgentToolType.LLM.value,
|
||||||
|
tool_name="lightrag.index_documents",
|
||||||
|
request_json=tool_request_json,
|
||||||
|
response_json={"phase": "indexing"},
|
||||||
|
status="running",
|
||||||
|
duration_ms=0,
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
|
tool_call_id = tool_call.id
|
||||||
|
|
||||||
|
def heartbeat_worker() -> None:
|
||||||
|
while not heartbeat_stop.wait(HEARTBEAT_INTERVAL_SECONDS):
|
||||||
|
heartbeat_db = session_factory()
|
||||||
|
try:
|
||||||
|
AgentRunService(heartbeat_db).merge_route_json(
|
||||||
|
agent_run_id,
|
||||||
|
{
|
||||||
|
"job_type": "knowledge_index_sync",
|
||||||
|
"phase": "indexing",
|
||||||
|
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Knowledge index heartbeat update failed run_id=%s",
|
||||||
|
agent_run_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
heartbeat_db.close()
|
||||||
|
|
||||||
|
heartbeat_thread = threading.Thread(
|
||||||
|
target=heartbeat_worker,
|
||||||
|
name=f"knowledge-index-heartbeat-{agent_run_id}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
heartbeat_thread.start()
|
||||||
|
|
||||||
response = rag_service.index_documents(document_ids=document_ids, force=force)
|
response = rag_service.index_documents(document_ids=document_ids, force=force)
|
||||||
succeeded_document_ids = [
|
succeeded_document_ids = [
|
||||||
@@ -117,16 +166,11 @@ class KnowledgeIndexTaskManager:
|
|||||||
|
|
||||||
duration_ms = int((perf_counter() - started) * 1000)
|
duration_ms = int((perf_counter() - started) * 1000)
|
||||||
tool_status = "succeeded" if not failed_document_ids else "failed"
|
tool_status = "succeeded" if not failed_document_ids else "failed"
|
||||||
run_service.record_tool_call(
|
heartbeat_stop.set()
|
||||||
run_id=agent_run_id,
|
if heartbeat_thread is not None:
|
||||||
tool_type=AgentToolType.LLM.value,
|
heartbeat_thread.join(timeout=1)
|
||||||
tool_name="lightrag.index_documents",
|
run_service.update_tool_call(
|
||||||
request_json={
|
tool_call_id,
|
||||||
"agent": AgentName.HERMES.value,
|
|
||||||
"folder": folder,
|
|
||||||
"document_ids": document_ids,
|
|
||||||
"force": force,
|
|
||||||
},
|
|
||||||
response_json=response,
|
response_json=response,
|
||||||
status=tool_status,
|
status=tool_status,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
@@ -166,22 +210,29 @@ class KnowledgeIndexTaskManager:
|
|||||||
finished_at=datetime.now(UTC),
|
finished_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
heartbeat_stop.set()
|
||||||
|
if heartbeat_thread is not None:
|
||||||
|
heartbeat_thread.join(timeout=1)
|
||||||
try:
|
try:
|
||||||
AgentRunService(db).record_tool_call(
|
if tool_call_id:
|
||||||
run_id=agent_run_id,
|
AgentRunService(db).update_tool_call(
|
||||||
tool_type=AgentToolType.LLM.value,
|
tool_call_id,
|
||||||
tool_name="lightrag.index_documents",
|
response_json={"error": str(exc)},
|
||||||
request_json={
|
status="failed",
|
||||||
"agent": AgentName.HERMES.value,
|
duration_ms=int((perf_counter() - started) * 1000),
|
||||||
"folder": folder,
|
error_message=str(exc),
|
||||||
"document_ids": document_ids,
|
)
|
||||||
"force": force,
|
else:
|
||||||
},
|
AgentRunService(db).record_tool_call(
|
||||||
response_json={"error": str(exc)},
|
run_id=agent_run_id,
|
||||||
status="failed",
|
tool_type=AgentToolType.LLM.value,
|
||||||
duration_ms=int((perf_counter() - started) * 1000),
|
tool_name="lightrag.index_documents",
|
||||||
error_message=str(exc),
|
request_json=tool_request_json,
|
||||||
)
|
response_json={"error": str(exc)},
|
||||||
|
status="failed",
|
||||||
|
duration_ms=int((perf_counter() - started) * 1000),
|
||||||
|
error_message=str(exc),
|
||||||
|
)
|
||||||
KnowledgeService(db=db).set_document_ingest_statuses(
|
KnowledgeService(db=db).set_document_ingest_statuses(
|
||||||
document_ids,
|
document_ids,
|
||||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||||
@@ -210,6 +261,9 @@ class KnowledgeIndexTaskManager:
|
|||||||
logger.exception("Knowledge index task finalization failed run_id=%s", agent_run_id)
|
logger.exception("Knowledge index task finalization failed run_id=%s", agent_run_id)
|
||||||
logger.exception("Knowledge index task failed run_id=%s", agent_run_id)
|
logger.exception("Knowledge index task failed run_id=%s", agent_run_id)
|
||||||
finally:
|
finally:
|
||||||
|
heartbeat_stop.set()
|
||||||
|
if heartbeat_thread is not None and heartbeat_thread.is_alive():
|
||||||
|
heartbeat_thread.join(timeout=1)
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,24 +83,23 @@ class KnowledgeNormalizationService:
|
|||||||
if rendered:
|
if rendered:
|
||||||
normalized_tables.append(f"## {candidate.title}\n\n{rendered}")
|
normalized_tables.append(f"## {candidate.title}\n\n{rendered}")
|
||||||
|
|
||||||
parts: list[str] = []
|
appendix_parts: list[str] = []
|
||||||
if section_appendix:
|
if section_appendix:
|
||||||
parts.append(section_appendix)
|
appendix_parts.append(section_appendix)
|
||||||
if answer_clue_appendix:
|
if answer_clue_appendix:
|
||||||
parts.append(answer_clue_appendix)
|
appendix_parts.append(answer_clue_appendix)
|
||||||
if normalized_tables:
|
if normalized_tables:
|
||||||
appendix = "\n\n".join(normalized_tables)
|
appendix = "\n\n".join(normalized_tables)
|
||||||
parts.append(
|
appendix_parts.append(
|
||||||
"# 结构化表格补充\n\n"
|
"# 结构化表格补充\n\n"
|
||||||
"以下表格由知识归纳阶段依据原文重新整理,供问答检索时优先理解行列关系。\n\n"
|
"以下表格由知识归纳阶段依据原文重新整理,供问答检索时优先理解行列关系。\n\n"
|
||||||
f"{appendix}"
|
f"{appendix}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not parts:
|
if not appendix_parts:
|
||||||
return normalized_text
|
return normalized_text
|
||||||
|
|
||||||
parts.append(f"# 原文\n\n{normalized_text}")
|
return "\n\n".join([normalized_text, *appendix_parts])
|
||||||
return "\n\n".join(parts)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_table_candidates(text: str) -> list[TableCandidate]:
|
def _extract_table_candidates(text: str) -> list[TableCandidate]:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ DEFAULT_LIGHTRAG_QUERY_MODE = "naive"
|
|||||||
DEFAULT_LLM_TIMEOUT_SECONDS = 180
|
DEFAULT_LLM_TIMEOUT_SECONDS = 180
|
||||||
DEFAULT_EMBEDDING_TIMEOUT_SECONDS = 120
|
DEFAULT_EMBEDDING_TIMEOUT_SECONDS = 120
|
||||||
MAX_KNOWLEDGE_HIT_CONTENT_LENGTH = 2200
|
MAX_KNOWLEDGE_HIT_CONTENT_LENGTH = 2200
|
||||||
|
MAX_KNOWLEDGE_HIT_EXCERPT_LENGTH = 220
|
||||||
MAX_QUERY_TERMS = 12
|
MAX_QUERY_TERMS = 12
|
||||||
QUERY_TERM_STOPWORDS = {
|
QUERY_TERM_STOPWORDS = {
|
||||||
"什么",
|
"什么",
|
||||||
@@ -62,6 +63,13 @@ TABLE_OR_STANDARD_QUERY_HINTS = (
|
|||||||
"档位",
|
"档位",
|
||||||
"额度",
|
"额度",
|
||||||
)
|
)
|
||||||
|
STRUCTURED_APPENDIX_LEADING_MARKERS = (
|
||||||
|
"# 章节导航",
|
||||||
|
"# 重点章节摘录",
|
||||||
|
"# 问答线索补充",
|
||||||
|
"# 结构化表格补充",
|
||||||
|
)
|
||||||
|
STRUCTURED_APPENDIX_LEADING_WINDOW = 220
|
||||||
|
|
||||||
_runtime_lock = threading.RLock()
|
_runtime_lock = threading.RLock()
|
||||||
_runtime_instance: _LightRagRuntime | None = None
|
_runtime_instance: _LightRagRuntime | None = None
|
||||||
@@ -830,7 +838,11 @@ class KnowledgeRagService:
|
|||||||
document_id, document_name = _parse_document_identity(file_path)
|
document_id, document_name = _parse_document_identity(file_path)
|
||||||
normalized_chunk_id = chunk_id or f"path-{rank}"
|
normalized_chunk_id = chunk_id or f"path-{rank}"
|
||||||
normalized_content = _truncate_text(content, max_length=MAX_KNOWLEDGE_HIT_CONTENT_LENGTH)
|
normalized_content = _truncate_text(content, max_length=MAX_KNOWLEDGE_HIT_CONTENT_LENGTH)
|
||||||
excerpt = _build_excerpt(normalized_content, max_length=220)
|
excerpt = _build_query_focused_excerpt(
|
||||||
|
normalized_content,
|
||||||
|
query_terms=query_terms,
|
||||||
|
max_length=MAX_KNOWLEDGE_HIT_EXCERPT_LENGTH,
|
||||||
|
)
|
||||||
candidates.append(
|
candidates.append(
|
||||||
{
|
{
|
||||||
"code": f"knowledge.{document_id or 'unknown'}.{normalized_chunk_id}",
|
"code": f"knowledge.{document_id or 'unknown'}.{normalized_chunk_id}",
|
||||||
@@ -907,8 +919,12 @@ class KnowledgeRagService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def is_query_ready_status(status_obj: Any) -> bool:
|
def is_query_ready_status(status_obj: Any) -> bool:
|
||||||
status_text = KnowledgeRagService._status_value(status_obj)
|
status_text = KnowledgeRagService._status_value(status_obj)
|
||||||
|
if status_text in {"failed", "error", "aborted"}:
|
||||||
|
return False
|
||||||
if status_text == "processed":
|
if status_text == "processed":
|
||||||
return True
|
return True
|
||||||
|
if status_text in {"pending", "processing", "preprocessed"}:
|
||||||
|
return False
|
||||||
|
|
||||||
chunks_count = getattr(status_obj, "chunks_count", None)
|
chunks_count = getattr(status_obj, "chunks_count", None)
|
||||||
if chunks_count is None and isinstance(status_obj, dict):
|
if chunks_count is None and isinstance(status_obj, dict):
|
||||||
@@ -1168,6 +1184,35 @@ def _build_excerpt(text: str, *, max_length: int = 180) -> str:
|
|||||||
return f"{normalized[: max_length - 3].rstrip()}..."
|
return f"{normalized[: max_length - 3].rstrip()}..."
|
||||||
|
|
||||||
|
|
||||||
|
def _build_query_focused_excerpt(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
query_terms: list[str],
|
||||||
|
max_length: int = 180,
|
||||||
|
) -> str:
|
||||||
|
normalized = " ".join(str(text or "").split()).strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lowered = normalized.lower()
|
||||||
|
match_positions = [
|
||||||
|
lowered.find(term)
|
||||||
|
for term in query_terms
|
||||||
|
if term and lowered.find(term) >= 0
|
||||||
|
]
|
||||||
|
if not match_positions:
|
||||||
|
return _build_excerpt(normalized, max_length=max_length)
|
||||||
|
|
||||||
|
start = max(0, min(match_positions) - max_length // 3)
|
||||||
|
end = min(len(normalized), start + max_length)
|
||||||
|
snippet = normalized[start:end].strip()
|
||||||
|
if start > 0:
|
||||||
|
snippet = f"...{snippet.lstrip()}"
|
||||||
|
if end < len(normalized):
|
||||||
|
snippet = f"{snippet.rstrip()}..."
|
||||||
|
return snippet
|
||||||
|
|
||||||
|
|
||||||
def _truncate_text(text: str, *, max_length: int) -> str:
|
def _truncate_text(text: str, *, max_length: int) -> str:
|
||||||
normalized = str(text or "").strip()
|
normalized = str(text or "").strip()
|
||||||
if len(normalized) <= max_length:
|
if len(normalized) <= max_length:
|
||||||
@@ -1243,19 +1288,43 @@ def _score_knowledge_hit(
|
|||||||
score += len(matched_terms) * 8
|
score += len(matched_terms) * 8
|
||||||
score += sum(1 for term in matched_terms if term in title) * 6
|
score += sum(1 for term in matched_terms if term in title) * 6
|
||||||
|
|
||||||
if "结构化表格补充" in content:
|
leading_appendix_marker = _leading_structured_appendix_marker(content)
|
||||||
score += 18
|
if leading_appendix_marker == "# 章节导航":
|
||||||
if "问答线索补充" in content:
|
score -= 24
|
||||||
score += 16 if not prefers_tabular_evidence else 8
|
elif leading_appendix_marker == "# 重点章节摘录":
|
||||||
if "重点章节摘录" in content:
|
score += 4 if matched_terms else -12
|
||||||
|
elif leading_appendix_marker == "# 问答线索补充":
|
||||||
|
score += 8 if matched_terms and not prefers_tabular_evidence else 2 if matched_terms else -20
|
||||||
|
elif leading_appendix_marker == "# 结构化表格补充":
|
||||||
|
if prefers_tabular_evidence and matched_terms:
|
||||||
|
score += 16
|
||||||
|
elif matched_terms:
|
||||||
|
score += 6
|
||||||
|
else:
|
||||||
|
score -= 18
|
||||||
|
|
||||||
|
if prefers_tabular_evidence and matched_terms and ("|" in content or "表" in content):
|
||||||
score += 10
|
score += 10
|
||||||
if "章节导航" in content:
|
if matched_terms and any(marker in content for marker in (":", ":")):
|
||||||
|
score += 10
|
||||||
|
if matched_terms and "\n" in content:
|
||||||
score += 4
|
score += 4
|
||||||
if prefers_tabular_evidence and ("|" in content or "表" in content or "结构化表格补充" in content):
|
if matched_terms and any(marker in content for marker in ("附表", "第", "条")):
|
||||||
score += 12
|
score += 4
|
||||||
if not prefers_tabular_evidence and any(marker in content for marker in ("第", "条", ":", "-", "•")):
|
if not prefers_tabular_evidence and matched_terms and any(marker in content for marker in ("第", "条", ":", "-", "•")):
|
||||||
score += 4
|
score += 4
|
||||||
if title and any(term in title for term in query_terms):
|
if title and any(term in title for term in query_terms):
|
||||||
score += 6
|
score += 6
|
||||||
|
if re.search(r"没有.{0,8}(信息|规定|说明|依据)", content):
|
||||||
|
score -= 12
|
||||||
|
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def _leading_structured_appendix_marker(content: str) -> str:
|
||||||
|
normalized = str(content or "").lstrip()
|
||||||
|
for marker in STRUCTURED_APPENDIX_LEADING_MARKERS:
|
||||||
|
index = normalized.find(marker)
|
||||||
|
if 0 <= index <= STRUCTURED_APPENDIX_LEADING_WINDOW:
|
||||||
|
return marker
|
||||||
|
return ""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -27,6 +28,7 @@ class PreparedOcrInput:
|
|||||||
page_index: int | None = None
|
page_index: int | None = None
|
||||||
preview_kind: str = ""
|
preview_kind: str = ""
|
||||||
preview_data_url: str = ""
|
preview_data_url: str = ""
|
||||||
|
text_layer: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -38,6 +40,7 @@ class AggregatedOcrDocument:
|
|||||||
model: str = "PP-OCRv5_mobile"
|
model: str = "PP-OCRv5_mobile"
|
||||||
summary_fragments: list[str] = field(default_factory=list)
|
summary_fragments: list[str] = field(default_factory=list)
|
||||||
text_fragments: list[str] = field(default_factory=list)
|
text_fragments: list[str] = field(default_factory=list)
|
||||||
|
text_layer_fragments: list[str] = field(default_factory=list)
|
||||||
score_values: list[float] = field(default_factory=list)
|
score_values: list[float] = field(default_factory=list)
|
||||||
warnings: list[str] = field(default_factory=list)
|
warnings: list[str] = field(default_factory=list)
|
||||||
lines: list[OcrRecognizeLineRead] = field(default_factory=list)
|
lines: list[OcrRecognizeLineRead] = field(default_factory=list)
|
||||||
@@ -112,12 +115,14 @@ class OcrService:
|
|||||||
|
|
||||||
if suffix == ".pdf":
|
if suffix == ".pdf":
|
||||||
try:
|
try:
|
||||||
|
text_layer = self._extract_pdf_text_layer(temp_path)
|
||||||
prepared_inputs.extend(
|
prepared_inputs.extend(
|
||||||
self._prepare_pdf_inputs(
|
self._prepare_pdf_inputs(
|
||||||
pdf_path=temp_path,
|
pdf_path=temp_path,
|
||||||
filename=normalized_name,
|
filename=normalized_name,
|
||||||
media_type=resolved_media_type,
|
media_type=resolved_media_type,
|
||||||
cleanup_paths=cleanup_paths,
|
cleanup_paths=cleanup_paths,
|
||||||
|
text_layer=text_layer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
@@ -261,6 +266,7 @@ class OcrService:
|
|||||||
filename: str,
|
filename: str,
|
||||||
media_type: str,
|
media_type: str,
|
||||||
cleanup_paths: list[Path],
|
cleanup_paths: list[Path],
|
||||||
|
text_layer: str = "",
|
||||||
) -> list[PreparedOcrInput]:
|
) -> list[PreparedOcrInput]:
|
||||||
output_dir = pdf_path.with_suffix("")
|
output_dir = pdf_path.with_suffix("")
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -283,10 +289,33 @@ class OcrService:
|
|||||||
page_index=page_index,
|
page_index=page_index,
|
||||||
preview_kind="image" if page_index == 0 else "",
|
preview_kind="image" if page_index == 0 else "",
|
||||||
preview_data_url=preview_data_url if page_index == 0 else "",
|
preview_data_url=preview_data_url if page_index == 0 else "",
|
||||||
|
text_layer=text_layer if page_index == 0 else "",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return descriptors
|
return descriptors
|
||||||
|
|
||||||
|
def _extract_pdf_text_layer(self, pdf_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
"pdftotext",
|
||||||
|
"-layout",
|
||||||
|
str(pdf_path),
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=self.settings.ocr_timeout_seconds,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError, UnicodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return self._normalize_extracted_text(completed.stdout)
|
||||||
|
|
||||||
def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||||
prefix = output_dir / "page"
|
prefix = output_dir / "page"
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
@@ -367,6 +396,8 @@ class OcrService:
|
|||||||
aggregated.preview_kind = descriptor.preview_kind
|
aggregated.preview_kind = descriptor.preview_kind
|
||||||
if descriptor.preview_data_url and not aggregated.preview_data_url:
|
if descriptor.preview_data_url and not aggregated.preview_data_url:
|
||||||
aggregated.preview_data_url = descriptor.preview_data_url
|
aggregated.preview_data_url = descriptor.preview_data_url
|
||||||
|
if descriptor.text_layer and descriptor.text_layer not in aggregated.text_layer_fragments:
|
||||||
|
aggregated.text_layer_fragments.append(descriptor.text_layer)
|
||||||
|
|
||||||
page_summary = str(payload.get("summary", "") or "").strip()
|
page_summary = str(payload.get("summary", "") or "").strip()
|
||||||
if page_summary:
|
if page_summary:
|
||||||
@@ -401,6 +432,20 @@ class OcrService:
|
|||||||
aggregated = aggregated_by_source.get(source_key)
|
aggregated = aggregated_by_source.get(source_key)
|
||||||
if aggregated is None:
|
if aggregated is None:
|
||||||
first_descriptor = descriptors[0]
|
first_descriptor = descriptors[0]
|
||||||
|
text_layer = self._collect_descriptor_text_layer(descriptors)
|
||||||
|
if text_layer:
|
||||||
|
fallback = AggregatedOcrDocument(
|
||||||
|
filename=first_descriptor.filename,
|
||||||
|
media_type=first_descriptor.media_type,
|
||||||
|
source_key=first_descriptor.source_key,
|
||||||
|
page_count=max(1, len(descriptors)),
|
||||||
|
preview_kind=first_descriptor.preview_kind,
|
||||||
|
preview_data_url=first_descriptor.preview_data_url,
|
||||||
|
warnings=["OCR worker 未返回该文件的识别结果,已使用 PDF 文本层。"],
|
||||||
|
)
|
||||||
|
fallback.text_layer_fragments.append(text_layer)
|
||||||
|
documents.append(self._finalize_document(fallback))
|
||||||
|
continue
|
||||||
documents.append(
|
documents.append(
|
||||||
OcrRecognizeDocumentRead(
|
OcrRecognizeDocumentRead(
|
||||||
filename=first_descriptor.filename,
|
filename=first_descriptor.filename,
|
||||||
@@ -416,6 +461,13 @@ class OcrService:
|
|||||||
|
|
||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_descriptor_text_layer(descriptors: list[PreparedOcrInput]) -> str:
|
||||||
|
for descriptor in descriptors:
|
||||||
|
if descriptor.text_layer:
|
||||||
|
return descriptor.text_layer
|
||||||
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_lines(
|
def _build_lines(
|
||||||
items: list[dict],
|
items: list[dict],
|
||||||
@@ -451,13 +503,26 @@ class OcrService:
|
|||||||
return summary
|
return summary
|
||||||
|
|
||||||
def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead:
|
def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead:
|
||||||
full_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
|
ocr_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
|
||||||
|
text_layer = "\n".join(fragment for fragment in aggregated.text_layer_fragments if fragment).strip()
|
||||||
|
full_text, used_text_layer = self._choose_document_text(ocr_text=ocr_text, text_layer=text_layer)
|
||||||
summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments)
|
summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments)
|
||||||
|
if used_text_layer or self._placeholder_ratio(summary) >= 0.12:
|
||||||
|
summary = self._summarize_text(full_text)
|
||||||
|
preview_kind = aggregated.preview_kind
|
||||||
|
preview_data_url = aggregated.preview_data_url
|
||||||
|
if (
|
||||||
|
used_text_layer
|
||||||
|
and aggregated.media_type == "application/pdf"
|
||||||
|
and self._placeholder_ratio(ocr_text) >= 0.12
|
||||||
|
):
|
||||||
|
preview_kind = ""
|
||||||
|
preview_data_url = ""
|
||||||
insight = self.document_intelligence_service.build_document_insight(
|
insight = self.document_intelligence_service.build_document_insight(
|
||||||
filename=aggregated.filename,
|
filename=aggregated.filename,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
text=full_text,
|
text=full_text,
|
||||||
preview_data_url=aggregated.preview_data_url,
|
preview_data_url=preview_data_url,
|
||||||
)
|
)
|
||||||
warnings = list(aggregated.warnings)
|
warnings = list(aggregated.warnings)
|
||||||
for warning in insight.warnings:
|
for warning in insight.warnings:
|
||||||
@@ -493,8 +558,8 @@ class OcrService:
|
|||||||
)
|
)
|
||||||
for field in insight.fields
|
for field in insight.fields
|
||||||
],
|
],
|
||||||
preview_kind=aggregated.preview_kind,
|
preview_kind=preview_kind,
|
||||||
preview_data_url=aggregated.preview_data_url,
|
preview_data_url=preview_data_url,
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
lines=sorted(
|
lines=sorted(
|
||||||
aggregated.lines,
|
aggregated.lines,
|
||||||
@@ -502,6 +567,45 @@ class OcrService:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _choose_document_text(cls, *, ocr_text: str, text_layer: str) -> tuple[str, bool]:
|
||||||
|
normalized_ocr_text = cls._normalize_extracted_text(ocr_text)
|
||||||
|
normalized_text_layer = cls._normalize_extracted_text(text_layer)
|
||||||
|
if not normalized_text_layer:
|
||||||
|
return normalized_ocr_text, False
|
||||||
|
if not normalized_ocr_text:
|
||||||
|
return normalized_text_layer, True
|
||||||
|
if cls._placeholder_ratio(normalized_ocr_text) >= 0.12 and cls._meaningful_char_count(normalized_text_layer) >= 8:
|
||||||
|
return normalized_text_layer, True
|
||||||
|
if cls._meaningful_char_count(normalized_text_layer) > cls._meaningful_char_count(normalized_ocr_text) * 1.3:
|
||||||
|
return normalized_text_layer, True
|
||||||
|
return normalized_ocr_text, False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_extracted_text(value: str) -> str:
|
||||||
|
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in str(value or "").replace("\r", "\n").split("\n")]
|
||||||
|
return "\n".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _summarize_text(value: str) -> str:
|
||||||
|
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
|
||||||
|
summary = ";".join(lines[:3])
|
||||||
|
if len(summary) > 180:
|
||||||
|
return f"{summary[:177]}..."
|
||||||
|
return summary
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _meaningful_char_count(value: str) -> int:
|
||||||
|
return len(re.findall(r"[0-9A-Za-z\u4e00-\u9fff]", str(value or "")))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _placeholder_ratio(value: str) -> float:
|
||||||
|
chars = [char for char in str(value or "") if not char.isspace()]
|
||||||
|
if not chars:
|
||||||
|
return 0.0
|
||||||
|
placeholder_count = sum(1 for char in chars if char in {"□", "<EFBFBD>"})
|
||||||
|
return placeholder_count / len(chars)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cleanup_temp_paths(paths: list[Path]) -> None:
|
def _cleanup_temp_paths(paths: list[Path]) -> None:
|
||||||
for path in reversed(paths):
|
for path in reversed(paths):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -119,10 +119,11 @@ class OrchestratorService:
|
|||||||
context_json=context_json,
|
context_json=context_json,
|
||||||
)
|
)
|
||||||
conversation_id = conversation.conversation_id
|
conversation_id = conversation.conversation_id
|
||||||
context_json = self.conversation_service.hydrate_context_json(
|
context_json = self.conversation_service.hydrate_context_json(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
context_json=context_json,
|
context_json=context_json,
|
||||||
)
|
message=payload.message,
|
||||||
|
)
|
||||||
|
|
||||||
route_json: dict[str, Any] = {
|
route_json: dict[str, Any] = {
|
||||||
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
||||||
@@ -173,9 +174,11 @@ class OrchestratorService:
|
|||||||
task_asset=task_asset,
|
task_asset=task_asset,
|
||||||
)
|
)
|
||||||
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
||||||
requires_confirmation = (
|
is_expense_review_action = self._is_expense_review_action(context_json)
|
||||||
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
requires_confirmation = (
|
||||||
)
|
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||||
|
and not is_expense_review_action
|
||||||
|
)
|
||||||
|
|
||||||
route_json = {
|
route_json = {
|
||||||
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
||||||
@@ -526,7 +529,11 @@ class OrchestratorService:
|
|||||||
failed_tool_count=1 if degraded else 0,
|
failed_tool_count=1 if degraded else 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
next_step = self._resolve_next_step(ontology, payload.source)
|
next_step = self._resolve_next_step(
|
||||||
|
ontology,
|
||||||
|
payload.source,
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
if next_step == "query_database":
|
if next_step == "query_database":
|
||||||
tool_payload, degraded = self._invoke_tool(
|
tool_payload, degraded = self._invoke_tool(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@@ -657,25 +664,38 @@ class OrchestratorService:
|
|||||||
),
|
),
|
||||||
"draft_only": True,
|
"draft_only": True,
|
||||||
}
|
}
|
||||||
fallback_factory = lambda exc: {
|
fallback_factory = lambda exc: {
|
||||||
"message": f"草稿生成暂时不可用,请稍后再试:{exc}",
|
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
|
||||||
"degraded": True,
|
"degraded": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ontology.scenario == "expense":
|
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||||
tool_type = AgentToolType.DATABASE.value
|
is_persistence_action = self._is_expense_persistence_action(context_json)
|
||||||
tool_name = "database.expense_claims.save_or_submit"
|
tool_type = (
|
||||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
AgentToolType.DATABASE.value
|
||||||
run_id=run_id,
|
if is_persistence_action
|
||||||
user_id=payload.user_id,
|
else AgentToolType.LLM.value
|
||||||
message=payload.message or "",
|
)
|
||||||
ontology=ontology,
|
tool_name = (
|
||||||
context_json=context_json,
|
"database.expense_claims.save_or_submit"
|
||||||
)
|
if is_persistence_action
|
||||||
fallback_factory = lambda exc: {
|
else "user_agent.expense_review_preview"
|
||||||
"message": f"报销草稿落库失败,请稍后再试:{exc}",
|
)
|
||||||
"degraded": True,
|
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(
|
tool_payload, degraded = self._invoke_tool(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@@ -781,10 +801,17 @@ class OrchestratorService:
|
|||||||
failed_tool_count=failed_tool_count,
|
failed_tool_count=failed_tool_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_next_step(ontology: OntologyParseResult, source: str) -> str:
|
def _resolve_next_step(
|
||||||
if ontology.clarification_required:
|
ontology: OntologyParseResult,
|
||||||
return "ask_clarification"
|
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":
|
if ontology.intent == "draft":
|
||||||
return "create_draft"
|
return "create_draft"
|
||||||
if ontology.scenario == "knowledge" or ontology.intent == "explain":
|
if ontology.scenario == "knowledge" or ontology.intent == "explain":
|
||||||
@@ -793,7 +820,28 @@ class OrchestratorService:
|
|||||||
return "run_rule"
|
return "run_rule"
|
||||||
if ontology.intent in {"query", "compare"}:
|
if ontology.intent in {"query", "compare"}:
|
||||||
return "query_database"
|
return "query_database"
|
||||||
return "create_draft"
|
return "create_draft"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_review_action(context_json: dict[str, Any]) -> bool:
|
||||||
|
review_action = str((context_json or {}).get("review_action") or "").strip()
|
||||||
|
return review_action in {
|
||||||
|
"save_draft",
|
||||||
|
"next_step",
|
||||||
|
"edit_review",
|
||||||
|
"link_to_existing_draft",
|
||||||
|
"create_new_claim_from_documents",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_persistence_action(context_json: dict[str, Any]) -> bool:
|
||||||
|
review_action = str((context_json or {}).get("review_action") or "").strip()
|
||||||
|
return review_action in {
|
||||||
|
"save_draft",
|
||||||
|
"next_step",
|
||||||
|
"link_to_existing_draft",
|
||||||
|
"create_new_claim_from_documents",
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _flatten_capability_codes(
|
def _flatten_capability_codes(
|
||||||
@@ -1140,16 +1188,18 @@ class OrchestratorService:
|
|||||||
if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip()
|
if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expense_types = list(
|
expense_types = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
str(item.normalized_value or item.value or "").strip()
|
str(item.normalized_value or item.value or "").strip()
|
||||||
for item in ontology.entities
|
for item in ontology.entities
|
||||||
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
|
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
status_values = list(
|
project_values = self._collect_expense_query_filter_values(ontology, "project")
|
||||||
dict.fromkeys(
|
location_values = self._collect_expense_query_filter_values(ontology, "location")
|
||||||
str(item.value).strip()
|
status_values = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(item.value).strip()
|
||||||
for item in ontology.constraints
|
for item in ontology.constraints
|
||||||
if item.field == "status" and item.operator == "=" and str(item.value).strip()
|
if item.field == "status" and item.operator == "=" and str(item.value).strip()
|
||||||
)
|
)
|
||||||
@@ -1164,10 +1214,24 @@ class OrchestratorService:
|
|||||||
|
|
||||||
if expense_claim_nos:
|
if expense_claim_nos:
|
||||||
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
|
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
|
||||||
if expense_types:
|
if expense_types:
|
||||||
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
||||||
if status_values:
|
if status_values:
|
||||||
conditions.append(ExpenseClaim.status.in_(status_values))
|
conditions.append(ExpenseClaim.status.in_(status_values))
|
||||||
|
if project_values:
|
||||||
|
project_conditions = []
|
||||||
|
for value in project_values:
|
||||||
|
pattern = f"%{value}%"
|
||||||
|
project_conditions.append(ExpenseClaim.project_code.ilike(pattern))
|
||||||
|
project_conditions.append(ExpenseClaim.reason.ilike(pattern))
|
||||||
|
conditions.append(or_(*project_conditions))
|
||||||
|
if location_values:
|
||||||
|
location_conditions = []
|
||||||
|
for value in location_values:
|
||||||
|
pattern = f"%{value}%"
|
||||||
|
location_conditions.append(ExpenseClaim.location.ilike(pattern))
|
||||||
|
location_conditions.append(ExpenseClaim.reason.ilike(pattern))
|
||||||
|
conditions.append(or_(*location_conditions))
|
||||||
|
|
||||||
for item in amount_constraints:
|
for item in amount_constraints:
|
||||||
amount_value = float(item.value)
|
amount_value = float(item.value)
|
||||||
@@ -1226,11 +1290,31 @@ class OrchestratorService:
|
|||||||
scoped_to_current_user = True
|
scoped_to_current_user = True
|
||||||
else:
|
else:
|
||||||
scope_label = "全部报销单"
|
scope_label = "全部报销单"
|
||||||
|
|
||||||
return conditions, scope_label, scoped_to_current_user
|
return conditions, scope_label, scoped_to_current_user
|
||||||
|
|
||||||
def _build_current_user_claim_conditions(
|
@staticmethod
|
||||||
self,
|
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,
|
user_id: str | None,
|
||||||
context_json: dict[str, Any],
|
context_json: dict[str, Any],
|
||||||
|
|||||||
77
server/src/app/services/risk_ontology_bridge.py
Normal file
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
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'))}"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ Requires-Dist: pydantic-settings<3.0.0,>=2.6.0
|
|||||||
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
|
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
|
||||||
Requires-Dist: email-validator<3.0.0,>=2.2.0
|
Requires-Dist: email-validator<3.0.0,>=2.2.0
|
||||||
Requires-Dist: python-multipart<1.0.0,>=0.0.20
|
Requires-Dist: python-multipart<1.0.0,>=0.0.20
|
||||||
|
Requires-Dist: openpyxl<4.0.0,>=3.1.5
|
||||||
Requires-Dist: lightrag-hku<1.5.0,>=1.4.16
|
Requires-Dist: lightrag-hku<1.5.0,>=1.4.16
|
||||||
Requires-Dist: qdrant-client<2.0.0,>=1.18.0
|
Requires-Dist: qdrant-client<2.0.0,>=1.18.0
|
||||||
Provides-Extra: dev
|
Provides-Extra: dev
|
||||||
|
|||||||
@@ -1,128 +1,139 @@
|
|||||||
README.md
|
README.md
|
||||||
pyproject.toml
|
pyproject.toml
|
||||||
src/app/__init__.py
|
src/app/__init__.py
|
||||||
src/app/main.py
|
src/app/main.py
|
||||||
src/app/api/__init__.py
|
src/app/api/__init__.py
|
||||||
src/app/api/deps.py
|
src/app/api/deps.py
|
||||||
src/app/api/router.py
|
src/app/api/router.py
|
||||||
src/app/api/v1/__init__.py
|
src/app/api/v1/__init__.py
|
||||||
src/app/api/v1/router.py
|
src/app/api/v1/router.py
|
||||||
src/app/api/v1/endpoints/__init__.py
|
src/app/api/v1/endpoints/__init__.py
|
||||||
src/app/api/v1/endpoints/agent_assets.py
|
src/app/api/v1/endpoints/agent_assets.py
|
||||||
src/app/api/v1/endpoints/agent_runs.py
|
src/app/api/v1/endpoints/agent_runs.py
|
||||||
src/app/api/v1/endpoints/audit_logs.py
|
src/app/api/v1/endpoints/audit_logs.py
|
||||||
src/app/api/v1/endpoints/auth.py
|
src/app/api/v1/endpoints/auth.py
|
||||||
src/app/api/v1/endpoints/bootstrap.py
|
src/app/api/v1/endpoints/bootstrap.py
|
||||||
src/app/api/v1/endpoints/employees.py
|
src/app/api/v1/endpoints/employees.py
|
||||||
src/app/api/v1/endpoints/health.py
|
src/app/api/v1/endpoints/health.py
|
||||||
src/app/api/v1/endpoints/knowledge.py
|
src/app/api/v1/endpoints/knowledge.py
|
||||||
src/app/api/v1/endpoints/ocr.py
|
src/app/api/v1/endpoints/ocr.py
|
||||||
src/app/api/v1/endpoints/ontology.py
|
src/app/api/v1/endpoints/ontology.py
|
||||||
src/app/api/v1/endpoints/orchestrator.py
|
src/app/api/v1/endpoints/orchestrator.py
|
||||||
src/app/api/v1/endpoints/reimbursements.py
|
src/app/api/v1/endpoints/reimbursements.py
|
||||||
src/app/api/v1/endpoints/settings.py
|
src/app/api/v1/endpoints/settings.py
|
||||||
src/app/api/v1/endpoints/system_logs.py
|
src/app/api/v1/endpoints/system_logs.py
|
||||||
src/app/core/__init__.py
|
src/app/core/__init__.py
|
||||||
src/app/core/admin_secret.py
|
src/app/core/admin_secret.py
|
||||||
src/app/core/agent_enums.py
|
src/app/core/agent_enums.py
|
||||||
src/app/core/bootstrap.py
|
src/app/core/bootstrap.py
|
||||||
src/app/core/config.py
|
src/app/core/config.py
|
||||||
src/app/core/logging.py
|
src/app/core/logging.py
|
||||||
src/app/core/openapi.py
|
src/app/core/openapi.py
|
||||||
src/app/core/secret_box.py
|
src/app/core/secret_box.py
|
||||||
src/app/core/security.py
|
src/app/core/security.py
|
||||||
src/app/db/__init__.py
|
src/app/db/__init__.py
|
||||||
src/app/db/base.py
|
src/app/db/base.py
|
||||||
src/app/db/base_class.py
|
src/app/db/base_class.py
|
||||||
src/app/db/session.py
|
src/app/db/session.py
|
||||||
src/app/middleware/__init__.py
|
src/app/middleware/__init__.py
|
||||||
src/app/middleware/logging.py
|
src/app/middleware/logging.py
|
||||||
src/app/models/__init__.py
|
src/app/models/__init__.py
|
||||||
src/app/models/agent_asset.py
|
src/app/models/agent_asset.py
|
||||||
src/app/models/agent_conversation.py
|
src/app/models/agent_conversation.py
|
||||||
src/app/models/agent_run.py
|
src/app/models/agent_run.py
|
||||||
src/app/models/approval.py
|
src/app/models/approval.py
|
||||||
src/app/models/audit_log.py
|
src/app/models/audit_log.py
|
||||||
src/app/models/employee.py
|
src/app/models/employee.py
|
||||||
src/app/models/employee_change_log.py
|
src/app/models/employee_change_log.py
|
||||||
src/app/models/financial_record.py
|
src/app/models/financial_record.py
|
||||||
src/app/models/organization.py
|
src/app/models/organization.py
|
||||||
src/app/models/reimbursement.py
|
src/app/models/reimbursement.py
|
||||||
src/app/models/role.py
|
src/app/models/role.py
|
||||||
src/app/models/system_model_setting.py
|
src/app/models/system_model_setting.py
|
||||||
src/app/models/system_setting.py
|
src/app/models/system_setting.py
|
||||||
src/app/models/system_setting_secret.py
|
src/app/models/system_setting_secret.py
|
||||||
src/app/repositories/__init__.py
|
src/app/repositories/__init__.py
|
||||||
src/app/repositories/agent_asset.py
|
src/app/repositories/agent_asset.py
|
||||||
src/app/repositories/agent_run.py
|
src/app/repositories/agent_run.py
|
||||||
src/app/repositories/audit_log.py
|
src/app/repositories/audit_log.py
|
||||||
src/app/repositories/employee.py
|
src/app/repositories/employee.py
|
||||||
src/app/repositories/reimbursement.py
|
src/app/repositories/reimbursement.py
|
||||||
src/app/repositories/settings.py
|
src/app/repositories/settings.py
|
||||||
src/app/schemas/__init__.py
|
src/app/schemas/__init__.py
|
||||||
src/app/schemas/agent_asset.py
|
src/app/schemas/agent_asset.py
|
||||||
src/app/schemas/agent_run.py
|
src/app/schemas/agent_run.py
|
||||||
src/app/schemas/audit_log.py
|
src/app/schemas/audit_log.py
|
||||||
src/app/schemas/auth.py
|
src/app/schemas/auth.py
|
||||||
src/app/schemas/bootstrap.py
|
src/app/schemas/bootstrap.py
|
||||||
src/app/schemas/common.py
|
src/app/schemas/common.py
|
||||||
src/app/schemas/employee.py
|
src/app/schemas/employee.py
|
||||||
src/app/schemas/knowledge.py
|
src/app/schemas/knowledge.py
|
||||||
src/app/schemas/ocr.py
|
src/app/schemas/ocr.py
|
||||||
src/app/schemas/ontology.py
|
src/app/schemas/ontology.py
|
||||||
src/app/schemas/orchestrator.py
|
src/app/schemas/orchestrator.py
|
||||||
src/app/schemas/reimbursement.py
|
src/app/schemas/reimbursement.py
|
||||||
src/app/schemas/settings.py
|
src/app/schemas/settings.py
|
||||||
src/app/schemas/system_log.py
|
src/app/schemas/system_log.py
|
||||||
src/app/schemas/user_agent.py
|
src/app/schemas/user_agent.py
|
||||||
src/app/services/__init__.py
|
src/app/services/__init__.py
|
||||||
src/app/services/agent_assets.py
|
src/app/services/agent_asset_spreadsheet.py
|
||||||
src/app/services/agent_conversations.py
|
src/app/services/agent_assets.py
|
||||||
src/app/services/agent_foundation.py
|
src/app/services/agent_conversations.py
|
||||||
src/app/services/agent_runs.py
|
src/app/services/agent_foundation.py
|
||||||
src/app/services/audit.py
|
src/app/services/agent_runs.py
|
||||||
src/app/services/auth.py
|
src/app/services/audit.py
|
||||||
src/app/services/document_intelligence.py
|
src/app/services/auth.py
|
||||||
src/app/services/employee.py
|
src/app/services/document_intelligence.py
|
||||||
src/app/services/employee_seed.py
|
src/app/services/employee.py
|
||||||
src/app/services/expense_claims.py
|
src/app/services/employee_seed.py
|
||||||
src/app/services/expense_rule_runtime.py
|
src/app/services/expense_claims.py
|
||||||
src/app/services/hermes_sync.py
|
src/app/services/expense_rule_runtime.py
|
||||||
src/app/services/knowledge.py
|
src/app/services/hermes_sync.py
|
||||||
src/app/services/knowledge_index_tasks.py
|
src/app/services/knowledge.py
|
||||||
src/app/services/knowledge_rag.py
|
src/app/services/knowledge_index_tasks.py
|
||||||
src/app/services/model_connectivity.py
|
src/app/services/knowledge_normalizer.py
|
||||||
src/app/services/ocr.py
|
src/app/services/knowledge_rag.py
|
||||||
src/app/services/ontology.py
|
src/app/services/knowledge_scheduler.py
|
||||||
src/app/services/orchestrator.py
|
src/app/services/knowledge_sync.py
|
||||||
src/app/services/reimbursement.py
|
src/app/services/model_connectivity.py
|
||||||
src/app/services/runtime_chat.py
|
src/app/services/ocr.py
|
||||||
src/app/services/settings.py
|
src/app/services/ontology.py
|
||||||
src/app/services/system_hermes.py
|
src/app/services/orchestrator.py
|
||||||
src/app/services/system_logs.py
|
src/app/services/reimbursement.py
|
||||||
src/app/services/user_agent.py
|
src/app/services/runtime_chat.py
|
||||||
src/x_financial_server.egg-info/PKG-INFO
|
src/app/services/settings.py
|
||||||
src/x_financial_server.egg-info/SOURCES.txt
|
src/app/services/system_hermes.py
|
||||||
src/x_financial_server.egg-info/dependency_links.txt
|
src/app/services/system_logs.py
|
||||||
src/x_financial_server.egg-info/requires.txt
|
src/app/services/user_agent.py
|
||||||
src/x_financial_server.egg-info/top_level.txt
|
src/x_financial_server.egg-info/PKG-INFO
|
||||||
tests/test_agent_asset_service.py
|
src/x_financial_server.egg-info/SOURCES.txt
|
||||||
tests/test_agent_foundation_endpoints.py
|
src/x_financial_server.egg-info/dependency_links.txt
|
||||||
tests/test_auth_service.py
|
src/x_financial_server.egg-info/requires.txt
|
||||||
tests/test_config_settings_reload.py
|
src/x_financial_server.egg-info/top_level.txt
|
||||||
tests/test_document_intelligence.py
|
tests/test_agent_asset_onlyoffice_key.py
|
||||||
tests/test_employee_service.py
|
tests/test_agent_asset_service.py
|
||||||
tests/test_env_file_precedence.py
|
tests/test_agent_asset_spreadsheet_import.py
|
||||||
tests/test_expense_claim_service.py
|
tests/test_agent_foundation_endpoints.py
|
||||||
tests/test_imports.py
|
tests/test_agent_runs_service.py
|
||||||
tests/test_knowledge_onlyoffice_config.py
|
tests/test_auth_service.py
|
||||||
tests/test_ocr_endpoints.py
|
tests/test_config_settings_reload.py
|
||||||
tests/test_ocr_service.py
|
tests/test_document_intelligence.py
|
||||||
tests/test_ontology_service.py
|
tests/test_employee_service.py
|
||||||
tests/test_openapi_schema.py
|
tests/test_env_file_precedence.py
|
||||||
tests/test_reimbursement_endpoints.py
|
tests/test_expense_claim_service.py
|
||||||
tests/test_server_start_dependencies.py
|
tests/test_imports.py
|
||||||
tests/test_settings_persistence.py
|
tests/test_knowledge_normalizer.py
|
||||||
tests/test_settings_service.py
|
tests/test_knowledge_onlyoffice_config.py
|
||||||
tests/test_system_logs_service.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
|
tests/test_user_agent_service.py
|
||||||
@@ -8,6 +8,7 @@ pydantic-settings<3.0.0,>=2.6.0
|
|||||||
python-dotenv<2.0.0,>=1.0.1
|
python-dotenv<2.0.0,>=1.0.1
|
||||||
email-validator<3.0.0,>=2.2.0
|
email-validator<3.0.0,>=2.2.0
|
||||||
python-multipart<1.0.0,>=0.0.20
|
python-multipart<1.0.0,>=0.0.20
|
||||||
|
openpyxl<4.0.0,>=3.1.5
|
||||||
lightrag-hku<1.5.0,>=1.4.16
|
lightrag-hku<1.5.0,>=1.4.16
|
||||||
qdrant-client<2.0.0,>=1.18.0
|
qdrant-client<2.0.0,>=1.18.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +1,84 @@
|
|||||||
{
|
{
|
||||||
"file_name": "行程单_2_鄂AX9877.pdf",
|
"file_name": "行程单_2_鄂AX9877.pdf",
|
||||||
"storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.pdf",
|
"storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.pdf",
|
||||||
"media_type": "application/pdf",
|
"media_type": "application/pdf",
|
||||||
"size_bytes": 32459,
|
"size_bytes": 32459,
|
||||||
"uploaded_at": "2026-05-16T08:41:42.540134+00:00",
|
"uploaded_at": "2026-05-16T08:41:42.540134+00:00",
|
||||||
"previewable": true,
|
"previewable": true,
|
||||||
"preview_kind": "image",
|
"preview_kind": "image",
|
||||||
"preview_storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.preview.png",
|
"preview_storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.preview.png",
|
||||||
"preview_media_type": "image/png",
|
"preview_media_type": "image/png",
|
||||||
"preview_file_name": "行程单_2_鄂AX9877.preview.png",
|
"preview_file_name": "行程单_2_鄂AX9877.preview.png",
|
||||||
"analysis": {
|
"analysis": {
|
||||||
"severity": "pass",
|
"severity": "pass",
|
||||||
"label": "AI提示符合条件",
|
"label": "AI提示符合条件",
|
||||||
"headline": "AI提示:附件符合基础校验条件",
|
"headline": "AI提示:附件符合基础校验条件",
|
||||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||||
"points": [
|
"points": [
|
||||||
"票据类型:已识别为出租车/网约车票据。",
|
"票据类型:已识别为出租车/网约车票据。",
|
||||||
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
|
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
|
||||||
"金额字段:已识别到与当前明细接近的金额 35.53 元。"
|
"金额字段:已识别到与当前明细接近的金额 35.53 元。"
|
||||||
],
|
],
|
||||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||||
},
|
},
|
||||||
"document_info": {
|
"document_info": {
|
||||||
"document_type": "taxi_receipt",
|
"document_type": "taxi_receipt",
|
||||||
"document_type_label": "出租车/网约车票据",
|
"document_type_label": "出租车/网约车票据",
|
||||||
"scene_code": "transport",
|
"scene_code": "transport",
|
||||||
"scene_label": "交通票据",
|
"scene_label": "交通票据",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"key": "amount",
|
"key": "amount",
|
||||||
"label": "金额",
|
"label": "金额",
|
||||||
"value": "35.53元"
|
"value": "35.53元"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "date",
|
"key": "date",
|
||||||
"label": "日期",
|
"label": "日期",
|
||||||
"value": "2026-03-04"
|
"value": "2026-03-04"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "merchant_name",
|
"key": "merchant_name",
|
||||||
"label": "商户",
|
"label": "商户",
|
||||||
"value": "全季酒店"
|
"value": "全季酒店"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"requirement_check": {
|
"requirement_check": {
|
||||||
"matches": true,
|
"matches": true,
|
||||||
"current_expense_type": "transport",
|
"current_expense_type": "transport",
|
||||||
"current_expense_type_label": "交通费",
|
"current_expense_type_label": "交通费",
|
||||||
"allowed_scene_labels": [
|
"allowed_scene_labels": [
|
||||||
"交通"
|
"交通"
|
||||||
],
|
],
|
||||||
"allowed_document_type_labels": [
|
"allowed_document_type_labels": [
|
||||||
"停车/通行费票据",
|
"停车/通行费票据",
|
||||||
"一般收据/凭证",
|
"一般收据/凭证",
|
||||||
"出租车/网约车票据",
|
"出租车/网约车票据",
|
||||||
"增值税发票"
|
"增值税发票"
|
||||||
],
|
],
|
||||||
"recognized_scene_code": "transport",
|
"recognized_scene_code": "transport",
|
||||||
"recognized_scene_label": "交通票据",
|
"recognized_scene_label": "交通票据",
|
||||||
"recognized_document_type": "taxi_receipt",
|
"recognized_document_type": "taxi_receipt",
|
||||||
"recognized_document_type_label": "出租车/网约车票据",
|
"recognized_document_type_label": "出租车/网约车票据",
|
||||||
"mismatch_severity": "high",
|
"mismatch_severity": "high",
|
||||||
"rule_code": "rule.expense.scene_submission_standard",
|
"rule_code": "rule.expense.scene_submission_standard",
|
||||||
"rule_name": "报销场景提交与附件标准",
|
"rule_name": "报销场景提交与附件标准",
|
||||||
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
|
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
|
||||||
},
|
},
|
||||||
"ocr_status": "recognized",
|
"ocr_status": "recognized",
|
||||||
"ocr_error": "",
|
"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_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_summary": "高德地图一打车;行程单;AMAP ITINERARY",
|
||||||
"ocr_avg_score": 0.9819406509399414,
|
"ocr_avg_score": 0.9819406509399414,
|
||||||
"ocr_line_count": 25,
|
"ocr_line_count": 25,
|
||||||
"ocr_classification_source": "rule",
|
"ocr_classification_source": "rule",
|
||||||
"ocr_classification_confidence": 0.88,
|
"ocr_classification_confidence": 0.88,
|
||||||
"ocr_classification_evidence": [
|
"ocr_classification_evidence": [
|
||||||
"滴滴出行",
|
"滴滴出行",
|
||||||
"滴滴",
|
"滴滴",
|
||||||
"打车",
|
"打车",
|
||||||
"上车"
|
"上车"
|
||||||
],
|
],
|
||||||
"ocr_warnings": []
|
"ocr_warnings": []
|
||||||
}
|
}
|
||||||
@@ -1,84 +1,84 @@
|
|||||||
{
|
{
|
||||||
"file_name": "行程单_1_鄂A1S987.pdf",
|
"file_name": "行程单_1_鄂A1S987.pdf",
|
||||||
"storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.pdf",
|
"storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.pdf",
|
||||||
"media_type": "application/pdf",
|
"media_type": "application/pdf",
|
||||||
"size_bytes": 34880,
|
"size_bytes": 34880,
|
||||||
"uploaded_at": "2026-05-16T08:17:53.656595+00:00",
|
"uploaded_at": "2026-05-16T08:17:53.656595+00:00",
|
||||||
"previewable": true,
|
"previewable": true,
|
||||||
"preview_kind": "image",
|
"preview_kind": "image",
|
||||||
"preview_storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.preview.png",
|
"preview_storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.preview.png",
|
||||||
"preview_media_type": "image/png",
|
"preview_media_type": "image/png",
|
||||||
"preview_file_name": "行程单_1_鄂A1S987.preview.png",
|
"preview_file_name": "行程单_1_鄂A1S987.preview.png",
|
||||||
"analysis": {
|
"analysis": {
|
||||||
"severity": "pass",
|
"severity": "pass",
|
||||||
"label": "AI提示符合条件",
|
"label": "AI提示符合条件",
|
||||||
"headline": "AI提示:附件符合基础校验条件",
|
"headline": "AI提示:附件符合基础校验条件",
|
||||||
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||||
"points": [
|
"points": [
|
||||||
"票据类型:已识别为出租车/网约车票据。",
|
"票据类型:已识别为出租车/网约车票据。",
|
||||||
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
|
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
|
||||||
"金额字段:已识别到与当前明细接近的金额 10.30 元。"
|
"金额字段:已识别到与当前明细接近的金额 10.30 元。"
|
||||||
],
|
],
|
||||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||||
},
|
},
|
||||||
"document_info": {
|
"document_info": {
|
||||||
"document_type": "taxi_receipt",
|
"document_type": "taxi_receipt",
|
||||||
"document_type_label": "出租车/网约车票据",
|
"document_type_label": "出租车/网约车票据",
|
||||||
"scene_code": "transport",
|
"scene_code": "transport",
|
||||||
"scene_label": "交通票据",
|
"scene_label": "交通票据",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"key": "amount",
|
"key": "amount",
|
||||||
"label": "金额",
|
"label": "金额",
|
||||||
"value": "10.3元"
|
"value": "10.3元"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "date",
|
"key": "date",
|
||||||
"label": "日期",
|
"label": "日期",
|
||||||
"value": "2026-03-01"
|
"value": "2026-03-01"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "merchant_name",
|
"key": "merchant_name",
|
||||||
"label": "商户",
|
"label": "商户",
|
||||||
"value": "全季酒店"
|
"value": "全季酒店"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"requirement_check": {
|
"requirement_check": {
|
||||||
"matches": true,
|
"matches": true,
|
||||||
"current_expense_type": "transport",
|
"current_expense_type": "transport",
|
||||||
"current_expense_type_label": "交通费",
|
"current_expense_type_label": "交通费",
|
||||||
"allowed_scene_labels": [
|
"allowed_scene_labels": [
|
||||||
"交通"
|
"交通"
|
||||||
],
|
],
|
||||||
"allowed_document_type_labels": [
|
"allowed_document_type_labels": [
|
||||||
"停车/通行费票据",
|
"停车/通行费票据",
|
||||||
"一般收据/凭证",
|
"一般收据/凭证",
|
||||||
"出租车/网约车票据",
|
"出租车/网约车票据",
|
||||||
"增值税发票"
|
"增值税发票"
|
||||||
],
|
],
|
||||||
"recognized_scene_code": "transport",
|
"recognized_scene_code": "transport",
|
||||||
"recognized_scene_label": "交通票据",
|
"recognized_scene_label": "交通票据",
|
||||||
"recognized_document_type": "taxi_receipt",
|
"recognized_document_type": "taxi_receipt",
|
||||||
"recognized_document_type_label": "出租车/网约车票据",
|
"recognized_document_type_label": "出租车/网约车票据",
|
||||||
"mismatch_severity": "high",
|
"mismatch_severity": "high",
|
||||||
"rule_code": "rule.expense.scene_submission_standard",
|
"rule_code": "rule.expense.scene_submission_standard",
|
||||||
"rule_name": "报销场景提交与附件标准",
|
"rule_name": "报销场景提交与附件标准",
|
||||||
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
|
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
|
||||||
},
|
},
|
||||||
"ocr_status": "recognized",
|
"ocr_status": "recognized",
|
||||||
"ocr_error": "",
|
"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_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_summary": "高德地图一打车;行程单;AMAP ITINERARY",
|
||||||
"ocr_avg_score": 0.9844024634361267,
|
"ocr_avg_score": 0.9844024634361267,
|
||||||
"ocr_line_count": 25,
|
"ocr_line_count": 25,
|
||||||
"ocr_classification_source": "rule",
|
"ocr_classification_source": "rule",
|
||||||
"ocr_classification_confidence": 0.88,
|
"ocr_classification_confidence": 0.88,
|
||||||
"ocr_classification_evidence": [
|
"ocr_classification_evidence": [
|
||||||
"滴滴出行",
|
"滴滴出行",
|
||||||
"滴滴",
|
"滴滴",
|
||||||
"打车",
|
"打车",
|
||||||
"上车"
|
"上车"
|
||||||
],
|
],
|
||||||
"ocr_warnings": []
|
"ocr_warnings": []
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -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": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
@@ -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": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
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": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
server/storage/font-test-after-install.png
Normal file
BIN
server/storage/font-test-after-install.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -2,25 +2,46 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"documents": [
|
"documents": [
|
||||||
{
|
{
|
||||||
"id": "bf761bd8eccf402bb676423d64401a56",
|
"id": "2c1cb358f08d44ceb0e4d287133206ec",
|
||||||
"folder": "报销制度",
|
"folder": "报销制度",
|
||||||
"original_name": "远光《公司支出管理办法(2024)》.pdf",
|
"original_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||||
"stored_name": "bf761bd8eccf402bb676423d64401a56__远光《公司支出管理办法(2024)》.pdf",
|
"stored_name": "2c1cb358f08d44ceb0e4d287133206ec__远光《公司支出管理办法(2024)》.pdf",
|
||||||
"mime_type": "application/pdf",
|
"mime_type": "application/pdf",
|
||||||
"extension": "pdf",
|
"extension": "pdf",
|
||||||
"size_bytes": 621401,
|
"size_bytes": 621401,
|
||||||
"sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
"sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
||||||
"created_at": "2026-05-09T08:39:53.788042+00:00",
|
"created_at": "2026-05-17T09:28:28.999515+00:00",
|
||||||
"updated_at": "2026-05-09T08:39:53.788042+00:00",
|
"updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||||
"uploaded_by": "admin",
|
"uploaded_by": "admin",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 3,
|
||||||
"ingest_status_updated_at": "2026-05-16T15:37:12.723203+00:00",
|
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
|
||||||
"ingest_agent_run_id": "run_94562b13f7a54341",
|
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
|
||||||
"ingest_completed_at": "2026-05-16T15:37:12.723203+00:00",
|
|
||||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-09T08:39:53.788042+00:00",
|
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||||
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece"
|
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
||||||
|
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a8f8465df08e455ebe133351721d49f8",
|
||||||
|
"folder": "报销制度",
|
||||||
|
"original_name": "无单需求文档0506.docx",
|
||||||
|
"stored_name": "a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
|
||||||
|
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"extension": "docx",
|
||||||
|
"size_bytes": 454307,
|
||||||
|
"sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9",
|
||||||
|
"created_at": "2026-05-17T13:00:09.485818+00:00",
|
||||||
|
"updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||||
|
"uploaded_by": "admin",
|
||||||
|
"version_number": 1,
|
||||||
|
"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": "run_3a0b0ecb941b4c8e"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,51 @@
|
|||||||
{
|
{
|
||||||
"bf761bd8eccf402bb676423d64401a56": {
|
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||||
"status": "processed",
|
"status": "processed",
|
||||||
"chunks_count": 11,
|
"chunks_count": 10,
|
||||||
"chunks_list": [
|
"chunks_list": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339",
|
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263",
|
||||||
"chunk-0e8b903e5d2a7deeadd9ec0ca70d964c",
|
"chunk-74c01decac4a10cd40a491786743b0ee",
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26",
|
"chunk-061324cc36078214691a6fc1cd0aaeea",
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e",
|
"chunk-613d6dfd4c5e9c807229a3147f96b584",
|
||||||
"chunk-30373ec763ee53fb2c91741699128f30",
|
"chunk-d26b288ed4001dc5c504dce0eb841362",
|
||||||
"chunk-2d84cd4e27b2bcd246988dabe93d2062",
|
"chunk-e9438f69c9e221d9f0f00a05ad84eac6",
|
||||||
"chunk-090b225cc6d57e9bf0cf7e0f34b4760c",
|
"chunk-9841d66d8fb8548aab40220663a51693",
|
||||||
"chunk-8881e68061e1b668defe35b1cd9d8a83",
|
"chunk-afc57a0e9548d1f484da6df6c182676b",
|
||||||
"chunk-cca4d7b1d51b1e831b80471cd168fef0",
|
"chunk-18d968b78afe916b419c1b5973421ebe",
|
||||||
"chunk-78998358de8a8cc3c018264c9a553b4d",
|
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
|
||||||
],
|
],
|
||||||
"content_summary": "# 章节导航\n\n以下内容由入库阶段从制度原文中提取,供检索时优先理解制度层级、条目和标准所在章节。\n\n- 第一章 总则.............................................................. 4\n- 第二章 职责分工 .......................................................... 4\n- 第三章 支出报销申请与审批 ................................",
|
"content_summary": "商密【中】\n\n 远光软件股份有限公司文件\n\n 远光制度〔2024〕14 号\n\n关于颁布《公司支出管理办法(2024)》的\n 通知\n\n公司各部门、分支机构、子公司:\n 为适应公司业务发展需要,优化、完善支出和报销标准,规\n范支出业务审批和报销过程,防范经营风险,依据国家有关法\n律法规,参照国家电网公司和国网数科公司有关管理规定,结\n合市场经营环境和公司实际情况,在广泛征求意见的基础上,\n公司对《公司支出管理办法》进行了修订,现予颁布。本办法自\n颁布之日起...",
|
||||||
"content_length": 25627,
|
"content_length": 25301,
|
||||||
"created_at": "2026-05-16T15:30:53.520431+00:00",
|
"created_at": "2026-05-17T09:57:22.410485+00:00",
|
||||||
"updated_at": "2026-05-16T15:37:12.723203+00:00",
|
"updated_at": "2026-05-17T10:01:33.272539+00:00",
|
||||||
"file_path": "/app/server/storage/knowledge/报销制度/bf761bd8eccf402bb676423d64401a56__远光《公司支出管理办法(2024)》.pdf",
|
"file_path": "/app/server/storage/knowledge/报销制度/2c1cb358f08d44ceb0e4d287133206ec__远光《公司支出管理办法(2024)》.pdf",
|
||||||
"track_id": "insert_20260516_153053_5bdb18b7",
|
"track_id": "insert_20260517_095722_e223c7de",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"processing_start_time": 1778945453,
|
"processing_start_time": 1779011842,
|
||||||
"processing_end_time": 1778945832
|
"processing_end_time": 1779012093
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"a8f8465df08e455ebe133351721d49f8": {
|
||||||
|
"status": "failed",
|
||||||
|
"error_msg": "Embedding func: Worker execution timeout after 60s",
|
||||||
|
"chunks_count": 6,
|
||||||
|
"chunks_list": [
|
||||||
|
"chunk-07de6ea74f60535b689f977295770273",
|
||||||
|
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8",
|
||||||
|
"chunk-1746bd83138e85e66a78e0cb9ad79272",
|
||||||
|
"chunk-ce44e4483e4119265b43eacb72e0326a",
|
||||||
|
"chunk-2187fa0609874bdda339c9850da45a26",
|
||||||
|
"chunk-2224d777c0b72d0b2dab622c79096c2c"
|
||||||
|
],
|
||||||
|
"content_summary": "# 产品需求文档\n## 文档信息\n| 项目 | 内容 |\n|------|------|\n| 项目名称 |\n无单报销\n|\n| 版本 | V1.0 |\n| 日期 | 2026-05-06 |\n| 状态 | 正式版 |\n---\n## 1. 项目概述\n### 1.1 项目背景\n面向\n大型企业,\n从业务人员视角出发,解决现有ERP使用体验不佳的问题。\n在ERP的发展历程中,“单据化”曾是财务合规的一大进步,它确保了每笔支出都有据可查。但不可否认,传统的人工填单确实\n也制造了很多\n“枷锁”。在AI时代,解...",
|
||||||
|
"content_length": 9088,
|
||||||
|
"created_at": "2026-05-19T15:59:57.283110+00:00",
|
||||||
|
"updated_at": "2026-05-19T16:00:57.323299+00:00",
|
||||||
|
"file_path": "/app/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
|
||||||
|
"track_id": "insert_20260519_155957_88c49850",
|
||||||
|
"metadata": {
|
||||||
|
"processing_start_time": 1779206397,
|
||||||
|
"processing_end_time": 1779206457
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,225 +1,268 @@
|
|||||||
{
|
{
|
||||||
"bf761bd8eccf402bb676423d64401a56": {
|
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||||
"entity_names": [
|
"entity_names": [
|
||||||
"公对公结算方式",
|
"工会委员会",
|
||||||
"业务招待",
|
"Business Original Documents",
|
||||||
"营销中心",
|
"First Approver",
|
||||||
"预算外支出",
|
"P8",
|
||||||
"第十四条业务招待费",
|
"一级部门总经理",
|
||||||
"材料采购",
|
"组织人事部",
|
||||||
"对外捐赠支出",
|
"业务原始凭据",
|
||||||
"第五章附则",
|
"营销中心",
|
||||||
"事业部总经理",
|
"保证金",
|
||||||
"第四章重点支出管理规定",
|
"投标保证金",
|
||||||
"Home Visit Travel Expenses Management Policy",
|
"餐补",
|
||||||
"组织人事部",
|
"第十四条业务招待费",
|
||||||
"三流一致原则",
|
"Chief Engineer",
|
||||||
"预算先行原则",
|
"业务招待",
|
||||||
"第五条计划财务部主要职责",
|
"Employee Welfare",
|
||||||
"业务招待费",
|
"经济舱",
|
||||||
"Long-Term Business Accommodation",
|
"2024年4月17日",
|
||||||
"无形资产",
|
"三等舱",
|
||||||
"各级管理人员",
|
"财务信息化系统",
|
||||||
"第二十三条",
|
"分管领导",
|
||||||
"第四条归口管理部门主要职责",
|
"重点支出管理规定",
|
||||||
"附表3",
|
"备用金借款",
|
||||||
"各委员会主任",
|
"Financial Review",
|
||||||
"信息管理部",
|
"第五章附则",
|
||||||
"经办人",
|
"Company Leadership",
|
||||||
"品牌及市场运营中心",
|
"第十九条",
|
||||||
"计划财务部",
|
"经办人",
|
||||||
"Department Head",
|
"预算内支出",
|
||||||
"业务原始凭据",
|
"Current Account Payment",
|
||||||
"经办部门",
|
"Business Entertainment",
|
||||||
"系统单据",
|
"Tax Control System Details",
|
||||||
"保证金",
|
"第二十一条",
|
||||||
"邮递费",
|
"成本中心归属",
|
||||||
"第十一条",
|
"岗位支出报销审批权限表",
|
||||||
"第二十一条",
|
"工会经费管理办法",
|
||||||
"分级授权原则",
|
"商旅系统",
|
||||||
"后勤服务部",
|
"Special Subsidy",
|
||||||
"Company Business Travel System",
|
"中国银行外汇折算价",
|
||||||
"国家电网公司",
|
"因公借款",
|
||||||
"Meeting Expenses",
|
"资产采购",
|
||||||
"工会委员会",
|
"广告费",
|
||||||
"特殊事项",
|
"First-Level Department General Manager",
|
||||||
"前款不清、后款不借",
|
"正式员工",
|
||||||
"Advertising and Promotion Expenses",
|
"一万元",
|
||||||
"广告宣传费",
|
"公司员工教育培训管理办法",
|
||||||
"异地挂职锻炼",
|
"责任原则",
|
||||||
"Grassroots Manager P4",
|
"第二章职责分工",
|
||||||
"Other Employees",
|
"预算先行",
|
||||||
"税控系统明细清单",
|
"Planning and Finance Department",
|
||||||
"商旅订票",
|
"Accommodation Cost Reimbursement",
|
||||||
"分支机构",
|
"Official Vehicle Subsidy",
|
||||||
"低值易耗品",
|
"第四条归口管理部门主要职责",
|
||||||
"产品规划设计部",
|
"Personal Service Compensation",
|
||||||
"培训费",
|
"邮递费",
|
||||||
"第一条规定义",
|
"附表3:支出归口管理部门与归口业务范围",
|
||||||
"经济舱6折及以下",
|
"员工",
|
||||||
"三个月",
|
"第二条目的",
|
||||||
"托运费",
|
"Director",
|
||||||
"Commercial Insurance",
|
"支出归口管理部门与归口业务范围",
|
||||||
"工会经费管理办法",
|
"其他支出(员工)",
|
||||||
"增值税专用发票",
|
"报销标准",
|
||||||
"第七条各级管理人员主要职责",
|
"5000000 Yuan Approval Limit",
|
||||||
"Compensation and Benefits Expenses",
|
"第十一条备用金借款",
|
||||||
"外聘专家",
|
"会议费",
|
||||||
"第十二条市内交通费",
|
"第十七条",
|
||||||
"分类控制原则",
|
"第七条各级管理人员主要职责",
|
||||||
"Value Reimbursement System",
|
"50000 Yuan Approval Limit",
|
||||||
"第八条支出报销申请",
|
"全资子公司",
|
||||||
"会议费",
|
"涉外业务汇率标准",
|
||||||
"轮船三等舱",
|
"总监",
|
||||||
"第四条归口管理",
|
"第十三条差旅费",
|
||||||
"终审岗",
|
"审批权限表",
|
||||||
"总经理",
|
"商旅订票规范",
|
||||||
"中国外汇交易中心",
|
"Final Approval Position",
|
||||||
"Middle And Grassroots Manager P4-P6",
|
"报销资格",
|
||||||
"办公用品",
|
"新增报销规定",
|
||||||
"办公室(党委办公室)",
|
"公司支出管理办法",
|
||||||
"控股子公司",
|
"Institution General Manager",
|
||||||
"支出审批权限表",
|
"房屋租金",
|
||||||
"公司领导",
|
"Staff Activities",
|
||||||
"证券与法律事务部",
|
"分包外包(内部单位)",
|
||||||
"支出审批流转程序",
|
"报销申请时限",
|
||||||
"第二条适用范围",
|
"Financial Information System",
|
||||||
"出差补贴标准",
|
"Expenditure Authorization Approval Scope",
|
||||||
"附表2",
|
"直辖市",
|
||||||
"高铁/动车二等座",
|
"培训费",
|
||||||
"P8",
|
"第十二条市内交通费",
|
||||||
"报销资料规范",
|
"第十五条",
|
||||||
"Staff P1-P3",
|
"终审岗",
|
||||||
"2024年4月17日",
|
"Remote Work Housing",
|
||||||
"董事长",
|
"Centralized Management department",
|
||||||
"通信费",
|
"第二十条",
|
||||||
"财务审核时限",
|
"办公室(党委办公室)",
|
||||||
"一万元",
|
"Three Flows Consistency Principle",
|
||||||
"工会支出",
|
"审批权限",
|
||||||
"交通工具等级标准",
|
"VAT Special Invoice",
|
||||||
"第二十条",
|
"后勤服务部",
|
||||||
"影像扫描",
|
"员工支出报销审批权限表",
|
||||||
"异地调动邮寄费",
|
"公司总裁",
|
||||||
"第二十二条",
|
"出差补贴",
|
||||||
"基层经理",
|
"Basic Level Managers",
|
||||||
"第二十四条附件",
|
"预付款项",
|
||||||
"公对私结算方式",
|
"附表1:员工支出报销审批权限表",
|
||||||
"第二十三条本办法的归口与实施",
|
"经办部门",
|
||||||
"异地挂职锻炼补贴标准",
|
"信息管理部",
|
||||||
"公司酒店住宿限额标准",
|
"通信费",
|
||||||
"经办部门(个人)",
|
"第十六条",
|
||||||
"投标保证金",
|
"增值税发票",
|
||||||
"远光制度〔2024〕14号",
|
"财务入账条件",
|
||||||
"Business Travel",
|
"Hotel Accommodation Standards",
|
||||||
"Communication Expenses",
|
"审批流转程序",
|
||||||
"交通费",
|
"Self-Driving Travel Provisions",
|
||||||
"远光软件股份有限公司",
|
"交通费",
|
||||||
"第三条管理原则",
|
"第九条支出报销审批",
|
||||||
"全资子公司",
|
"薪酬福利支出分配计划",
|
||||||
"第十三条差旅费",
|
"产品规划设计部",
|
||||||
"薪酬福利支出",
|
"因公用车补贴",
|
||||||
"Relocation Expenses",
|
"Committee Chairpersons",
|
||||||
"住宿费",
|
"Business Division General Manager",
|
||||||
"公司支出管理办法(2024)",
|
"组织安排",
|
||||||
"中层经理",
|
"1 Yuan Per Person Per Kilometer Reimbursement",
|
||||||
"第六条经办部门主要职责",
|
"Separation of Approval and Processing Principle",
|
||||||
"Business Trip",
|
"第五条计划财务部主要职责",
|
||||||
"批办分离原则",
|
"200000 Yuan Approval Limit",
|
||||||
"备用金借款",
|
"公司各部门",
|
||||||
"岗位支出业务",
|
"第十四条",
|
||||||
"公司支出管理办法",
|
"Other Areas",
|
||||||
"第二十四条",
|
"分支机构",
|
||||||
"报销业务",
|
"Departments And Units",
|
||||||
"第七条管理人员",
|
"计划财务部",
|
||||||
"外包分包业务",
|
"Other Employees",
|
||||||
"归口管理部门",
|
"第二十三条",
|
||||||
"第十条",
|
"公司团建管理办法",
|
||||||
"财务审核",
|
"火车硬席",
|
||||||
"备用金",
|
"税控系统明细清单",
|
||||||
"预付款项",
|
"Trade Union Fund",
|
||||||
"支出报销申请",
|
"报销标准变化情况",
|
||||||
"公司团建管理办法",
|
"薪酬福利支出",
|
||||||
"总工程师",
|
"Hong Kong, Macau, And Taiwan Region",
|
||||||
"商旅系统",
|
"对外捐赠支出",
|
||||||
"Training Expenses",
|
"Multi-Level Approval Rule",
|
||||||
"固定资产",
|
"Three Working Days Deadline",
|
||||||
"DAP研发中心",
|
"Employee Remuneration",
|
||||||
"第五条计划财务部",
|
"销售退款",
|
||||||
"第一条目的",
|
"股权投资、兼并收购",
|
||||||
"全列软席列车二等座",
|
"控股子公司",
|
||||||
"涉外业务汇率标准",
|
"取消报销规定",
|
||||||
"客服及商务",
|
"Procurement Management Regulations",
|
||||||
"其他支出",
|
"Middle Managers",
|
||||||
"快递费",
|
"差旅费",
|
||||||
"需求计划",
|
"批办分离",
|
||||||
"党委办公室",
|
"住宿费",
|
||||||
"财务信息化系统",
|
"Travel Allowance Standards",
|
||||||
"P5及以上",
|
"第二十三条本办法的归口与实施",
|
||||||
"因公借款",
|
"Senior Vice President",
|
||||||
"效益优先原则",
|
"供应商",
|
||||||
"市内交通",
|
"人事归口管理部门",
|
||||||
"Business Entertainment Expenses",
|
"Management Personnel At All Levels",
|
||||||
"第二章职责分工",
|
"效益优先",
|
||||||
"出差补贴",
|
"Operating Department Individual",
|
||||||
"人力资源服务部",
|
"Remote Work Housing Rental Expenses",
|
||||||
"P4及以下",
|
"取消报销规定内容",
|
||||||
"因公用车补贴",
|
"Company",
|
||||||
"Company Leader P8 And Above",
|
"修订说明",
|
||||||
"国网数科公司",
|
"国网数科公司",
|
||||||
"Mailing and Courier Expenses",
|
"Vice President",
|
||||||
"季度清理",
|
"分级授权",
|
||||||
"附表1",
|
"Expenditure Reimbursement Application",
|
||||||
"正式员工",
|
"第二十四条附件",
|
||||||
"审批流转程序",
|
"第二十二条",
|
||||||
"后续审批人",
|
"出租车",
|
||||||
"商密【中】",
|
"Night High-Speed Rail Provision",
|
||||||
"High-Level Manager P7",
|
"各级管理人员",
|
||||||
"Home Visit Travel Expenses",
|
"受益原则",
|
||||||
"支出成本中心归属",
|
"公司员工因公通讯费用实施细则",
|
||||||
"供应商",
|
"公司支出管理办法(2024)",
|
||||||
"High-Speed Rail And Bullet Train",
|
"出差补贴标准",
|
||||||
"资产采购",
|
"Bid Security Deposit Approval Limits Table",
|
||||||
"附表1:员工支出报销审批权限表",
|
"第二条范围",
|
||||||
"第九条支出报销审批",
|
"Company Property Rental Management",
|
||||||
"审批权限",
|
"调动工作",
|
||||||
"经济舱5折及以下",
|
"远光软件股份有限公司",
|
||||||
"产业投资部",
|
"市内交通费",
|
||||||
"第六条经办部门",
|
"交通工具等级标准",
|
||||||
"第十条支出成本中心归属",
|
"Operator",
|
||||||
"第九条",
|
"第八条支出报销申请",
|
||||||
"邮件费",
|
"Directly-Controlled Municipalities And Special Administrative Regions",
|
||||||
"第四章",
|
"出差规定",
|
||||||
"第十三条",
|
"业务招待费",
|
||||||
"Travel Allowance",
|
"Senior Managers",
|
||||||
"第十一条备用金借款",
|
"逐级审批规则",
|
||||||
"财务部门",
|
"Company Business Travel System",
|
||||||
"公司",
|
"广告宣传费",
|
||||||
"中国银行",
|
"Transportation Cost Reimbursement",
|
||||||
"Business Travel Ticket Booking",
|
"财务",
|
||||||
"市内交通费",
|
"第一章总则",
|
||||||
"发票",
|
"材料采购",
|
||||||
"第十二条",
|
"人力资源服务部",
|
||||||
"支出报销审批",
|
"证券与法律事务部",
|
||||||
"经济舱",
|
"Transportation Level Standards",
|
||||||
"第一审批人",
|
"归口管理部门",
|
||||||
"品牌",
|
"商旅客服",
|
||||||
"火车硬席",
|
"第四章重点支出管理规定",
|
||||||
"审批时限",
|
"出差审批程序",
|
||||||
"预算内支出",
|
"Business Trip Approval",
|
||||||
"President",
|
"西藏",
|
||||||
"差旅费",
|
"附表2:岗位支出报销审批权限表",
|
||||||
"高层经理",
|
"第十八条",
|
||||||
"厉行节约原则",
|
"第二十四条",
|
||||||
"第三章支出报销申请与审批",
|
"Company Hotel Accommodation Limit Standards",
|
||||||
"第二条范围",
|
"办法",
|
||||||
"基建工程",
|
"DAP研发中心",
|
||||||
"逐级审批规则",
|
"新增规定内容",
|
||||||
"招标采购规定",
|
"基本补助",
|
||||||
"第一章总则"
|
"Travel Allowance",
|
||||||
],
|
"异地挂职锻炼补贴标准",
|
||||||
"count": 215,
|
"部门负责人",
|
||||||
"create_time": 1778945832,
|
"Provincial Capitals",
|
||||||
"update_time": 1778945832,
|
"特区",
|
||||||
"_id": "bf761bd8eccf402bb676423d64401a56"
|
"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,182 +1,166 @@
|
|||||||
{
|
{
|
||||||
"bf761bd8eccf402bb676423d64401a56": {
|
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||||
"relation_pairs": [
|
"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"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"第二十四条",
|
"第十一条备用金借款",
|
||||||
"附表2"
|
"第四章重点支出管理规定"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"第二十三条",
|
"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"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"count": 43,
|
|
||||||
"create_time": 1778945832,
|
|
||||||
"update_time": 1778945832,
|
|
||||||
"_id": "bf761bd8eccf402bb676423d64401a56"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,389 +1,353 @@
|
|||||||
{
|
{
|
||||||
"第一章总则<SEP>远光软件股份有限公司": {
|
"第一章总则<SEP>远光软件股份有限公司": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945805,
|
"create_time": 1779012088,
|
||||||
"update_time": 1778945805,
|
"update_time": 1779012088,
|
||||||
"_id": "第一章总则<SEP>远光软件股份有限公司"
|
"_id": "第一章总则<SEP>远光软件股份有限公司"
|
||||||
},
|
},
|
||||||
"第三章支出报销申请与审批<SEP>财务信息化系统": {
|
"第十一条备用金借款<SEP>第四章重点支出管理规定": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945805,
|
"create_time": 1779012088,
|
||||||
"update_time": 1778945805,
|
"update_time": 1779012088,
|
||||||
"_id": "第三章支出报销申请与审批<SEP>财务信息化系统"
|
"_id": "第十一条备用金借款<SEP>第四章重点支出管理规定"
|
||||||
},
|
},
|
||||||
"第十一条备用金借款<SEP>第四章重点支出管理规定": {
|
"公司支出管理办法<SEP>办公室(党委办公室)": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945805,
|
"create_time": 1779012088,
|
||||||
"update_time": 1778945805,
|
"update_time": 1779012088,
|
||||||
"_id": "第十一条备用金借款<SEP>第四章重点支出管理规定"
|
"_id": "公司支出管理办法<SEP>办公室(党委办公室)"
|
||||||
},
|
},
|
||||||
"第二十三条本办法的归口与实施<SEP>第五章附则": {
|
"计划财务部<SEP>远光软件股份有限公司": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945805,
|
"create_time": 1779012076,
|
||||||
"update_time": 1778945805,
|
"update_time": 1779012076,
|
||||||
"_id": "第二十三条本办法的归口与实施<SEP>第五章附则"
|
"_id": "计划财务部<SEP>远光软件股份有限公司"
|
||||||
},
|
},
|
||||||
"第十二条市内交通费<SEP>第四章重点支出管理规定": {
|
"第一章总则<SEP>第三条管理原则": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945808,
|
"create_time": 1779012076,
|
||||||
"update_time": 1778945808,
|
"update_time": 1779012076,
|
||||||
"_id": "第十二条市内交通费<SEP>第四章重点支出管理规定"
|
"_id": "第一章总则<SEP>第三条管理原则"
|
||||||
},
|
},
|
||||||
"归口管理部门<SEP>报销业务": {
|
"Company<SEP>Management Personnel At All Levels": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945808,
|
"create_time": 1779012076,
|
||||||
"update_time": 1778945808,
|
"update_time": 1779012076,
|
||||||
"_id": "归口管理部门<SEP>报销业务"
|
"_id": "Company<SEP>Management Personnel At All Levels"
|
||||||
},
|
},
|
||||||
"第十三条差旅费<SEP>第四章重点支出管理规定": {
|
"Centralized Management department<SEP>Company": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945808,
|
"create_time": 1779012077,
|
||||||
"update_time": 1778945808,
|
"update_time": 1779012077,
|
||||||
"_id": "第十三条差旅费<SEP>第四章重点支出管理规定"
|
"_id": "Centralized Management department<SEP>Company"
|
||||||
},
|
},
|
||||||
"第二十四条附件<SEP>第五章附则": {
|
"Company<SEP>Planning and Finance Department": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945809,
|
"create_time": 1779012077,
|
||||||
"update_time": 1778945809,
|
"update_time": 1779012077,
|
||||||
"_id": "第二十四条附件<SEP>第五章附则"
|
"_id": "Company<SEP>Planning and Finance Department"
|
||||||
},
|
},
|
||||||
"业务原始凭据<SEP>经办人": {
|
"Company<SEP>Operating Department Individual": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945809,
|
"create_time": 1779012078,
|
||||||
"update_time": 1778945809,
|
"update_time": 1779012078,
|
||||||
"_id": "业务原始凭据<SEP>经办人"
|
"_id": "Company<SEP>Operating Department Individual"
|
||||||
},
|
},
|
||||||
"报销业务<SEP>财务部门": {
|
"公司支出管理办法<SEP>工会委员会": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012079,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012079,
|
||||||
"_id": "报销业务<SEP>财务部门"
|
"_id": "公司支出管理办法<SEP>工会委员会"
|
||||||
},
|
},
|
||||||
"增值税专用发票<SEP>税控系统明细清单": {
|
"Expenditure Reimbursement Application<SEP>Operator": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012079,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012079,
|
||||||
"_id": "增值税专用发票<SEP>税控系统明细清单"
|
"_id": "Expenditure Reimbursement Application<SEP>Operator"
|
||||||
},
|
},
|
||||||
"第十四条业务招待费<SEP>第四章重点支出管理规定": {
|
"公司支出管理办法<SEP>营销中心": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012079,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012079,
|
||||||
"_id": "第十四条业务招待费<SEP>第四章重点支出管理规定"
|
"_id": "公司支出管理办法<SEP>营销中心"
|
||||||
},
|
},
|
||||||
"经办部门<SEP>需求计划": {
|
"第四条归口管理部门主要职责<SEP>计划财务部": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012079,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012079,
|
||||||
"_id": "经办部门<SEP>需求计划"
|
"_id": "第四条归口管理部门主要职责<SEP>计划财务部"
|
||||||
},
|
},
|
||||||
"系统单据<SEP>经办人": {
|
"Tax Control System Details<SEP>VAT Special Invoice": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012079,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012079,
|
||||||
"_id": "系统单据<SEP>经办人"
|
"_id": "Tax Control System Details<SEP>VAT Special Invoice"
|
||||||
},
|
},
|
||||||
"报销业务<SEP>经办部门": {
|
"Operating Department Individual<SEP>Procurement Management Regulations": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012081,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012081,
|
||||||
"_id": "报销业务<SEP>经办部门"
|
"_id": "Operating Department Individual<SEP>Procurement Management Regulations"
|
||||||
},
|
},
|
||||||
"供应商<SEP>公司": {
|
"Business Original Documents<SEP>Operator": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012094,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012094,
|
||||||
"_id": "供应商<SEP>公司"
|
"_id": "Business Original Documents<SEP>Operator"
|
||||||
},
|
},
|
||||||
"工会委员会<SEP>工会支出": {
|
"Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012094,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012094,
|
||||||
"_id": "工会委员会<SEP>工会支出"
|
"_id": "Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice"
|
||||||
},
|
},
|
||||||
"出差补贴<SEP>组织人事部": {
|
"公司<SEP>第十七条": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012094,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012094,
|
||||||
"_id": "出差补贴<SEP>组织人事部"
|
"_id": "公司<SEP>第十七条"
|
||||||
},
|
},
|
||||||
"公司支出管理办法<SEP>差旅费": {
|
"Operator<SEP>Three Working Days Deadline": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945812,
|
"create_time": 1779012083,
|
||||||
"update_time": 1778945812,
|
"update_time": 1779012083,
|
||||||
"_id": "公司支出管理办法<SEP>差旅费"
|
"_id": "Operator<SEP>Three Working Days Deadline"
|
||||||
},
|
},
|
||||||
"各级管理人员<SEP>报销业务": {
|
"Departments And Units<SEP>Night High-Speed Rail Provision": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-613d6dfd4c5e9c807229a3147f96b584"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945813,
|
"create_time": 1779012084,
|
||||||
"update_time": 1778945813,
|
"update_time": 1779012084,
|
||||||
"_id": "各级管理人员<SEP>报销业务"
|
"_id": "Departments And Units<SEP>Night High-Speed Rail Provision"
|
||||||
},
|
},
|
||||||
"公司支出管理办法<SEP>投标保证金": {
|
"公司<SEP>第十八条": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945815,
|
"create_time": 1779012084,
|
||||||
"update_time": 1778945815,
|
"update_time": 1779012084,
|
||||||
"_id": "公司支出管理办法<SEP>投标保证金"
|
"_id": "公司<SEP>第十八条"
|
||||||
},
|
},
|
||||||
"第二十条<SEP>薪酬福利支出": {
|
"公司<SEP>第十九条": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945815,
|
"create_time": 1779012084,
|
||||||
"update_time": 1778945815,
|
"update_time": 1779012084,
|
||||||
"_id": "第二十条<SEP>薪酬福利支出"
|
"_id": "公司<SEP>第十九条"
|
||||||
},
|
},
|
||||||
"对外捐赠支出<SEP>第二十一条": {
|
"报销标准变化情况<SEP>远光软件股份有限公司": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
"chunk-18d968b78afe916b419c1b5973421ebe"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945815,
|
"create_time": 1779012084,
|
||||||
"update_time": 1778945815,
|
"update_time": 1779012084,
|
||||||
"_id": "对外捐赠支出<SEP>第二十一条"
|
"_id": "报销标准变化情况<SEP>远光软件股份有限公司"
|
||||||
},
|
},
|
||||||
"涉外业务汇率标准<SEP>第二十二条": {
|
"取消报销规定内容<SEP>报销标准变化情况": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
"chunk-18d968b78afe916b419c1b5973421ebe"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945827,
|
"create_time": 1779012085,
|
||||||
"update_time": 1778945827,
|
"update_time": 1779012085,
|
||||||
"_id": "涉外业务汇率标准<SEP>第二十二条"
|
"_id": "取消报销规定内容<SEP>报销标准变化情况"
|
||||||
},
|
},
|
||||||
"第二十三条<SEP>计划财务部": {
|
"Financial Review<SEP>Operator": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945815,
|
"create_time": 1779012085,
|
||||||
"update_time": 1778945815,
|
"update_time": 1779012085,
|
||||||
"_id": "第二十三条<SEP>计划财务部"
|
"_id": "Financial Review<SEP>Operator"
|
||||||
},
|
},
|
||||||
"第二十四条<SEP>附表1": {
|
"公司支出管理办法(2024)<SEP>远光软件股份有限公司": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945816,
|
"create_time": 1779012085,
|
||||||
"update_time": 1778945816,
|
"update_time": 1779012085,
|
||||||
"_id": "第二十四条<SEP>附表1"
|
"_id": "公司支出管理办法(2024)<SEP>远光软件股份有限公司"
|
||||||
},
|
},
|
||||||
"第二十四条<SEP>附表2": {
|
"远光制度〔2024〕14号<SEP>远光软件股份有限公司": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945816,
|
"create_time": 1779012086,
|
||||||
"update_time": 1778945816,
|
"update_time": 1779012086,
|
||||||
"_id": "第二十四条<SEP>附表2"
|
"_id": "远光制度〔2024〕14号<SEP>远光软件股份有限公司"
|
||||||
},
|
},
|
||||||
"发票<SEP>报销业务": {
|
"Departments And Units<SEP>Taxi Usage Regulations": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-613d6dfd4c5e9c807229a3147f96b584"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945816,
|
"create_time": 1779012099,
|
||||||
"update_time": 1778945816,
|
"update_time": 1779012099,
|
||||||
"_id": "发票<SEP>报销业务"
|
"_id": "Departments And Units<SEP>Taxi Usage Regulations"
|
||||||
},
|
},
|
||||||
"支出审批流转程序<SEP>逐级审批规则": {
|
"控股子公司<SEP>计划财务部": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945816,
|
"create_time": 1779012099,
|
||||||
"update_time": 1778945816,
|
"update_time": 1779012099,
|
||||||
"_id": "支出审批流转程序<SEP>逐级审批规则"
|
"_id": "控股子公司<SEP>计划财务部"
|
||||||
},
|
},
|
||||||
"公司支出管理办法<SEP>审批流转程序": {
|
"公司<SEP>第二十条": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945820,
|
"create_time": 1779012086,
|
||||||
"update_time": 1778945820,
|
"update_time": 1779012086,
|
||||||
"_id": "公司支出管理办法<SEP>审批流转程序"
|
"_id": "公司<SEP>第二十条"
|
||||||
},
|
},
|
||||||
"特殊事项<SEP>终审岗": {
|
"商旅系统<SEP>差旅费": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945820,
|
"create_time": 1779012086,
|
||||||
"update_time": 1778945820,
|
"update_time": 1779012086,
|
||||||
"_id": "特殊事项<SEP>终审岗"
|
"_id": "商旅系统<SEP>差旅费"
|
||||||
},
|
},
|
||||||
"事业部总经理<SEP>逐级审批规则": {
|
"业务招待费<SEP>差旅费": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945821,
|
"create_time": 1779012089,
|
||||||
"update_time": 1778945821,
|
"update_time": 1779012089,
|
||||||
"_id": "事业部总经理<SEP>逐级审批规则"
|
"_id": "业务招待费<SEP>差旅费"
|
||||||
},
|
},
|
||||||
"计划财务部<SEP>远光软件股份有限公司": {
|
"公司<SEP>第二十一条": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945821,
|
"create_time": 1779012089,
|
||||||
"update_time": 1778945821,
|
"update_time": 1779012089,
|
||||||
"_id": "计划财务部<SEP>远光软件股份有限公司"
|
"_id": "公司<SEP>第二十一条"
|
||||||
},
|
},
|
||||||
"发票<SEP>经办人": {
|
"广告宣传费<SEP>第十六条": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945822,
|
"create_time": 1779012089,
|
||||||
"update_time": 1778945822,
|
"update_time": 1779012089,
|
||||||
"_id": "发票<SEP>经办人"
|
"_id": "广告宣传费<SEP>第十六条"
|
||||||
},
|
},
|
||||||
"支出审批流转程序<SEP>终审岗": {
|
"组织人事部<SEP>调动工作": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945823,
|
"create_time": 1779012090,
|
||||||
"update_time": 1778945823,
|
"update_time": 1779012090,
|
||||||
"_id": "支出审批流转程序<SEP>终审岗"
|
"_id": "组织人事部<SEP>调动工作"
|
||||||
},
|
},
|
||||||
"归口管理部门<SEP>远光软件股份有限公司": {
|
"会议费<SEP>差旅费": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945824,
|
"create_time": 1779012092,
|
||||||
"update_time": 1778945824,
|
"update_time": 1779012092,
|
||||||
"_id": "归口管理部门<SEP>远光软件股份有限公司"
|
"_id": "会议费<SEP>差旅费"
|
||||||
},
|
},
|
||||||
"国家电网公司<SEP>远光软件股份有限公司": {
|
"业务招待费<SEP>第十四条": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945824,
|
"create_time": 1779012092,
|
||||||
"update_time": 1778945824,
|
"update_time": 1779012092,
|
||||||
"_id": "国家电网公司<SEP>远光软件股份有限公司"
|
"_id": "业务招待费<SEP>第十四条"
|
||||||
},
|
},
|
||||||
"归口管理部门<SEP>计划财务部": {
|
"会议费<SEP>第十五条": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945824,
|
"create_time": 1779012092,
|
||||||
"update_time": 1778945824,
|
"update_time": 1779012092,
|
||||||
"_id": "归口管理部门<SEP>计划财务部"
|
"_id": "会议费<SEP>第十五条"
|
||||||
},
|
},
|
||||||
"各级管理人员<SEP>支出报销审批": {
|
"会议费<SEP>公司总裁": {
|
||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"create_time": 1778945824,
|
"create_time": 1779012093,
|
||||||
"update_time": 1778945824,
|
"update_time": 1779012093,
|
||||||
"_id": "各级管理人员<SEP>支出报销审批"
|
"_id": "会议费<SEP>公司总裁"
|
||||||
},
|
}
|
||||||
"国网数科公司<SEP>远光软件股份有限公司": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1778945828,
|
|
||||||
"update_time": 1778945828,
|
|
||||||
"_id": "国网数科公司<SEP>远光软件股份有限公司"
|
|
||||||
},
|
|
||||||
"分支机构<SEP>远光软件股份有限公司": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1778945831,
|
|
||||||
"update_time": 1778945831,
|
|
||||||
"_id": "分支机构<SEP>远光软件股份有限公司"
|
|
||||||
},
|
|
||||||
"全资子公司<SEP>远光软件股份有限公司": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1778945832,
|
|
||||||
"update_time": 1778945832,
|
|
||||||
"_id": "全资子公司<SEP>远光软件股份有限公司"
|
|
||||||
},
|
|
||||||
"控股子公司<SEP>远光软件股份有限公司": {
|
|
||||||
"chunk_ids": [
|
|
||||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
|
||||||
],
|
|
||||||
"count": 1,
|
|
||||||
"create_time": 1778945832,
|
|
||||||
"update_time": 1778945832,
|
|
||||||
"_id": "控股子公司<SEP>远光软件股份有限公司"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
22
server/tests/test_agent_asset_onlyoffice_key.py
Normal file
22
server/tests/test_agent_asset_onlyoffice_key.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from app.services.agent_asset_spreadsheet import RuleSpreadsheetMeta
|
||||||
|
from app.services.agent_assets import AgentAssetService
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_spreadsheet_onlyoffice_key_uses_safe_characters() -> None:
|
||||||
|
metadata = RuleSpreadsheetMeta(
|
||||||
|
file_name="公司差旅费报销规则.xlsx",
|
||||||
|
storage_key="rules/finance-rules/公司差旅费报销规则.xlsx",
|
||||||
|
mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
size_bytes=1,
|
||||||
|
checksum="abc123",
|
||||||
|
updated_at="2026-05-17T14:32:00+00:00",
|
||||||
|
updated_by="system",
|
||||||
|
)
|
||||||
|
|
||||||
|
key = AgentAssetService._build_onlyoffice_document_key(
|
||||||
|
"asset:id",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert key == "asset_id-abc123"
|
||||||
|
assert ":" not in key
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
from app.core.agent_enums import (
|
from app.core.agent_enums import (
|
||||||
AgentAssetContentType,
|
AgentAssetContentType,
|
||||||
AgentAssetDomain,
|
AgentAssetDomain,
|
||||||
@@ -17,15 +22,61 @@ from app.core.agent_enums import (
|
|||||||
AgentRunSource,
|
AgentRunSource,
|
||||||
AgentRunStatus,
|
AgentRunStatus,
|
||||||
)
|
)
|
||||||
|
from app.core.config import SERVER_DIR
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
from app.models.agent_asset import AgentAsset
|
||||||
|
from app.models.employee import Employee
|
||||||
from app.schemas.agent_asset import (
|
from app.schemas.agent_asset import (
|
||||||
AgentAssetCreate,
|
AgentAssetCreate,
|
||||||
AgentAssetReviewCreate,
|
AgentAssetReviewCreate,
|
||||||
AgentAssetVersionCreate,
|
AgentAssetVersionCreate,
|
||||||
)
|
)
|
||||||
|
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||||
|
from app.services.agent_asset_spreadsheet import (
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
|
FINANCE_RULES_LIBRARY,
|
||||||
|
)
|
||||||
from app.services.agent_assets import AgentAssetService
|
from app.services.agent_assets import AgentAssetService
|
||||||
from app.services.agent_runs import AgentRunService
|
from app.services.agent_runs import AgentRunService
|
||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
|
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||||
|
from app.services.settings import OnlyOfficeRuntimeConfig
|
||||||
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
|
||||||
|
temp_server_dir = tmp_path / "server"
|
||||||
|
temp_rules_root = temp_server_dir / "rules"
|
||||||
|
temp_finance_rules = temp_rules_root / FINANCE_RULES_LIBRARY
|
||||||
|
temp_finance_rules.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY
|
||||||
|
for file_name in (
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
|
):
|
||||||
|
source_path = real_finance_rules / file_name
|
||||||
|
if source_path.exists():
|
||||||
|
shutil.copy2(source_path, temp_finance_rules / file_name)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.agent_asset_spreadsheet.SERVER_DIR",
|
||||||
|
temp_server_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
def init_manager(self, storage_root=None, rule_root=None) -> None:
|
||||||
|
self.storage_root = Path(storage_root or tmp_path / "storage").resolve()
|
||||||
|
self.asset_root = (self.storage_root / "agent_assets").resolve()
|
||||||
|
self.rule_root = Path(rule_root or temp_rules_root).resolve()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.agent_asset_spreadsheet.AgentAssetSpreadsheetManager.__init__",
|
||||||
|
init_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_session() -> Session:
|
def build_session() -> Session:
|
||||||
@@ -39,6 +90,30 @@ def build_session() -> Session:
|
|||||||
return session_factory()
|
return session_factory()
|
||||||
|
|
||||||
|
|
||||||
|
def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则表") -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = sheet_name
|
||||||
|
for row in rows:
|
||||||
|
sheet.append(row)
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_multi_sheet_workbook_bytes(sheets: dict[str, list[list[object]]]) -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
default_sheet = workbook.active
|
||||||
|
workbook.remove(default_sheet)
|
||||||
|
for sheet_name, rows in sheets.items():
|
||||||
|
sheet = workbook.create_sheet(sheet_name)
|
||||||
|
for row in rows:
|
||||||
|
sheet.append(row)
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
|
def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
@@ -46,7 +121,8 @@ def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation(
|
|||||||
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
assert len(rules) >= 3
|
assert len(rules) >= 3
|
||||||
assert any(
|
assert any(
|
||||||
item.code == "rule.expense.travel_risk_control_standard" and item.status == AgentAssetStatus.ACTIVE.value
|
item.code == "rule.expense.travel_risk_control_standard"
|
||||||
|
and item.status == AgentAssetStatus.ACTIVE.value
|
||||||
for item in rules
|
for item in rules
|
||||||
)
|
)
|
||||||
assert all(
|
assert all(
|
||||||
@@ -75,6 +151,26 @@ def test_agent_asset_service_seeds_all_foundation_asset_types() -> None:
|
|||||||
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
|
||||||
|
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
travel_rule = next(item for item in rules if item.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
|
communication_rule = next(
|
||||||
|
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
|
||||||
|
)
|
||||||
|
travel_config = travel_rule.config_json or {}
|
||||||
|
communication_config = communication_rule.config_json or {}
|
||||||
|
|
||||||
|
assert travel_rule.scenario_json == ["差旅"]
|
||||||
|
assert travel_config["scenario_category"] == "差旅"
|
||||||
|
assert travel_config["ai_review_category"] == "差旅"
|
||||||
|
assert communication_rule.scenario_json == ["费用科目"]
|
||||||
|
assert communication_config["scenario_category"] == "费用科目"
|
||||||
|
assert communication_config["ai_review_category"] == "费用科目"
|
||||||
|
|
||||||
|
|
||||||
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
@@ -120,10 +216,370 @@ def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
|||||||
|
|
||||||
assert activated.status == AgentAssetStatus.ACTIVE.value
|
assert activated.status == AgentAssetStatus.ACTIVE.value
|
||||||
assert activated.current_version == "v1.0.0"
|
assert activated.current_version == "v1.0.0"
|
||||||
|
assert activated.working_version == "v1.0.0"
|
||||||
|
assert activated.published_version == "v1.0.0"
|
||||||
assert activated.latest_review is not None
|
assert activated.latest_review is not None
|
||||||
assert activated.latest_review.review_status == AgentReviewStatus.APPROVED.value
|
assert activated.latest_review.review_status == AgentReviewStatus.APPROVED.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_working_version_does_not_replace_published_version_until_activation() -> 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
created = service.create_version(
|
||||||
|
rule.id,
|
||||||
|
AgentAssetVersionCreate(
|
||||||
|
version="v1.1.1",
|
||||||
|
content="# 差旅报销风险管控制度\n\n- 工作稿",
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN,
|
||||||
|
change_note="新增工作稿",
|
||||||
|
created_by="finance_user",
|
||||||
|
),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
detail = service.get_asset(rule.id)
|
||||||
|
|
||||||
|
assert created.is_working is True
|
||||||
|
assert created.is_published is False
|
||||||
|
assert created.lifecycle_state == "draft"
|
||||||
|
assert detail is not None
|
||||||
|
assert detail.status == AgentAssetStatus.ACTIVE.value
|
||||||
|
assert detail.current_version == "v1.1.1"
|
||||||
|
assert detail.working_version == "v1.1.1"
|
||||||
|
assert detail.published_version == "v1.1.0"
|
||||||
|
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)
|
||||||
|
rule = next(
|
||||||
|
item
|
||||||
|
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
if item.code == "rule.expense.travel_risk_control_standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
service.create_version(
|
||||||
|
rule.id,
|
||||||
|
AgentAssetVersionCreate(
|
||||||
|
version="v1.1.1",
|
||||||
|
content=(
|
||||||
|
"# 工作稿\n\n"
|
||||||
|
'```expense-rule\n{"kind":"travel_policy","version":1}\n```'
|
||||||
|
),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN,
|
||||||
|
change_note="未上线草稿",
|
||||||
|
created_by="finance_user",
|
||||||
|
),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||||||
|
|
||||||
|
assert catalog.travel_policy is not None
|
||||||
|
assert catalog.travel_policy.rule_version == "v1.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_version_creates_new_working_copy_without_rewriting_published_version() -> 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
restored = service.restore_version_as_working_copy(
|
||||||
|
rule.id,
|
||||||
|
"v1.0.0",
|
||||||
|
actor="manager_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert restored.working_version == "v1.1.1"
|
||||||
|
assert restored.current_version == "v1.1.1"
|
||||||
|
assert restored.published_version == "v1.1.0"
|
||||||
|
assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spreadsheet_upload_records_sheet_and_cell_changes_without_versions() -> 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_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
service.upload_rule_spreadsheet(
|
||||||
|
rule.id,
|
||||||
|
filename="公司差旅费报销规则.xlsx",
|
||||||
|
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 any(
|
||||||
|
item.cell == "A3" and item.change_type == "added"
|
||||||
|
for item in latest.cell_changes
|
||||||
|
)
|
||||||
|
assert not hasattr(latest, "version")
|
||||||
|
|
||||||
|
|
||||||
|
def test_spreadsheet_content_reads_current_rule_file_without_version_snapshot() -> 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_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
detail = service.get_asset(rule.id)
|
||||||
|
assert detail is not None
|
||||||
|
|
||||||
|
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)
|
||||||
|
assert not service.spreadsheet_manager.asset_root.exists()
|
||||||
|
|
||||||
|
current_path, _, _ = service.get_rule_spreadsheet_content(rule.id)
|
||||||
|
|
||||||
|
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:
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
service.create_version(
|
||||||
|
rule.id,
|
||||||
|
AgentAssetVersionCreate(
|
||||||
|
version="v1.1.1",
|
||||||
|
content="# 工作稿",
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN,
|
||||||
|
change_note="补充口径",
|
||||||
|
created_by="finance_user",
|
||||||
|
),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
service.create_review(
|
||||||
|
rule.id,
|
||||||
|
AgentAssetReviewCreate(
|
||||||
|
version="v1.1.1",
|
||||||
|
reviewer="finance_user",
|
||||||
|
review_status=AgentReviewStatus.PENDING,
|
||||||
|
review_note="请审核",
|
||||||
|
),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
service.create_review(
|
||||||
|
rule.id,
|
||||||
|
AgentAssetReviewCreate(
|
||||||
|
version="v1.1.1",
|
||||||
|
reviewer="manager_user",
|
||||||
|
review_status=AgentReviewStatus.APPROVED,
|
||||||
|
review_note="可以上线",
|
||||||
|
),
|
||||||
|
actor="manager_user",
|
||||||
|
)
|
||||||
|
service.activate_asset(rule.id, actor="manager_user")
|
||||||
|
|
||||||
|
timeline = service.list_version_timeline(rule.id)
|
||||||
|
event_types = [item.event_type for item in timeline if item.version == "v1.1.1"]
|
||||||
|
|
||||||
|
assert "created" in event_types
|
||||||
|
assert "submitted" in event_types
|
||||||
|
assert "approved" in event_types
|
||||||
|
assert "published" in event_types
|
||||||
|
|
||||||
|
|
||||||
def test_agent_asset_service_returns_recent_versions_for_rule_detail() -> None:
|
def test_agent_asset_service_returns_recent_versions_for_rule_detail() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
@@ -166,6 +622,126 @@ def test_agent_asset_service_returns_travel_policy_rule_detail() -> None:
|
|||||||
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
|
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
travel_spreadsheet_rule = db.scalar(
|
||||||
|
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
|
)
|
||||||
|
assert travel_spreadsheet_rule is not None
|
||||||
|
travel_spreadsheet_rule.status = AgentAssetStatus.REVIEW.value
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||||||
|
|
||||||
|
assert catalog.travel_policy is not None
|
||||||
|
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||||
|
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则"
|
||||||
|
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450
|
||||||
|
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450
|
||||||
|
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500
|
||||||
|
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
|
||||||
|
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
|
||||||
|
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
|
||||||
|
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1
|
||||||
|
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
db.add(
|
||||||
|
Employee(
|
||||||
|
employee_no="E9001",
|
||||||
|
name="测试员工",
|
||||||
|
email="traveler@example.com",
|
||||||
|
position="产品经理",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = TravelReimbursementCalculatorService(db).calculate(
|
||||||
|
TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"),
|
||||||
|
CurrentUserContext(
|
||||||
|
username="traveler@example.com",
|
||||||
|
name="测试员工",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.rule_name == "公司差旅费报销规则"
|
||||||
|
assert result.grade == "P4"
|
||||||
|
assert result.grade_band == "mid"
|
||||||
|
assert result.matched_city == "北京"
|
||||||
|
assert result.hotel_rate == 450
|
||||||
|
assert result.hotel_amount == 1350
|
||||||
|
assert result.allowance_region == "直辖市/特区"
|
||||||
|
assert result.total_allowance_rate == 100
|
||||||
|
assert result.allowance_amount == 300
|
||||||
|
assert result.total_amount == 1650
|
||||||
|
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text
|
||||||
|
assert "参考可报销总金额为 1650.00 元" in result.summary_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
db.add(
|
||||||
|
Employee(
|
||||||
|
employee_no="E9002",
|
||||||
|
name="其他地区员工",
|
||||||
|
email="other-region@example.com",
|
||||||
|
position="产品经理",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = TravelReimbursementCalculatorService(db).calculate(
|
||||||
|
TravelReimbursementCalculatorRequest(days=2, location="吉林延边"),
|
||||||
|
CurrentUserContext(
|
||||||
|
username="other-region@example.com",
|
||||||
|
name="其他地区员工",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.matched_city == "延边(其他地区)"
|
||||||
|
assert result.city_tier == "tier_3"
|
||||||
|
assert result.hotel_rate == 380
|
||||||
|
assert result.hotel_amount == 760
|
||||||
|
assert result.allowance_region == "其他地区"
|
||||||
|
assert result.total_allowance_rate == 90
|
||||||
|
assert result.allowance_amount == 180
|
||||||
|
assert result.total_amount == 940
|
||||||
|
|
||||||
|
|
||||||
|
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
db.add(
|
||||||
|
Employee(
|
||||||
|
employee_no="E9003",
|
||||||
|
name="无效地点员工",
|
||||||
|
email="invalid-location@example.com",
|
||||||
|
position="产品经理",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="未识别为有效出差地区"):
|
||||||
|
TravelReimbursementCalculatorService(db).calculate(
|
||||||
|
TravelReimbursementCalculatorRequest(days=2, location="背景"),
|
||||||
|
CurrentUserContext(
|
||||||
|
username="invalid-location@example.com",
|
||||||
|
name="无效地点员工",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentRunService(db)
|
service = AgentRunService(db)
|
||||||
|
|||||||
41
server/tests/test_agent_asset_spreadsheet_import.py
Normal file
41
server/tests/test_agent_asset_spreadsheet_import.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from openpyxl import Workbook, load_workbook
|
||||||
|
|
||||||
|
from app.services.agent_asset_spreadsheet import AgentAssetSpreadsheetManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_from_uploaded_content_preserves_sheet_values() -> None:
|
||||||
|
source = Workbook()
|
||||||
|
first = source.active
|
||||||
|
first.title = "差旅标准"
|
||||||
|
first.append(["城市", "住宿费"])
|
||||||
|
first.append(["北京", 500])
|
||||||
|
second = source.create_sheet("补贴标准")
|
||||||
|
second.append(["区域", "餐补"])
|
||||||
|
second.append(["直辖市", 75])
|
||||||
|
|
||||||
|
source_buffer = BytesIO()
|
||||||
|
source.save(source_buffer)
|
||||||
|
|
||||||
|
rebuilt = AgentAssetSpreadsheetManager.rebuild_from_uploaded_content(
|
||||||
|
source_buffer.getvalue()
|
||||||
|
)
|
||||||
|
workbook = load_workbook(BytesIO(rebuilt), data_only=False)
|
||||||
|
|
||||||
|
assert workbook.sheetnames == ["差旅标准", "补贴标准"]
|
||||||
|
assert workbook["差旅标准"]["A2"].value == "北京"
|
||||||
|
assert workbook["差旅标准"]["B2"].value == "500"
|
||||||
|
assert workbook["补贴标准"]["A2"].value == "直辖市"
|
||||||
|
assert workbook["补贴标准"]["B2"].value == "75"
|
||||||
|
|
||||||
|
|
||||||
|
def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则表") -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = sheet_name
|
||||||
|
for row in rows:
|
||||||
|
sheet.append(row)
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
@@ -1,98 +1,98 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.core.agent_enums import AgentAssetStatus
|
from app.core.agent_enums import AgentAssetStatus
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.services.agent_assets import AgentAssetService
|
from app.services.agent_assets import AgentAssetService
|
||||||
|
|
||||||
|
|
||||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite+pysqlite:///:memory:",
|
"sqlite+pysqlite:///:memory:",
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
)
|
)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_db
|
app.dependency_overrides[get_db] = override_db
|
||||||
return TestClient(app), session_factory
|
return TestClient(app), session_factory
|
||||||
|
|
||||||
|
|
||||||
def test_list_agent_assets_endpoint_returns_seeded_items() -> None:
|
def test_list_agent_assets_endpoint_returns_seeded_items() -> None:
|
||||||
client, _ = build_client()
|
client, _ = build_client()
|
||||||
|
|
||||||
response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
|
response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload
|
assert payload
|
||||||
assert all(item["asset_type"] == "rule" for item in 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)
|
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:
|
def test_get_agent_asset_detail_endpoint_returns_version_history() -> None:
|
||||||
client, _ = build_client()
|
client, _ = build_client()
|
||||||
|
|
||||||
list_response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
|
list_response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
|
||||||
asset_id = next(
|
asset_id = next(
|
||||||
item["id"]
|
item["id"]
|
||||||
for item in list_response.json()
|
for item in list_response.json()
|
||||||
if item["code"] == "rule.expense.travel_risk_control_standard"
|
if item["code"] == "rule.expense.travel_risk_control_standard"
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(f"/api/v1/agent-assets/{asset_id}")
|
response = client.get(f"/api/v1/agent-assets/{asset_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload["recent_versions"]
|
assert payload["recent_versions"]
|
||||||
assert payload["current_version_content_type"] == "markdown"
|
assert payload["current_version_content_type"] == "markdown"
|
||||||
assert payload["current_version"] == "v1.1.0"
|
assert payload["current_version"] == "v1.1.0"
|
||||||
assert "行程闭环" in payload["current_version_content"]
|
assert "行程闭环" in payload["current_version_content"]
|
||||||
|
|
||||||
|
|
||||||
def test_activate_pending_rule_endpoint_is_blocked() -> None:
|
def test_activate_pending_rule_endpoint_is_blocked() -> None:
|
||||||
client, session_factory = build_client()
|
client, session_factory = build_client()
|
||||||
|
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
pending_rule = next(
|
pending_rule = next(
|
||||||
item
|
item
|
||||||
for item in AgentAssetService(db).list_assets(asset_type="rule")
|
for item in AgentAssetService(db).list_assets(asset_type="rule")
|
||||||
if item.status == AgentAssetStatus.REVIEW.value
|
if item.status == AgentAssetStatus.REVIEW.value
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1/agent-assets/{pending_rule.id}/activate",
|
f"/api/v1/agent-assets/{pending_rule.id}/activate",
|
||||||
headers={"x-actor": "pytest"},
|
headers={"x-actor": "pytest"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "审核" in response.json()["detail"]
|
assert "审核" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_list_audit_logs_endpoint_returns_seeded_logs() -> None:
|
def test_list_audit_logs_endpoint_returns_seeded_logs() -> None:
|
||||||
client, _ = build_client()
|
client, _ = build_client()
|
||||||
|
|
||||||
response = client.get("/api/v1/audit-logs")
|
response = client.get("/api/v1/audit-logs")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload
|
assert payload
|
||||||
assert any(item["action"] == "review_rule" for item in payload)
|
assert any(item["action"] == "review_rule" for item in payload)
|
||||||
|
|||||||
85
server/tests/test_agent_runs_service.py
Normal file
85
server/tests/test_agent_runs_service.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus, AgentToolType
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.services.agent_runs import AgentRunService
|
||||||
|
|
||||||
|
|
||||||
|
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 test_agent_run_service_marks_stale_knowledge_sync_run_failed_on_read() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentRunService(db)
|
||||||
|
created = service.create_run(
|
||||||
|
agent=AgentName.HERMES.value,
|
||||||
|
source=AgentRunSource.USER_MESSAGE.value,
|
||||||
|
status=AgentRunStatus.RUNNING.value,
|
||||||
|
route_json={
|
||||||
|
"job_type": "knowledge_index_sync",
|
||||||
|
"heartbeat_at": (datetime.now(UTC) - timedelta(minutes=31)).isoformat(),
|
||||||
|
"requested_document_ids": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched = service.get_run(created.run_id)
|
||||||
|
running_runs = service.list_runs(
|
||||||
|
agent=AgentName.HERMES.value,
|
||||||
|
status=AgentRunStatus.RUNNING.value,
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.status == AgentRunStatus.FAILED.value
|
||||||
|
assert fetched.error_message == "Knowledge index heartbeat timed out."
|
||||||
|
assert all(item.run_id != created.run_id for item in running_runs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_run_service_updates_existing_tool_call() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentRunService(db)
|
||||||
|
run = service.create_run(
|
||||||
|
agent=AgentName.HERMES.value,
|
||||||
|
source=AgentRunSource.USER_MESSAGE.value,
|
||||||
|
status=AgentRunStatus.RUNNING.value,
|
||||||
|
route_json={"job_type": "knowledge_index_sync"},
|
||||||
|
)
|
||||||
|
tool_call = service.record_tool_call(
|
||||||
|
run_id=run.run_id,
|
||||||
|
tool_type=AgentToolType.LLM.value,
|
||||||
|
tool_name="lightrag.index_documents",
|
||||||
|
request_json={"document_ids": ["doc-1"]},
|
||||||
|
response_json={"phase": "indexing"},
|
||||||
|
status="running",
|
||||||
|
duration_ms=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = service.update_tool_call(
|
||||||
|
tool_call.id,
|
||||||
|
response_json={"track_id": "insert_123"},
|
||||||
|
status="succeeded",
|
||||||
|
duration_ms=1250,
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
|
fetched = service.get_run(run.run_id)
|
||||||
|
|
||||||
|
assert updated.status == "succeeded"
|
||||||
|
assert updated.duration_ms == 1250
|
||||||
|
assert fetched is not None
|
||||||
|
assert len(fetched.tool_calls) == 1
|
||||||
|
assert fetched.tool_calls[0].status == "succeeded"
|
||||||
|
assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"}
|
||||||
@@ -51,6 +51,57 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
|
|||||||
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
|
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_intelligence_extracts_hotel_total_fee_instead_of_date_year() -> None:
|
||||||
|
insight = build_document_insight(
|
||||||
|
filename="hotel-invoice.png",
|
||||||
|
summary="酒店住宿票据",
|
||||||
|
text="北京中心酒店 金额 2026-02-20 入住 总费用是828元 离店日期 2026-02-21",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert insight.document_type == "hotel_invoice"
|
||||||
|
assert any(field.label == "金额" and field.value == "828元" for field in insight.fields)
|
||||||
|
assert not any(field.label == "金额" and field.value == "2026元" for field in insight.fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None:
|
||||||
|
insight = build_document_insight(
|
||||||
|
filename="铁路电子客票.pdf",
|
||||||
|
summary="电子发票(铁路电子客票)",
|
||||||
|
text=(
|
||||||
|
"电子发票(铁路电子客票)\n"
|
||||||
|
"发票号码:26319166100006175398\n"
|
||||||
|
"上海虹桥站\n"
|
||||||
|
"武汉站\n"
|
||||||
|
"G456\n"
|
||||||
|
"二等座\n"
|
||||||
|
"票价:¥354.00"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert insight.document_type == "train_ticket"
|
||||||
|
assert insight.document_type_label == "火车/高铁票"
|
||||||
|
assert insight.scene_code == "travel"
|
||||||
|
assert any(field.label == "金额" and field.value == "354元" for field in insight.fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_intelligence_labels_train_ticket_date_as_train_departure_time() -> None:
|
||||||
|
insight = build_document_insight(
|
||||||
|
filename="铁路电子客票.pdf",
|
||||||
|
summary="铁路电子客票",
|
||||||
|
text=(
|
||||||
|
"中国铁路电子客票 开票日期 2026-02-18 "
|
||||||
|
"G456 上海虹桥-武汉 2026-02-20 08:30开 票价:¥354.00"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert insight.document_type == "train_ticket"
|
||||||
|
assert any(
|
||||||
|
field.key == "date" and field.label == "列车出发时间" and field.value == "2026-02-20 08:30"
|
||||||
|
for field in insight.fields
|
||||||
|
)
|
||||||
|
assert not any(field.label == "开票日期" for field in insight.fields)
|
||||||
|
|
||||||
|
|
||||||
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
|
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite+pysqlite:///:memory:",
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine, func, select
|
from sqlalchemy import create_engine, func, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -135,6 +137,120 @@ def test_enable_employee_restores_status_and_logs_change() -> None:
|
|||||||
assert any(item.action == "启用员工账号" for item in updated.history)
|
assert any(item.action == "启用员工账号" for item in updated.history)
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_repairs_do_not_run_on_every_list() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
|
||||||
|
updated = service.update_employee(
|
||||||
|
employee.id,
|
||||||
|
EmployeeUpdate(position="测试岗位-不会被回滚"),
|
||||||
|
)
|
||||||
|
|
||||||
|
listed = next(item for item in service.list_employees() if item.id == employee.id)
|
||||||
|
assert updated.position == "测试岗位-不会被回滚"
|
||||||
|
assert listed.position == "测试岗位-不会被回滚"
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_update_appends_recent_history() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
current_codes = list(employee.roleCodes)
|
||||||
|
next_codes = ["finance", "user"] if "finance" not in current_codes else ["user"]
|
||||||
|
|
||||||
|
updated = service.update_employee(employee.id, EmployeeUpdate(role_codes=next_codes))
|
||||||
|
|
||||||
|
assert any("更新系统角色" in item.action for item in updated.history)
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_change_logs_keep_only_latest_five() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
persisted = db.get(Employee, employee.id)
|
||||||
|
assert persisted is not None
|
||||||
|
|
||||||
|
for index in range(7):
|
||||||
|
service._append_change_log(
|
||||||
|
persisted,
|
||||||
|
action=f"测试变更-{index}",
|
||||||
|
owner="单元测试",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
service._trim_employee_change_logs(persisted.id)
|
||||||
|
db.commit()
|
||||||
|
hydrated = db.get(Employee, employee.id)
|
||||||
|
assert hydrated is not None
|
||||||
|
assert len(hydrated.change_logs) == 5
|
||||||
|
assert hydrated.change_logs[0].action == "测试变更-6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_meta_includes_organization_options() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
meta = service.get_employee_meta()
|
||||||
|
|
||||||
|
assert meta.organizationOptions
|
||||||
|
assert all(item.code and item.name for item in meta.organizationOptions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_employee_changes_organization() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
organizations = service.repository.list_organization_units()
|
||||||
|
current_code = employee.organization.code if employee.organization else None
|
||||||
|
target = next(unit for unit in organizations if unit.unit_code != current_code)
|
||||||
|
|
||||||
|
updated = service.update_employee(
|
||||||
|
employee.id,
|
||||||
|
EmployeeUpdate(organization_unit_code=target.unit_code),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.organization is not None
|
||||||
|
assert updated.organization.code == target.unit_code
|
||||||
|
assert updated.department == target.name
|
||||||
|
assert any("更新员工信息" in item.action for item in updated.history)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_employee_rejects_unknown_organization() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="部门编码"):
|
||||||
|
service.update_employee(
|
||||||
|
employee.id,
|
||||||
|
EmployeeUpdate(organization_unit_code="ORG-NOT-EXISTS"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_employee_changes_manager() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employees = service.list_employees()
|
||||||
|
employee = employees[0]
|
||||||
|
manager = next(item for item in employees if item.id != employee.id)
|
||||||
|
|
||||||
|
updated = service.update_employee(
|
||||||
|
employee.id,
|
||||||
|
EmployeeUpdate(manager_employee_no=manager.employeeNo),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.managerEmployeeNo == manager.employeeNo
|
||||||
|
assert updated.manager == manager.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_history_datetime_uses_local_timezone_without_seconds() -> None:
|
||||||
|
value = datetime(2026, 5, 20, 6, 30, 45, tzinfo=UTC)
|
||||||
|
formatted = EmployeeService._format_history_datetime(value)
|
||||||
|
|
||||||
|
assert formatted == "2026年5月20日14时30分"
|
||||||
|
assert "秒" not in formatted
|
||||||
|
|
||||||
|
|
||||||
def test_update_employee_rejects_invalid_date_format() -> None:
|
def test_update_employee_rejects_invalid_date_format() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = EmployeeService(db)
|
service = EmployeeService(db)
|
||||||
|
|||||||
153
server/tests/test_employee_spreadsheet_import.py
Normal file
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"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user