feat: 添加风险规则及 agent assets 功能增强

This commit is contained in:
caoxiaozhu
2026-05-19 16:19:03 +00:00
parent d460ee0fe7
commit 54ffef66d3
52 changed files with 26036 additions and 25171 deletions

View File

@@ -1,457 +1,457 @@
# 语义本体协议设计
## 1. 定位
语义本体协议是用户问题、定时任务、规则中心、MCP、数据库查询和 Agent 之间的统一中间层。
它解决的问题是:
- 用户到底在问哪个业务域?
- 这属于什么场景?
- 用户想做什么?
- 问题中涉及哪些对象?
- 有没有时间、金额、状态、部门等过滤条件?
- 是否涉及风险?
- 下一步应该查知识库、查数据库、跑规则、调 MCP还是追问
## 2. 第一版核心字段
第一版建议只强制落 8 个字段。
```json
{
"domain": "",
"scenario": "",
"intent": "",
"entities": [],
"time_range": {},
"constraints": {},
"risk_signals": [],
"next_step": ""
}
```
### 2.1 domain
一级业务域。
建议枚举:
```text
reimbursement
accounts_receivable
accounts_payable
general_finance
system_operation
```
含义:
- `reimbursement`:报销、差旅、发票、补件。
- `accounts_receivable`:应收账款、客户开票、收款、账龄。
- `accounts_payable`:应付账款、供应商发票、付款、对账。
- `general_finance`:通用财务知识、制度、统计。
- `system_operation`系统巡检、任务运行、规则维护、MCP 健康检查。
### 2.2 scenario
细分场景。
报销:
```text
travel_reimbursement
daily_expense
invoice_validation
attachment_review
policy_overrun
reimbursement_audit
```
应收:
```text
customer_invoice
collection_followup
receivable_aging
payment_matching
bad_debt_risk
contract_receivable
```
应付:
```text
vendor_invoice
payment_request
payable_aging
vendor_reconciliation
invoice_matching
cash_outflow_forecast
```
系统运营:
```text
daily_risk_scan
daily_finance_statistics
knowledge_accumulation
mcp_health_check
rule_quality_review
```
### 2.3 intent
用户或任务的意图。
建议枚举:
```text
query
explain
create
validate
summarize
reconcile
monitor
predict
remind
generate
optimize
```
### 2.4 entities
识别出的业务对象。
统一结构:
```json
{
"type": "invoice",
"value": "INV-202605001",
"normalized_value": "INV-202605001",
"role": "target",
"confidence": 0.92
}
```
常见实体:
```text
employee
department
customer
vendor
invoice
contract
reimbursement_request
payment_order
receipt
bank_transaction
cost_center
project
policy
approval_node
rule
task
```
### 2.5 time_range
统一描述时间。
```json
{
"raw": "上个月",
"start": "2026-04-01",
"end": "2026-04-30",
"granularity": "month"
}
```
Hermes 定时任务也使用同一字段。
例如每日风险巡检:
```json
{
"raw": "昨日",
"start": "2026-05-09",
"end": "2026-05-09",
"granularity": "day"
}
```
### 2.6 constraints
查询、判断或执行条件。
```json
{
"status": "overdue",
"aging_days": ">30",
"amount": {
"operator": ">",
"value": 50000,
"currency": "CNY"
},
"department": "销售部",
"risk_level": ["medium", "high"]
}
```
### 2.7 risk_signals
风险信号。
建议枚举:
```text
duplicate_invoice
missing_attachment
policy_overrun
over_budget
overdue_receivable
bad_debt_risk
vendor_payment_risk
payment_mismatch
contract_mismatch
cashflow_pressure
mcp_unavailable
rule_quality_issue
```
### 2.8 next_step
下一步动作。
建议枚举:
```text
answer
ask_clarification
query_database
run_rule
call_mcp
search_knowledge
create_draft
create_task
generate_report
notify_user
escalate_to_human
```
## 3. 扩展字段
后续可以增加:
```json
{
"schema_version": "1.1",
"confidence": 0.86,
"ambiguity": [],
"missing_slots": [],
"required_capabilities": [],
"normalized_query": "",
"permission_scope": {},
"audit_tags": []
}
```
## 4. 混合语义解析架构
第一版可上线实现不应只依赖关键词和正则。
推荐采用:
```text
输入上下文装配
用户文本 + 页面上下文 + 附件名称 + OCR/VLM 摘要
预抽取
时间、金额、单号、显式对象
LLM 结构化解析
输出 scenario / intent / entities / missing_slots / ambiguity
Schema 校验
JSON 解析、字段枚举、必填校验、类型归一化
规则兜底
模型失败、低置信度或字段缺失时回退到规则解析
澄清追问
低置信度、歧义、缺槽位时不允许直接查库
```
设计原则:
- 模型优先负责“理解意图和场景”。
- 规则优先负责“校验、补全和兜底”。
- 附件名称、OCR、VLM 结果只能作为证据,不等于已确认事实。
- 所有语义输出都必须标记置信度和来源。
## 5. 推荐新增字段
为支持模型优先解析,建议在扩展字段中至少增加:
```json
{
"missing_slots": [],
"ambiguity": [],
"field_confidence": {},
"field_source": {},
"attachment_context": [],
"parse_strategy": "llm_primary_with_rule_fallback"
}
```
字段说明:
- `missing_slots`:还缺哪些关键字段,例如费用类型、单据号、客户单位。
- `ambiguity`:当前可能混淆的理解结果。
- `field_confidence`:字段级置信度,而不是只给整体分数。
- `field_source`:字段来自 `llm``rule``ocr``vlm` 还是 `user_context`
- `attachment_context`:本次可供语义解析使用的附件摘要。
- `parse_strategy`:标记本次是模型主解析还是规则回退。
## 6. 叙述型财务输入
语义层必须支持“不是查询句”的自然叙述。
典型样例:
```text
我今天去客户现场招待了客户花销了1000元
我垫付了打车费和餐费,帮我看看怎么报
上传了三张票,帮我整理成报销草稿
```
这类输入不能默认识别成 `query`
建议默认策略:
- 优先识别为 `reimbursement` 域。
- 场景优先落到 `daily_expense``travel_reimbursement``attachment_review`
- 意图优先落到 `create``generate``validate`
- 缺失关键字段时返回 `ask_clarification`,而不是直接查数据库。
## 7. 模糊短句与澄清规则
以下输入应优先追问:
```text
我要报销
这个为什么还没处理
帮我看一下这个
上传好了,下一步呢
```
处理原则:
- 不允许直接执行工具。
- 不允许直接落到应收、应付查询。
- 必须生成澄清问题。
- 必须在审计中记录触发追问的原因。
扩展原则:
- 先不要把所有字段都做成数据库列。
- 语义结果建议存 JSONB。
- 使用 `schema_version` 管理版本。
- Orchestrator 只依赖稳定字段。
- 新字段以可选方式加入,不影响老任务。
## 4. 示例
### 4.1 用户查询应收账龄
用户问:
```text
上个月哪些客户应收逾期超过 30 天?
```
解析:
```json
{
"domain": "accounts_receivable",
"scenario": "receivable_aging",
"intent": "query",
"entities": [
{
"type": "customer",
"value": "客户",
"role": "group_by"
}
],
"time_range": {
"raw": "上个月",
"start": "2026-04-01",
"end": "2026-04-30",
"granularity": "month"
},
"constraints": {
"aging_days": ">30",
"status": "overdue"
},
"risk_signals": ["overdue_receivable"],
"next_step": "query_database"
}
```
### 4.2 用户解释发票拦截
用户问:
```text
这张发票为什么报销被拦截?
```
解析:
```json
{
"domain": "reimbursement",
"scenario": "invoice_validation",
"intent": "explain",
"entities": [
{
"type": "invoice",
"value": "这张发票",
"role": "target"
}
],
"time_range": {},
"constraints": {},
"risk_signals": ["unknown"],
"next_step": "run_rule"
}
```
### 4.3 Hermes 每日风险巡检
任务配置:
```json
{
"domain": "reimbursement",
"scenario": "daily_risk_scan",
"intent": "monitor",
"entities": [],
"time_range": {
"raw": "昨日"
},
"constraints": {
"risk_level": ["medium", "high"]
},
"risk_signals": [
"duplicate_invoice",
"missing_attachment",
"policy_overrun"
],
"next_step": "run_rule"
}
```
# 语义本体协议设计
## 1. 定位
语义本体协议是用户问题、定时任务、规则中心、MCP、数据库查询和 Agent 之间的统一中间层。
它解决的问题是:
- 用户到底在问哪个业务域?
- 这属于什么场景?
- 用户想做什么?
- 问题中涉及哪些对象?
- 有没有时间、金额、状态、部门等过滤条件?
- 是否涉及风险?
- 下一步应该查知识库、查数据库、跑规则、调 MCP还是追问
## 2. 第一版核心字段
第一版建议只强制落 8 个字段。
```json
{
"domain": "",
"scenario": "",
"intent": "",
"entities": [],
"time_range": {},
"constraints": {},
"risk_signals": [],
"next_step": ""
}
```
### 2.1 domain
一级业务域。
建议枚举:
```text
reimbursement
accounts_receivable
accounts_payable
general_finance
system_operation
```
含义:
- `reimbursement`:报销、差旅、发票、补件。
- `accounts_receivable`:应收账款、客户开票、收款、账龄。
- `accounts_payable`:应付账款、供应商发票、付款、对账。
- `general_finance`:通用财务知识、制度、统计。
- `system_operation`系统巡检、任务运行、规则维护、MCP 健康检查。
### 2.2 scenario
细分场景。
报销:
```text
travel_reimbursement
daily_expense
invoice_validation
attachment_review
policy_overrun
reimbursement_audit
```
应收:
```text
customer_invoice
collection_followup
receivable_aging
payment_matching
bad_debt_risk
contract_receivable
```
应付:
```text
vendor_invoice
payment_request
payable_aging
vendor_reconciliation
invoice_matching
cash_outflow_forecast
```
系统运营:
```text
daily_risk_scan
daily_finance_statistics
knowledge_accumulation
mcp_health_check
rule_quality_review
```
### 2.3 intent
用户或任务的意图。
建议枚举:
```text
query
explain
create
validate
summarize
reconcile
monitor
predict
remind
generate
optimize
```
### 2.4 entities
识别出的业务对象。
统一结构:
```json
{
"type": "invoice",
"value": "INV-202605001",
"normalized_value": "INV-202605001",
"role": "target",
"confidence": 0.92
}
```
常见实体:
```text
employee
department
customer
vendor
invoice
contract
reimbursement_request
payment_order
receipt
bank_transaction
cost_center
project
policy
approval_node
rule
task
```
### 2.5 time_range
统一描述时间。
```json
{
"raw": "上个月",
"start": "2026-04-01",
"end": "2026-04-30",
"granularity": "month"
}
```
Hermes 定时任务也使用同一字段。
例如每日风险巡检:
```json
{
"raw": "昨日",
"start": "2026-05-09",
"end": "2026-05-09",
"granularity": "day"
}
```
### 2.6 constraints
查询、判断或执行条件。
```json
{
"status": "overdue",
"aging_days": ">30",
"amount": {
"operator": ">",
"value": 50000,
"currency": "CNY"
},
"department": "销售部",
"risk_level": ["medium", "high"]
}
```
### 2.7 risk_signals
风险信号。
建议枚举:
```text
duplicate_invoice
missing_attachment
policy_overrun
over_budget
overdue_receivable
bad_debt_risk
vendor_payment_risk
payment_mismatch
contract_mismatch
cashflow_pressure
mcp_unavailable
rule_quality_issue
```
### 2.8 next_step
下一步动作。
建议枚举:
```text
answer
ask_clarification
query_database
run_rule
call_mcp
search_knowledge
create_draft
create_task
generate_report
notify_user
escalate_to_human
```
## 3. 扩展字段
后续可以增加:
```json
{
"schema_version": "1.1",
"confidence": 0.86,
"ambiguity": [],
"missing_slots": [],
"required_capabilities": [],
"normalized_query": "",
"permission_scope": {},
"audit_tags": []
}
```
## 4. 混合语义解析架构
第一版可上线实现不应只依赖关键词和正则。
推荐采用:
```text
输入上下文装配
用户文本 + 页面上下文 + 附件名称 + OCR/VLM 摘要
预抽取
时间、金额、单号、显式对象
LLM 结构化解析
输出 scenario / intent / entities / missing_slots / ambiguity
Schema 校验
JSON 解析、字段枚举、必填校验、类型归一化
规则兜底
模型失败、低置信度或字段缺失时回退到规则解析
澄清追问
低置信度、歧义、缺槽位时不允许直接查库
```
设计原则:
- 模型优先负责“理解意图和场景”。
- 规则优先负责“校验、补全和兜底”。
- 附件名称、OCR、VLM 结果只能作为证据,不等于已确认事实。
- 所有语义输出都必须标记置信度和来源。
## 5. 推荐新增字段
为支持模型优先解析,建议在扩展字段中至少增加:
```json
{
"missing_slots": [],
"ambiguity": [],
"field_confidence": {},
"field_source": {},
"attachment_context": [],
"parse_strategy": "llm_primary_with_rule_fallback"
}
```
字段说明:
- `missing_slots`:还缺哪些关键字段,例如费用类型、单据号、客户单位。
- `ambiguity`:当前可能混淆的理解结果。
- `field_confidence`:字段级置信度,而不是只给整体分数。
- `field_source`:字段来自 `llm``rule``ocr``vlm` 还是 `user_context`
- `attachment_context`:本次可供语义解析使用的附件摘要。
- `parse_strategy`:标记本次是模型主解析还是规则回退。
## 6. 叙述型财务输入
语义层必须支持“不是查询句”的自然叙述。
典型样例:
```text
我今天去客户现场招待了客户花销了1000元
我垫付了打车费和餐费,帮我看看怎么报
上传了三张票,帮我整理成报销草稿
```
这类输入不能默认识别成 `query`
建议默认策略:
- 优先识别为 `reimbursement` 域。
- 场景优先落到 `daily_expense``travel_reimbursement``attachment_review`
- 意图优先落到 `create``generate``validate`
- 缺失关键字段时返回 `ask_clarification`,而不是直接查数据库。
## 7. 模糊短句与澄清规则
以下输入应优先追问:
```text
我要报销
这个为什么还没处理
帮我看一下这个
上传好了,下一步呢
```
处理原则:
- 不允许直接执行工具。
- 不允许直接落到应收、应付查询。
- 必须生成澄清问题。
- 必须在审计中记录触发追问的原因。
扩展原则:
- 先不要把所有字段都做成数据库列。
- 语义结果建议存 JSONB。
- 使用 `schema_version` 管理版本。
- Orchestrator 只依赖稳定字段。
- 新字段以可选方式加入,不影响老任务。
## 4. 示例
### 4.1 用户查询应收账龄
用户问:
```text
上个月哪些客户应收逾期超过 30 天?
```
解析:
```json
{
"domain": "accounts_receivable",
"scenario": "receivable_aging",
"intent": "query",
"entities": [
{
"type": "customer",
"value": "客户",
"role": "group_by"
}
],
"time_range": {
"raw": "上个月",
"start": "2026-04-01",
"end": "2026-04-30",
"granularity": "month"
},
"constraints": {
"aging_days": ">30",
"status": "overdue"
},
"risk_signals": ["overdue_receivable"],
"next_step": "query_database"
}
```
### 4.2 用户解释发票拦截
用户问:
```text
这张发票为什么报销被拦截?
```
解析:
```json
{
"domain": "reimbursement",
"scenario": "invoice_validation",
"intent": "explain",
"entities": [
{
"type": "invoice",
"value": "这张发票",
"role": "target"
}
],
"time_range": {},
"constraints": {},
"risk_signals": ["unknown"],
"next_step": "run_rule"
}
```
### 4.3 Hermes 每日风险巡检
任务配置:
```json
{
"domain": "reimbursement",
"scenario": "daily_risk_scan",
"intent": "monitor",
"entities": [],
"time_range": {
"raw": "昨日"
},
"constraints": {
"risk_level": ["medium", "high"]
},
"risk_signals": [
"duplicate_invoice",
"missing_attachment",
"policy_overrun"
],
"next_step": "run_rule"
}
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,6 @@ from app.schemas.agent_asset import (
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead,
AgentAssetUpdate,
AgentAssetVersionCompareRead,
AgentAssetVersionCreate,
AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead,
@@ -167,7 +166,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
db: DbSession,
version: Annotated[
str | None,
Query(description="可选的规则版本号;不传时默认当前版本"),
Query(description="兼容旧前端的可选参数;表格规则始终打开当前规则表"),
] = None,
) -> AgentAssetOnlyOfficeConfigRead:
try:
@@ -184,7 +183,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
"/{asset_id}/spreadsheet/content",
response_class=FileResponse,
summary="下载或预览规则 Excel 文件",
description="按版本返回规则 Excel 快照,用于浏览器预览或下载。",
description="返回当前规则 Excel 文件,用于浏览器预览或下载。",
)
def get_agent_asset_spreadsheet_content(
asset_id: str,
@@ -192,7 +191,7 @@ def get_agent_asset_spreadsheet_content(
db: DbSession,
version: Annotated[
str | None,
Query(description="可选的规则版本号;不传时默认当前版本"),
Query(description="兼容旧前端的可选参数;不传时返回当前规则表"),
] = None,
) -> FileResponse:
try:
@@ -215,18 +214,18 @@ def get_agent_asset_spreadsheet_content(
def get_agent_asset_spreadsheet_onlyoffice_content(
asset_id: str,
db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="规则版本号。"),
],
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
],
version: Annotated[
str | None,
Query(description="兼容旧 ONLYOFFICE URL当前表格模式不再使用。"),
] = None,
) -> FileResponse:
try:
service = AgentAssetService(db)
service.validate_rule_spreadsheet_access_token(asset_id, version, access_token)
service.validate_rule_spreadsheet_access_token(asset_id, access_token)
file_path, media_type, filename = service.get_rule_spreadsheet_content(
asset_id,
version=version,
@@ -246,7 +245,7 @@ def get_agent_asset_spreadsheet_onlyoffice_content(
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="上传规则 Excel 文件",
description="为指定规则上传新的 Excel 快照,并自动生成新规则版本",
description="为指定规则上传新的 Excel 文件,并记录本次表格修改",
)
def upload_agent_asset_spreadsheet(
asset_id: str,
@@ -311,16 +310,16 @@ def import_agent_asset_spreadsheet_content(
"/{asset_id}/spreadsheet/onlyoffice/callback",
response_model=AgentAssetOnlyOfficeCallbackRead,
summary="接收规则 Excel 的 ONLYOFFICE 回调",
description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本",
description="接收 ONLYOFFICE 回写内容,并记录本次表格修改",
)
def handle_agent_asset_spreadsheet_onlyoffice_callback(
asset_id: str,
payload: AgentAssetOnlyOfficeCallbackWrite,
db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="打开编辑器时对应的规则版本号"),
],
str | None,
Query(description="兼容旧 ONLYOFFICE 回调;当前表格模式不再使用"),
] = None,
actor_name: Annotated[
str | None,
Query(description="发起编辑的用户显示名。"),
@@ -601,25 +600,3 @@ def get_agent_asset_version_timeline(
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/versions/compare",
response_model=AgentAssetVersionCompareRead,
summary="比较两个规则表版本",
description="对比两个 Excel 规则表版本的工作表变化与单元格级差异。",
)
def compare_agent_asset_spreadsheet_versions(
asset_id: str,
_: CurrentUser,
db: DbSession,
base_version: Annotated[str, Query(min_length=1, description="基准版本号")],
target_version: Annotated[str, Query(min_length=1, description="对比版本号")],
) -> AgentAssetVersionCompareRead:
try:
return AgentAssetService(db).compare_spreadsheet_versions(
asset_id,
base_version=base_version,
target_version=target_version,
)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -133,22 +133,10 @@ class AgentAssetSpreadsheetDiffSheetRead(BaseModel):
change_type: str
class AgentAssetVersionCompareRead(BaseModel):
base_version: str
target_version: str
added_sheet_count: int = 0
removed_sheet_count: int = 0
changed_sheet_count: int = 0
changed_cell_count: int = 0
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
class AgentAssetSpreadsheetChangeRecordRead(BaseModel):
id: str
actor: str
changed_at: datetime
version: str | None = None
summary: str
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)

View File

@@ -36,7 +36,6 @@ from app.schemas.agent_asset import (
AgentAssetSpreadsheetDiffCellRead,
AgentAssetSpreadsheetDiffSheetRead,
AgentAssetUpdate,
AgentAssetVersionCompareRead,
AgentAssetVersionCreate,
AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead,
@@ -511,18 +510,16 @@ class AgentAssetService:
return self._build_onlyoffice_spreadsheet_config(
asset_id=asset_id,
current_user=current_user,
resolved_version=resolved_version,
metadata=metadata,
editable=resolved_version == PREVIEW_RULE_CURRENT_VERSION,
)
asset = self._require_spreadsheet_rule(asset_id)
resolved_version, metadata = self._resolve_current_spreadsheet_meta(asset)
_, metadata = self._resolve_current_spreadsheet_meta(asset)
editable = self._can_edit_current_spreadsheet(current_user)
return self._build_onlyoffice_spreadsheet_config(
asset_id=asset.id,
current_user=current_user,
resolved_version=resolved_version,
metadata=metadata,
editable=editable,
)
@@ -555,7 +552,6 @@ class AgentAssetService:
def validate_rule_spreadsheet_access_token(
self,
asset_id: str,
version: str,
access_token: str,
) -> None:
onlyoffice_settings = resolve_onlyoffice_settings()
@@ -571,7 +567,6 @@ class AgentAssetService:
if (
payload.get("scope") != "agent-asset-spreadsheet"
or payload.get("asset_id") != asset_id
or payload.get("version") != version
):
raise ValueError("ONLYOFFICE 文件访问令牌无效。")
@@ -604,7 +599,6 @@ class AgentAssetService:
)
changed_sheet_count = self._count_changed_sheets(sheet_changes, cell_changes)
changed_cell_count = len(cell_changes)
next_version = self._next_available_version(asset)
metadata = self._store_current_rule_spreadsheet(
asset,
@@ -613,45 +607,10 @@ class AgentAssetService:
actor=actor,
source=source,
)
snapshot_metadata = self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
library=self._resolve_spreadsheet_rule_library(asset),
asset_id=asset.id,
version=next_version,
file_name=file_name,
content=content,
actor_name=actor,
source=source,
)
operation_label = (
change_note
or (
"ONLYOFFICE 在线编辑"
if source == "onlyoffice"
else f"上传并覆盖当前规则表:{normalized_name}"
)
)
summary = self._build_spreadsheet_change_summary(
operation_label,
sheet_changes,
cell_changes,
)
version_content = self.spreadsheet_manager.build_version_markdown(
rule_name=asset.name,
version=next_version,
metadata=snapshot_metadata,
)
self.create_version(
asset.id,
AgentAssetVersionCreate(
version=next_version,
content=version_content,
content_type=AgentAssetContentType.MARKDOWN,
change_note=summary,
created_by=actor,
),
actor=actor,
request_id=request_id,
)
self.audit_service.log_action(
actor=actor,
action="edit_rule_spreadsheet",
@@ -660,13 +619,11 @@ class AgentAssetService:
before_json={"storage_key": current_metadata.storage_key},
after_json={
"summary": summary,
"version": next_version,
"changed_sheet_count": changed_sheet_count,
"changed_cell_count": changed_cell_count,
"sheet_changes": [item.model_dump() for item in sheet_changes],
"cell_changes": [item.model_dump() for item in cell_changes[:500]],
"storage_key": metadata.storage_key,
"snapshot_storage_key": snapshot_metadata.storage_key,
},
request_id=request_id,
)
@@ -705,7 +662,7 @@ class AgentAssetService:
self,
asset_id: str,
*,
version: str,
version: str | None = None,
payload: dict[str, Any],
actor_name: str | None = None,
) -> None:
@@ -721,8 +678,6 @@ class AgentAssetService:
callback = self._parse_onlyoffice_callback(payload)
if callback.status not in {2, 6} or not callback.download_url:
return
if str(version or "").strip() not in {"", "current", self._resolve_working_version(asset)}:
return
_, current_metadata = self._resolve_current_spreadsheet_meta(asset)
request = Request(
@@ -924,44 +879,6 @@ class AgentAssetService:
return sorted(events, key=lambda item: item.event_time)
def compare_spreadsheet_versions(
self,
asset_id: str,
*,
base_version: str,
target_version: str,
) -> AgentAssetVersionCompareRead:
self._ensure_ready()
asset = self._require_spreadsheet_rule(asset_id)
resolved_base, base_meta = self._resolve_spreadsheet_version_meta(
asset,
version=base_version,
)
resolved_target, target_meta = self._resolve_spreadsheet_version_meta(
asset,
version=target_version,
)
base_workbook = self._load_spreadsheet_for_compare(base_meta)
target_workbook = self._load_spreadsheet_for_compare(target_meta)
sheet_changes, cell_changes = self._collect_workbook_changes(
base_workbook,
target_workbook,
)
added_sheet_count = sum(1 for item in sheet_changes if item.change_type == "added")
removed_sheet_count = sum(1 for item in sheet_changes if item.change_type == "removed")
return AgentAssetVersionCompareRead(
base_version=resolved_base,
target_version=resolved_target,
added_sheet_count=added_sheet_count,
removed_sheet_count=removed_sheet_count,
changed_sheet_count=self._count_changed_sheets(sheet_changes, cell_changes),
changed_cell_count=len(cell_changes),
sheet_changes=sheet_changes,
cell_changes=cell_changes[:500],
)
def list_spreadsheet_change_records(
self,
asset_id: str,
@@ -981,8 +898,7 @@ class AgentAssetService:
id=log.id,
actor=log.actor,
changed_at=log.created_at,
version=str((log.after_json or {}).get("version") or "").strip() or None,
summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"),
summary=str((log.after_json or {}).get("summary") or "表格内容已保存。"),
sheet_changes=[
AgentAssetSpreadsheetDiffSheetRead.model_validate(item)
for item in ((log.after_json or {}).get("sheet_changes") or [])
@@ -1292,7 +1208,6 @@ class AgentAssetService:
*,
asset_id: str,
current_user: CurrentUserContext,
resolved_version: str,
metadata: RuleSpreadsheetMeta,
editable: bool,
) -> AgentAssetOnlyOfficeConfigRead:
@@ -1307,21 +1222,21 @@ class AgentAssetService:
backend_base_url = onlyoffice_settings.backend_url.rstrip("/")
public_url = onlyoffice_settings.public_url.rstrip("/")
access_token = self._build_onlyoffice_access_token(asset_id, resolved_version)
access_token = self._build_onlyoffice_access_token(asset_id)
document_url = (
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/content"
f"?version={resolved_version}&access_token={access_token}"
f"?access_token={access_token}"
)
callback_url = (
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/callback"
f"?version={resolved_version}&actor_name={quote(current_user.name)}"
f"?actor_name={quote(current_user.name)}"
)
config: dict[str, Any] = {
"documentType": "cell",
"document": {
"fileType": Path(metadata.file_name).suffix.lstrip(".").lower() or "xlsx",
"key": self._build_onlyoffice_document_key(asset_id, resolved_version, metadata),
"key": self._build_onlyoffice_document_key(asset_id, metadata),
"title": metadata.file_name,
"url": document_url,
"permissions": {
@@ -1462,19 +1377,6 @@ class AgentAssetService:
major, minor, patch = [int(item) for item in parts]
return f"v{major}.{minor}.{patch + 1}"
@staticmethod
def _can_edit_spreadsheet_version(
asset: AgentAsset,
current_user: CurrentUserContext,
version: str,
) -> bool:
role_codes = {str(item).strip() for item in current_user.role_codes}
can_edit = current_user.is_admin or "manager" in role_codes or "finance" in role_codes
return (
can_edit
and AgentAssetService._resolve_working_version(asset) == str(version or "").strip()
)
@staticmethod
def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool:
role_codes = {str(item).strip() for item in current_user.role_codes}
@@ -1483,23 +1385,21 @@ class AgentAssetService:
@staticmethod
def _build_onlyoffice_document_key(
asset_id: str,
version: str,
metadata: RuleSpreadsheetMeta,
) -> str:
fingerprint = metadata.checksum or metadata.updated_at or metadata.file_name
raw_key = f"{asset_id}-{version}-{fingerprint}"
raw_key = f"{asset_id}-{fingerprint}"
return "".join(
character if character.isalnum() or character in {"-", "_", ".", "="} else "_"
for character in raw_key
)
@staticmethod
def _build_onlyoffice_access_token(asset_id: str, version: str) -> str:
def _build_onlyoffice_access_token(asset_id: str) -> str:
onlyoffice_settings = resolve_onlyoffice_settings()
payload = {
"scope": "agent-asset-spreadsheet",
"asset_id": asset_id,
"version": version,
}
return jwt.encode(payload, onlyoffice_settings.jwt_secret, algorithm="HS256")
@@ -1646,7 +1546,6 @@ class AgentAssetService:
@staticmethod
def _build_spreadsheet_change_summary(
operation_label: str,
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead],
cell_changes: list[AgentAssetSpreadsheetDiffCellRead],
) -> str:
@@ -1655,15 +1554,15 @@ class AgentAssetService:
| {item.sheet_name for item in cell_changes}
)
if not sheet_names:
return f"{operation_label}文件内容已保存,未发现单元格级差异。"
return "文件内容已保存,未发现单元格级差异。"
preview = "".join(sheet_names[:3])
if len(sheet_names) > 3:
preview = f"{preview}"
sheet_text = f"涉及 {len(sheet_names)} 个工作表({preview}"
if cell_changes:
return f"{operation_label}{sheet_text},共 {len(cell_changes)} 处单元格改动。"
return f"{operation_label}{sheet_text},工作表结构发生变化。"
return f"{sheet_text},共 {len(cell_changes)} 处单元格改动。"
return f"{sheet_text},工作表结构发生变化。"
def _next_available_version(self, asset: AgentAsset) -> str:
candidate = self._increment_version(self._resolve_working_version(asset))

View File

@@ -1,70 +1,70 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.audit_log import AuditLog
from app.repositories.audit_log import AuditLogRepository
from app.schemas.audit_log import AuditLogRead
from app.services.agent_foundation import AgentFoundationService
logger = get_logger("app.services.audit")
class AuditLogService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AuditLogRepository(db)
def list_logs(
self,
*,
resource_type: str | None = None,
resource_id: str | None = None,
action: str | None = None,
limit: int = 50,
) -> list[AuditLogRead]:
self._ensure_ready()
items = self.repository.list(
resource_type=resource_type,
resource_id=resource_id,
action=action,
limit=limit,
)
return [AuditLogRead.model_validate(item) for item in items]
def log_action(
self,
*,
actor: str,
action: str,
resource_type: str,
resource_id: str,
before_json: dict[str, Any] | None = None,
after_json: dict[str, Any] | None = None,
request_id: str | None = None,
) -> AuditLog:
log = AuditLog(
actor=actor,
action=action,
resource_type=resource_type,
resource_id=resource_id,
before_json=before_json,
after_json=after_json,
request_id=request_id or uuid.uuid4().hex,
)
created = self.repository.create(log)
logger.info(
"Created audit log id=%s action=%s resource=%s:%s",
created.id,
created.action,
created.resource_type,
created.resource_id,
)
return created
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.audit_log import AuditLog
from app.repositories.audit_log import AuditLogRepository
from app.schemas.audit_log import AuditLogRead
from app.services.agent_foundation import AgentFoundationService
logger = get_logger("app.services.audit")
class AuditLogService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AuditLogRepository(db)
def list_logs(
self,
*,
resource_type: str | None = None,
resource_id: str | None = None,
action: str | None = None,
limit: int = 50,
) -> list[AuditLogRead]:
self._ensure_ready()
items = self.repository.list(
resource_type=resource_type,
resource_id=resource_id,
action=action,
limit=limit,
)
return [AuditLogRead.model_validate(item) for item in items]
def log_action(
self,
*,
actor: str,
action: str,
resource_type: str,
resource_id: str,
before_json: dict[str, Any] | None = None,
after_json: dict[str, Any] | None = None,
request_id: str | None = None,
) -> AuditLog:
log = AuditLog(
actor=actor,
action=action,
resource_type=resource_type,
resource_id=resource_id,
before_json=before_json,
after_json=after_json,
request_id=request_id or uuid.uuid4().hex,
)
created = self.repository.create(log)
logger.info(
"Created audit log id=%s action=%s resource=%s:%s",
created.id,
created.action,
created.resource_type,
created.resource_id,
)
return created
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -177,16 +177,16 @@ SLOT_LABELS = {
}
DATE_TEXT_PATTERN = re.compile(r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?)")
AMOUNT_TEXT_PATTERN = re.compile(
r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)"
)
AMOUNT_TEXT_PATTERN = re.compile(
r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)"
)
DOCUMENT_AMOUNT_PATTERN = re.compile(
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)"
r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
)
DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)")
SOURCE_LABELS = {
SOURCE_LABELS = {
"user_text": "用户描述",
"user_form": "用户修改",
"ocr": "票据识别",
@@ -215,7 +215,7 @@ INFERRED_REASON_LABELS = {
"welfare": "员工福利",
"other": "其他费用",
}
SYSTEM_GENERATED_REASON_PREFIXES = (
SYSTEM_GENERATED_REASON_PREFIXES = (
"我上传了",
"请按当前已识别信息",
"请把当前上传的票据",
@@ -225,20 +225,20 @@ SYSTEM_GENERATED_REASON_PREFIXES = (
"我已修改识别信息",
"查看报销草稿",
"请解释一下当前这笔报销的合规风险和待补充项",
)
AMOUNT_UNIT_ALIASES = {
"": "",
"": "",
"": "",
"": "",
"块钱": "",
"元整": "",
"万员": "万元",
"万圆": "万元",
"万园": "万元",
"万块": "万元",
"万元整": "万元",
}
)
AMOUNT_UNIT_ALIASES = {
"": "",
"": "",
"": "",
"": "",
"块钱": "",
"元整": "",
"万员": "万元",
"万圆": "万元",
"万园": "万元",
"万块": "万元",
"万元整": "万元",
}
class UserAgentService:
@@ -1742,7 +1742,7 @@ class UserAgentService:
if is_submitted:
body = (
f"主题:{subject}\n"
f"结论:报销单已提交,当前节点为 {approval_stage or '审批中'}\n"
f"结论:报销单已提交,当前节点为 {approval_stage or '审批中'}\n"
"建议:后续可在个人报销列表中跟踪审批进度,必要时再补充说明或附件。\n"
f"原始问题:{payload.message}"
)
@@ -2381,7 +2381,7 @@ class UserAgentService:
if review_action == "next_step":
if draft_payload is not None and draft_payload.status == "submitted":
stage_text = draft_payload.approval_stage or "审批中"
return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}".strip()
return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}".strip()
if payload.tool_payload.get("submission_blocked"):
return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。"
return (
@@ -2947,19 +2947,19 @@ class UserAgentService:
"expense_type_code": "",
}
participants: list[str] = []
for item in payload.ontology.entities:
if item.type == "employee" and not values["employee_name"]:
values["employee_name"] = item.value
elif item.type == "customer" and not values["customer"]:
values["customer"] = item.value
elif item.type == "amount" and item.role != "threshold" and not values["amount"]:
normalized_amount = str(item.normalized_value or "").strip()
values["amount"] = f"{normalized_amount}" if normalized_amount else item.value
elif item.type == "expense_type" and not values["expense_type_code"]:
values["expense_type_code"] = item.normalized_value
values["expense_type"] = EXPENSE_TYPE_LABELS.get(
item.normalized_value,
item.value,
for item in payload.ontology.entities:
if item.type == "employee" and not values["employee_name"]:
values["employee_name"] = item.value
elif item.type == "customer" and not values["customer"]:
values["customer"] = item.value
elif item.type == "amount" and item.role != "threshold" and not values["amount"]:
normalized_amount = str(item.normalized_value or "").strip()
values["amount"] = f"{normalized_amount}" if normalized_amount else item.value
elif item.type == "expense_type" and not values["expense_type_code"]:
values["expense_type_code"] = item.normalized_value
values["expense_type"] = EXPENSE_TYPE_LABELS.get(
item.normalized_value,
item.value,
)
elif item.type in {"participant", "person"} and item.value.strip():
participants.append(item.value.strip())
@@ -3189,7 +3189,24 @@ class UserAgentService:
evidence="来源于用户修改后的结构化表单。",
)
inferred_reason = self._infer_reason_from_claim_groups(
claim_groups=claim_groups,
)
reason_value = self._resolve_reason_text(self._resolve_reason_source_text(payload))
if inferred_reason:
return self._build_slot_value(
value=inferred_reason,
raw_value=reason_value or inferred_reason,
normalized_value=inferred_reason,
source="ocr",
confidence=0.82,
evidence=(
"系统已根据票据识别结果预置场景类型;原始描述仍保留为补充说明。"
if reason_value
else "系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。"
),
)
if reason_value:
return self._build_slot_value(
value=reason_value,
@@ -3199,19 +3216,6 @@ class UserAgentService:
confidence=0.76,
evidence="系统从用户原始描述中提取了本次费用事由,建议继续核对。",
)
inferred_reason = self._infer_reason_from_claim_groups(
claim_groups=claim_groups,
)
if inferred_reason:
return self._build_slot_value(
value=inferred_reason,
raw_value=inferred_reason,
normalized_value=inferred_reason,
source="ocr",
confidence=0.68,
evidence="系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。",
)
return self._build_slot_value()
def _build_amount_slot(
@@ -3358,17 +3362,17 @@ class UserAgentService:
return self._build_slot_value()
@staticmethod
def _normalize_amount_text(value: str) -> str:
cleaned = str(value or "").strip()
if not cleaned:
return ""
for alias, canonical in sorted(AMOUNT_UNIT_ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
cleaned = cleaned.replace(alias, canonical)
match = AMOUNT_TEXT_PATTERN.search(cleaned)
if not match:
return cleaned
number = float(match.group(1))
return f"{number:.2f}"
def _normalize_amount_text(value: str) -> str:
cleaned = str(value or "").strip()
if not cleaned:
return ""
for alias, canonical in sorted(AMOUNT_UNIT_ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
cleaned = cleaned.replace(alias, canonical)
match = AMOUNT_TEXT_PATTERN.search(cleaned)
if not match:
return cleaned
number = float(match.group(1))
return f"{number:.2f}"
@staticmethod
def _normalize_expense_type_input(value: str) -> tuple[str, str]:

View File

@@ -1,139 +1,139 @@
README.md
pyproject.toml
src/app/__init__.py
src/app/main.py
src/app/api/__init__.py
src/app/api/deps.py
src/app/api/router.py
src/app/api/v1/__init__.py
src/app/api/v1/router.py
src/app/api/v1/endpoints/__init__.py
src/app/api/v1/endpoints/agent_assets.py
src/app/api/v1/endpoints/agent_runs.py
src/app/api/v1/endpoints/audit_logs.py
src/app/api/v1/endpoints/auth.py
src/app/api/v1/endpoints/bootstrap.py
src/app/api/v1/endpoints/employees.py
src/app/api/v1/endpoints/health.py
src/app/api/v1/endpoints/knowledge.py
src/app/api/v1/endpoints/ocr.py
src/app/api/v1/endpoints/ontology.py
src/app/api/v1/endpoints/orchestrator.py
src/app/api/v1/endpoints/reimbursements.py
src/app/api/v1/endpoints/settings.py
src/app/api/v1/endpoints/system_logs.py
src/app/core/__init__.py
src/app/core/admin_secret.py
src/app/core/agent_enums.py
src/app/core/bootstrap.py
src/app/core/config.py
src/app/core/logging.py
src/app/core/openapi.py
src/app/core/secret_box.py
src/app/core/security.py
src/app/db/__init__.py
src/app/db/base.py
src/app/db/base_class.py
src/app/db/session.py
src/app/middleware/__init__.py
src/app/middleware/logging.py
src/app/models/__init__.py
src/app/models/agent_asset.py
src/app/models/agent_conversation.py
src/app/models/agent_run.py
src/app/models/approval.py
src/app/models/audit_log.py
src/app/models/employee.py
src/app/models/employee_change_log.py
src/app/models/financial_record.py
src/app/models/organization.py
src/app/models/reimbursement.py
src/app/models/role.py
src/app/models/system_model_setting.py
src/app/models/system_setting.py
src/app/models/system_setting_secret.py
src/app/repositories/__init__.py
src/app/repositories/agent_asset.py
src/app/repositories/agent_run.py
src/app/repositories/audit_log.py
src/app/repositories/employee.py
src/app/repositories/reimbursement.py
src/app/repositories/settings.py
src/app/schemas/__init__.py
src/app/schemas/agent_asset.py
src/app/schemas/agent_run.py
src/app/schemas/audit_log.py
src/app/schemas/auth.py
src/app/schemas/bootstrap.py
src/app/schemas/common.py
src/app/schemas/employee.py
src/app/schemas/knowledge.py
src/app/schemas/ocr.py
src/app/schemas/ontology.py
src/app/schemas/orchestrator.py
src/app/schemas/reimbursement.py
src/app/schemas/settings.py
src/app/schemas/system_log.py
src/app/schemas/user_agent.py
src/app/services/__init__.py
src/app/services/agent_asset_spreadsheet.py
src/app/services/agent_assets.py
src/app/services/agent_conversations.py
src/app/services/agent_foundation.py
src/app/services/agent_runs.py
src/app/services/audit.py
src/app/services/auth.py
src/app/services/document_intelligence.py
src/app/services/employee.py
src/app/services/employee_seed.py
src/app/services/expense_claims.py
src/app/services/expense_rule_runtime.py
src/app/services/hermes_sync.py
src/app/services/knowledge.py
src/app/services/knowledge_index_tasks.py
src/app/services/knowledge_normalizer.py
src/app/services/knowledge_rag.py
src/app/services/knowledge_scheduler.py
src/app/services/knowledge_sync.py
src/app/services/model_connectivity.py
src/app/services/ocr.py
src/app/services/ontology.py
src/app/services/orchestrator.py
src/app/services/reimbursement.py
src/app/services/runtime_chat.py
src/app/services/settings.py
src/app/services/system_hermes.py
src/app/services/system_logs.py
src/app/services/user_agent.py
src/x_financial_server.egg-info/PKG-INFO
src/x_financial_server.egg-info/SOURCES.txt
src/x_financial_server.egg-info/dependency_links.txt
src/x_financial_server.egg-info/requires.txt
src/x_financial_server.egg-info/top_level.txt
tests/test_agent_asset_onlyoffice_key.py
tests/test_agent_asset_service.py
tests/test_agent_asset_spreadsheet_import.py
tests/test_agent_foundation_endpoints.py
tests/test_agent_runs_service.py
tests/test_auth_service.py
tests/test_config_settings_reload.py
tests/test_document_intelligence.py
tests/test_employee_service.py
tests/test_env_file_precedence.py
tests/test_expense_claim_service.py
tests/test_imports.py
tests/test_knowledge_normalizer.py
tests/test_knowledge_onlyoffice_config.py
tests/test_knowledge_rag_service.py
tests/test_knowledge_service.py
tests/test_ocr_endpoints.py
tests/test_ocr_service.py
tests/test_ontology_service.py
tests/test_openapi_schema.py
tests/test_reimbursement_endpoints.py
tests/test_runtime_chat_service.py
tests/test_server_start_dependencies.py
tests/test_settings_persistence.py
tests/test_settings_service.py
tests/test_system_logs_service.py
README.md
pyproject.toml
src/app/__init__.py
src/app/main.py
src/app/api/__init__.py
src/app/api/deps.py
src/app/api/router.py
src/app/api/v1/__init__.py
src/app/api/v1/router.py
src/app/api/v1/endpoints/__init__.py
src/app/api/v1/endpoints/agent_assets.py
src/app/api/v1/endpoints/agent_runs.py
src/app/api/v1/endpoints/audit_logs.py
src/app/api/v1/endpoints/auth.py
src/app/api/v1/endpoints/bootstrap.py
src/app/api/v1/endpoints/employees.py
src/app/api/v1/endpoints/health.py
src/app/api/v1/endpoints/knowledge.py
src/app/api/v1/endpoints/ocr.py
src/app/api/v1/endpoints/ontology.py
src/app/api/v1/endpoints/orchestrator.py
src/app/api/v1/endpoints/reimbursements.py
src/app/api/v1/endpoints/settings.py
src/app/api/v1/endpoints/system_logs.py
src/app/core/__init__.py
src/app/core/admin_secret.py
src/app/core/agent_enums.py
src/app/core/bootstrap.py
src/app/core/config.py
src/app/core/logging.py
src/app/core/openapi.py
src/app/core/secret_box.py
src/app/core/security.py
src/app/db/__init__.py
src/app/db/base.py
src/app/db/base_class.py
src/app/db/session.py
src/app/middleware/__init__.py
src/app/middleware/logging.py
src/app/models/__init__.py
src/app/models/agent_asset.py
src/app/models/agent_conversation.py
src/app/models/agent_run.py
src/app/models/approval.py
src/app/models/audit_log.py
src/app/models/employee.py
src/app/models/employee_change_log.py
src/app/models/financial_record.py
src/app/models/organization.py
src/app/models/reimbursement.py
src/app/models/role.py
src/app/models/system_model_setting.py
src/app/models/system_setting.py
src/app/models/system_setting_secret.py
src/app/repositories/__init__.py
src/app/repositories/agent_asset.py
src/app/repositories/agent_run.py
src/app/repositories/audit_log.py
src/app/repositories/employee.py
src/app/repositories/reimbursement.py
src/app/repositories/settings.py
src/app/schemas/__init__.py
src/app/schemas/agent_asset.py
src/app/schemas/agent_run.py
src/app/schemas/audit_log.py
src/app/schemas/auth.py
src/app/schemas/bootstrap.py
src/app/schemas/common.py
src/app/schemas/employee.py
src/app/schemas/knowledge.py
src/app/schemas/ocr.py
src/app/schemas/ontology.py
src/app/schemas/orchestrator.py
src/app/schemas/reimbursement.py
src/app/schemas/settings.py
src/app/schemas/system_log.py
src/app/schemas/user_agent.py
src/app/services/__init__.py
src/app/services/agent_asset_spreadsheet.py
src/app/services/agent_assets.py
src/app/services/agent_conversations.py
src/app/services/agent_foundation.py
src/app/services/agent_runs.py
src/app/services/audit.py
src/app/services/auth.py
src/app/services/document_intelligence.py
src/app/services/employee.py
src/app/services/employee_seed.py
src/app/services/expense_claims.py
src/app/services/expense_rule_runtime.py
src/app/services/hermes_sync.py
src/app/services/knowledge.py
src/app/services/knowledge_index_tasks.py
src/app/services/knowledge_normalizer.py
src/app/services/knowledge_rag.py
src/app/services/knowledge_scheduler.py
src/app/services/knowledge_sync.py
src/app/services/model_connectivity.py
src/app/services/ocr.py
src/app/services/ontology.py
src/app/services/orchestrator.py
src/app/services/reimbursement.py
src/app/services/runtime_chat.py
src/app/services/settings.py
src/app/services/system_hermes.py
src/app/services/system_logs.py
src/app/services/user_agent.py
src/x_financial_server.egg-info/PKG-INFO
src/x_financial_server.egg-info/SOURCES.txt
src/x_financial_server.egg-info/dependency_links.txt
src/x_financial_server.egg-info/requires.txt
src/x_financial_server.egg-info/top_level.txt
tests/test_agent_asset_onlyoffice_key.py
tests/test_agent_asset_service.py
tests/test_agent_asset_spreadsheet_import.py
tests/test_agent_foundation_endpoints.py
tests/test_agent_runs_service.py
tests/test_auth_service.py
tests/test_config_settings_reload.py
tests/test_document_intelligence.py
tests/test_employee_service.py
tests/test_env_file_precedence.py
tests/test_expense_claim_service.py
tests/test_imports.py
tests/test_knowledge_normalizer.py
tests/test_knowledge_onlyoffice_config.py
tests/test_knowledge_rag_service.py
tests/test_knowledge_service.py
tests/test_ocr_endpoints.py
tests/test_ocr_service.py
tests/test_ontology_service.py
tests/test_openapi_schema.py
tests/test_reimbursement_endpoints.py
tests/test_runtime_chat_service.py
tests/test_server_start_dependencies.py
tests/test_settings_persistence.py
tests/test_settings_service.py
tests/test_system_logs_service.py
tests/test_user_agent_service.py

View File

@@ -1,84 +1,84 @@
{
"file_name": "行程单_2_鄂AX9877.pdf",
"storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.pdf",
"media_type": "application/pdf",
"size_bytes": 32459,
"uploaded_at": "2026-05-16T08:41:42.540134+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "行程单_2_鄂AX9877.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为出租车/网约车票据。",
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
"金额字段:已识别到与当前明细接近的金额 35.53 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "taxi_receipt",
"document_type_label": "出租车/网约车票据",
"scene_code": "transport",
"scene_label": "交通票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "35.53元"
},
{
"key": "date",
"label": "日期",
"value": "2026-03-04"
},
{
"key": "merchant_name",
"label": "商户",
"value": "全季酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "transport",
"current_expense_type_label": "交通费",
"allowed_scene_labels": [
"交通"
],
"allowed_document_type_labels": [
"停车/通行费票据",
"一般收据/凭证",
"出租车/网约车票据",
"增值税发票"
],
"recognized_scene_code": "transport",
"recognized_scene_label": "交通票据",
"recognized_document_type": "taxi_receipt",
"recognized_document_type_label": "出租车/网约车票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "高德地图一打车\n行程单\nAMAP ITINERARY\n1申请时间2026-03-04\n【行程时间2026-03-0407:05至2026-03-0407:33\n|行程人手机号18602700270\n1共计1单行程合计35.53元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-04\n1\n滴滴出行\n武汉市\n全季酒店武汉工程大学店\n武汉站\n35.53元\n07:05\n页码1/1",
"ocr_summary": "高德地图一打车行程单AMAP ITINERARY",
"ocr_avg_score": 0.9819406509399414,
"ocr_line_count": 25,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"滴滴出行",
"滴滴",
"打车",
"上车"
],
"ocr_warnings": []
{
"file_name": "行程单_2_鄂AX9877.pdf",
"storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.pdf",
"media_type": "application/pdf",
"size_bytes": 32459,
"uploaded_at": "2026-05-16T08:41:42.540134+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "行程单_2_鄂AX9877.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为出租车/网约车票据。",
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
"金额字段:已识别到与当前明细接近的金额 35.53 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "taxi_receipt",
"document_type_label": "出租车/网约车票据",
"scene_code": "transport",
"scene_label": "交通票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "35.53元"
},
{
"key": "date",
"label": "日期",
"value": "2026-03-04"
},
{
"key": "merchant_name",
"label": "商户",
"value": "全季酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "transport",
"current_expense_type_label": "交通费",
"allowed_scene_labels": [
"交通"
],
"allowed_document_type_labels": [
"停车/通行费票据",
"一般收据/凭证",
"出租车/网约车票据",
"增值税发票"
],
"recognized_scene_code": "transport",
"recognized_scene_label": "交通票据",
"recognized_document_type": "taxi_receipt",
"recognized_document_type_label": "出租车/网约车票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "高德地图一打车\n行程单\nAMAP ITINERARY\n1申请时间2026-03-04\n【行程时间2026-03-0407:05至2026-03-0407:33\n|行程人手机号18602700270\n1共计1单行程合计35.53元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-04\n1\n滴滴出行\n武汉市\n全季酒店武汉工程大学店\n武汉站\n35.53元\n07:05\n页码1/1",
"ocr_summary": "高德地图一打车行程单AMAP ITINERARY",
"ocr_avg_score": 0.9819406509399414,
"ocr_line_count": 25,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"滴滴出行",
"滴滴",
"打车",
"上车"
],
"ocr_warnings": []
}

View File

@@ -1,84 +1,84 @@
{
"file_name": "行程单_1_鄂A1S987.pdf",
"storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.pdf",
"media_type": "application/pdf",
"size_bytes": 34880,
"uploaded_at": "2026-05-16T08:17:53.656595+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "行程单_1_鄂A1S987.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为出租车/网约车票据。",
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
"金额字段:已识别到与当前明细接近的金额 10.30 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "taxi_receipt",
"document_type_label": "出租车/网约车票据",
"scene_code": "transport",
"scene_label": "交通票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "10.3元"
},
{
"key": "date",
"label": "日期",
"value": "2026-03-01"
},
{
"key": "merchant_name",
"label": "商户",
"value": "全季酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "transport",
"current_expense_type_label": "交通费",
"allowed_scene_labels": [
"交通"
],
"allowed_document_type_labels": [
"停车/通行费票据",
"一般收据/凭证",
"出租车/网约车票据",
"增值税发票"
],
"recognized_scene_code": "transport",
"recognized_scene_label": "交通票据",
"recognized_document_type": "taxi_receipt",
"recognized_document_type_label": "出租车/网约车票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "高德地图一打车\n行程单\nAMAP ITINERARY\n1申请时间2026-03-01\n【行程时间2026-03-0113:23至2026-03-0113:40\n行程人手机号18602700270\n|共计1单行程合计10.30元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-01\n1\n滴滴出行\n13:23\n武汉市\n金融港北地铁站\n全季酒店武汉工程大学店\n10.30元\n页码1/1",
"ocr_summary": "高德地图一打车行程单AMAP ITINERARY",
"ocr_avg_score": 0.9844024634361267,
"ocr_line_count": 25,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"滴滴出行",
"滴滴",
"打车",
"上车"
],
"ocr_warnings": []
{
"file_name": "行程单_1_鄂A1S987.pdf",
"storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.pdf",
"media_type": "application/pdf",
"size_bytes": 34880,
"uploaded_at": "2026-05-16T08:17:53.656595+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "行程单_1_鄂A1S987.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为出租车/网约车票据。",
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
"金额字段:已识别到与当前明细接近的金额 10.30 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "taxi_receipt",
"document_type_label": "出租车/网约车票据",
"scene_code": "transport",
"scene_label": "交通票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "10.3元"
},
{
"key": "date",
"label": "日期",
"value": "2026-03-01"
},
{
"key": "merchant_name",
"label": "商户",
"value": "全季酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "transport",
"current_expense_type_label": "交通费",
"allowed_scene_labels": [
"交通"
],
"allowed_document_type_labels": [
"停车/通行费票据",
"一般收据/凭证",
"出租车/网约车票据",
"增值税发票"
],
"recognized_scene_code": "transport",
"recognized_scene_label": "交通票据",
"recognized_document_type": "taxi_receipt",
"recognized_document_type_label": "出租车/网约车票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "高德地图一打车\n行程单\nAMAP ITINERARY\n1申请时间2026-03-01\n【行程时间2026-03-0113:23至2026-03-0113:40\n行程人手机号18602700270\n|共计1单行程合计10.30元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-01\n1\n滴滴出行\n13:23\n武汉市\n金融港北地铁站\n全季酒店武汉工程大学店\n10.30元\n页码1/1",
"ocr_summary": "高德地图一打车行程单AMAP ITINERARY",
"ocr_avg_score": 0.9844024634361267,
"ocr_line_count": 25,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"滴滴出行",
"滴滴",
"打车",
"上车"
],
"ocr_warnings": []
}

View File

@@ -35,13 +35,13 @@
"updated_at": "2026-05-17T13:00:09.485818+00:00",
"uploaded_by": "admin",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-17T13:00:09.485818+00:00",
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-19T16:00:57.418443+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_57f2d8727aaa4374"
}
]
}

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,268 +1,268 @@
{
"2c1cb358f08d44ceb0e4d287133206ec": {
"entity_names": [
"工会委员会",
"Business Original Documents",
"First Approver",
"P8",
"一级部门总经理",
"组织人事部",
"业务原始凭据",
"营销中心",
"保证金",
"投标保证金",
"餐补",
"第十四条业务招待费",
"Chief Engineer",
"业务招待",
"Employee Welfare",
"经济舱",
"2024年4月17日",
"三等舱",
"财务信息化系统",
"分管领导",
"重点支出管理规定",
"备用金借款",
"Financial Review",
"第五章附则",
"Company Leadership",
"第十九条",
"经办人",
"预算内支出",
"Current Account Payment",
"Business Entertainment",
"Tax Control System Details",
"第二十一条",
"成本中心归属",
"岗位支出报销审批权限表",
"工会经费管理办法",
"商旅系统",
"Special Subsidy",
"中国银行外汇折算价",
"因公借款",
"资产采购",
"广告费",
"First-Level Department General Manager",
"正式员工",
"一万元",
"公司员工教育培训管理办法",
"责任原则",
"第二章职责分工",
"预算先行",
"Planning and Finance Department",
"Accommodation Cost Reimbursement",
"Official Vehicle Subsidy",
"第四条归口管理部门主要职责",
"Personal Service Compensation",
"邮递费",
"附表3支出归口管理部门与归口业务范围",
"员工",
"第二条目的",
"Director",
"支出归口管理部门与归口业务范围",
"其他支出(员工)",
"报销标准",
"5000000 Yuan Approval Limit",
"第十一条备用金借款",
"会议费",
"第十七条",
"第七条各级管理人员主要职责",
"50000 Yuan Approval Limit",
"全资子公司",
"涉外业务汇率标准",
"总监",
"第十三条差旅费",
"审批权限表",
"商旅订票规范",
"Final Approval Position",
"报销资格",
"新增报销规定",
"公司支出管理办法",
"Institution General Manager",
"房屋租金",
"Staff Activities",
"分包外包(内部单位)",
"报销申请时限",
"Financial Information System",
"Expenditure Authorization Approval Scope",
"直辖市",
"培训费",
"第十二条市内交通费",
"第十五条",
"终审岗",
"Remote Work Housing",
"Centralized Management department",
"第二十条",
"办公室(党委办公室)",
"Three Flows Consistency Principle",
"审批权限",
"VAT Special Invoice",
"后勤服务部",
"员工支出报销审批权限表",
"公司总裁",
"出差补贴",
"Basic Level Managers",
"预付款项",
"附表1员工支出报销审批权限表",
"经办部门",
"信息管理部",
"通信费",
"第十六条",
"增值税发票",
"财务入账条件",
"Hotel Accommodation Standards",
"审批流转程序",
"Self-Driving Travel Provisions",
"交通费",
"第九条支出报销审批",
"薪酬福利支出分配计划",
"产品规划设计部",
"因公用车补贴",
"Committee Chairpersons",
"Business Division General Manager",
"组织安排",
"1 Yuan Per Person Per Kilometer Reimbursement",
"Separation of Approval and Processing Principle",
"第五条计划财务部主要职责",
"200000 Yuan Approval Limit",
"公司各部门",
"第十四条",
"Other Areas",
"分支机构",
"Departments And Units",
"计划财务部",
"Other Employees",
"第二十三条",
"公司团建管理办法",
"火车硬席",
"税控系统明细清单",
"Trade Union Fund",
"报销标准变化情况",
"薪酬福利支出",
"Hong Kong, Macau, And Taiwan Region",
"对外捐赠支出",
"Multi-Level Approval Rule",
"Three Working Days Deadline",
"Employee Remuneration",
"销售退款",
"股权投资、兼并收购",
"控股子公司",
"取消报销规定",
"Procurement Management Regulations",
"Middle Managers",
"差旅费",
"批办分离",
"住宿费",
"Travel Allowance Standards",
"第二十三条本办法的归口与实施",
"Senior Vice President",
"供应商",
"人事归口管理部门",
"Management Personnel At All Levels",
"效益优先",
"Operating Department Individual",
"Remote Work Housing Rental Expenses",
"取消报销规定内容",
"Company",
"修订说明",
"国网数科公司",
"Vice President",
"分级授权",
"Expenditure Reimbursement Application",
"第二十四条附件",
"第二十二条",
"出租车",
"Night High-Speed Rail Provision",
"各级管理人员",
"受益原则",
"公司员工因公通讯费用实施细则",
"公司支出管理办法(2024)",
"出差补贴标准",
"Bid Security Deposit Approval Limits Table",
"第二条范围",
"Company Property Rental Management",
"调动工作",
"远光软件股份有限公司",
"市内交通费",
"交通工具等级标准",
"Operator",
"第八条支出报销申请",
"Directly-Controlled Municipalities And Special Administrative Regions",
"出差规定",
"业务招待费",
"Senior Managers",
"逐级审批规则",
"Company Business Travel System",
"广告宣传费",
"Transportation Cost Reimbursement",
"财务",
"第一章总则",
"材料采购",
"人力资源服务部",
"证券与法律事务部",
"Transportation Level Standards",
"归口管理部门",
"商旅客服",
"第四章重点支出管理规定",
"出差审批程序",
"Business Trip Approval",
"西藏",
"附表2岗位支出报销审批权限表",
"第十八条",
"第二十四条",
"Company Hotel Accommodation Limit Standards",
"办法",
"DAP研发中心",
"新增规定内容",
"基本补助",
"Travel Allowance",
"异地挂职锻炼补贴标准",
"部门负责人",
"Provincial Capitals",
"特区",
"Transportation Tickets",
"第三章支出报销申请与审批",
"品牌及市场运营中心",
"分包外包(外部单位)",
"探亲路费",
"President",
"凭据报销",
"基本出差补贴",
"Taxi Usage Regulations",
"Government Fees",
"Commercial Travel System",
"远光制度202414号",
"审批权限变化情况",
"基建工程",
"支出报销申请与审批",
"中国外汇交易中心参考汇率",
"Department Manager",
"支出报销审批",
"预算调整决策程序",
"公司1号文",
"External Conference Accommodation",
"厉行节约",
"Commercial Insurance",
"公司",
"第三条管理原则",
"捐赠申请",
"分类控制",
"业务宣传费",
"产业投资部",
"公司员工探亲管理办法",
"Subsequent Approver",
"100000 Yuan Approval Limit",
"Tax Authority Recognized Invoice",
"国家电网公司",
"业务佐证材料",
"第六条经办部门(个人)主要职责",
"结算起点",
"第十条支出成本中心归属",
"母公司"
],
"count": 258,
"create_time": 1779012093,
"update_time": 1779012093,
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
}
{
"2c1cb358f08d44ceb0e4d287133206ec": {
"entity_names": [
"工会委员会",
"Business Original Documents",
"First Approver",
"P8",
"一级部门总经理",
"组织人事部",
"业务原始凭据",
"营销中心",
"保证金",
"投标保证金",
"餐补",
"第十四条业务招待费",
"Chief Engineer",
"业务招待",
"Employee Welfare",
"经济舱",
"2024年4月17日",
"三等舱",
"财务信息化系统",
"分管领导",
"重点支出管理规定",
"备用金借款",
"Financial Review",
"第五章附则",
"Company Leadership",
"第十九条",
"经办人",
"预算内支出",
"Current Account Payment",
"Business Entertainment",
"Tax Control System Details",
"第二十一条",
"成本中心归属",
"岗位支出报销审批权限表",
"工会经费管理办法",
"商旅系统",
"Special Subsidy",
"中国银行外汇折算价",
"因公借款",
"资产采购",
"广告费",
"First-Level Department General Manager",
"正式员工",
"一万元",
"公司员工教育培训管理办法",
"责任原则",
"第二章职责分工",
"预算先行",
"Planning and Finance Department",
"Accommodation Cost Reimbursement",
"Official Vehicle Subsidy",
"第四条归口管理部门主要职责",
"Personal Service Compensation",
"邮递费",
"附表3支出归口管理部门与归口业务范围",
"员工",
"第二条目的",
"Director",
"支出归口管理部门与归口业务范围",
"其他支出(员工)",
"报销标准",
"5000000 Yuan Approval Limit",
"第十一条备用金借款",
"会议费",
"第十七条",
"第七条各级管理人员主要职责",
"50000 Yuan Approval Limit",
"全资子公司",
"涉外业务汇率标准",
"总监",
"第十三条差旅费",
"审批权限表",
"商旅订票规范",
"Final Approval Position",
"报销资格",
"新增报销规定",
"公司支出管理办法",
"Institution General Manager",
"房屋租金",
"Staff Activities",
"分包外包(内部单位)",
"报销申请时限",
"Financial Information System",
"Expenditure Authorization Approval Scope",
"直辖市",
"培训费",
"第十二条市内交通费",
"第十五条",
"终审岗",
"Remote Work Housing",
"Centralized Management department",
"第二十条",
"办公室(党委办公室)",
"Three Flows Consistency Principle",
"审批权限",
"VAT Special Invoice",
"后勤服务部",
"员工支出报销审批权限表",
"公司总裁",
"出差补贴",
"Basic Level Managers",
"预付款项",
"附表1员工支出报销审批权限表",
"经办部门",
"信息管理部",
"通信费",
"第十六条",
"增值税发票",
"财务入账条件",
"Hotel Accommodation Standards",
"审批流转程序",
"Self-Driving Travel Provisions",
"交通费",
"第九条支出报销审批",
"薪酬福利支出分配计划",
"产品规划设计部",
"因公用车补贴",
"Committee Chairpersons",
"Business Division General Manager",
"组织安排",
"1 Yuan Per Person Per Kilometer Reimbursement",
"Separation of Approval and Processing Principle",
"第五条计划财务部主要职责",
"200000 Yuan Approval Limit",
"公司各部门",
"第十四条",
"Other Areas",
"分支机构",
"Departments And Units",
"计划财务部",
"Other Employees",
"第二十三条",
"公司团建管理办法",
"火车硬席",
"税控系统明细清单",
"Trade Union Fund",
"报销标准变化情况",
"薪酬福利支出",
"Hong Kong, Macau, And Taiwan Region",
"对外捐赠支出",
"Multi-Level Approval Rule",
"Three Working Days Deadline",
"Employee Remuneration",
"销售退款",
"股权投资、兼并收购",
"控股子公司",
"取消报销规定",
"Procurement Management Regulations",
"Middle Managers",
"差旅费",
"批办分离",
"住宿费",
"Travel Allowance Standards",
"第二十三条本办法的归口与实施",
"Senior Vice President",
"供应商",
"人事归口管理部门",
"Management Personnel At All Levels",
"效益优先",
"Operating Department Individual",
"Remote Work Housing Rental Expenses",
"取消报销规定内容",
"Company",
"修订说明",
"国网数科公司",
"Vice President",
"分级授权",
"Expenditure Reimbursement Application",
"第二十四条附件",
"第二十二条",
"出租车",
"Night High-Speed Rail Provision",
"各级管理人员",
"受益原则",
"公司员工因公通讯费用实施细则",
"公司支出管理办法(2024)",
"出差补贴标准",
"Bid Security Deposit Approval Limits Table",
"第二条范围",
"Company Property Rental Management",
"调动工作",
"远光软件股份有限公司",
"市内交通费",
"交通工具等级标准",
"Operator",
"第八条支出报销申请",
"Directly-Controlled Municipalities And Special Administrative Regions",
"出差规定",
"业务招待费",
"Senior Managers",
"逐级审批规则",
"Company Business Travel System",
"广告宣传费",
"Transportation Cost Reimbursement",
"财务",
"第一章总则",
"材料采购",
"人力资源服务部",
"证券与法律事务部",
"Transportation Level Standards",
"归口管理部门",
"商旅客服",
"第四章重点支出管理规定",
"出差审批程序",
"Business Trip Approval",
"西藏",
"附表2岗位支出报销审批权限表",
"第十八条",
"第二十四条",
"Company Hotel Accommodation Limit Standards",
"办法",
"DAP研发中心",
"新增规定内容",
"基本补助",
"Travel Allowance",
"异地挂职锻炼补贴标准",
"部门负责人",
"Provincial Capitals",
"特区",
"Transportation Tickets",
"第三章支出报销申请与审批",
"品牌及市场运营中心",
"分包外包(外部单位)",
"探亲路费",
"President",
"凭据报销",
"基本出差补贴",
"Taxi Usage Regulations",
"Government Fees",
"Commercial Travel System",
"远光制度202414号",
"审批权限变化情况",
"基建工程",
"支出报销申请与审批",
"中国外汇交易中心参考汇率",
"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"
}
}

View File

@@ -1,166 +1,166 @@
{
"2c1cb358f08d44ceb0e4d287133206ec": {
"relation_pairs": [
[
"Departments And Units",
"Taxi Usage Regulations"
],
[
"取消报销规定内容",
"报销标准变化情况"
],
[
"业务招待费",
"第十四条"
],
[
"控股子公司",
"计划财务部"
],
[
"公司支出管理办法",
"工会委员会"
],
[
"第一章总则",
"第三条管理原则"
],
[
"广告宣传费",
"第十六条"
],
[
"Tax Control System Details",
"VAT Special Invoice"
],
[
"Expenditure Reimbursement Application",
"Tax Authority Recognized Invoice"
],
[
"远光制度202414号",
"远光软件股份有限公司"
],
[
"Financial Review",
"Operator"
],
[
"Operating Department Individual",
"Procurement Management Regulations"
],
[
"会议费",
"第十五条"
],
[
"Company",
"Management Personnel At All Levels"
],
[
"公司",
"第十七条"
],
[
"公司",
"第十八条"
],
[
"Operator",
"Three Working Days Deadline"
],
[
"第十一条备用金借款",
"第四章重点支出管理规定"
],
[
"Expenditure Reimbursement Application",
"Operator"
],
[
"业务招待费",
"差旅费"
],
[
"公司",
"第二十一条"
],
[
"公司支出管理办法(2024)",
"远光软件股份有限公司"
],
[
"第四条归口管理部门主要职责",
"计划财务部"
],
[
"会议费",
"差旅费"
],
[
"Company",
"Operating Department Individual"
],
[
"商旅系统",
"差旅费"
],
[
"会议费",
"公司总裁"
],
[
"计划财务部",
"远光软件股份有限公司"
],
[
"公司",
"第十九条"
],
[
"公司",
"第二十条"
],
[
"Company",
"Planning and Finance Department"
],
[
"公司支出管理办法",
"营销中心"
],
[
"Business Original Documents",
"Operator"
],
[
"公司支出管理办法",
"办公室(党委办公室)"
],
[
"Departments And Units",
"Night High-Speed Rail Provision"
],
[
"Centralized Management department",
"Company"
],
[
"组织人事部",
"调动工作"
],
[
"报销标准变化情况",
"远光软件股份有限公司"
],
[
"第一章总则",
"远光软件股份有限公司"
]
],
"count": 39,
"create_time": 1779012093,
"update_time": 1779012093,
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
}
{
"2c1cb358f08d44ceb0e4d287133206ec": {
"relation_pairs": [
[
"Departments And Units",
"Taxi Usage Regulations"
],
[
"取消报销规定内容",
"报销标准变化情况"
],
[
"业务招待费",
"第十四条"
],
[
"控股子公司",
"计划财务部"
],
[
"公司支出管理办法",
"工会委员会"
],
[
"第一章总则",
"第三条管理原则"
],
[
"广告宣传费",
"第十六条"
],
[
"Tax Control System Details",
"VAT Special Invoice"
],
[
"Expenditure Reimbursement Application",
"Tax Authority Recognized Invoice"
],
[
"远光制度202414号",
"远光软件股份有限公司"
],
[
"Financial Review",
"Operator"
],
[
"Operating Department Individual",
"Procurement Management Regulations"
],
[
"会议费",
"第十五条"
],
[
"Company",
"Management Personnel At All Levels"
],
[
"公司",
"第十七条"
],
[
"公司",
"第十八条"
],
[
"Operator",
"Three Working Days Deadline"
],
[
"第十一条备用金借款",
"第四章重点支出管理规定"
],
[
"Expenditure Reimbursement Application",
"Operator"
],
[
"业务招待费",
"差旅费"
],
[
"公司",
"第二十一条"
],
[
"公司支出管理办法(2024)",
"远光软件股份有限公司"
],
[
"第四条归口管理部门主要职责",
"计划财务部"
],
[
"会议费",
"差旅费"
],
[
"Company",
"Operating Department Individual"
],
[
"商旅系统",
"差旅费"
],
[
"会议费",
"公司总裁"
],
[
"计划财务部",
"远光软件股份有限公司"
],
[
"公司",
"第十九条"
],
[
"公司",
"第二十条"
],
[
"Company",
"Planning and Finance Department"
],
[
"公司支出管理办法",
"营销中心"
],
[
"Business Original Documents",
"Operator"
],
[
"公司支出管理办法",
"办公室(党委办公室)"
],
[
"Departments And Units",
"Night High-Speed Rail Provision"
],
[
"Centralized Management department",
"Company"
],
[
"组织人事部",
"调动工作"
],
[
"报销标准变化情况",
"远光软件股份有限公司"
],
[
"第一章总则",
"远光软件股份有限公司"
]
],
"count": 39,
"create_time": 1779012093,
"update_time": 1779012093,
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
}
}

View File

@@ -1,353 +1,353 @@
{
"第一章总则<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012088,
"update_time": 1779012088,
"_id": "第一章总则<SEP>远光软件股份有限公司"
},
"第十一条备用金借款<SEP>第四章重点支出管理规定": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012088,
"update_time": 1779012088,
"_id": "第十一条备用金借款<SEP>第四章重点支出管理规定"
},
"公司支出管理办法<SEP>办公室(党委办公室)": {
"chunk_ids": [
"chunk-afc57a0e9548d1f484da6df6c182676b"
],
"count": 1,
"create_time": 1779012088,
"update_time": 1779012088,
"_id": "公司支出管理办法<SEP>办公室(党委办公室)"
},
"计划财务部<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012076,
"update_time": 1779012076,
"_id": "计划财务部<SEP>远光软件股份有限公司"
},
"第一章总则<SEP>第三条管理原则": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012076,
"update_time": 1779012076,
"_id": "第一章总则<SEP>第三条管理原则"
},
"Company<SEP>Management Personnel At All Levels": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012076,
"update_time": 1779012076,
"_id": "Company<SEP>Management Personnel At All Levels"
},
"Centralized Management department<SEP>Company": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012077,
"update_time": 1779012077,
"_id": "Centralized Management department<SEP>Company"
},
"Company<SEP>Planning and Finance Department": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012077,
"update_time": 1779012077,
"_id": "Company<SEP>Planning and Finance Department"
},
"Company<SEP>Operating Department Individual": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012078,
"update_time": 1779012078,
"_id": "Company<SEP>Operating Department Individual"
},
"公司支出管理办法<SEP>工会委员会": {
"chunk_ids": [
"chunk-afc57a0e9548d1f484da6df6c182676b"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "公司支出管理办法<SEP>工会委员会"
},
"Expenditure Reimbursement Application<SEP>Operator": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "Expenditure Reimbursement Application<SEP>Operator"
},
"公司支出管理办法<SEP>营销中心": {
"chunk_ids": [
"chunk-afc57a0e9548d1f484da6df6c182676b"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "公司支出管理办法<SEP>营销中心"
},
"第四条归口管理部门主要职责<SEP>计划财务部": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "第四条归口管理部门主要职责<SEP>计划财务部"
},
"Tax Control System Details<SEP>VAT Special Invoice": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "Tax Control System Details<SEP>VAT Special Invoice"
},
"Operating Department Individual<SEP>Procurement Management Regulations": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012081,
"update_time": 1779012081,
"_id": "Operating Department Individual<SEP>Procurement Management Regulations"
},
"Business Original Documents<SEP>Operator": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012094,
"update_time": 1779012094,
"_id": "Business Original Documents<SEP>Operator"
},
"Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012094,
"update_time": 1779012094,
"_id": "Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice"
},
"公司<SEP>第十七条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012094,
"update_time": 1779012094,
"_id": "公司<SEP>第十七条"
},
"Operator<SEP>Three Working Days Deadline": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012083,
"update_time": 1779012083,
"_id": "Operator<SEP>Three Working Days Deadline"
},
"Departments And Units<SEP>Night High-Speed Rail Provision": {
"chunk_ids": [
"chunk-613d6dfd4c5e9c807229a3147f96b584"
],
"count": 1,
"create_time": 1779012084,
"update_time": 1779012084,
"_id": "Departments And Units<SEP>Night High-Speed Rail Provision"
},
"公司<SEP>第十八条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012084,
"update_time": 1779012084,
"_id": "公司<SEP>第十八条"
},
"公司<SEP>第十九条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012084,
"update_time": 1779012084,
"_id": "公司<SEP>第十九条"
},
"报销标准变化情况<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-18d968b78afe916b419c1b5973421ebe"
],
"count": 1,
"create_time": 1779012084,
"update_time": 1779012084,
"_id": "报销标准变化情况<SEP>远光软件股份有限公司"
},
"取消报销规定内容<SEP>报销标准变化情况": {
"chunk_ids": [
"chunk-18d968b78afe916b419c1b5973421ebe"
],
"count": 1,
"create_time": 1779012085,
"update_time": 1779012085,
"_id": "取消报销规定内容<SEP>报销标准变化情况"
},
"Financial Review<SEP>Operator": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012085,
"update_time": 1779012085,
"_id": "Financial Review<SEP>Operator"
},
"公司支出管理办法(2024)<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
],
"count": 1,
"create_time": 1779012085,
"update_time": 1779012085,
"_id": "公司支出管理办法(2024)<SEP>远光软件股份有限公司"
},
"远光制度202414号<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
],
"count": 1,
"create_time": 1779012086,
"update_time": 1779012086,
"_id": "远光制度202414号<SEP>远光软件股份有限公司"
},
"Departments And Units<SEP>Taxi Usage Regulations": {
"chunk_ids": [
"chunk-613d6dfd4c5e9c807229a3147f96b584"
],
"count": 1,
"create_time": 1779012099,
"update_time": 1779012099,
"_id": "Departments And Units<SEP>Taxi Usage Regulations"
},
"控股子公司<SEP>计划财务部": {
"chunk_ids": [
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
],
"count": 1,
"create_time": 1779012099,
"update_time": 1779012099,
"_id": "控股子公司<SEP>计划财务部"
},
"公司<SEP>第二十条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012086,
"update_time": 1779012086,
"_id": "公司<SEP>第二十条"
},
"商旅系统<SEP>差旅费": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012086,
"update_time": 1779012086,
"_id": "商旅系统<SEP>差旅费"
},
"业务招待费<SEP>差旅费": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012089,
"update_time": 1779012089,
"_id": "业务招待费<SEP>差旅费"
},
"公司<SEP>第二十一条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012089,
"update_time": 1779012089,
"_id": "公司<SEP>第二十一条"
},
"广告宣传费<SEP>第十六条": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012089,
"update_time": 1779012089,
"_id": "广告宣传费<SEP>第十六条"
},
"组织人事部<SEP>调动工作": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012090,
"update_time": 1779012090,
"_id": "组织人事部<SEP>调动工作"
},
"会议费<SEP>差旅费": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012092,
"update_time": 1779012092,
"_id": "会议费<SEP>差旅费"
},
"业务招待费<SEP>第十四条": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012092,
"update_time": 1779012092,
"_id": "业务招待费<SEP>第十四条"
},
"会议费<SEP>第十五条": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012092,
"update_time": 1779012092,
"_id": "会议费<SEP>第十五条"
},
"会议费<SEP>公司总裁": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012093,
"update_time": 1779012093,
"_id": "会议费<SEP>公司总裁"
}
{
"第一章总则<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012088,
"update_time": 1779012088,
"_id": "第一章总则<SEP>远光软件股份有限公司"
},
"第十一条备用金借款<SEP>第四章重点支出管理规定": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012088,
"update_time": 1779012088,
"_id": "第十一条备用金借款<SEP>第四章重点支出管理规定"
},
"公司支出管理办法<SEP>办公室(党委办公室)": {
"chunk_ids": [
"chunk-afc57a0e9548d1f484da6df6c182676b"
],
"count": 1,
"create_time": 1779012088,
"update_time": 1779012088,
"_id": "公司支出管理办法<SEP>办公室(党委办公室)"
},
"计划财务部<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012076,
"update_time": 1779012076,
"_id": "计划财务部<SEP>远光软件股份有限公司"
},
"第一章总则<SEP>第三条管理原则": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012076,
"update_time": 1779012076,
"_id": "第一章总则<SEP>第三条管理原则"
},
"Company<SEP>Management Personnel At All Levels": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012076,
"update_time": 1779012076,
"_id": "Company<SEP>Management Personnel At All Levels"
},
"Centralized Management department<SEP>Company": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012077,
"update_time": 1779012077,
"_id": "Centralized Management department<SEP>Company"
},
"Company<SEP>Planning and Finance Department": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012077,
"update_time": 1779012077,
"_id": "Company<SEP>Planning and Finance Department"
},
"Company<SEP>Operating Department Individual": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012078,
"update_time": 1779012078,
"_id": "Company<SEP>Operating Department Individual"
},
"公司支出管理办法<SEP>工会委员会": {
"chunk_ids": [
"chunk-afc57a0e9548d1f484da6df6c182676b"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "公司支出管理办法<SEP>工会委员会"
},
"Expenditure Reimbursement Application<SEP>Operator": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "Expenditure Reimbursement Application<SEP>Operator"
},
"公司支出管理办法<SEP>营销中心": {
"chunk_ids": [
"chunk-afc57a0e9548d1f484da6df6c182676b"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "公司支出管理办法<SEP>营销中心"
},
"第四条归口管理部门主要职责<SEP>计划财务部": {
"chunk_ids": [
"chunk-aa5435156b829944c173fa1d2d7a93d4"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "第四条归口管理部门主要职责<SEP>计划财务部"
},
"Tax Control System Details<SEP>VAT Special Invoice": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012079,
"update_time": 1779012079,
"_id": "Tax Control System Details<SEP>VAT Special Invoice"
},
"Operating Department Individual<SEP>Procurement Management Regulations": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012081,
"update_time": 1779012081,
"_id": "Operating Department Individual<SEP>Procurement Management Regulations"
},
"Business Original Documents<SEP>Operator": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012094,
"update_time": 1779012094,
"_id": "Business Original Documents<SEP>Operator"
},
"Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012094,
"update_time": 1779012094,
"_id": "Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice"
},
"公司<SEP>第十七条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012094,
"update_time": 1779012094,
"_id": "公司<SEP>第十七条"
},
"Operator<SEP>Three Working Days Deadline": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012083,
"update_time": 1779012083,
"_id": "Operator<SEP>Three Working Days Deadline"
},
"Departments And Units<SEP>Night High-Speed Rail Provision": {
"chunk_ids": [
"chunk-613d6dfd4c5e9c807229a3147f96b584"
],
"count": 1,
"create_time": 1779012084,
"update_time": 1779012084,
"_id": "Departments And Units<SEP>Night High-Speed Rail Provision"
},
"公司<SEP>第十八条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012084,
"update_time": 1779012084,
"_id": "公司<SEP>第十八条"
},
"公司<SEP>第十九条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012084,
"update_time": 1779012084,
"_id": "公司<SEP>第十九条"
},
"报销标准变化情况<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-18d968b78afe916b419c1b5973421ebe"
],
"count": 1,
"create_time": 1779012084,
"update_time": 1779012084,
"_id": "报销标准变化情况<SEP>远光软件股份有限公司"
},
"取消报销规定内容<SEP>报销标准变化情况": {
"chunk_ids": [
"chunk-18d968b78afe916b419c1b5973421ebe"
],
"count": 1,
"create_time": 1779012085,
"update_time": 1779012085,
"_id": "取消报销规定内容<SEP>报销标准变化情况"
},
"Financial Review<SEP>Operator": {
"chunk_ids": [
"chunk-74c01decac4a10cd40a491786743b0ee"
],
"count": 1,
"create_time": 1779012085,
"update_time": 1779012085,
"_id": "Financial Review<SEP>Operator"
},
"公司支出管理办法(2024)<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
],
"count": 1,
"create_time": 1779012085,
"update_time": 1779012085,
"_id": "公司支出管理办法(2024)<SEP>远光软件股份有限公司"
},
"远光制度202414号<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
],
"count": 1,
"create_time": 1779012086,
"update_time": 1779012086,
"_id": "远光制度202414号<SEP>远光软件股份有限公司"
},
"Departments And Units<SEP>Taxi Usage Regulations": {
"chunk_ids": [
"chunk-613d6dfd4c5e9c807229a3147f96b584"
],
"count": 1,
"create_time": 1779012099,
"update_time": 1779012099,
"_id": "Departments And Units<SEP>Taxi Usage Regulations"
},
"控股子公司<SEP>计划财务部": {
"chunk_ids": [
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
],
"count": 1,
"create_time": 1779012099,
"update_time": 1779012099,
"_id": "控股子公司<SEP>计划财务部"
},
"公司<SEP>第二十条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012086,
"update_time": 1779012086,
"_id": "公司<SEP>第二十条"
},
"商旅系统<SEP>差旅费": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012086,
"update_time": 1779012086,
"_id": "商旅系统<SEP>差旅费"
},
"业务招待费<SEP>差旅费": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012089,
"update_time": 1779012089,
"_id": "业务招待费<SEP>差旅费"
},
"公司<SEP>第二十一条": {
"chunk_ids": [
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
],
"count": 1,
"create_time": 1779012089,
"update_time": 1779012089,
"_id": "公司<SEP>第二十一条"
},
"广告宣传费<SEP>第十六条": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012089,
"update_time": 1779012089,
"_id": "广告宣传费<SEP>第十六条"
},
"组织人事部<SEP>调动工作": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012090,
"update_time": 1779012090,
"_id": "组织人事部<SEP>调动工作"
},
"会议费<SEP>差旅费": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012092,
"update_time": 1779012092,
"_id": "会议费<SEP>差旅费"
},
"业务招待费<SEP>第十四条": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012092,
"update_time": 1779012092,
"_id": "业务招待费<SEP>第十四条"
},
"会议费<SEP>第十五条": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012092,
"update_time": 1779012092,
"_id": "会议费<SEP>第十五条"
},
"会议费<SEP>公司总裁": {
"chunk_ids": [
"chunk-d26b288ed4001dc5c504dce0eb841362"
],
"count": 1,
"create_time": 1779012093,
"update_time": 1779012093,
"_id": "会议费<SEP>公司总裁"
}
}

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -310,7 +310,7 @@ def test_restore_version_creates_new_working_copy_without_rewriting_published_ve
assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿"
def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
def test_spreadsheet_upload_records_sheet_and_cell_changes_without_versions() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
@@ -322,34 +322,33 @@ def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
service.upload_rule_spreadsheet(
rule.id,
filename="公司差旅费报销规则.xlsx",
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
actor="finance_user",
)
base_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
service.upload_rule_spreadsheet(
rule.id,
filename="公司差旅费报销规则.xlsx",
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
actor="finance_user",
)
target_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
diff = service.compare_spreadsheet_versions(
rule.id,
base_version=base_version or "",
target_version=target_version or "",
)
records = service.list_spreadsheet_change_records(rule.id)
latest = records[0]
assert diff.changed_sheet_count == 1
assert diff.changed_cell_count == 3
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 diff.cell_changes
for item in latest.cell_changes
)
assert any(item.cell == "A3" and item.change_type == "added" for item in diff.cell_changes)
assert any(
item.cell == "A3" and item.change_type == "added"
for item in latest.cell_changes
)
assert not hasattr(latest, "version")
def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_copy() -> None:
def test_spreadsheet_content_reads_current_rule_file_without_version_snapshot() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
@@ -366,7 +365,6 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
)
detail = service.get_asset(rule.id)
assert detail is not None
working_version = detail.working_version or ""
current_asset = service.repository.get(rule.id)
assert current_asset is not None
@@ -375,23 +373,13 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
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()
original_live_bytes = live_path.read_bytes()
try:
live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]]))
snapshot_path, _, _ = service.get_rule_spreadsheet_content(
rule.id,
version=working_version,
)
current_path, _, _ = service.get_rule_spreadsheet_content(rule.id)
assert snapshot_path != live_path
assert FINANCE_RULES_LIBRARY in snapshot_path.parts
assert ".versions" in snapshot_path.parts
assert "agent_assets" not in snapshot_path.parts
workbook = load_workbook(snapshot_path, data_only=False)
assert workbook.active["B2"].value == 500
finally:
live_path.write_bytes(original_live_bytes)
assert current_path == live_path
assert ".versions" not in current_path.parts
workbook = load_workbook(current_path, data_only=False)
assert workbook.active["B2"].value == 500
def test_spreadsheet_change_records_return_recent_edit_details() -> None:
@@ -454,7 +442,6 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
)
detail = service.get_asset(rule.id)
assert detail is not None
first_version = detail.working_version
service.upload_rule_spreadsheet(
rule.id,
@@ -473,7 +460,7 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
changed_sheets = {item.sheet_name for item in latest.sheet_changes}
changed_cell_sheets = {item.sheet_name for item in latest.cell_changes}
assert latest.version != first_version
assert not hasattr(latest, "version")
assert latest.changed_sheet_count == 2
assert {"差旅标准", "填表说明"}.issubset(changed_sheets)
assert {"差旅标准", "填表说明"}.issubset(changed_cell_sheets)
@@ -513,6 +500,8 @@ def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -
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:

View File

@@ -1,98 +1,98 @@
from __future__ import annotations
from collections.abc import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.core.agent_enums import AgentAssetStatus
from app.db.base import Base
from app.main import create_app
from app.services.agent_assets import AgentAssetService
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def test_list_agent_assets_endpoint_returns_seeded_items() -> None:
client, _ = build_client()
response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
assert response.status_code == 200
payload = response.json()
assert payload
assert all(item["asset_type"] == "rule" for item in payload)
assert any(item["code"] == "rule.expense.travel_risk_control_standard" for item in payload)
def test_get_agent_asset_detail_endpoint_returns_version_history() -> None:
client, _ = build_client()
list_response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
asset_id = next(
item["id"]
for item in list_response.json()
if item["code"] == "rule.expense.travel_risk_control_standard"
)
response = client.get(f"/api/v1/agent-assets/{asset_id}")
assert response.status_code == 200
payload = response.json()
assert payload["recent_versions"]
assert payload["current_version_content_type"] == "markdown"
assert payload["current_version"] == "v1.1.0"
assert "行程闭环" in payload["current_version_content"]
def test_activate_pending_rule_endpoint_is_blocked() -> None:
client, session_factory = build_client()
with session_factory() as db:
pending_rule = next(
item
for item in AgentAssetService(db).list_assets(asset_type="rule")
if item.status == AgentAssetStatus.REVIEW.value
)
response = client.post(
f"/api/v1/agent-assets/{pending_rule.id}/activate",
headers={"x-actor": "pytest"},
)
assert response.status_code == 400
assert "审核" in response.json()["detail"]
def test_list_audit_logs_endpoint_returns_seeded_logs() -> None:
client, _ = build_client()
response = client.get("/api/v1/audit-logs")
assert response.status_code == 200
payload = response.json()
assert payload
assert any(item["action"] == "review_rule" for item in payload)
from __future__ import annotations
from collections.abc import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.core.agent_enums import AgentAssetStatus
from app.db.base import Base
from app.main import create_app
from app.services.agent_assets import AgentAssetService
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def test_list_agent_assets_endpoint_returns_seeded_items() -> None:
client, _ = build_client()
response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
assert response.status_code == 200
payload = response.json()
assert payload
assert all(item["asset_type"] == "rule" for item in payload)
assert any(item["code"] == "rule.expense.travel_risk_control_standard" for item in payload)
def test_get_agent_asset_detail_endpoint_returns_version_history() -> None:
client, _ = build_client()
list_response = client.get("/api/v1/agent-assets", params={"asset_type": "rule"})
asset_id = next(
item["id"]
for item in list_response.json()
if item["code"] == "rule.expense.travel_risk_control_standard"
)
response = client.get(f"/api/v1/agent-assets/{asset_id}")
assert response.status_code == 200
payload = response.json()
assert payload["recent_versions"]
assert payload["current_version_content_type"] == "markdown"
assert payload["current_version"] == "v1.1.0"
assert "行程闭环" in payload["current_version_content"]
def test_activate_pending_rule_endpoint_is_blocked() -> None:
client, session_factory = build_client()
with session_factory() as db:
pending_rule = next(
item
for item in AgentAssetService(db).list_assets(asset_type="rule")
if item.status == AgentAssetStatus.REVIEW.value
)
response = client.post(
f"/api/v1/agent-assets/{pending_rule.id}/activate",
headers={"x-actor": "pytest"},
)
assert response.status_code == 400
assert "审核" in response.json()["detail"]
def test_list_audit_logs_endpoint_returns_seeded_logs() -> None:
client, _ = build_client()
response = client.get("/api/v1/audit-logs")
assert response.status_code == 200
payload = response.json()
assert payload
assert any(item["action"] == "review_rule" for item in payload)

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,261 +1,261 @@
<template>
<Teleport to="body">
<Transition name="shared-confirm">
<div
v-if="open"
class="shared-confirm-mask"
role="presentation"
@click.self="handleMaskClose"
>
<section
class="shared-confirm-card"
role="alertdialog"
aria-modal="true"
:aria-labelledby="titleId"
@click.stop
>
<span v-if="badge" class="shared-confirm-badge" :class="badgeTone">
{{ badge }}
</span>
<h4 :id="titleId">{{ title }}</h4>
<p v-if="description">{{ description }}</p>
<div v-if="$slots.default" class="shared-confirm-body">
<slot></slot>
</div>
<div class="shared-confirm-actions">
<button
type="button"
class="shared-confirm-btn cancel"
:disabled="busy"
@click="handleCancel"
>
{{ cancelText }}
</button>
<button
type="button"
class="shared-confirm-btn confirm"
:class="confirmTone"
:disabled="busy"
@click="$emit('confirm')"
>
<i v-if="confirmIcon" :class="busy ? 'mdi mdi-loading mdi-spin' : confirmIcon"></i>
<span>{{ busy ? busyText : confirmText }}</span>
</button>
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
badge: { type: String, default: '' },
badgeTone: { type: String, default: 'info' },
title: { type: String, required: true },
description: { type: String, default: '' },
cancelText: { type: String, default: '取消' },
confirmText: { type: String, default: '确认' },
busyText: { type: String, default: '处理中...' },
confirmTone: { type: String, default: 'primary' },
confirmIcon: { type: String, default: '' },
busy: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true }
})
const emit = defineEmits(['close', 'cancel', 'confirm'])
const instance = getCurrentInstance()
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
function handleMaskClose() {
if (!props.closeOnMask || props.busy) {
return
}
emit('close')
}
function handleCancel() {
if (props.busy) {
return
}
emit('cancel')
emit('close')
}
</script>
<style scoped>
.shared-confirm-mask {
position: fixed;
inset: 0;
z-index: 10020;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.shared-confirm-card {
width: min(480px, 100%);
display: grid;
gap: 14px;
padding: 24px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98));
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18);
}
.shared-confirm-badge {
display: inline-flex;
width: fit-content;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.shared-confirm-badge.info {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.shared-confirm-badge.warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.shared-confirm-badge.danger {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.shared-confirm-card h4 {
margin: 0;
color: #0f172a;
font-size: 22px;
line-height: 1.35;
font-weight: 800;
}
.shared-confirm-card p {
margin: 0;
color: #5b6b83;
font-size: 14px;
line-height: 1.7;
}
.shared-confirm-body {
display: grid;
gap: 10px;
}
.shared-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.shared-confirm-btn {
min-width: 140px;
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border-radius: 14px;
font-size: 14px;
font-weight: 800;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
}
.shared-confirm-btn.cancel {
border: 1px solid #d7e0ea;
background: rgba(255, 255, 255, 0.92);
color: #475569;
}
.shared-confirm-btn.confirm {
border: 1px solid transparent;
color: #fff;
}
.shared-confirm-btn.confirm.primary {
background: linear-gradient(135deg, #10b981, #059669);
box-shadow: 0 12px 24px rgba(5, 150, 105, 0.22);
}
.shared-confirm-btn.confirm.danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 12px 24px rgba(220, 38, 38, 0.22);
}
.shared-confirm-btn.cancel:hover:not(:disabled) {
border-color: rgba(16, 185, 129, 0.3);
color: #047857;
}
.shared-confirm-btn.confirm:hover:not(:disabled) {
transform: translateY(-1px);
}
.shared-confirm-btn:disabled {
cursor: not-allowed;
opacity: 0.68;
box-shadow: none;
transform: none;
}
.shared-confirm-enter-active,
.shared-confirm-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.shared-confirm-enter-from,
.shared-confirm-leave-to {
opacity: 0;
}
.shared-confirm-enter-from .shared-confirm-card,
.shared-confirm-leave-to .shared-confirm-card {
transform: translateY(8px) scale(0.98);
}
@media (max-width: 720px) {
.shared-confirm-mask {
padding: 18px;
}
.shared-confirm-card {
padding: 20px;
border-radius: 20px;
}
.shared-confirm-card h4 {
font-size: 19px;
}
.shared-confirm-actions {
flex-direction: column;
}
.shared-confirm-btn {
width: 100%;
}
}
</style>
<template>
<Teleport to="body">
<Transition name="shared-confirm">
<div
v-if="open"
class="shared-confirm-mask"
role="presentation"
@click.self="handleMaskClose"
>
<section
class="shared-confirm-card"
role="alertdialog"
aria-modal="true"
:aria-labelledby="titleId"
@click.stop
>
<span v-if="badge" class="shared-confirm-badge" :class="badgeTone">
{{ badge }}
</span>
<h4 :id="titleId">{{ title }}</h4>
<p v-if="description">{{ description }}</p>
<div v-if="$slots.default" class="shared-confirm-body">
<slot></slot>
</div>
<div class="shared-confirm-actions">
<button
type="button"
class="shared-confirm-btn cancel"
:disabled="busy"
@click="handleCancel"
>
{{ cancelText }}
</button>
<button
type="button"
class="shared-confirm-btn confirm"
:class="confirmTone"
:disabled="busy"
@click="$emit('confirm')"
>
<i v-if="confirmIcon" :class="busy ? 'mdi mdi-loading mdi-spin' : confirmIcon"></i>
<span>{{ busy ? busyText : confirmText }}</span>
</button>
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
badge: { type: String, default: '' },
badgeTone: { type: String, default: 'info' },
title: { type: String, required: true },
description: { type: String, default: '' },
cancelText: { type: String, default: '取消' },
confirmText: { type: String, default: '确认' },
busyText: { type: String, default: '处理中...' },
confirmTone: { type: String, default: 'primary' },
confirmIcon: { type: String, default: '' },
busy: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true }
})
const emit = defineEmits(['close', 'cancel', 'confirm'])
const instance = getCurrentInstance()
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
function handleMaskClose() {
if (!props.closeOnMask || props.busy) {
return
}
emit('close')
}
function handleCancel() {
if (props.busy) {
return
}
emit('cancel')
emit('close')
}
</script>
<style scoped>
.shared-confirm-mask {
position: fixed;
inset: 0;
z-index: 10020;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.shared-confirm-card {
width: min(480px, 100%);
display: grid;
gap: 14px;
padding: 24px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98));
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18);
}
.shared-confirm-badge {
display: inline-flex;
width: fit-content;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.shared-confirm-badge.info {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.shared-confirm-badge.warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.shared-confirm-badge.danger {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.shared-confirm-card h4 {
margin: 0;
color: #0f172a;
font-size: 22px;
line-height: 1.35;
font-weight: 800;
}
.shared-confirm-card p {
margin: 0;
color: #5b6b83;
font-size: 14px;
line-height: 1.7;
}
.shared-confirm-body {
display: grid;
gap: 10px;
}
.shared-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.shared-confirm-btn {
min-width: 140px;
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border-radius: 14px;
font-size: 14px;
font-weight: 800;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
}
.shared-confirm-btn.cancel {
border: 1px solid #d7e0ea;
background: rgba(255, 255, 255, 0.92);
color: #475569;
}
.shared-confirm-btn.confirm {
border: 1px solid transparent;
color: #fff;
}
.shared-confirm-btn.confirm.primary {
background: linear-gradient(135deg, #10b981, #059669);
box-shadow: 0 12px 24px rgba(5, 150, 105, 0.22);
}
.shared-confirm-btn.confirm.danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 12px 24px rgba(220, 38, 38, 0.22);
}
.shared-confirm-btn.cancel:hover:not(:disabled) {
border-color: rgba(16, 185, 129, 0.3);
color: #047857;
}
.shared-confirm-btn.confirm:hover:not(:disabled) {
transform: translateY(-1px);
}
.shared-confirm-btn:disabled {
cursor: not-allowed;
opacity: 0.68;
box-shadow: none;
transform: none;
}
.shared-confirm-enter-active,
.shared-confirm-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.shared-confirm-enter-from,
.shared-confirm-leave-to {
opacity: 0;
}
.shared-confirm-enter-from .shared-confirm-card,
.shared-confirm-leave-to .shared-confirm-card {
transform: translateY(8px) scale(0.98);
}
@media (max-width: 720px) {
.shared-confirm-mask {
padding: 18px;
}
.shared-confirm-card {
padding: 20px;
border-radius: 20px;
}
.shared-confirm-card h4 {
font-size: 19px;
}
.shared-confirm-actions {
flex-direction: column;
}
.shared-confirm-btn {
width: 100%;
}
}
</style>

View File

@@ -1,310 +1,310 @@
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const SESSION_TYPE_EXPENSE = 'expense'
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function hasMissingAttachment(request) {
const expenseItems = Array.isArray(request?.expenseItems) ? request.expenseItems : []
if (expenseItems.length) {
return expenseItems.some((item) => !String(item?.invoiceId || item?.invoice_id || '').trim())
}
const attachmentSummary = String(request?.attachmentSummary || '').trim()
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
}
function hasPendingInfo(request) {
if (!request) {
return false
}
if (request.approvalKey === 'draft' || request.approvalKey === 'supplement') {
return true
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
return true
}
return [
request.profileDepartment,
request.profilePosition,
request.profileGrade,
request.profileManager,
request.reason,
request.occurredDisplay
].some(isPlaceholderValue)
}
function resolveDetailAlertTone(request) {
if (request?.approvalKey === 'completed') return 'success'
if (request?.approvalKey === 'rejected') return 'danger'
return 'warning'
}
function buildDetailAlerts(request) {
if (!request) {
return []
}
const alerts = []
const nodeLabel = String(request.node || request.approval || '').trim()
if (nodeLabel) {
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
}
if (hasMissingAttachment(request)) {
alerts.push({ label: '缺少票据', tone: 'warning' })
}
if (hasPendingInfo(request)) {
alerts.push({ label: '待补信息', tone: 'warning' })
}
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
}
export function useAppShell() {
const route = useRoute()
const router = useRouter()
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [], conversation: null })
const smartEntrySessionId = ref(0)
const { activeView, currentView, setView } = useNavigation()
const {
requests,
loading: requestsLoading,
error: requestsError,
search,
filters,
ranges,
activeRange,
filteredRequests,
approveRequest,
rejectRequest,
reload: reloadRequests
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const selectedRequest = computed(() => {
const requestId = String(route.params.requestId || '')
if (!requestId) {
return null
}
const rawRequest = requests.value.find(
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
)
return normalizeRequestForUi(rawRequest)
})
const detailMode = computed(() => route.name === 'app-request-detail')
const logDetailMode = computed(() => route.name === 'app-log-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '报销单详情',
desc: '查看报销明细、票据材料、审批进度与风险提示。'
}
}
if (logDetailMode.value) {
return {
title: '日志详情',
desc: '查看单条日志的解析结果、上下文信息与原始记录。'
}
}
return currentView.value
})
const requestSummary = computed(() =>
filteredRequests.value.reduce(
(summary, item) => {
const request = normalizeRequestForUi(item)
if (!request) {
return summary
}
summary.total += 1
if (request.approvalKey === 'draft') {
summary.draft += 1
} else if (request.approvalKey === 'in_progress') {
summary.inProgress += 1
} else if (request.approvalKey === 'supplement') {
summary.supplement += 1
} else if (request.approvalKey === 'completed') {
summary.completed += 1
}
return summary
},
{ total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 }
)
)
function handleApprove(request) {
const message = approveRequest(request)
toast(message)
}
function handleReject(request) {
const message = rejectRequest(request)
toast(message)
}
function handleNavigate(view) {
smartEntryOpen.value = false
setView(view)
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null }
smartEntrySessionId.value += 1
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return latestPayload?.found ? latestPayload.conversation || null : null
} catch (error) {
console.warn('Failed to restore latest expense conversation for smart entry:', error)
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
return null
}
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
async function handleDraftSaved(payload = {}) {
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
smartEntryOpen.value = false
await reloadRequests()
if (status === 'submitted') {
toast(`${claimNo || '该'}单据已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else {
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
}
router.push({ name: 'app-requests' })
}
function openRequestDetail(request) {
router.push({
name: 'app-request-detail',
params: { requestId: request.claimId || request.id }
})
}
function closeRequestDetail() {
router.push({ name: 'app-requests' })
}
async function handleRequestUpdated() {
await reloadRequests()
}
async function handleRequestDeleted() {
await reloadRequests()
router.push({ name: 'app-requests' })
}
return {
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
customRange,
detailMode,
logDetailMode,
filteredRequests,
filters,
handleApprove,
handleDraftSaved,
handleNavigate,
handleReject,
handleRequestDeleted,
handleRequestUpdated,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
requestSummary,
requestsError,
requestsLoading,
reloadRequests,
requests,
search,
selectedRequest,
setView,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
detailAlerts,
toast,
topBarView
}
}
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const SESSION_TYPE_EXPENSE = 'expense'
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function hasMissingAttachment(request) {
const expenseItems = Array.isArray(request?.expenseItems) ? request.expenseItems : []
if (expenseItems.length) {
return expenseItems.some((item) => !String(item?.invoiceId || item?.invoice_id || '').trim())
}
const attachmentSummary = String(request?.attachmentSummary || '').trim()
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
}
function hasPendingInfo(request) {
if (!request) {
return false
}
if (request.approvalKey === 'draft' || request.approvalKey === 'supplement') {
return true
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
return true
}
return [
request.profileDepartment,
request.profilePosition,
request.profileGrade,
request.profileManager,
request.reason,
request.occurredDisplay
].some(isPlaceholderValue)
}
function resolveDetailAlertTone(request) {
if (request?.approvalKey === 'completed') return 'success'
if (request?.approvalKey === 'rejected') return 'danger'
return 'warning'
}
function buildDetailAlerts(request) {
if (!request) {
return []
}
const alerts = []
const nodeLabel = String(request.node || request.approval || '').trim()
if (nodeLabel) {
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
}
if (hasMissingAttachment(request)) {
alerts.push({ label: '缺少票据', tone: 'warning' })
}
if (hasPendingInfo(request)) {
alerts.push({ label: '待补信息', tone: 'warning' })
}
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
}
export function useAppShell() {
const route = useRoute()
const router = useRouter()
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [], conversation: null })
const smartEntrySessionId = ref(0)
const { activeView, currentView, setView } = useNavigation()
const {
requests,
loading: requestsLoading,
error: requestsError,
search,
filters,
ranges,
activeRange,
filteredRequests,
approveRequest,
rejectRequest,
reload: reloadRequests
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const selectedRequest = computed(() => {
const requestId = String(route.params.requestId || '')
if (!requestId) {
return null
}
const rawRequest = requests.value.find(
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
)
return normalizeRequestForUi(rawRequest)
})
const detailMode = computed(() => route.name === 'app-request-detail')
const logDetailMode = computed(() => route.name === 'app-log-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '报销单详情',
desc: '查看报销明细、票据材料、审批进度与风险提示。'
}
}
if (logDetailMode.value) {
return {
title: '日志详情',
desc: '查看单条日志的解析结果、上下文信息与原始记录。'
}
}
return currentView.value
})
const requestSummary = computed(() =>
filteredRequests.value.reduce(
(summary, item) => {
const request = normalizeRequestForUi(item)
if (!request) {
return summary
}
summary.total += 1
if (request.approvalKey === 'draft') {
summary.draft += 1
} else if (request.approvalKey === 'in_progress') {
summary.inProgress += 1
} else if (request.approvalKey === 'supplement') {
summary.supplement += 1
} else if (request.approvalKey === 'completed') {
summary.completed += 1
}
return summary
},
{ total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 }
)
)
function handleApprove(request) {
const message = approveRequest(request)
toast(message)
}
function handleReject(request) {
const message = rejectRequest(request)
toast(message)
}
function handleNavigate(view) {
smartEntryOpen.value = false
setView(view)
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null }
smartEntrySessionId.value += 1
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return latestPayload?.found ? latestPayload.conversation || null : null
} catch (error) {
console.warn('Failed to restore latest expense conversation for smart entry:', error)
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
return null
}
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
async function handleDraftSaved(payload = {}) {
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
smartEntryOpen.value = false
await reloadRequests()
if (status === 'submitted') {
toast(`${claimNo || '该'}单据已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else {
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
}
router.push({ name: 'app-requests' })
}
function openRequestDetail(request) {
router.push({
name: 'app-request-detail',
params: { requestId: request.claimId || request.id }
})
}
function closeRequestDetail() {
router.push({ name: 'app-requests' })
}
async function handleRequestUpdated() {
await reloadRequests()
}
async function handleRequestDeleted() {
await reloadRequests()
router.push({ name: 'app-requests' })
}
return {
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
customRange,
detailMode,
logDetailMode,
filteredRequests,
filters,
handleApprove,
handleDraftSaved,
handleNavigate,
handleReject,
handleRequestDeleted,
handleRequestUpdated,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
requestSummary,
requestsError,
requestsLoading,
reloadRequests,
requests,
search,
selectedRequest,
setView,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
detailAlerts,
toast,
topBarView
}
}

View File

@@ -84,16 +84,12 @@ export function fetchAgentAssetDetail(assetId) {
return apiRequest(`/agent-assets/${assetId}`)
}
export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version = '') {
const query = buildQuery({ version })
return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config${query}`)
export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId) {
return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config`)
}
export function fetchAgentAssetSpreadsheetBlob(assetId, version = '', disposition = 'inline') {
export function fetchAgentAssetSpreadsheetBlob(assetId, disposition = 'inline') {
const search = new URLSearchParams()
if (version) {
search.set('version', String(version).trim())
}
if (disposition) {
search.set('disposition', String(disposition).trim())
}
@@ -148,14 +144,6 @@ export function saveAgentAssetRuleJson(assetId, payload, options = {}) {
})
}
export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) {
const query = new URLSearchParams({
base_version: String(baseVersion || '').trim(),
target_version: String(targetVersion || '').trim()
})
return apiRequest(`/agent-assets/${assetId}/versions/compare?${query.toString()}`)
}
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
return apiRequest(
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`

View File

@@ -103,7 +103,7 @@
<div class="spreadsheet-editor-actions">
<span class="spreadsheet-mode-pill">
{{ selectedSpreadsheetVersionModeLabel }}
{{ selectedSpreadsheetModeLabel }}
</span>
</div>
</header>
@@ -153,35 +153,34 @@
</footer>
</section>
<aside class="spreadsheet-version-center">
<header class="version-center-head">
<div>
<h3>最近修改</h3>
<p>展示最近 30 在线编辑保存后的具体改动</p>
</div>
</header>
<section class="version-center-section version-history-section">
<div v-if="selectedSpreadsheetChangeRecords.length" class="version-center-list">
<button
v-for="item in selectedSpreadsheetChangeRecords"
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
type="button"
class="version-center-item change-record-item"
@click="openSpreadsheetChangeDetail(item)"
>
<aside class="spreadsheet-change-center">
<header class="change-center-head">
<div>
<h3>最近修改</h3>
<p>展示最近 30 次保存后的具体改动</p>
</div>
</header>
<section class="change-center-section change-history-section">
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
<button
v-for="item in selectedSpreadsheetChangeRecords"
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
type="button"
class="change-center-item change-record-item"
@click="openSpreadsheetChangeDetail(item)"
>
<div class="change-record-head">
<div>
<strong>{{ item.actor }}</strong>
<span>{{ item.time }}</span>
</div>
<b>{{ item.changeCountLabel }}</b>
</div>
<p>{{ item.summary }}</p>
<small v-if="item.version">关联版本{{ item.version }}</small>
<small v-if="item.sheetPreview.length">
涉及工作表{{ item.sheetPreview.join('') }}
<template v-if="item.remainingSheetCount"> {{ item.changedSheetNames.length }} </template>
</div>
<p>{{ item.summary }}</p>
<small v-if="item.sheetPreview.length">
涉及工作表{{ item.sheetPreview.join('') }}
<template v-if="item.remainingSheetCount"> {{ item.changedSheetNames.length }} </template>
</small>
<div v-if="item.previewChanges.length" class="change-record-preview">
<span
@@ -197,9 +196,9 @@
</small>
</button>
</div>
<p v-else class="version-flow-empty">暂无修改记录</p>
</section>
</aside>
<p v-else class="change-flow-empty">暂无修改记录</p>
</section>
</aside>
</div>
</section>
@@ -1086,11 +1085,9 @@
<span>{{ item.timeLabel }}</span>
</header>
<p>{{ item.description || item.note || '暂无补充说明' }}</p>
<small>
操作人{{ item.actor }}
<template v-if="item.version"> · 关联版本{{ item.version }}</template>
<template v-if="item.source_version"> · 来源版本{{ item.source_version }}</template>
</small>
<small>
操作人{{ item.actor }}
</small>
</div>
</article>
</div>
@@ -1129,12 +1126,8 @@
<span>修改时间</span>
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
</article>
<article v-if="selectedSpreadsheetChangeRecord.version">
<span>关联版本</span>
<strong>{{ selectedSpreadsheetChangeRecord.version }}</strong>
</article>
<article>
<span>修改工作表</span>
<article>
<span>修改工作表</span>
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
</article>
<article>
@@ -1203,127 +1196,6 @@
</div>
</Transition>
<Transition name="drawer-fade">
<div v-if="versionCompareOpen" class="rule-drawer-backdrop" @click.self="closeVersionCompare">
<aside class="rule-drawer compare-drawer">
<header class="rule-drawer-head">
<div>
<span>版本治理</span>
<h3>版本差异对比</h3>
</div>
<button type="button" @click="closeVersionCompare">
<i class="mdi mdi-close"></i>
</button>
</header>
<section class="compare-toolbar">
<label>
<span>基准版本</span>
<select v-model="compareBaseVersion" @change="loadVersionCompare">
<option
v-for="item in selectedSkill?.history || []"
:key="`base-${item.version}`"
:value="item.version"
>
{{ item.version }} · {{ item.lifecycleMeta.label }}
</option>
</select>
</label>
<i class="mdi mdi-arrow-right"></i>
<label>
<span>对比版本</span>
<select v-model="compareTargetVersion" @change="loadVersionCompare">
<option
v-for="item in selectedSkill?.history || []"
:key="`target-${item.version}`"
:value="item.version"
>
{{ item.version }} · {{ item.lifecycleMeta.label }}
</option>
</select>
</label>
</section>
<div v-if="versionCompareLoading" class="rule-drawer-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在生成版本差异...</span>
</div>
<div v-else-if="versionCompareError" class="rule-drawer-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ versionCompareError }}</span>
</div>
<div v-else-if="versionComparePayload" class="compare-content">
<section class="compare-summary-grid">
<article>
<span>新增工作表</span>
<strong>{{ versionComparePayload.added_sheet_count }}</strong>
</article>
<article>
<span>删除工作表</span>
<strong>{{ versionComparePayload.removed_sheet_count }}</strong>
</article>
<article>
<span>修改工作表</span>
<strong>{{ versionComparePayload.changed_sheet_count }}</strong>
</article>
<article>
<span>变更单元格</span>
<strong>{{ versionComparePayload.changed_cell_count }}</strong>
</article>
</section>
<section class="compare-panel">
<header>
<strong>工作表变化</strong>
</header>
<div v-if="versionCompareSheetRows.length" class="compare-sheet-list">
<span
v-for="item in versionCompareSheetRows"
:key="`${item.sheet_name}-${item.change_type}`"
:class="item.meta.tone"
>
{{ item.sheet_name }} · {{ item.meta.label }}
</span>
</div>
<p v-else>没有新增或删除工作表</p>
</section>
<section class="compare-panel compare-cell-panel">
<header>
<strong>单元格差异</strong>
<small>最多展示前 500 </small>
</header>
<div v-if="versionCompareCellRows.length" class="compare-table-wrap">
<table>
<thead>
<tr>
<th>工作表</th>
<th>位置</th>
<th>类型</th>
<th>旧值</th>
<th>新值</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in versionCompareCellRows"
:key="`${item.sheet_name}-${item.cell}`"
>
<td>{{ item.sheet_name }}</td>
<td>{{ item.cell }}</td>
<td><b :class="item.meta.tone">{{ item.meta.label }}</b></td>
<td>{{ item.before_value ?? '-' }}</td>
<td>{{ item.after_value ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else>两个版本内容一致没有发现单元格级差异</p>
</section>
</div>
</aside>
</div>
</Transition>
</section>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<Teleport to="body">
<Transition name="assistant-modal">
<div class="assistant-overlay">
<Transition name="assistant-modal" @after-leave="emitCloseAfterLeave">
<div v-if="workbenchVisible" class="assistant-overlay">
<section class="assistant-modal">
<div class="assistant-header-actions">
<button
@@ -30,8 +30,7 @@
type="button"
title="关闭工作台"
aria-label="关闭对话工作台"
@pointerdown.stop.prevent="requestCloseWorkbench"
@click.stop.prevent="requestCloseWorkbench"
@click="requestCloseWorkbench"
>
<i class="mdi mdi-close"></i>
</button>
@@ -41,8 +40,8 @@
<header class="assistant-header">
<div class="assistant-header-main">
<div>
<h2>财务AI工作台</h2>
<p>个人工作台发起报销智能录入统一走这里右侧会根据你的意图实时切换状态视图</p>
<h2>财务助手</h2>
<p>个人财务中心 · 报销识别票据核对与制度咨询右侧会随处理进度展示识别结果与风险提示</p>
</div>
</div>
</header>
@@ -79,7 +78,7 @@
<div class="message-bubble">
<header class="message-meta">
<strong>{{ message.role === 'assistant' ? 'AI 助手' : '我' }}</strong>
<strong>{{ message.role === 'assistant' ? (message.assistantName || ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
<time>{{ message.time }}</time>
</header>
<p
@@ -89,15 +88,35 @@
{{ message.text }}
</p>
<div
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
></div>
<div
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
></motion>
<motion
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
class="welcome-quick-actions"
>
<p class="welcome-quick-actions-title">您可以对我进行以下操作</p>
<div class="welcome-quick-action-grid">
<button
v-for="action in message.welcomeQuickActions"
:key="`${message.id}-${action.label}`"
type="button"
class="welcome-quick-action-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click="runWelcomeQuickAction(action)"
>
<i :class="action.icon"></i>
<span>{{ action.label }}</span>
</button>
</motion>
</motion>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
</div>
</motion>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
<strong>风险标签</strong>
@@ -409,28 +428,120 @@
</div>
<div class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }">
<button
v-if="!isKnowledgeSession"
type="button"
class="tool-btn composer-side-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="上传附件"
@click="triggerFileUpload"
>
<div v-if="!isKnowledgeSession" class="composer-leading-actions">
<button
type="button"
class="tool-btn composer-side-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="上传附件"
@click="triggerFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
</button>
<div class="composer-date-anchor">
<button
type="button"
class="tool-btn composer-side-btn"
:class="{ active: composerDatePickerOpen }"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="选择业务发生时间"
:aria-expanded="composerDatePickerOpen"
@click.stop="toggleComposerDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="composerDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="业务发生时间"
@click.stop
>
<div class="composer-date-mode-tabs">
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: composerDateMode === 'single' }"
@click="setComposerDateMode('single')"
>
当天
</button>
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: composerDateMode === 'range' }"
@click="setComposerDateMode('range')"
>
时间段
</button>
</div>
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="composerSingleDate" type="date" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="composerRangeStartDate" type="date" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="composerRangeEndDate" type="date" :min="composerRangeStartDate" />
</label>
</div>
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
<div class="composer-date-popover-actions">
<button type="button" class="composer-date-cancel-btn" @click="closeComposerDatePicker">
取消
</button>
<button
type="button"
class="composer-date-apply-btn"
:disabled="!composerCanApplyDateSelection"
@click="applyComposerDateSelection"
>
插入标签
</button>
</div>
</div>
</div>
</div>
<div class="composer-shell">
<textarea
ref="composerTextareaRef"
v-model="composerDraft"
rows="1"
:placeholder="composerPlaceholder"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@input="handleComposerInput"
@keydown.enter.exact.stop
@keydown.ctrl.enter.prevent="submitComposer"
/>
<div class="composer-shell-body">
<span
v-for="tag in composerBusinessTimeTags"
:key="tag.id"
class="composer-biz-time-tag"
>
<i class="mdi mdi-calendar-check"></i>
<span class="composer-biz-time-tag-label">{{ tag.label }}</span>
<button
type="button"
class="composer-biz-time-tag-remove"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="移除业务发生时间"
@click="removeComposerBusinessTimeTag(tag.id)"
>
<i class="mdi mdi-close"></i>
</button>
</span>
<textarea
ref="composerTextareaRef"
v-model="composerDraft"
rows="1"
:placeholder="composerPlaceholder"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@input="handleComposerInput"
@keydown.enter.exact.stop
@keydown.ctrl.enter.prevent="submitComposer"
/>
</div>
</div>
<button class="send-btn composer-side-btn" type="submit" :disabled="!canSubmit || reviewActionBusy || sessionSwitchBusy" aria-label="发送">

View File

@@ -7,7 +7,6 @@ import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
activateAgentAsset,
compareAgentAssetSpreadsheetVersions,
createAgentAssetReview,
createAgentAssetVersion,
fetchAgentAssetDetail,
@@ -969,6 +968,17 @@ function buildRowMetric(asset, typeKey) {
return normalizeText(asset.config_json?.agent) || '未配置 Agent'
}
function formatSpreadsheetChangeSummary(summary) {
const normalized = normalizeText(summary)
return (
normalized
.replace(/^(ONLYOFFICE\s*)?在线编辑[:]\s*/i, '')
.replace(/^ONLYOFFICE\s*在线编辑保存[。.]?\s*/i, '')
.replace(/^保存表格[:]\s*/i, '')
.trim() || '表格内容已保存。'
)
}
function buildListItem(asset) {
const typeKey = resolveTypeKey(asset.asset_type)
const tabId = resolveTabId(asset, typeKey)
@@ -993,6 +1003,9 @@ function buildListItem(asset) {
: ''
)
const isRiskRule = tabId === 'riskRules'
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
const ruleDocument = readRuleDocumentMeta(asset)
const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : ''
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
@@ -1003,6 +1016,9 @@ function buildListItem(asset) {
tabId,
type: typeKey,
isPreviewMock: Boolean(asset.isPreviewMock),
usesSpreadsheetRule,
usesJsonRiskRule,
ruleDocument,
typeLabel: tabMeta.typeLabel,
short: makeShort(asset.name),
name: asset.name,
@@ -1582,12 +1598,6 @@ export default {
const versionTimelineLoading = ref(false)
const versionTimelineError = ref('')
const versionTimelineItems = ref([])
const versionCompareOpen = ref(false)
const versionCompareLoading = ref(false)
const versionCompareError = ref('')
const versionComparePayload = ref(null)
const compareBaseVersion = ref('')
const compareTargetVersion = ref('')
const spreadsheetChangeRecordsByAsset = ref({})
const spreadsheetChangeDetailOpen = ref(false)
const selectedSpreadsheetChangeRecord = ref(null)
@@ -1595,8 +1605,7 @@ export default {
let spreadsheetOnlyOfficeLoadTimer = null
let spreadsheetOnlyOfficeHadLocalEdits = false
let spreadsheetOnlyOfficeSyncSeq = 0
let spreadsheetOnlyOfficeVersionPollTimer = null
let spreadsheetOnlyOfficeRefreshTimer = null
let spreadsheetOnlyOfficeChangePollTimer = null
const assetBuckets = ref({
financialRules: [],
riskRules: [],
@@ -1649,8 +1658,7 @@ export default {
() =>
canEditSelected.value &&
selectedSkillUsesSpreadsheet.value &&
!detailBusy.value &&
selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
!detailBusy.value
)
const canDownloadSpreadsheet = computed(
() =>
@@ -1661,26 +1669,17 @@ export default {
const canEditSpreadsheetInline = computed(
() =>
selectedSkillUsesSpreadsheet.value &&
selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion &&
(selectedSkill.value?.isPreviewMock || canEditSelected.value)
)
const selectedDisplayHistory = computed(
() =>
selectedSkill.value?.history?.find((item) => item.version === selectedSkill.value?.displayVersion) || null
)
const selectedSpreadsheetFileName = computed(
() =>
normalizeText(
selectedDisplayHistory.value?.spreadsheetMeta?.file_name || selectedSkill.value?.ruleDocument?.file_name
) || '未上传规则表'
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
)
const selectedSpreadsheetVersionModeLabel = computed(() => {
const selectedSpreadsheetModeLabel = computed(() => {
if (selectedSkill.value?.isPreviewMock) {
return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑' : 'ONLYOFFICE 预览'
return canEditSpreadsheetInline.value ? '可编辑' : '只读'
}
return selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
? '在线可编辑'
: '只读预览'
return canEditSpreadsheetInline.value ? '在线可编辑' : '只读'
})
const selectedVersionTimelineItems = computed(() =>
versionTimelineItems.value.map((item) => ({
@@ -1709,6 +1708,7 @@ export default {
return {
...item,
time: formatDateTime(item.changed_at),
summary: formatSpreadsheetChangeSummary(item.summary),
changeCountLabel: item.changed_cell_count
? `${item.changed_cell_count} 处改动`
: `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`,
@@ -1736,22 +1736,6 @@ export default {
}))
: []
)
const versionCompareCellRows = computed(() =>
Array.isArray(versionComparePayload.value?.cell_changes)
? versionComparePayload.value.cell_changes.map((item) => ({
...item,
meta: resolveDiffChangeMeta(item.change_type)
}))
: []
)
const versionCompareSheetRows = computed(() =>
Array.isArray(versionComparePayload.value?.sheet_changes)
? versionComparePayload.value.sheet_changes.map((item) => ({
...item,
meta: resolveDiffChangeMeta(item.change_type)
}))
: []
)
const detailBusy = computed(() => Boolean(actionState.value))
const showReviewNote = computed(
() => selectedSkillIsRule.value && (selectedSkill.value?.reviewNote || selectedSkill.value?.reviewTimeLabel)
@@ -1922,7 +1906,6 @@ export default {
watch(
() => [
selectedSkill.value?.id || '',
selectedSkill.value?.displayVersion || '',
selectedSkill.value?.loading ? '1' : '0',
selectedSkill.value?.usesSpreadsheetRule ? '1' : '0'
],
@@ -1938,7 +1921,6 @@ export default {
)
watch(activeType, () => {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor()
selectedSkill.value = null
versionSwitchTarget.value = null
@@ -2034,8 +2016,7 @@ export default {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
stopSpreadsheetOnlyOfficeVersionSync()
clearSpreadsheetPendingChangeRecord(selectedSkill.value?.id, selectedSkill.value?.displayVersion)
stopSpreadsheetOnlyOfficeChangeSync()
spreadsheetOnlyOfficeHadLocalEdits = false
spreadsheetOnlyOfficeSyncSeq += 1
if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) {
@@ -2045,87 +2026,10 @@ export default {
spreadsheetOnlyOfficeReady.value = false
}
function appendSpreadsheetChangeRecord(record) {
const assetId = normalizeText(record?.assetId)
const version = normalizeText(record?.version)
if (!assetId || !version) {
return
}
const nextRecord = {
version,
operationLabel: normalizeText(record?.operationLabel) || '表格修改',
operationActor: normalizeText(record?.operationActor) || resolveActor(),
note: normalizeText(record?.note) || '用户修改了表格内容。',
time: record?.time || new Date().toISOString(),
isWorking: record?.isWorking !== false,
isPendingLocalEdit: Boolean(record?.isPendingLocalEdit),
disabledReason: normalizeText(record?.disabledReason)
}
const current = spreadsheetChangeRecordsByAsset.value[assetId] || []
const deduped = current.filter(
(item) =>
!(
item.version === nextRecord.version &&
item.operationLabel === nextRecord.operationLabel &&
item.note === nextRecord.note
)
)
spreadsheetChangeRecordsByAsset.value = {
...spreadsheetChangeRecordsByAsset.value,
[assetId]: [nextRecord, ...deduped].slice(0, 30)
}
}
function clearSpreadsheetPendingChangeRecord(assetId, version) {
const normalizedAssetId = normalizeText(assetId)
const normalizedVersion = normalizeText(version)
if (!normalizedAssetId) {
return
}
const current = spreadsheetChangeRecordsByAsset.value[normalizedAssetId] || []
spreadsheetChangeRecordsByAsset.value = {
...spreadsheetChangeRecordsByAsset.value,
[normalizedAssetId]: current.filter(
(item) => !(item.isPendingLocalEdit && (!normalizedVersion || item.version === normalizedVersion))
)
}
}
function markSpreadsheetPendingChange(assetId, version) {
const normalizedAssetId = normalizeText(assetId)
const normalizedVersion = normalizeText(version)
if (!normalizedAssetId || !normalizedVersion) {
return
}
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
appendSpreadsheetChangeRecord({
assetId: normalizedAssetId,
version: normalizedVersion,
operationLabel: '编辑中',
operationActor: resolveActor(),
note: '检测到未保存的表格改动,保存后会生成新版本并可查看差异。',
time: new Date().toISOString(),
isWorking: true,
isPendingLocalEdit: true,
disabledReason: '当前是本地未保存修改,保存后才会生成可对比的版本。'
})
}
function stopSpreadsheetOnlyOfficeVersionSync() {
if (spreadsheetOnlyOfficeVersionPollTimer) {
window.clearTimeout(spreadsheetOnlyOfficeVersionPollTimer)
spreadsheetOnlyOfficeVersionPollTimer = null
}
}
function stopSpreadsheetOnlyOfficeDeferredRefresh() {
if (spreadsheetOnlyOfficeRefreshTimer) {
window.clearTimeout(spreadsheetOnlyOfficeRefreshTimer)
spreadsheetOnlyOfficeRefreshTimer = null
function stopSpreadsheetOnlyOfficeChangeSync() {
if (spreadsheetOnlyOfficeChangePollTimer) {
window.clearTimeout(spreadsheetOnlyOfficeChangePollTimer)
spreadsheetOnlyOfficeChangePollTimer = null
}
}
@@ -2164,7 +2068,6 @@ export default {
latest.id,
latest.changed_at,
latest.actor,
latest.version,
latest.summary,
latest.changed_sheet_count,
latest.changed_cell_count,
@@ -2193,36 +2096,14 @@ export default {
return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1)
}
function scheduleSpreadsheetEditorRefreshAfterSave(assetId, savedVersion) {
function scheduleSpreadsheetOnlyOfficeChangeSync(assetId, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
const normalizedSavedVersion = normalizeText(savedVersion)
if (!normalizedAssetId || !normalizedSavedVersion) {
return
}
stopSpreadsheetOnlyOfficeDeferredRefresh()
spreadsheetOnlyOfficeRefreshTimer = window.setTimeout(async () => {
spreadsheetOnlyOfficeRefreshTimer = null
if (
selectedSkill.value?.id !== normalizedAssetId ||
selectedSkill.value?.displayVersion === normalizedSavedVersion
) {
return
}
await loadSelectedAssetDetail(normalizedAssetId)
}, 3200)
}
function scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
const normalizedVersion = normalizeText(version)
if (!normalizedAssetId || !normalizedVersion) {
if (!normalizedAssetId) {
return
}
const syncSeq = ++spreadsheetOnlyOfficeSyncSeq
stopSpreadsheetOnlyOfficeVersionSync()
stopSpreadsheetOnlyOfficeChangeSync()
const previousLatestChangeKey = getLatestSpreadsheetChangeKey(normalizedAssetId)
const runSync = async () => {
@@ -2231,31 +2112,13 @@ export default {
}
try {
const detail = await fetchAgentAssetDetail(normalizedAssetId)
const nextWorkingVersion = normalizeText(detail?.working_version || detail?.current_version)
if (nextWorkingVersion && nextWorkingVersion !== normalizedVersion) {
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
await refreshCurrentAssets()
await refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestChangeKey)
if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) {
return
}
// ONLYOFFICE 的保存回调刚结束时立即销毁并重挂编辑器,偶发会让新文档会话
// 还没完全就绪就被再次打开,表现为“加载超时”。先刷新右侧修改记录,再留
// 一个很短的缓冲窗口后切换到新工作版本,用户无需退出重进。
scheduleSpreadsheetEditorRefreshAfterSave(normalizedAssetId, nextWorkingVersion)
stopSpreadsheetOnlyOfficeVersionSync()
return
}
const changeRecordRefreshed = await refreshSpreadsheetChangeRecordsAfterSave(
normalizedAssetId,
previousLatestChangeKey
)
if (changeRecordRefreshed) {
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
await refreshCurrentAssets()
stopSpreadsheetOnlyOfficeVersionSync()
stopSpreadsheetOnlyOfficeChangeSync()
return
}
} catch {
@@ -2268,22 +2131,21 @@ export default {
if (attempt >= 29) {
return
}
spreadsheetOnlyOfficeVersionPollTimer = window.setTimeout(() => {
scheduleSpreadsheetOnlyOfficeVersionSync(normalizedAssetId, normalizedVersion, attempt + 1)
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
scheduleSpreadsheetOnlyOfficeChangeSync(normalizedAssetId, attempt + 1)
}, 2000)
}
spreadsheetOnlyOfficeVersionPollTimer = window.setTimeout(() => {
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
runSync().catch(() => {})
}, attempt === 0 ? 800 : 2000)
}
function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version) {
function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId) {
return (
mountSeq !== spreadsheetOnlyOfficeMountSeq ||
!selectedSkillUsesSpreadsheet.value ||
selectedSkill.value?.id !== assetId ||
selectedSkill.value?.displayVersion !== version ||
selectedSkill.value?.loading
)
}
@@ -2296,7 +2158,6 @@ export default {
const mountSeq = ++spreadsheetOnlyOfficeMountSeq
const assetId = selectedSkill.value.id
const version = selectedSkill.value.displayVersion
const editable = canEditSpreadsheetInline.value
spreadsheetOnlyOfficeLoading.value = true
@@ -2305,25 +2166,25 @@ export default {
destroySpreadsheetOnlyOfficeEditor()
try {
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
await loadOnlyOfficeApi(payload.documentServerUrl)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。')
throw new Error('表格编辑器未正确加载。')
}
// Host id must be unique for every mount. ONLYOFFICE mutates its host DOM
// during lifecycle teardown; reusing the same element can leave the next
// DocEditor instance with a dead container even though config loading succeeds.
spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${version}-${mountSeq}`
spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${mountSeq}`
await nextTick()
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
@@ -2334,7 +2195,7 @@ export default {
})
const upstreamEvents = config.events || {}
spreadsheetOnlyOfficeLoadTimer = window.setTimeout(() => {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (retryAttempt < 1) {
@@ -2345,14 +2206,14 @@ export default {
}, 600)
return
}
spreadsheetOnlyOfficeError.value = 'ONLYOFFICE 加载超时,请重新切换版本后重试。'
spreadsheetOnlyOfficeError.value = '表格加载超时,请退出详情后重试。'
spreadsheetOnlyOfficeLoading.value = false
destroySpreadsheetOnlyOfficeEditor()
}, 15000)
config.events = {
...upstreamEvents,
onAppReady(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
@@ -2364,7 +2225,7 @@ export default {
upstreamEvents.onAppReady?.(event)
},
onError(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
@@ -2374,8 +2235,8 @@ export default {
const errorCode = event?.data?.errorCode
const errorDescription = event?.data?.errorDescription
spreadsheetOnlyOfficeError.value = errorDescription
? `ONLYOFFICE 加载失败:${errorDescription}`
: `ONLYOFFICE 加载失败${errorCode ? `(错误码 ${errorCode}` : '。'}`
? `表格加载失败:${errorDescription}`
: `表格加载失败${errorCode ? `(错误码 ${errorCode}` : '。'}`
spreadsheetOnlyOfficeLoading.value = false
upstreamEvents.onError?.(event)
},
@@ -2383,17 +2244,16 @@ export default {
const hasChanges = Boolean(event?.data)
if (hasChanges) {
spreadsheetOnlyOfficeHadLocalEdits = true
markSpreadsheetPendingChange(assetId, version)
if (!spreadsheetOnlyOfficeVersionPollTimer) {
scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version)
if (!spreadsheetOnlyOfficeChangePollTimer) {
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
}
} else if (
spreadsheetOnlyOfficeHadLocalEdits &&
editable &&
!isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)
!isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)
) {
spreadsheetOnlyOfficeHadLocalEdits = false
scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version)
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
}
upstreamEvents.onDocumentStateChange?.(event)
}
@@ -2402,11 +2262,11 @@ export default {
spreadsheetOnlyOfficeHostId.value,
config
)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
destroySpreadsheetOnlyOfficeEditor()
}
} catch (error) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。'
@@ -2431,7 +2291,6 @@ export default {
try {
const blob = await fetchAgentAssetSpreadsheetBlob(
selectedSkill.value.id,
selectedSkill.value.displayVersion,
'attachment'
)
const objectUrl = URL.createObjectURL(blob)
@@ -2462,7 +2321,7 @@ export default {
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSpreadsheetChangeRecords(selectedSkill.value.id)
toast(`已导入 ${file.name} 的表格内容,并生成新版本`)
toast(`已导入 ${file.name} 的表格内容,右侧会记录本次修改`)
} catch (error) {
toast(error?.message || '规则表内容导入失败,请稍后重试。')
} finally {
@@ -2560,7 +2419,7 @@ export default {
const detail = await fetchAgentAssetDetail(assetId)
selectedSkill.value = buildDetailViewModel(detail, runs.value)
if (selectedSkill.value?.type === 'rules') {
if (!selectedSkill.value.usesJsonRiskRule) {
if (!selectedSkill.value.usesSpreadsheetRule && !selectedSkill.value.usesJsonRiskRule) {
loadVersionTimeline(assetId, { silent: true }).catch(() => {})
}
if (selectedSkill.value.usesSpreadsheetRule) {
@@ -2677,7 +2536,6 @@ export default {
}
function openAssetDetail(asset) {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
@@ -2688,17 +2546,18 @@ export default {
versionSwitchTarget.value = null
return
}
const opensSpreadsheetRule = Boolean(asset?.usesSpreadsheetRule)
selectedSkill.value = {
...asset,
configJson: {},
isPreviewMock: false,
usesSpreadsheetRule: false,
usesJsonRiskRule: false,
usesSpreadsheetRule: opensSpreadsheetRule,
usesJsonRiskRule: Boolean(asset?.usesJsonRiskRule),
riskRuleJsonText: '{}',
riskRuleSummary: null,
riskRuleDescription: '',
riskRuleSourceRef: '',
ruleDocument: null,
ruleDocument: asset?.ruleDocument || null,
scenarioList: [],
fields: [],
promptSections: [],
@@ -2714,16 +2573,18 @@ export default {
runtimeKind: 'policy_rule_draft',
displayVersion: asset.version,
displayVersionChangeNote: '无版本说明',
loading: true,
reviewStatusLabel: '加载中',
loading: !opensSpreadsheetRule,
reviewStatusLabel: opensSpreadsheetRule ? '' : '加载中',
reviewStatusTone: 'draft'
}
versionSwitchTarget.value = null
if (opensSpreadsheetRule) {
loadSpreadsheetChangeRecords(asset.id).catch(() => {})
}
loadSelectedAssetDetail(asset.id).catch(() => {})
}
function closeDetail() {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
@@ -2732,9 +2593,7 @@ export default {
detailLoading.value = false
versionSwitchTarget.value = null
versionTimelineOpen.value = false
versionCompareOpen.value = false
versionTimelineItems.value = []
versionComparePayload.value = null
}
function openVersionSwitch(version) {
@@ -3062,66 +2921,6 @@ export default {
versionTimelineOpen.value = false
}
async function openVersionCompare(options = {}) {
if (!selectedSkill.value?.id) {
return
}
const defaultBase =
options.baseVersion || selectedSkill.value.publishedVersion || selectedSkill.value.workingVersion || ''
let defaultTarget =
options.targetVersion || selectedSkill.value.workingVersion || selectedSkill.value.publishedVersion || ''
if (!options.targetVersion && defaultBase === defaultTarget) {
defaultTarget =
selectedSkill.value.history.find((item) => item.version !== defaultBase)?.version || defaultTarget
}
compareBaseVersion.value = defaultBase
compareTargetVersion.value = defaultTarget
versionCompareOpen.value = true
await loadVersionCompare()
}
function openSpreadsheetChangeRecord(item) {
if (selectedSkill.value?.isPreviewMock) {
toast('预览数据暂不支持真实的线上差异对比。')
return
}
const publishedVersion = normalizeText(selectedSkill.value?.publishedVersion)
if (!selectedSkill.value?.id || !publishedVersion || publishedVersion === '-') {
toast('当前还没有线上版本,暂时无法查看与线上差异。')
return
}
openVersionCompare({
baseVersion: publishedVersion,
targetVersion: item.version
}).catch(() => {})
}
function closeVersionCompare() {
versionCompareOpen.value = false
}
async function loadVersionCompare() {
if (!selectedSkill.value?.id || !compareBaseVersion.value || !compareTargetVersion.value) {
return
}
versionCompareLoading.value = true
versionCompareError.value = ''
try {
versionComparePayload.value = await compareAgentAssetSpreadsheetVersions(
selectedSkill.value.id,
compareBaseVersion.value,
compareTargetVersion.value
)
} catch (error) {
versionComparePayload.value = null
versionCompareError.value = error?.message || '版本差异对比失败,请稍后重试。'
} finally {
versionCompareLoading.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
loadAssets({ force: true }).catch(() => {})
@@ -3129,7 +2928,6 @@ export default {
})
onBeforeUnmount(() => {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor()
document.removeEventListener('click', handleDocumentClick)
})
@@ -3186,7 +2984,7 @@ export default {
selectedSkillUsesSpreadsheet,
selectedSkillUsesJsonRisk,
selectedSpreadsheetFileName,
selectedSpreadsheetVersionModeLabel,
selectedSpreadsheetModeLabel,
selectedVersionTimelineItems,
selectedSpreadsheetChangeRecords,
detailBusy,
@@ -3205,18 +3003,10 @@ export default {
versionTimelineOpen,
versionTimelineLoading,
versionTimelineError,
versionCompareOpen,
versionCompareLoading,
versionCompareError,
versionComparePayload,
versionCompareCellRows,
versionCompareSheetRows,
spreadsheetChangeDetailOpen,
selectedSpreadsheetChangeRecord,
selectedSpreadsheetChangeSheetRows,
selectedSpreadsheetChangeCellRows,
compareBaseVersion,
compareTargetVersion,
openAssetDetail,
closeDetail,
resetFilters,
@@ -3243,12 +3033,8 @@ export default {
restoreSelectedVersion,
openVersionTimeline,
closeVersionTimeline,
openSpreadsheetChangeRecord,
openSpreadsheetChangeDetail,
closeSpreadsheetChangeDetail,
openVersionCompare,
closeVersionCompare,
loadVersionCompare,
loadAssets
}
}

File diff suppressed because it is too large Load Diff