feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
BIN
server/rules/finance-rules/公司费用申请审批规则.xlsx
Normal file
BIN
server/rules/finance-rules/公司费用申请审批规则.xlsx
Normal file
Binary file not shown.
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.large_expense_without_preapproval",
|
||||
"name": "大额费用未事前申请",
|
||||
"description": "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。",
|
||||
"name": "?????????",
|
||||
"description": "???????? 2000 ?????????????",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"risk_category": "????",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"template_key": "composite_rule_v1",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
@@ -34,68 +34,75 @@
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"label": "????",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"label": "????",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"label": "??",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"label": "??",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"label": "???ID",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.claim_no",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"label": "????",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"label": "??????",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"label": "??????",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "preapproval_required_amount_threshold",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
@@ -103,31 +110,89 @@
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.claim_no",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_preapproval_threshold",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": [
|
||||
"claim.amount"
|
||||
],
|
||||
"threshold": 2000,
|
||||
"compare": "gt"
|
||||
},
|
||||
{
|
||||
"id": "application_present",
|
||||
"operator": "exists_any",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "not_specific_preapproval_type",
|
||||
"operator": "not_contains_any",
|
||||
"fields": [
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"meal",
|
||||
"entertainment",
|
||||
"office",
|
||||
"????",
|
||||
"??",
|
||||
"????",
|
||||
"??"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"大额费用",
|
||||
"未申请",
|
||||
"先申请后报销"
|
||||
],
|
||||
"condition_summary": "金额达到大额阈值且缺少已通过申请单时触发。",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"amount_exceeds_preapproval_threshold",
|
||||
{
|
||||
"not": "application_present"
|
||||
},
|
||||
"not_specific_preapproval_type"
|
||||
]
|
||||
},
|
||||
"formula": "amount > threshold AND NOT hasApplication",
|
||||
"condition_summary": "?????????????????? 2000 ????????????????",
|
||||
"message_template": "?????? 2000 ?????????????????????????",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"threshold_amount": 2000,
|
||||
"rule_ir": {
|
||||
"facts": [
|
||||
{
|
||||
"id": "A",
|
||||
"label": "????",
|
||||
"fields": [
|
||||
"claim.amount"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "B",
|
||||
"label": "???",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"hit_logic": "A > threshold AND NOT EXISTS(B)"
|
||||
}
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -141,16 +206,16 @@
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"owner": "??????",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.805274+00:00",
|
||||
"source_ref": "??????????",
|
||||
"created_at": "2026-06-05T00:00:00+08:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high",
|
||||
"rule_title": "大额费用未事前申请",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"rule_title": "?????????",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.meal_high_value_without_preapproval",
|
||||
"name": "大额业务招待未申请",
|
||||
"description": "业务招待金额或人均金额超过制度阈值但未事前审批。",
|
||||
"name": "??????????",
|
||||
"description": "????????? 500 ?????????????",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"risk_category": "????",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"template_key": "composite_rule_v1",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
"meal",
|
||||
"entertainment"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
@@ -24,7 +25,8 @@
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
"meal",
|
||||
"entertainment"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
@@ -34,74 +36,75 @@
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"label": "????",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"label": "????",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"label": "??",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"label": "??",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"label": "???ID",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.claim_no",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"label": "????",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"label": "??????",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"label": "??????",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "material.attendee_list_uploaded",
|
||||
"label": "参与人清单已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "preapproval_required_amount_threshold",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
@@ -109,32 +112,73 @@
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.claim_no",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name",
|
||||
"material.attendee_list_uploaded"
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_preapproval_threshold",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": [
|
||||
"claim.amount"
|
||||
],
|
||||
"threshold": 500,
|
||||
"compare": "gt"
|
||||
},
|
||||
{
|
||||
"id": "application_present",
|
||||
"operator": "exists_any",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"业务招待",
|
||||
"人均超标",
|
||||
"未申请"
|
||||
],
|
||||
"condition_summary": "业务招待金额超过申请阈值且没有通过申请时触发。",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"amount_exceeds_preapproval_threshold",
|
||||
{
|
||||
"not": "application_present"
|
||||
}
|
||||
]
|
||||
},
|
||||
"formula": "amount > threshold AND NOT hasApplication",
|
||||
"condition_summary": "??????????? 500 ????????????????",
|
||||
"message_template": "??????? 500 ?????????????????????????",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
"meal",
|
||||
"entertainment"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"threshold_amount": 500,
|
||||
"rule_ir": {
|
||||
"facts": [
|
||||
{
|
||||
"id": "A",
|
||||
"label": "????",
|
||||
"fields": [
|
||||
"claim.amount"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "B",
|
||||
"label": "???",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"hit_logic": "A > threshold AND NOT EXISTS(B)"
|
||||
}
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -144,29 +188,30 @@
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 84
|
||||
"risk_score": 88
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"owner": "??????",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.818641+00:00",
|
||||
"source_ref": "??????????",
|
||||
"created_at": "2026-06-05T00:00:00+08:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 84,
|
||||
"risk_score": 88,
|
||||
"risk_level": "high",
|
||||
"rule_title": "大额业务招待未申请",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"rule_title": "??????????",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
"meal",
|
||||
"entertainment"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 84,
|
||||
"risk_score": 88,
|
||||
"risk_level": "high"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.office_bulk_without_purchase",
|
||||
"name": "办公用品大额采购未申请",
|
||||
"description": "批量办公用品或设备采购达到阈值但未走采购申请。",
|
||||
"name": "???????????",
|
||||
"description": "???????????????? 2000 ???????????",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"risk_category": "????",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"template_key": "composite_rule_v1",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
@@ -34,68 +34,75 @@
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"label": "????",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"label": "????",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"label": "??",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"label": "??",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"label": "???ID",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.claim_no",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"label": "????",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"label": "??????",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"label": "??????",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"label": "????",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"template_key": "composite_rule_v1",
|
||||
"semantic_type": "preapproval_required_amount_threshold",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
@@ -103,31 +110,72 @@
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.claim_no",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_preapproval_threshold",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": [
|
||||
"claim.amount"
|
||||
],
|
||||
"threshold": 2000,
|
||||
"compare": "gt"
|
||||
},
|
||||
{
|
||||
"id": "application_present",
|
||||
"operator": "exists_any",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"办公采购",
|
||||
"大额办公用品",
|
||||
"采购申请"
|
||||
],
|
||||
"condition_summary": "办公用品单次金额达到采购阈值且缺少采购申请时触发。",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"amount_exceeds_preapproval_threshold",
|
||||
{
|
||||
"not": "application_present"
|
||||
}
|
||||
]
|
||||
},
|
||||
"formula": "amount > threshold AND NOT hasApplication",
|
||||
"condition_summary": "???????????????? 2000 ????????????????",
|
||||
"message_template": "??????? 2000 ??????????????????????????????",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true
|
||||
"budget_required": true,
|
||||
"threshold_amount": 2000,
|
||||
"rule_ir": {
|
||||
"facts": [
|
||||
{
|
||||
"id": "A",
|
||||
"label": "????",
|
||||
"fields": [
|
||||
"claim.amount"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "B",
|
||||
"label": "???",
|
||||
"fields": [
|
||||
"application.id",
|
||||
"application.claim_no"
|
||||
]
|
||||
}
|
||||
],
|
||||
"hit_logic": "A > threshold AND NOT EXISTS(B)"
|
||||
}
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
@@ -135,22 +183,22 @@
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 78
|
||||
"risk_score": 84
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"owner": "??????",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.811910+00:00",
|
||||
"source_ref": "??????????",
|
||||
"created_at": "2026-06-05T00:00:00+08:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 78,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "办公用品大额采购未申请",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high",
|
||||
"rule_title": "???????????",
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "????????",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
@@ -159,7 +207,7 @@
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 78,
|
||||
"risk_level": "medium"
|
||||
"severity": "high",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||
PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.3.1}"
|
||||
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
|
||||
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
|
||||
@@ -15,6 +17,6 @@ apt-get install -y libgl1 libglib2.0-0
|
||||
|
||||
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
|
||||
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
|
||||
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==3.2.0" "paddleocr==3.5.0"
|
||||
"${OCR_VENV_DIR}/bin/pip" install "paddlepaddle==${PADDLEPADDLE_VERSION}" "paddleocr==${PADDLEOCR_VERSION}"
|
||||
|
||||
echo "PaddleOCR mobile runtime 已安装到 ${OCR_VENV_DIR}"
|
||||
echo "PaddleOCR mobile runtime ${PADDLEOCR_VERSION} / PaddlePaddle ${PADDLEPADDLE_VERSION} 已安装到 ${OCR_VENV_DIR}"
|
||||
|
||||
@@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--lang", default="ch")
|
||||
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
|
||||
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
|
||||
parser.add_argument("--enable-mkldnn", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -106,6 +107,8 @@ def main() -> int:
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
lang=args.lang,
|
||||
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
|
||||
enable_mkldnn=args.enable_mkldnn,
|
||||
)
|
||||
|
||||
documents = []
|
||||
|
||||
@@ -188,6 +188,8 @@ if is_container; then
|
||||
fi
|
||||
|
||||
SERVER_RELOAD="${SERVER_RELOAD:-$DEFAULT_SERVER_RELOAD}"
|
||||
SERVER_WORKERS="${SERVER_WORKERS:-${WEB_CONCURRENCY:-1}}"
|
||||
export SERVER_WORKERS
|
||||
|
||||
needs_windows_python() {
|
||||
is_msys || is_wsl
|
||||
@@ -355,6 +357,12 @@ start_server() {
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
|
||||
fi
|
||||
|
||||
if [ "$SERVER_WORKERS" -gt 1 ] 2>/dev/null; then
|
||||
BACKGROUND_SCHEDULERS_ENABLED="${BACKGROUND_SCHEDULERS_ENABLED:-false}"
|
||||
export BACKGROUND_SCHEDULERS_ENABLED
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT" --workers "$SERVER_WORKERS"
|
||||
fi
|
||||
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
@@ -50,7 +51,7 @@ async def recognize_ocr_documents(
|
||||
upload.content_type,
|
||||
)
|
||||
)
|
||||
result = OcrService(db).recognize_files(payload)
|
||||
result = await run_in_threadpool(lambda: OcrService(db).recognize_files(payload))
|
||||
return ReceiptFolderService().persist_ocr_batch(
|
||||
files=payload,
|
||||
result=result,
|
||||
|
||||
@@ -11,10 +11,20 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse, StewardThinkingEvent
|
||||
from app.schemas.steward import (
|
||||
StewardPlanRequest,
|
||||
StewardPlanResponse,
|
||||
StewardRuntimeDecisionRequest,
|
||||
StewardRuntimeDecisionResponse,
|
||||
StewardSlotDecisionRequest,
|
||||
StewardSlotDecisionResponse,
|
||||
StewardThinkingEvent,
|
||||
)
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.steward_intent_agent import StewardIntentAgent
|
||||
from app.services.steward_planner import StewardPlannerService
|
||||
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
|
||||
from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent
|
||||
|
||||
router = APIRouter(prefix="/steward")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -39,6 +49,32 @@ def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPl
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/slot-decisions",
|
||||
response_model=StewardSlotDecisionResponse,
|
||||
summary="判断小财管家当前任务字段缺口",
|
||||
description="结合当前任务、本体字段和用户上下文,使用 function calling 判断下一步应先追问用户还是展示核对结果。",
|
||||
)
|
||||
def create_steward_slot_decision(
|
||||
payload: StewardSlotDecisionRequest,
|
||||
db: DbSession,
|
||||
) -> StewardSlotDecisionResponse:
|
||||
return StewardSlotDecisionAgent(RuntimeChatService(db)).decide(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/runtime-decisions",
|
||||
response_model=StewardRuntimeDecisionResponse,
|
||||
summary="判断小财管家运行时下一步动作",
|
||||
description="结合任务队列、当前结构化结果和用户输入,使用 function calling 判断应提交当前单据、继续下一任务、补字段或重新规划。",
|
||||
)
|
||||
def create_steward_runtime_decision(
|
||||
payload: StewardRuntimeDecisionRequest,
|
||||
db: DbSession,
|
||||
) -> StewardRuntimeDecisionResponse:
|
||||
return StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plans/stream",
|
||||
summary="流式生成小财管家任务计划",
|
||||
@@ -60,8 +96,8 @@ async def _iter_steward_plan_events(
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_agent_stream_start",
|
||||
stage="stream_start",
|
||||
title="意图识别智能体接管",
|
||||
content="已收到任务描述,正在调用小财管家意图识别智能体拆解申请、报销和附件线索。",
|
||||
title="读取用户输入",
|
||||
content="我先判断这句话里是否同时包含申请、报销或附件归集事项,再决定处理顺序。",
|
||||
status="running",
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
@@ -75,7 +111,7 @@ async def _iter_steward_plan_events(
|
||||
|
||||
for event in plan.thinking_events:
|
||||
yield _encode_stream_event("thinking", event.model_dump(mode="json"))
|
||||
await asyncio.sleep(0.18)
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
yield _encode_stream_event("plan", plan.model_dump(mode="json"))
|
||||
|
||||
|
||||
@@ -38,10 +38,16 @@ class Settings(BaseSettings):
|
||||
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
|
||||
|
||||
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
|
||||
web_port: int = Field(default=5173, alias="WEB_PORT")
|
||||
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
|
||||
app_port: int = Field(default=8000, alias="SERVER_PORT")
|
||||
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
|
||||
web_port: int = Field(default=5173, alias="WEB_PORT")
|
||||
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
|
||||
app_port: int = Field(default=8000, alias="SERVER_PORT")
|
||||
server_workers: int = Field(default=1, alias="SERVER_WORKERS")
|
||||
web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY")
|
||||
background_schedulers_enabled: bool = Field(
|
||||
default=True,
|
||||
alias="BACKGROUND_SCHEDULERS_ENABLED",
|
||||
)
|
||||
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
|
||||
|
||||
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
|
||||
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
|
||||
@@ -49,8 +55,11 @@ class Settings(BaseSettings):
|
||||
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
|
||||
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
|
||||
|
||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
|
||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
|
||||
sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE")
|
||||
sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW")
|
||||
sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT")
|
||||
|
||||
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
||||
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
||||
@@ -70,6 +79,7 @@ class Settings(BaseSettings):
|
||||
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
|
||||
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
|
||||
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
|
||||
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
|
||||
ocr_language: str = Field(default="ch", alias="OCR_LANGUAGE")
|
||||
seed_demo_financial_records: bool = Field(
|
||||
default=False,
|
||||
|
||||
@@ -18,11 +18,20 @@ def configure_session_factory() -> None:
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
|
||||
_engine = create_engine(
|
||||
settings.resolved_database_url,
|
||||
echo=settings.sqlalchemy_echo,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
engine_kwargs = {
|
||||
"echo": settings.sqlalchemy_echo,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
if not settings.resolved_database_url.startswith("sqlite"):
|
||||
engine_kwargs.update(
|
||||
{
|
||||
"pool_size": max(1, int(settings.sqlalchemy_pool_size or 10)),
|
||||
"max_overflow": max(0, int(settings.sqlalchemy_max_overflow or 20)),
|
||||
"pool_timeout": max(1, int(settings.sqlalchemy_pool_timeout or 30)),
|
||||
}
|
||||
)
|
||||
|
||||
_engine = create_engine(settings.resolved_database_url, **engine_kwargs)
|
||||
_session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,23 @@ from app.services.knowledge_rag import shutdown_knowledge_rag_runtime
|
||||
from app.services.knowledge_scheduler import knowledge_index_scheduler
|
||||
|
||||
|
||||
def _effective_server_workers(settings: object) -> int:
|
||||
server_workers = getattr(settings, "server_workers", None)
|
||||
web_concurrency = getattr(settings, "web_concurrency", None)
|
||||
workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers
|
||||
try:
|
||||
return max(1, int(workers or 1))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def _should_start_background_schedulers(settings: object) -> bool:
|
||||
if not bool(getattr(settings, "background_schedulers_enabled", True)):
|
||||
return False
|
||||
|
||||
return _effective_server_workers(settings) <= 1
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings = get_settings()
|
||||
@@ -34,11 +51,19 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
prepare_agent_foundation()
|
||||
prepare_knowledge_library()
|
||||
sync_repository_hermes_skills()
|
||||
knowledge_index_scheduler.start()
|
||||
finance_dashboard_scheduler.start()
|
||||
employee_profile_scheduler.start()
|
||||
digital_employee_reminder_scheduler.start()
|
||||
finance_report_scheduler.start()
|
||||
schedulers_started = _should_start_background_schedulers(settings)
|
||||
if schedulers_started:
|
||||
knowledge_index_scheduler.start()
|
||||
finance_dashboard_scheduler.start()
|
||||
employee_profile_scheduler.start()
|
||||
digital_employee_reminder_scheduler.start()
|
||||
finance_report_scheduler.start()
|
||||
else:
|
||||
logger.warning(
|
||||
"Background schedulers skipped - workers=%s enabled=%s",
|
||||
_effective_server_workers(settings),
|
||||
settings.background_schedulers_enabled,
|
||||
)
|
||||
logger.info(
|
||||
"Server ready - host=%s port=%s prefix=%s",
|
||||
settings.app_host,
|
||||
@@ -46,11 +71,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.api_v1_prefix,
|
||||
)
|
||||
yield
|
||||
finance_report_scheduler.shutdown()
|
||||
digital_employee_reminder_scheduler.shutdown()
|
||||
employee_profile_scheduler.shutdown()
|
||||
finance_dashboard_scheduler.shutdown()
|
||||
knowledge_index_scheduler.shutdown()
|
||||
if schedulers_started:
|
||||
finance_report_scheduler.shutdown()
|
||||
digital_employee_reminder_scheduler.shutdown()
|
||||
employee_profile_scheduler.shutdown()
|
||||
finance_dashboard_scheduler.shutdown()
|
||||
knowledge_index_scheduler.shutdown()
|
||||
knowledge_index_task_manager.shutdown()
|
||||
shutdown_knowledge_rag_runtime()
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -28,6 +30,74 @@ class AgentRunRepository:
|
||||
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def list_light(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
status: str | None = None,
|
||||
source: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(
|
||||
AgentRun.id.label("id"),
|
||||
AgentRun.run_id.label("run_id"),
|
||||
AgentRun.agent.label("agent"),
|
||||
AgentRun.source.label("source"),
|
||||
AgentRun.user_id.label("user_id"),
|
||||
AgentRun.task_id.label("task_id"),
|
||||
AgentRun.permission_level.label("permission_level"),
|
||||
AgentRun.status.label("status"),
|
||||
AgentRun.result_summary.label("result_summary"),
|
||||
AgentRun.error_message.label("error_message"),
|
||||
AgentRun.started_at.label("started_at"),
|
||||
AgentRun.finished_at.label("finished_at"),
|
||||
AgentRun.route_json["job_type"].as_string().label("route_job_type"),
|
||||
AgentRun.route_json["task_type"].as_string().label("route_task_type"),
|
||||
AgentRun.route_json["task_code"].as_string().label("route_task_code"),
|
||||
AgentRun.route_json["task_name"].as_string().label("route_task_name"),
|
||||
AgentRun.route_json["task_title"].as_string().label("route_task_title"),
|
||||
AgentRun.route_json["asset_name"].as_string().label("route_asset_name"),
|
||||
AgentRun.route_json["selected_agent"].as_string().label("route_selected_agent"),
|
||||
AgentRun.route_json["phase"].as_string().label("route_phase"),
|
||||
AgentRun.route_json["stage"].as_string().label("route_stage"),
|
||||
AgentRun.route_json["report_type"].as_string().label("route_report_type"),
|
||||
AgentRun.route_json["snapshot_key"].as_string().label("route_snapshot_key"),
|
||||
AgentRun.route_json["folder"].as_string().label("route_folder"),
|
||||
AgentRun.route_json["heartbeat_at"].as_string().label("route_heartbeat_at"),
|
||||
AgentRun.route_json["progress"].label("route_progress"),
|
||||
AgentRun.ontology_json["scenario"].as_string().label("ontology_scenario"),
|
||||
AgentRun.ontology_json["intent"].as_string().label("ontology_intent"),
|
||||
AgentRun.ontology_json["parse_strategy"].as_string().label("ontology_parse_strategy"),
|
||||
)
|
||||
if agent:
|
||||
stmt = stmt.where(AgentRun.agent == agent)
|
||||
if status:
|
||||
stmt = stmt.where(AgentRun.status == status)
|
||||
if source:
|
||||
stmt = stmt.where(AgentRun.source == source)
|
||||
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(limit)
|
||||
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
|
||||
|
||||
def list_light_tool_calls(self, run_ids: list[str]) -> list[dict[str, Any]]:
|
||||
if not run_ids:
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
AgentToolCall.id.label("id"),
|
||||
AgentToolCall.run_id.label("run_id"),
|
||||
AgentToolCall.tool_type.label("tool_type"),
|
||||
AgentToolCall.tool_name.label("tool_name"),
|
||||
AgentToolCall.status.label("status"),
|
||||
AgentToolCall.duration_ms.label("duration_ms"),
|
||||
AgentToolCall.error_message.label("error_message"),
|
||||
AgentToolCall.created_at.label("created_at"),
|
||||
)
|
||||
.where(AgentToolCall.run_id.in_(run_ids))
|
||||
.order_by(AgentToolCall.created_at.asc())
|
||||
)
|
||||
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
|
||||
|
||||
def get_by_run_id(self, run_id: str) -> AgentRun | None:
|
||||
stmt = select(AgentRun).where(AgentRun.run_id == run_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
@@ -28,7 +28,7 @@ class NotificationStatePatch(BaseModel):
|
||||
|
||||
|
||||
class NotificationStateBatchPatch(BaseModel):
|
||||
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100)
|
||||
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=500)
|
||||
|
||||
|
||||
class NotificationStateRead(BaseModel):
|
||||
|
||||
@@ -8,6 +8,18 @@ from pydantic import BaseModel, Field
|
||||
StewardTaskType = Literal["expense_application", "reimbursement"]
|
||||
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
|
||||
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardSlotNextAction = Literal["ask_user", "render_preview"]
|
||||
StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardRuntimeNextAction = Literal[
|
||||
"plan_new_tasks",
|
||||
"submit_current_application",
|
||||
"continue_next_task",
|
||||
"fill_current_slot",
|
||||
"ask_user",
|
||||
"cancel_current_action",
|
||||
"no_op",
|
||||
]
|
||||
StewardTaskStatus = Literal[
|
||||
"planned",
|
||||
"needs_confirmation",
|
||||
@@ -88,3 +100,50 @@ class StewardPlanResponse(BaseModel):
|
||||
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
|
||||
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
|
||||
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
|
||||
|
||||
|
||||
class StewardSlotOption(BaseModel):
|
||||
label: str = Field(description="用户可见选项文案。")
|
||||
value: str = Field(description="写回本体字段的选项值。")
|
||||
field_key: str = Field(description="对应 canonical ontology field。")
|
||||
description: str = Field(default="", description="选项说明。")
|
||||
|
||||
|
||||
class StewardSlotDecisionRequest(BaseModel):
|
||||
task_type: StewardTaskType = Field(description="当前小财管家正在推进的任务类型。")
|
||||
user_message: str = Field(description="用户原始话术或小财管家携带的任务上下文。")
|
||||
ontology_fields: dict[str, str] = Field(default_factory=dict, description="当前已抽取的 canonical ontology 字段。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="上游意图识别给出的 canonical 缺失字段。")
|
||||
task_context: dict[str, Any] = Field(default_factory=dict, description="当前任务、附件、申请预览等上下文。")
|
||||
|
||||
|
||||
class StewardSlotDecisionResponse(BaseModel):
|
||||
decision_source: StewardSlotDecisionSource = Field(default="rule_fallback", description="字段决策来源。")
|
||||
next_action: StewardSlotNextAction = Field(description="下一步应追问用户还是展示核对结果。")
|
||||
required_fields: list[str] = Field(default_factory=list, description="模型认为当前业务需要的 canonical 字段。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="当前仍缺失的 canonical 字段。")
|
||||
question: str = Field(default="", description="需要追问时展示给用户的问题。")
|
||||
options: list[StewardSlotOption] = Field(default_factory=list, description="可直接选择的补充选项。")
|
||||
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
|
||||
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
|
||||
|
||||
|
||||
class StewardRuntimeDecisionRequest(BaseModel):
|
||||
user_message: str = Field(description="用户当前输入。")
|
||||
session_type: str = Field(default="steward", description="当前前端会话类型。")
|
||||
runtime_state: dict[str, Any] = Field(default_factory=dict, description="小财管家运行时上下文。")
|
||||
context_json: dict[str, Any] = Field(default_factory=dict, description="调用方补充上下文。")
|
||||
|
||||
|
||||
class StewardRuntimeDecisionResponse(BaseModel):
|
||||
decision_source: StewardRuntimeDecisionSource = Field(default="rule_fallback", description="运行时决策来源。")
|
||||
next_action: StewardRuntimeNextAction = Field(description="小财管家下一步动作。")
|
||||
target_task_id: str = Field(default="", description="关联的小财管家任务 ID。")
|
||||
target_message_id: str = Field(default="", description="关联的前端消息 ID。")
|
||||
field_key: str = Field(default="", description="补字段时对应 canonical ontology field。")
|
||||
field_value: str = Field(default="", description="补字段时用户提供的字段值。")
|
||||
confirmation_required: bool = Field(default=False, description="执行该动作前是否仍需要用户二次确认。")
|
||||
question: str = Field(default="", description="需要追问用户时展示的问题。")
|
||||
response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。")
|
||||
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
|
||||
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
|
||||
|
||||
@@ -24,6 +24,8 @@ COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimburs
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
|
||||
COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement"
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx"
|
||||
FINANCE_RULES_LIBRARY = "finance-rules"
|
||||
RISK_RULES_LIBRARY = "risk-rules"
|
||||
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.core.logging import get_logger
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
AgentAssetSpreadsheetManager,
|
||||
@@ -26,6 +27,8 @@ from app.services.agent_foundation_constants import (
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG,
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
@@ -301,6 +304,35 @@ class AgentFoundationAssetSeedMixin:
|
||||
"rule_template_label": "通信费报销 Excel 模板",
|
||||
},
|
||||
)
|
||||
company_preapproval_rule = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
name="公司费用申请审批规则",
|
||||
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宣",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
published_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
working_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
},
|
||||
)
|
||||
skill_expense_asset = AgentAsset(
|
||||
asset_type=AgentAssetType.SKILL.value,
|
||||
code="skill.expense.summary_lookup",
|
||||
@@ -468,6 +500,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
*platform_risk_assets,
|
||||
company_travel_rule,
|
||||
company_communication_rule,
|
||||
company_preapproval_rule,
|
||||
skill_expense_asset,
|
||||
skill_ar_asset,
|
||||
invoice_mcp_asset,
|
||||
@@ -495,6 +528,11 @@ class AgentFoundationAssetSeedMixin:
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
|
||||
self._hide_deprecated_finance_rule_assets()
|
||||
|
||||
@@ -581,6 +619,18 @@ class AgentFoundationAssetSeedMixin:
|
||||
change_note="初始化通信费报销 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=company_preapproval_rule.name,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
metadata=company_preapproval_rule_meta,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化费用申请审批 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=skill_expense_asset,
|
||||
version="v1.0.0",
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.agent_asset import AgentAsset
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
AgentAssetSpreadsheetManager,
|
||||
@@ -25,6 +26,8 @@ from app.services.agent_foundation_constants import (
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG,
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
@@ -115,6 +118,10 @@ class AgentFoundationAssetTopUpMixin:
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
|
||||
)
|
||||
|
||||
company_preapproval_rule = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_PREAPPROVAL_RULE_CODE)
|
||||
)
|
||||
|
||||
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
|
||||
|
||||
attachment_rule = self._create_seed_asset(
|
||||
@@ -392,6 +399,36 @@ class AgentFoundationAssetTopUpMixin:
|
||||
},
|
||||
)
|
||||
|
||||
if COMPANY_PREAPPROVAL_RULE_CODE not in existing_codes:
|
||||
|
||||
company_preapproval_rule = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
name="公司费用申请审批规则",
|
||||
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON),
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宣",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
},
|
||||
)
|
||||
|
||||
if company_travel_rule is not None:
|
||||
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
|
||||
if not str(company_travel_rule.current_version or "").strip():
|
||||
@@ -536,6 +573,77 @@ class AgentFoundationAssetTopUpMixin:
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
if company_preapproval_rule is not None:
|
||||
company_preapproval_rule.scenario_json = list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
|
||||
if not str(company_preapproval_rule.current_version or "").strip():
|
||||
company_preapproval_rule.current_version = COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
if not str(company_preapproval_rule.working_version or "").strip():
|
||||
company_preapproval_rule.working_version = company_preapproval_rule.current_version
|
||||
if not str(company_preapproval_rule.published_version or "").strip():
|
||||
company_preapproval_rule.published_version = company_preapproval_rule.current_version
|
||||
if not str(company_preapproval_rule.status or "").strip():
|
||||
company_preapproval_rule.status = AgentAssetStatus.ACTIVE.value
|
||||
|
||||
company_preapproval_rule.description = (
|
||||
"通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。"
|
||||
)
|
||||
company_preapproval_rule.config_json = {
|
||||
**(company_preapproval_rule.config_json or {}),
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
"finance_rule_code": "expense.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请审批规则",
|
||||
"expense_types": ["meal", "entertainment", "office", "all"],
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": "费用申请审批 Excel 模板",
|
||||
}
|
||||
company_preapproval_rule_meta = self._ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
company_preapproval_rule,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
|
||||
self._ensure_asset_version(
|
||||
company_preapproval_rule,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=company_preapproval_rule.name,
|
||||
version=str(
|
||||
company_preapproval_rule.current_version
|
||||
or COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
),
|
||||
metadata=company_preapproval_rule_meta,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化费用申请审批 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
if (
|
||||
str(company_preapproval_rule.current_version or "").strip()
|
||||
== COMPANY_PREAPPROVAL_RULE_VERSION
|
||||
):
|
||||
self._ensure_asset_review(
|
||||
company_preapproval_rule,
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
reviewer="顾承宣",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版费用申请审批规则表已确认,可作为财务规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
self._hide_deprecated_finance_rule_assets()
|
||||
|
||||
if "skill.ar.aging_summary" not in existing_codes:
|
||||
|
||||
@@ -82,10 +82,14 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION = "v1.0.0"
|
||||
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",)
|
||||
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",)
|
||||
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON = ("费用申请",)
|
||||
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
|
||||
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"
|
||||
|
||||
@@ -12,6 +12,8 @@ from app.models.agent_asset import AgentAsset
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
@@ -19,6 +21,7 @@ from app.services.agent_asset_spreadsheet import (
|
||||
)
|
||||
from app.services.agent_foundation_constants import (
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
)
|
||||
from app.services.finance_rule_catalog import (
|
||||
@@ -54,6 +57,14 @@ class AgentFoundationSpreadsheetMixin:
|
||||
expense_types=["communication"],
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="费用申请审批规则",
|
||||
expense_types=["meal", "entertainment", "office", "all"],
|
||||
)
|
||||
)
|
||||
return synced_count
|
||||
|
||||
def _ensure_core_finance_rule_asset(
|
||||
@@ -92,14 +103,19 @@ class AgentFoundationSpreadsheetMixin:
|
||||
asset.status = AgentAssetStatus.DISABLED.value
|
||||
asset.scenario_json = ["已废弃"]
|
||||
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
|
||||
deprecated_reason = (
|
||||
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
|
||||
if replacement
|
||||
else (
|
||||
if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||
deprecated_reason = (
|
||||
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
|
||||
)
|
||||
elif replacement == COMPANY_PREAPPROVAL_RULE_CODE:
|
||||
deprecated_reason = (
|
||||
"申请审批阈值已并入公司费用申请审批规则,不再作为独立财务规则展示。"
|
||||
)
|
||||
else:
|
||||
deprecated_reason = (
|
||||
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
|
||||
"不再作为独立财务规则表展示。"
|
||||
)
|
||||
)
|
||||
asset.config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"enabled": False,
|
||||
@@ -258,6 +274,93 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
)
|
||||
|
||||
def _ensure_company_preapproval_rule_spreadsheet_seed(
|
||||
|
||||
self,
|
||||
|
||||
asset: AgentAsset,
|
||||
|
||||
*,
|
||||
|
||||
version: str,
|
||||
|
||||
actor_name: str,
|
||||
|
||||
):
|
||||
|
||||
return self._ensure_finance_rule_spreadsheet_seed(
|
||||
|
||||
asset,
|
||||
|
||||
version=version,
|
||||
|
||||
actor_name=actor_name,
|
||||
|
||||
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
|
||||
fallback_sheet_name="费用申请审批规则",
|
||||
|
||||
workbook_sheets=[
|
||||
(
|
||||
"费用申请审批规则",
|
||||
[
|
||||
[
|
||||
"费用类型代码",
|
||||
"费用类型",
|
||||
"触发条件",
|
||||
"阈值金额",
|
||||
"前置要求",
|
||||
"审批要求",
|
||||
"风险动作",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
"meal/entertainment",
|
||||
"业务招待费",
|
||||
"单次费用金额大于 500 元",
|
||||
500,
|
||||
"必须先提交费用申请单,并说明客户、参与人和招待事由",
|
||||
"申请单需按审批链完成审批后方可报销",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"适配 meal 与 entertainment 两个本体费用类型",
|
||||
],
|
||||
[
|
||||
"office",
|
||||
"办公用品费",
|
||||
"单次或批量采购金额大于 2000 元",
|
||||
2000,
|
||||
"必须先提交办公采购或费用申请单",
|
||||
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"覆盖办公用品、办公耗材、低值易耗品等场景",
|
||||
],
|
||||
[
|
||||
"all",
|
||||
"通用大额费用",
|
||||
"任意费用金额大于 2000 元",
|
||||
2000,
|
||||
"必须进入费用申请和审批流程",
|
||||
"至少完成直属领导审批;按预算和财务规则继续流转",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"差旅、通信等已有专项规则时可同时适用专项规则",
|
||||
],
|
||||
],
|
||||
),
|
||||
(
|
||||
"字段说明",
|
||||
[
|
||||
["字段", "说明"],
|
||||
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
|
||||
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
|
||||
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
|
||||
["审批要求", "说明申请单进入审批链后的最低审批要求"],
|
||||
["风险动作", "说明报销阶段未满足规则时的系统处理"],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
def _read_or_build_company_travel_rule_file(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -24,6 +25,34 @@ logger = get_logger("app.services.agent_runs")
|
||||
|
||||
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
|
||||
KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"}
|
||||
LIST_ROUTE_FIELDS = (
|
||||
("route_job_type", "job_type"),
|
||||
("route_task_type", "task_type"),
|
||||
("route_task_code", "task_code"),
|
||||
("route_task_name", "task_name"),
|
||||
("route_task_title", "task_title"),
|
||||
("route_asset_name", "asset_name"),
|
||||
("route_selected_agent", "selected_agent"),
|
||||
("route_phase", "phase"),
|
||||
("route_stage", "stage"),
|
||||
("route_report_type", "report_type"),
|
||||
("route_snapshot_key", "snapshot_key"),
|
||||
("route_folder", "folder"),
|
||||
("route_heartbeat_at", "heartbeat_at"),
|
||||
)
|
||||
LIST_ONTOLOGY_FIELDS = (
|
||||
("ontology_scenario", "scenario"),
|
||||
("ontology_intent", "intent"),
|
||||
("ontology_parse_strategy", "parse_strategy"),
|
||||
)
|
||||
LIST_PROGRESS_FIELDS = {
|
||||
"percent",
|
||||
"total_documents",
|
||||
"completed_documents",
|
||||
"failed_documents",
|
||||
"skipped_documents",
|
||||
"current_stage",
|
||||
}
|
||||
|
||||
|
||||
class AgentRunService:
|
||||
@@ -41,8 +70,22 @@ class AgentRunService:
|
||||
) -> list[AgentRunRead]:
|
||||
self._ensure_ready()
|
||||
self._reconcile_stale_knowledge_index_runs()
|
||||
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
|
||||
return [self._serialize_run(item) for item in runs]
|
||||
rows = self.repository.list_light(
|
||||
agent=agent,
|
||||
status=status,
|
||||
source=source,
|
||||
limit=limit,
|
||||
)
|
||||
tool_calls_by_run_id = self._group_light_tool_calls(
|
||||
self.repository.list_light_tool_calls([str(item["run_id"]) for item in rows])
|
||||
)
|
||||
return [
|
||||
self._serialize_run_list_item(
|
||||
item,
|
||||
tool_calls_by_run_id.get(str(item["run_id"]), []),
|
||||
)
|
||||
for item in rows
|
||||
]
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRunRead | None:
|
||||
self._ensure_ready()
|
||||
@@ -435,3 +478,99 @@ class AgentRunService:
|
||||
if semantic_parse
|
||||
else None,
|
||||
)
|
||||
|
||||
def _serialize_run_list_item(
|
||||
self,
|
||||
row: dict[str, Any],
|
||||
tool_calls: list[dict[str, Any]],
|
||||
) -> AgentRunRead:
|
||||
return AgentRunRead(
|
||||
id=str(row["id"]),
|
||||
run_id=str(row["run_id"]),
|
||||
agent=str(row["agent"]),
|
||||
source=str(row["source"]),
|
||||
user_id=row.get("user_id"),
|
||||
task_id=row.get("task_id"),
|
||||
ontology_json=self._build_list_ontology_json(row),
|
||||
route_json=self._build_list_route_json(row),
|
||||
permission_level=str(row["permission_level"]),
|
||||
status=str(row["status"]),
|
||||
result_summary=row.get("result_summary"),
|
||||
error_message=row.get("error_message"),
|
||||
started_at=row["started_at"],
|
||||
finished_at=row.get("finished_at"),
|
||||
tool_calls=[self._serialize_light_tool_call(item) for item in tool_calls],
|
||||
semantic_parse=None,
|
||||
)
|
||||
|
||||
def _build_list_route_json(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for source_key, target_key in LIST_ROUTE_FIELDS:
|
||||
self._set_if_present(payload, target_key, row.get(source_key))
|
||||
|
||||
progress = self._coerce_json_object(row.get("route_progress"))
|
||||
compact_progress = {
|
||||
key: value
|
||||
for key, value in progress.items()
|
||||
if key in LIST_PROGRESS_FIELDS and self._is_scalar_json_value(value)
|
||||
}
|
||||
if compact_progress:
|
||||
payload["progress"] = compact_progress
|
||||
return payload
|
||||
|
||||
def _build_list_ontology_json(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for source_key, target_key in LIST_ONTOLOGY_FIELDS:
|
||||
self._set_if_present(payload, target_key, row.get(source_key))
|
||||
return payload
|
||||
|
||||
def _serialize_light_tool_call(self, row: dict[str, Any]) -> AgentToolCallRead:
|
||||
return AgentToolCallRead(
|
||||
id=str(row["id"]),
|
||||
run_id=str(row["run_id"]),
|
||||
tool_type=str(row["tool_type"]),
|
||||
tool_name=str(row["tool_name"]),
|
||||
request_json={},
|
||||
response_json={},
|
||||
status=str(row["status"]),
|
||||
duration_ms=int(row.get("duration_ms") or 0),
|
||||
error_message=row.get("error_message"),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _group_light_tool_calls(
|
||||
tool_calls: list[dict[str, Any]],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for tool_call in tool_calls:
|
||||
grouped.setdefault(str(tool_call.get("run_id") or ""), []).append(tool_call)
|
||||
return grouped
|
||||
|
||||
@staticmethod
|
||||
def _coerce_json_object(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if normalized.startswith("{") and normalized.endswith("}"):
|
||||
try:
|
||||
loaded = json.loads(normalized)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _set_if_present(payload: dict[str, Any], key: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return
|
||||
if not AgentRunService._is_scalar_json_value(value):
|
||||
return
|
||||
payload[key] = value
|
||||
|
||||
@staticmethod
|
||||
def _is_scalar_json_value(value: Any) -> bool:
|
||||
return value is None or isinstance(value, str | int | float | bool)
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
@@ -30,7 +30,7 @@ BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
|
||||
BUDGET_MONITOR_APPROVAL_GRADE = "P8"
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPLICATION_ARCHIVE_STAGE,)
|
||||
ARCHIVED_REIMBURSEMENT_STAGES = (
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
@@ -67,24 +67,31 @@ class ExpenseClaimAccessPolicy:
|
||||
normalized_type == "application",
|
||||
normalized_type.like("%\\_application", escape="\\"),
|
||||
)
|
||||
return or_(
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
and_(
|
||||
application_condition,
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
stage.in_(APPLICATION_ARCHIVED_STAGES),
|
||||
),
|
||||
and_(
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
or_(
|
||||
stage == "",
|
||||
stage.is_(None),
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
reimbursement_condition = and_(
|
||||
~application_condition,
|
||||
or_(
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
and_(
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
or_(
|
||||
stage == "",
|
||||
stage.is_(None),
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
application_archive_condition = and_(
|
||||
application_condition,
|
||||
normalized_status.in_(ARCHIVED_CLAIM_STATUSES),
|
||||
stage.in_(APPLICATION_ARCHIVED_STAGES),
|
||||
)
|
||||
return or_(
|
||||
reimbursement_condition,
|
||||
application_archive_condition,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def has_claim_delete_access(current_user: CurrentUserContext) -> bool:
|
||||
@@ -96,8 +103,6 @@ class ExpenseClaimAccessPolicy:
|
||||
def is_archived_claim(claim: ExpenseClaim) -> bool:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
|
||||
return True
|
||||
normalized_type = str(claim.expense_type or "").strip().lower()
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
is_application_claim = (
|
||||
@@ -105,11 +110,9 @@ class ExpenseClaimAccessPolicy:
|
||||
or normalized_type == "application"
|
||||
or normalized_type.endswith("_application")
|
||||
)
|
||||
if (
|
||||
is_application_claim
|
||||
and normalized_status in ARCHIVED_CLAIM_STATUSES
|
||||
and stage in APPLICATION_ARCHIVED_STAGES
|
||||
):
|
||||
if is_application_claim:
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in APPLICATION_ARCHIVED_STAGES
|
||||
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
|
||||
return True
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE
|
||||
|
||||
|
||||
APPLICATION_REIMBURSEMENT_TYPE_MAP = {
|
||||
@@ -15,6 +19,7 @@ APPLICATION_REIMBURSEMENT_TYPE_MAP = {
|
||||
"expense_application": "other",
|
||||
"application": "other",
|
||||
}
|
||||
APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"}
|
||||
|
||||
|
||||
class ExpenseClaimApplicationHandoffMixin:
|
||||
@@ -130,3 +135,116 @@ class ExpenseClaimApplicationHandoffMixin:
|
||||
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
|
||||
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}。"
|
||||
return draft_claim
|
||||
|
||||
@staticmethod
|
||||
def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]:
|
||||
application_ids: set[str] = set()
|
||||
application_nos: set[str] = set()
|
||||
for flag in list(claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
source = str(flag.get("source") or "").strip()
|
||||
has_application_reference = any(
|
||||
str(flag.get(key) or "").strip()
|
||||
for key in (
|
||||
"application_claim_id",
|
||||
"applicationClaimId",
|
||||
"application_claim_no",
|
||||
"applicationClaimNo",
|
||||
)
|
||||
)
|
||||
if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference:
|
||||
continue
|
||||
application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip()
|
||||
application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip()
|
||||
if application_id:
|
||||
application_ids.add(application_id)
|
||||
if application_no:
|
||||
application_nos.add(application_no)
|
||||
return application_ids, application_nos
|
||||
|
||||
def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]:
|
||||
application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim)
|
||||
conditions = []
|
||||
if application_ids:
|
||||
conditions.append(ExpenseClaim.id.in_(application_ids))
|
||||
if application_nos:
|
||||
conditions.append(ExpenseClaim.claim_no.in_(application_nos))
|
||||
if not conditions:
|
||||
return []
|
||||
|
||||
claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all())
|
||||
return [claim for claim in claims if self._is_expense_application_claim(claim)]
|
||||
|
||||
def _archive_linked_applications_after_reimbursement_paid(
|
||||
self,
|
||||
*,
|
||||
reimbursement_claim: ExpenseClaim,
|
||||
payment_flag: dict[str, Any],
|
||||
operator: str,
|
||||
current_user: Any,
|
||||
) -> list[dict[str, str]]:
|
||||
archived_applications: list[dict[str, str]] = []
|
||||
payment_event_id = str(payment_flag.get("payment_event_id") or "").strip()
|
||||
for application_claim in self._find_linked_application_claims(reimbursement_claim):
|
||||
previous_status = str(application_claim.status or "").strip()
|
||||
previous_stage = str(application_claim.approval_stage or "").strip()
|
||||
if previous_stage == APPLICATION_ARCHIVE_STAGE:
|
||||
continue
|
||||
|
||||
normalized_status = previous_status.lower()
|
||||
if normalized_status not in {"approved", "completed"}:
|
||||
continue
|
||||
|
||||
before_json = self._serialize_claim(application_claim)
|
||||
archive_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "application_archive_sync",
|
||||
"event_type": "expense_application_archived_by_reimbursement",
|
||||
"archive_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "申请归档",
|
||||
"message": (
|
||||
f"关联报销单 {reimbursement_claim.claim_no} 已完成付款,"
|
||||
"系统同步将申请单归档。"
|
||||
),
|
||||
"operator": operator,
|
||||
"operator_username": getattr(current_user, "username", ""),
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in getattr(current_user, "role_codes", [])
|
||||
if str(item).strip()
|
||||
],
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": application_claim.claim_no,
|
||||
"reimbursement_claim_id": reimbursement_claim.id,
|
||||
"reimbursement_claim_no": reimbursement_claim.claim_no,
|
||||
"payment_event_id": payment_event_id,
|
||||
"previous_status": previous_status,
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": "approved",
|
||||
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"expense_application",
|
||||
)
|
||||
application_claim.status = "approved"
|
||||
application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE
|
||||
application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag]
|
||||
archived_applications.append(
|
||||
{
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": str(application_claim.claim_no or "").strip(),
|
||||
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
|
||||
}
|
||||
)
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_application.archive_by_reimbursement",
|
||||
resource_type="expense_claim",
|
||||
resource_id=application_claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(application_claim),
|
||||
)
|
||||
|
||||
return archived_applications
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
@@ -62,7 +62,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
if merged_budget_approval:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
@@ -73,7 +73,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
else:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
if requires_budget_review:
|
||||
@@ -99,7 +99,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
label = "预算管理者审核通过"
|
||||
if is_application_claim:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
next_stage = APPLICATION_LINK_STATUS_STAGE
|
||||
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
next_status = "submitted"
|
||||
@@ -186,7 +186,7 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
if is_application_claim and next_stage == APPROVAL_DONE_STAGE:
|
||||
if is_application_claim and next_stage == APPLICATION_LINK_STATUS_STAGE:
|
||||
if previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion(
|
||||
claim,
|
||||
@@ -289,6 +289,15 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"reimbursement",
|
||||
)
|
||||
|
||||
archived_applications = self._archive_linked_applications_after_reimbursement_paid(
|
||||
reimbursement_claim=claim,
|
||||
payment_flag=payment_flag,
|
||||
operator=operator,
|
||||
current_user=current_user,
|
||||
)
|
||||
if archived_applications:
|
||||
payment_flag["archived_application_claims"] = archived_applications
|
||||
|
||||
claim.status = PAYMENT_PAID_STATUS
|
||||
claim.approval_stage = PAYMENT_PAID_STAGE
|
||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag]
|
||||
|
||||
@@ -63,6 +63,8 @@ def build_platform_risk_flag(
|
||||
"rule_type": "risk",
|
||||
"rule_code": str(manifest.get("rule_code") or "").strip(),
|
||||
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
|
||||
"finance_rule_code": str(manifest.get("finance_rule_code") or "").strip(),
|
||||
"finance_rule_sheet": str(manifest.get("finance_rule_sheet") or "").strip(),
|
||||
"severity": severity,
|
||||
"action": action,
|
||||
"label": label,
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any
|
||||
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
@@ -73,6 +75,8 @@ CANONICAL_APPROVAL_STAGES = {
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
PAYMENT_PENDING_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
@@ -214,8 +218,10 @@ def _approved_stage(raw_stage: str, is_application_claim: bool) -> str:
|
||||
stage = _normalize_stage_alias(raw_stage)
|
||||
lowered = str(raw_stage or "").strip().lower()
|
||||
if is_application_claim:
|
||||
if not stage or lowered == "completed":
|
||||
return APPROVAL_DONE_STAGE
|
||||
if stage == APPLICATION_ARCHIVE_STAGE:
|
||||
return APPLICATION_ARCHIVE_STAGE
|
||||
if not stage or lowered == "completed" or stage == APPROVAL_DONE_STAGE:
|
||||
return APPLICATION_LINK_STATUS_STAGE
|
||||
return stage
|
||||
if stage in {ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE}:
|
||||
return stage
|
||||
|
||||
@@ -2,6 +2,8 @@ DIRECT_MANAGER_APPROVAL_STAGE = "直属领导审批"
|
||||
BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批"
|
||||
FINANCE_APPROVAL_STAGE = "财务审批"
|
||||
APPROVAL_DONE_STAGE = "审批完成"
|
||||
APPLICATION_LINK_STATUS_STAGE = "关联单据状态"
|
||||
APPLICATION_ARCHIVE_STAGE = "申请归档"
|
||||
ARCHIVE_ACCOUNTING_STAGE = "归档入账"
|
||||
PAYMENT_PENDING_STATUS = "pending_payment"
|
||||
PAYMENT_PAID_STATUS = "paid"
|
||||
|
||||
@@ -858,7 +858,7 @@ class ExpenseClaimService(
|
||||
self._release_budget_for_delete(claim, current_user)
|
||||
self._delete_claim_analysis_records(resource_id)
|
||||
self._attachment_storage.delete_claim_files(claim)
|
||||
ReceiptFolderService().delete_receipts_for_claim(resource_id)
|
||||
ReceiptFolderService().unlink_receipts_for_claim(resource_id)
|
||||
self.db.delete(claim)
|
||||
self.db.commit()
|
||||
|
||||
@@ -1021,4 +1021,3 @@ class ExpenseClaimService(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.agent_asset_spreadsheet import COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
)
|
||||
|
||||
DEPRECATED_FINANCE_RULE_CODES = (
|
||||
"rule.expense.company_transport_hotel_detail_reimbursement",
|
||||
@@ -17,4 +20,6 @@ DEPRECATED_FINANCE_RULE_REPLACEMENTS = {
|
||||
"rule.expense.company_transport_hotel_detail_reimbursement": (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
),
|
||||
"rule.expense.company_meal_expense_reimbursement": COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
"rule.expense.company_office_expense_reimbursement": COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -26,10 +24,23 @@ class HermesEmployeeProfileScannerService:
|
||||
summary["baseline_summary"] = baseline_summary
|
||||
logger.info(
|
||||
"Hermes employee profile scan completed: %s",
|
||||
json.dumps(summary, ensure_ascii=False),
|
||||
self._build_log_summary(summary),
|
||||
)
|
||||
return summary
|
||||
|
||||
def _build_log_summary(self, summary: dict) -> dict:
|
||||
baseline_summary = self._as_dict(summary.get("baseline_summary"))
|
||||
buckets = baseline_summary.get("buckets")
|
||||
return {
|
||||
"target_employee_count": self._to_int(summary.get("target_employee_count")),
|
||||
"snapshot_count": self._to_int(summary.get("snapshot_count")),
|
||||
"high_attention_employee_count": self._to_int(
|
||||
summary.get("high_attention_employee_count")
|
||||
),
|
||||
"window_days": summary.get("window_days") or [],
|
||||
"baseline_bucket_count": len(buckets) if isinstance(buckets, list) else 0,
|
||||
}
|
||||
|
||||
def _build_baseline_summary(self) -> dict:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
@@ -42,3 +53,14 @@ class HermesEmployeeProfileScannerService:
|
||||
for claim in self.db.scalars(stmt).all()
|
||||
]
|
||||
return ProfileBaselineUpdater().build_from_claims(claims).as_dict()
|
||||
|
||||
@staticmethod
|
||||
def _as_dict(value: object) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: object) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -17,6 +20,7 @@ from app.services.document_intelligence import DocumentIntelligenceService
|
||||
|
||||
WORKER_JSON_PREFIX = "__OCR_JSON__="
|
||||
SUPPORTED_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".pdf"}
|
||||
OCR_RESULT_CACHE_LIMIT = 32
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -50,6 +54,12 @@ class AggregatedOcrDocument:
|
||||
|
||||
|
||||
class OcrService:
|
||||
_cache_lock = threading.Lock()
|
||||
_result_cache: OrderedDict[str, OcrRecognizeDocumentRead] = OrderedDict()
|
||||
_worker_semaphore_lock = threading.Lock()
|
||||
_worker_semaphore: threading.Semaphore | None = None
|
||||
_worker_semaphore_limit = 0
|
||||
|
||||
def __init__(self, db: Session | None = None) -> None:
|
||||
self.settings = get_settings()
|
||||
self.document_intelligence_service = DocumentIntelligenceService(db)
|
||||
@@ -70,6 +80,7 @@ class OcrService:
|
||||
python_bin = self._resolve_python_bin()
|
||||
worker_path = self._resolve_worker_path()
|
||||
worker_payload: dict = {}
|
||||
cache_keys_by_source: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
for filename, content, media_type in files:
|
||||
@@ -109,6 +120,16 @@ class OcrService:
|
||||
)
|
||||
continue
|
||||
|
||||
cache_key = self._build_cache_key(content)
|
||||
cached_document = self._read_cached_document(
|
||||
cache_key,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
)
|
||||
if cached_document is not None:
|
||||
documents.append(cached_document)
|
||||
continue
|
||||
|
||||
temp_path = temp_root / f"{uuid4().hex}{suffix}"
|
||||
temp_path.write_bytes(content)
|
||||
cleanup_paths.append(temp_path)
|
||||
@@ -116,15 +137,16 @@ class OcrService:
|
||||
if suffix == ".pdf":
|
||||
try:
|
||||
text_layer = self._extract_pdf_text_layer(temp_path)
|
||||
prepared_inputs.extend(
|
||||
self._prepare_pdf_inputs(
|
||||
pdf_path=temp_path,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
pdf_inputs = self._prepare_pdf_inputs(
|
||||
pdf_path=temp_path,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
text_layer=text_layer,
|
||||
)
|
||||
prepared_inputs.extend(pdf_inputs)
|
||||
for item in pdf_inputs:
|
||||
cache_keys_by_source.setdefault(item.source_key, cache_key)
|
||||
except RuntimeError as exc:
|
||||
documents.append(
|
||||
OcrRecognizeDocumentRead(
|
||||
@@ -135,10 +157,11 @@ class OcrService:
|
||||
)
|
||||
continue
|
||||
|
||||
source_key = uuid4().hex
|
||||
prepared_inputs.append(
|
||||
PreparedOcrInput(
|
||||
input_path=temp_path,
|
||||
source_key=uuid4().hex,
|
||||
source_key=source_key,
|
||||
filename=normalized_name,
|
||||
media_type=resolved_media_type,
|
||||
preview_kind="image" if resolved_media_type.startswith("image/") else "",
|
||||
@@ -149,6 +172,7 @@ class OcrService:
|
||||
),
|
||||
)
|
||||
)
|
||||
cache_keys_by_source[source_key] = cache_key
|
||||
|
||||
if prepared_inputs:
|
||||
worker_payload = self._invoke_worker(
|
||||
@@ -156,11 +180,15 @@ class OcrService:
|
||||
worker_path=worker_path,
|
||||
input_paths=[item.input_path for item in prepared_inputs],
|
||||
)
|
||||
documents.extend(
|
||||
self._build_documents(
|
||||
worker_documents=worker_payload.get("documents", []),
|
||||
prepared_inputs=prepared_inputs,
|
||||
)
|
||||
recognized_documents = self._build_documents(
|
||||
worker_documents=worker_payload.get("documents", []),
|
||||
prepared_inputs=prepared_inputs,
|
||||
)
|
||||
documents.extend(recognized_documents)
|
||||
self._write_cached_documents(
|
||||
recognized_documents,
|
||||
prepared_inputs=prepared_inputs,
|
||||
cache_keys_by_source=cache_keys_by_source,
|
||||
)
|
||||
|
||||
success_count = sum(
|
||||
@@ -215,6 +243,79 @@ class OcrService:
|
||||
raise RuntimeError(f"OCR worker 不存在:{worker_path}")
|
||||
return str(worker_path)
|
||||
|
||||
def _build_cache_key(self, content: bytes) -> str:
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
return "|".join(
|
||||
[
|
||||
self.settings.ocr_language,
|
||||
self.settings.ocr_text_detection_model,
|
||||
self.settings.ocr_text_recognition_model,
|
||||
digest,
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _read_cached_document(
|
||||
cls,
|
||||
cache_key: str,
|
||||
*,
|
||||
filename: str,
|
||||
media_type: str,
|
||||
) -> OcrRecognizeDocumentRead | None:
|
||||
if not cache_key:
|
||||
return None
|
||||
with cls._cache_lock:
|
||||
cached = cls._result_cache.get(cache_key)
|
||||
if cached is None:
|
||||
return None
|
||||
cls._result_cache.move_to_end(cache_key)
|
||||
return cached.model_copy(update={"filename": filename, "media_type": media_type})
|
||||
|
||||
@classmethod
|
||||
def _write_cached_documents(
|
||||
cls,
|
||||
documents: list[OcrRecognizeDocumentRead],
|
||||
*,
|
||||
prepared_inputs: list[PreparedOcrInput],
|
||||
cache_keys_by_source: dict[str, str],
|
||||
) -> None:
|
||||
if not documents or not cache_keys_by_source:
|
||||
return
|
||||
|
||||
source_order: list[str] = []
|
||||
seen_sources: set[str] = set()
|
||||
for item in prepared_inputs:
|
||||
if item.source_key in seen_sources:
|
||||
continue
|
||||
seen_sources.add(item.source_key)
|
||||
source_order.append(item.source_key)
|
||||
|
||||
with cls._cache_lock:
|
||||
for source_key, document in zip(source_order, documents, strict=False):
|
||||
cache_key = cache_keys_by_source.get(source_key, "")
|
||||
if not cache_key:
|
||||
continue
|
||||
cls._result_cache[cache_key] = document.model_copy(
|
||||
update={
|
||||
"receipt_id": "",
|
||||
"receipt_status": "",
|
||||
"receipt_preview_url": "",
|
||||
"receipt_source_url": "",
|
||||
}
|
||||
)
|
||||
cls._result_cache.move_to_end(cache_key)
|
||||
while len(cls._result_cache) > OCR_RESULT_CACHE_LIMIT:
|
||||
cls._result_cache.popitem(last=False)
|
||||
|
||||
@classmethod
|
||||
def _resolve_worker_semaphore(cls, limit: int) -> threading.Semaphore:
|
||||
normalized_limit = max(1, int(limit or 1))
|
||||
with cls._worker_semaphore_lock:
|
||||
if cls._worker_semaphore is None or cls._worker_semaphore_limit != normalized_limit:
|
||||
cls._worker_semaphore = threading.Semaphore(normalized_limit)
|
||||
cls._worker_semaphore_limit = normalized_limit
|
||||
return cls._worker_semaphore
|
||||
|
||||
def _invoke_worker(
|
||||
self,
|
||||
*,
|
||||
@@ -235,13 +336,15 @@ class OcrService:
|
||||
for path in input_paths:
|
||||
command.extend(["--input", str(path)])
|
||||
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.settings.ocr_timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
semaphore = self._resolve_worker_semaphore(self.settings.ocr_max_concurrent_workers)
|
||||
with semaphore:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.settings.ocr_timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()
|
||||
raise RuntimeError(f"OCR 执行失败:{detail or 'worker 返回非 0 状态码。'}")
|
||||
|
||||
@@ -336,11 +336,11 @@ class ReceiptFolderService:
|
||||
shutil.rmtree(receipt_dir)
|
||||
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id)
|
||||
|
||||
def delete_receipts_for_claim(self, claim_id: str) -> int:
|
||||
def unlink_receipts_for_claim(self, claim_id: str) -> int:
|
||||
normalized_claim_id = str(claim_id or "").strip()
|
||||
if not normalized_claim_id:
|
||||
return 0
|
||||
deleted_count = 0
|
||||
unlinked_count = 0
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
for meta_path in list(self.root.glob("*/*/meta.json")):
|
||||
try:
|
||||
@@ -349,9 +349,18 @@ class ReceiptFolderService:
|
||||
continue
|
||||
if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id:
|
||||
continue
|
||||
shutil.rmtree(meta_path.parent, ignore_errors=True)
|
||||
deleted_count += 1
|
||||
return deleted_count
|
||||
meta["status"] = "unlinked"
|
||||
meta["linked_claim_id"] = ""
|
||||
meta["linked_claim_no"] = ""
|
||||
meta["linked_item_id"] = ""
|
||||
meta["linked_at"] = ""
|
||||
meta["updated_at"] = datetime.now(UTC).isoformat()
|
||||
self._write_meta(meta_path.parent, meta)
|
||||
unlinked_count += 1
|
||||
return unlinked_count
|
||||
|
||||
def delete_receipts_for_claim(self, claim_id: str) -> int:
|
||||
return self.unlink_receipts_for_claim(claim_id)
|
||||
|
||||
def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
|
||||
meta = self._read_receipt_meta(receipt_id, current_user)
|
||||
|
||||
@@ -603,6 +603,8 @@ class RiskRuleTemplateExecutor:
|
||||
)
|
||||
if normalized.startswith("attachment."):
|
||||
return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
|
||||
if normalized.startswith("application."):
|
||||
return self._resolve_application_values(normalized.removeprefix("application."), claim)
|
||||
if normalized.startswith("budget."):
|
||||
return self._resolve_budget_values(normalized.removeprefix("budget."), contexts)
|
||||
return []
|
||||
@@ -714,6 +716,99 @@ class RiskRuleTemplateExecutor:
|
||||
values.append(budget_context.get(key))
|
||||
return self._normalize_values(values)
|
||||
|
||||
def _resolve_application_values(self, field_key: str, claim: ExpenseClaim) -> list[str]:
|
||||
values: list[Any] = []
|
||||
normalized_key = str(field_key or "").strip()
|
||||
alias_map = {
|
||||
"id": (
|
||||
"application_claim_id",
|
||||
"applicationClaimId",
|
||||
"application_id",
|
||||
"applicationId",
|
||||
"claim_id",
|
||||
"claimId",
|
||||
"id",
|
||||
),
|
||||
"claim_no": (
|
||||
"application_claim_no",
|
||||
"applicationClaimNo",
|
||||
"application_no",
|
||||
"applicationNo",
|
||||
"claim_no",
|
||||
"claimNo",
|
||||
"no",
|
||||
),
|
||||
"status": ("application_status", "applicationStatus", "status"),
|
||||
"approved_amount": (
|
||||
"application_approved_amount",
|
||||
"applicationApprovedAmount",
|
||||
"approved_amount",
|
||||
"approvedAmount",
|
||||
"application_amount",
|
||||
"applicationAmount",
|
||||
"amount",
|
||||
),
|
||||
"amount": (
|
||||
"application_amount",
|
||||
"applicationAmount",
|
||||
"approved_amount",
|
||||
"approvedAmount",
|
||||
"amount",
|
||||
),
|
||||
"expense_type": (
|
||||
"application_expense_type",
|
||||
"applicationExpenseType",
|
||||
"expense_type",
|
||||
"expenseType",
|
||||
),
|
||||
"department_name": (
|
||||
"application_department_name",
|
||||
"applicationDepartmentName",
|
||||
"department_name",
|
||||
"departmentName",
|
||||
),
|
||||
"reason": ("application_reason", "applicationReason", "reason"),
|
||||
}
|
||||
lookup_keys = alias_map.get(
|
||||
normalized_key,
|
||||
(normalized_key, normalized_key.replace("_", ""), normalized_key.replace("_", "-")),
|
||||
)
|
||||
for source in self._iter_application_contexts(claim):
|
||||
for key in lookup_keys:
|
||||
if key in source and source.get(key) not in (None, ""):
|
||||
values.append(source.get(key))
|
||||
return self._normalize_values(values)
|
||||
|
||||
@staticmethod
|
||||
def _iter_application_contexts(claim: ExpenseClaim) -> list[dict[str, Any]]:
|
||||
contexts: list[dict[str, Any]] = []
|
||||
application_sources = {"application_detail", "application_handoff", "application_link"}
|
||||
nested_keys = (
|
||||
"application_detail",
|
||||
"applicationDetail",
|
||||
"review_form_values",
|
||||
"reviewFormValues",
|
||||
"expense_scene_selection",
|
||||
"expenseSceneSelection",
|
||||
)
|
||||
for flag in list(getattr(claim, "risk_flags_json", None) or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
source = str(flag.get("source") or "").strip()
|
||||
has_application_anchor = (
|
||||
source in application_sources
|
||||
or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
|
||||
or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail"))
|
||||
)
|
||||
if not has_application_anchor:
|
||||
continue
|
||||
contexts.append(flag)
|
||||
for key in nested_keys:
|
||||
nested = flag.get(key)
|
||||
if isinstance(nested, dict):
|
||||
contexts.append(nested)
|
||||
return contexts
|
||||
|
||||
def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]:
|
||||
values: list[Any] = []
|
||||
for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}:
|
||||
|
||||
@@ -229,10 +229,9 @@ class StewardModelPlanBuilder:
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_agent_function_call",
|
||||
stage="llm_function_call",
|
||||
title="意图识别智能体接管",
|
||||
title="拆解财务事项",
|
||||
content=(
|
||||
"已调用系统主模型的 submit_steward_intent_plan 工具,"
|
||||
"把用户话术转换为可校验的结构化财务任务计划。"
|
||||
"我正在把这句话拆成可执行的财务事项,并检查每一项应该进入申请流程还是报销流程。"
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -255,6 +254,10 @@ class StewardModelPlanBuilder:
|
||||
)
|
||||
if len(events) == 1:
|
||||
events.extend(self.planner._build_thinking_events(tasks, attachment_groups, attachments)[1:])
|
||||
else:
|
||||
gap_event = self.planner._build_business_gap_thinking_event(tasks)
|
||||
if gap_event:
|
||||
events.append(gap_event)
|
||||
return events
|
||||
|
||||
def _sanitize_model_missing_fields(
|
||||
|
||||
@@ -52,6 +52,39 @@ REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报
|
||||
MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?")
|
||||
ISO_DATE_PATTERN = re.compile(r"(?P<year>\d{4})[-/年](?P<month>\d{1,2})[-/月](?P<day>\d{1,2})(?:日)?")
|
||||
|
||||
BUSINESS_FIELD_LABELS = {
|
||||
"expense_type": "费用类型",
|
||||
"time_range": "时间",
|
||||
"location": "地点",
|
||||
"reason": "事由",
|
||||
"amount": "金额",
|
||||
"transport_mode": "出行方式",
|
||||
"attachments": "附件/凭证",
|
||||
"customer_name": "客户或项目对象",
|
||||
"merchant_name": "商户/开票方",
|
||||
"department_name": "所属部门",
|
||||
"employee_name": "申请人",
|
||||
"employee_no": "员工编号",
|
||||
}
|
||||
|
||||
EXPENSE_TYPE_LABELS = {
|
||||
"travel": "差旅",
|
||||
"transport": "交通费",
|
||||
"entertainment": "业务招待费",
|
||||
"office": "办公用品",
|
||||
"meeting": "会议费",
|
||||
"training": "培训费",
|
||||
"other": "其他费用",
|
||||
}
|
||||
|
||||
TRANSPORT_MODE_LABELS = {
|
||||
"train": "火车/高铁",
|
||||
"flight": "飞机",
|
||||
"taxi": "出租车/网约车",
|
||||
"subway": "地铁",
|
||||
"other": "其他交通方式",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlannedTaskDraft:
|
||||
@@ -372,6 +405,8 @@ class StewardPlannerService:
|
||||
required = ["expense_type", "time_range", "reason"]
|
||||
if task_type == "expense_application":
|
||||
required.append("location")
|
||||
if fields.get("expense_type") in {"travel", "transport"}:
|
||||
required.append("transport_mode")
|
||||
return [key for key in required if not str(fields.get(key) or "").strip()]
|
||||
|
||||
@staticmethod
|
||||
@@ -543,10 +578,13 @@ class StewardPlannerService:
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_ontology_mapping",
|
||||
stage="ontology_mapping",
|
||||
title="映射业务本体字段",
|
||||
title="核对业务要素",
|
||||
content=ontology_summary,
|
||||
),
|
||||
]
|
||||
gap_event = self._build_business_gap_thinking_event(tasks)
|
||||
if gap_event:
|
||||
events.append(gap_event)
|
||||
if attachments:
|
||||
events.append(
|
||||
StewardThinkingEvent(
|
||||
@@ -580,23 +618,82 @@ class StewardPlannerService:
|
||||
if fields.get("location"):
|
||||
anchors.append(fields["location"])
|
||||
if fields.get("expense_type"):
|
||||
anchors.append(fields["expense_type"])
|
||||
anchors.append(StewardPlannerService._format_business_field_value("expense_type", fields["expense_type"]))
|
||||
anchor_text = "、".join(anchors) if anchors else "待补充关键字段"
|
||||
parts.append(f"{task_label}:{task.title}({anchor_text})")
|
||||
return ";".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str:
|
||||
canonical_keys = []
|
||||
missing_keys = []
|
||||
mapped_labels = []
|
||||
missing_labels = []
|
||||
for task in tasks:
|
||||
canonical_keys.extend(task.ontology_fields.keys())
|
||||
missing_keys.extend(task.missing_fields)
|
||||
unique_keys = sorted({item for item in canonical_keys if item})
|
||||
unique_missing = sorted({item for item in missing_keys if item})
|
||||
mapped = "、".join(unique_keys) if unique_keys else "暂无稳定字段"
|
||||
missing = ";缺失字段:" + "、".join(unique_missing) if unique_missing else ""
|
||||
return f"已使用 canonical ontology fields:{mapped}{missing}。兼容字段只作为输入别名,不直接进入业务逻辑。"
|
||||
mapped_labels.extend(StewardPlannerService._business_field_label(key) for key in task.ontology_fields.keys())
|
||||
missing_labels.extend(StewardPlannerService._business_field_label(key) for key in task.missing_fields)
|
||||
mapped = "、".join(dict.fromkeys(label for label in mapped_labels if label)) or "暂无稳定业务要素"
|
||||
missing = ";还缺少:" + "、".join(dict.fromkeys(label for label in missing_labels if label)) if missing_labels else ""
|
||||
return f"已把用户输入归一为业务要素:{mapped}{missing}。后续执行仍会先让用户确认。"
|
||||
|
||||
@staticmethod
|
||||
def _build_business_gap_thinking_event(tasks: list[StewardTask]) -> StewardThinkingEvent | None:
|
||||
gap_lines = []
|
||||
for task in tasks:
|
||||
if not task.missing_fields:
|
||||
continue
|
||||
missing_labels = [
|
||||
StewardPlannerService._business_field_label(key)
|
||||
for key in task.missing_fields
|
||||
if key
|
||||
]
|
||||
if not missing_labels:
|
||||
continue
|
||||
if task.task_type == "expense_application" and "transport_mode" in task.missing_fields:
|
||||
gap_lines.append(
|
||||
(
|
||||
f"{task.title}已识别到{StewardPlannerService._summarize_known_business_points(task)},"
|
||||
"但用户没有说明出行方式;出行方式会影响交通费用测算,进入申请单核对后需要先追问火车、飞机或轮船。"
|
||||
)
|
||||
)
|
||||
else:
|
||||
gap_lines.append(
|
||||
(
|
||||
f"{task.title}还缺少{'、'.join(dict.fromkeys(missing_labels))},"
|
||||
"需要在对应步骤里继续向用户确认,不能直接执行入库或提交。"
|
||||
)
|
||||
)
|
||||
if not gap_lines:
|
||||
return None
|
||||
return StewardThinkingEvent(
|
||||
event_id="intent_business_gap_check",
|
||||
stage="business_gap_check",
|
||||
title="判断待补充信息",
|
||||
content=";".join(gap_lines),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_known_business_points(task: StewardTask) -> str:
|
||||
parts = []
|
||||
for key in ("time_range", "location", "reason", "expense_type"):
|
||||
value = str(task.ontology_fields.get(key) or "").strip()
|
||||
if value:
|
||||
parts.append(
|
||||
f"{StewardPlannerService._business_field_label(key)}为"
|
||||
f"{StewardPlannerService._format_business_field_value(key, value)}"
|
||||
)
|
||||
return "、".join(parts) or "部分业务要素"
|
||||
|
||||
@staticmethod
|
||||
def _business_field_label(key: str) -> str:
|
||||
return BUSINESS_FIELD_LABELS.get(str(key or "").strip(), str(key or "").strip())
|
||||
|
||||
@staticmethod
|
||||
def _format_business_field_value(key: str, value: str) -> str:
|
||||
cleaned = str(value or "").strip()
|
||||
if key == "expense_type":
|
||||
return EXPENSE_TYPE_LABELS.get(cleaned, cleaned)
|
||||
if key == "transport_mode":
|
||||
return TRANSPORT_MODE_LABELS.get(cleaned, cleaned)
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _summarize_attachment_correlation(
|
||||
|
||||
197
server/src/app/services/steward_runtime_decision_agent.py
Normal file
197
server/src/app/services/steward_runtime_decision_agent.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.steward import (
|
||||
StewardRuntimeDecisionRequest,
|
||||
StewardRuntimeDecisionResponse,
|
||||
)
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
|
||||
STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision"
|
||||
|
||||
RUNTIME_NEXT_ACTIONS = {
|
||||
"plan_new_tasks",
|
||||
"submit_current_application",
|
||||
"continue_next_task",
|
||||
"fill_current_slot",
|
||||
"ask_user",
|
||||
"cancel_current_action",
|
||||
"no_op",
|
||||
}
|
||||
|
||||
|
||||
class StewardRuntimeDecisionAgent:
|
||||
"""用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。"""
|
||||
|
||||
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
|
||||
self.runtime_chat_service = runtime_chat_service
|
||||
|
||||
def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse:
|
||||
normalized_request = self._normalize_request(request)
|
||||
result = self.runtime_chat_service.complete_with_tool_call(
|
||||
self._build_messages(normalized_request),
|
||||
tools=[self._build_tool_schema()],
|
||||
tool_choice={
|
||||
"type": "function",
|
||||
"function": {"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME},
|
||||
},
|
||||
max_tokens=1000,
|
||||
temperature=0.05,
|
||||
timeout_seconds=30,
|
||||
max_attempts=1,
|
||||
)
|
||||
traces = result.calls_as_dicts()
|
||||
if result.tool_call is not None and result.tool_call.name == STEWARD_RUNTIME_DECISION_FUNCTION_NAME:
|
||||
response = self._build_response_from_model_payload(result.tool_call.arguments, normalized_request, traces)
|
||||
if response is not None:
|
||||
return response
|
||||
return self._build_rule_fallback(normalized_request, traces)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest:
|
||||
return StewardRuntimeDecisionRequest(
|
||||
user_message=str(request.user_message or "").strip(),
|
||||
session_type=str(request.session_type or "steward").strip() or "steward",
|
||||
runtime_state=request.runtime_state if isinstance(request.runtime_state, dict) else {},
|
||||
context_json=request.context_json if isinstance(request.context_json, dict) else {},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]:
|
||||
payload = {
|
||||
"user_message": request.user_message,
|
||||
"session_type": request.session_type,
|
||||
"runtime_state": request.runtime_state,
|
||||
"context_json": request.context_json,
|
||||
}
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是 X-Financial 小财管家的运行时决策智能体。"
|
||||
"你必须基于 runtime_state 判断用户当前输入对应哪个等待动作,不能把每次输入都当成全新任务。"
|
||||
"runtime_state 会包含 current_task、remaining_tasks、completed_tasks、pending_application、"
|
||||
"pending_steward_action、waiting_for、recent_structured_result 等上下文。"
|
||||
"如果用户是在确认当前申请核对表无误,应返回 submit_current_application;"
|
||||
"如果用户是在确认继续下一项,应返回 continue_next_task;"
|
||||
"如果用户补充了当前等待字段,应返回 fill_current_slot;"
|
||||
"如果当前结构化结果仍缺字段,应返回 ask_user;"
|
||||
"只有当前没有可匹配上下文,且用户输入明显是新财务事项时,才返回 plan_new_tasks。"
|
||||
"提交、入库、绑定、审批等高风险动作只返回结构化意图,实际执行由系统安全校验完成。"
|
||||
"rationale 和 response_text 必须面向用户,不暴露内部推理链。"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_tool_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": STEWARD_RUNTIME_DECISION_FUNCTION_NAME,
|
||||
"description": "提交小财管家基于运行时上下文的下一步动作决策。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"next_action": {
|
||||
"type": "string",
|
||||
"enum": sorted(RUNTIME_NEXT_ACTIONS),
|
||||
},
|
||||
"target_task_id": {"type": "string"},
|
||||
"target_message_id": {"type": "string"},
|
||||
"field_key": {"type": "string"},
|
||||
"field_value": {"type": "string"},
|
||||
"confirmation_required": {"type": "boolean"},
|
||||
"question": {"type": "string"},
|
||||
"response_text": {"type": "string"},
|
||||
"rationale": {"type": "string"},
|
||||
},
|
||||
"required": [
|
||||
"next_action",
|
||||
"target_task_id",
|
||||
"target_message_id",
|
||||
"field_key",
|
||||
"field_value",
|
||||
"confirmation_required",
|
||||
"question",
|
||||
"response_text",
|
||||
"rationale",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _build_response_from_model_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
request: StewardRuntimeDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardRuntimeDecisionResponse | None:
|
||||
next_action = str(payload.get("next_action") or "").strip()
|
||||
if next_action not in RUNTIME_NEXT_ACTIONS:
|
||||
return None
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="llm_function_call",
|
||||
next_action=next_action, # type: ignore[arg-type]
|
||||
target_task_id=self._clean_text(payload.get("target_task_id")),
|
||||
target_message_id=self._clean_text(payload.get("target_message_id")),
|
||||
field_key=self._clean_text(payload.get("field_key")),
|
||||
field_value=self._clean_text(payload.get("field_value")),
|
||||
confirmation_required=bool(payload.get("confirmation_required")),
|
||||
question=self._clean_text(payload.get("question")),
|
||||
response_text=self._clean_text(payload.get("response_text")),
|
||||
rationale=self._clean_text(payload.get("rationale")),
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
def _build_rule_fallback(
|
||||
self,
|
||||
request: StewardRuntimeDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardRuntimeDecisionResponse:
|
||||
state = request.runtime_state
|
||||
pending_application = state.get("pending_application") if isinstance(state.get("pending_application"), dict) else {}
|
||||
pending_steward_action = state.get("pending_steward_action") if isinstance(state.get("pending_steward_action"), dict) else {}
|
||||
waiting_for = str(state.get("waiting_for") or "").strip()
|
||||
message = request.user_message.replace(" ", "")
|
||||
confirmation_text = message in {"确认", "确定", "无误", "确认提交", "可以提交", "提交", "没问题"}
|
||||
if confirmation_text and pending_application.get("ready_to_submit"):
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="submit_current_application",
|
||||
target_message_id=str(pending_application.get("message_id") or ""),
|
||||
target_task_id=str(pending_application.get("task_id") or ""),
|
||||
rationale="模型运行时决策暂不可用,我先按当前待提交申请单上下文处理你的确认。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
if confirmation_text and pending_steward_action:
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="continue_next_task",
|
||||
target_message_id=str(pending_steward_action.get("message_id") or ""),
|
||||
target_task_id=str(pending_steward_action.get("target_task_id") or ""),
|
||||
rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
if waiting_for:
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="ask_user",
|
||||
question="我需要先确认当前等待事项,请补充或选择当前问题对应的信息。",
|
||||
rationale="模型运行时决策暂不可用,当前仍存在等待用户补充的信息。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="plan_new_tasks",
|
||||
rationale="模型运行时决策暂不可用,当前没有可安全匹配的等待动作,回到任务规划。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
301
server/src/app/services/steward_slot_decision_agent.py
Normal file
301
server/src/app/services/steward_slot_decision_agent.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.steward import (
|
||||
StewardSlotDecisionRequest,
|
||||
StewardSlotDecisionResponse,
|
||||
StewardSlotOption,
|
||||
)
|
||||
from app.services.ontology_field_registry import normalize_ontology_form_values
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER, BUSINESS_CANONICAL_FIELDS
|
||||
|
||||
|
||||
STEWARD_SLOT_DECISION_FUNCTION_NAME = "submit_steward_slot_decision"
|
||||
|
||||
|
||||
FIELD_CATALOG: dict[str, dict[str, str]] = {
|
||||
"expense_type": {"label": "费用类型", "description": "申请或报销所属费用场景,如差旅、交通、住宿、业务招待。"},
|
||||
"time_range": {"label": "时间", "description": "申请时为出差起止日期,报销时为费用发生日期。"},
|
||||
"location": {"label": "地点", "description": "出差目的地、费用发生地或业务活动地点。"},
|
||||
"reason": {"label": "事由", "description": "出差、报销或业务活动的业务原因。"},
|
||||
"amount": {"label": "金额", "description": "报销时为实际金额;申请时金额可由系统估算,不应默认要求用户填写。"},
|
||||
"transport_mode": {"label": "出行方式", "description": "差旅申请交通费用测算所需字段,由用户明确选择或表达。"},
|
||||
"attachments": {"label": "附件/凭证", "description": "发票、行程单、付款截图或其他证明材料。"},
|
||||
"customer_name": {"label": "客户或项目对象", "description": "业务招待、客户拜访或项目支撑涉及的对象。"},
|
||||
"merchant_name": {"label": "商户/开票方", "description": "报销票据上的商户或开票方。"},
|
||||
"department_name": {"label": "所属部门", "description": "申请人或费用归属部门。"},
|
||||
"employee_name": {"label": "申请人", "description": "发起申请或报销的员工。"},
|
||||
"employee_no": {"label": "员工编号", "description": "公司内部员工编号。"},
|
||||
}
|
||||
|
||||
APPLICATION_NON_BLOCKING_FIELDS = {"amount", "attachments", "employee_no", "department_name", "employee_name"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StewardSlotDecisionAgentResult:
|
||||
payload: dict[str, Any]
|
||||
model_call_traces: list[dict[str, Any]]
|
||||
|
||||
|
||||
class StewardSlotDecisionAgent:
|
||||
"""用大模型 function calling 判断当前任务缺什么,以及下一步是否应先追问。"""
|
||||
|
||||
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
|
||||
self.runtime_chat_service = runtime_chat_service
|
||||
|
||||
def decide(self, request: StewardSlotDecisionRequest) -> StewardSlotDecisionResponse:
|
||||
normalized_request = self._normalize_request(request)
|
||||
result = self.runtime_chat_service.complete_with_tool_call(
|
||||
self._build_messages(normalized_request),
|
||||
tools=[self._build_tool_schema()],
|
||||
tool_choice={
|
||||
"type": "function",
|
||||
"function": {"name": STEWARD_SLOT_DECISION_FUNCTION_NAME},
|
||||
},
|
||||
max_tokens=1200,
|
||||
temperature=0.05,
|
||||
timeout_seconds=30,
|
||||
max_attempts=1,
|
||||
)
|
||||
if result.tool_call is not None and result.tool_call.name == STEWARD_SLOT_DECISION_FUNCTION_NAME:
|
||||
response = self._build_response_from_model_payload(
|
||||
result.tool_call.arguments,
|
||||
normalized_request,
|
||||
result.calls_as_dicts(),
|
||||
)
|
||||
if response is not None:
|
||||
return response
|
||||
return self._build_rule_fallback(normalized_request, result.calls_as_dicts())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_request(request: StewardSlotDecisionRequest) -> StewardSlotDecisionRequest:
|
||||
normalized_fields = {
|
||||
key: value
|
||||
for key, value in normalize_ontology_form_values(request.ontology_fields).items()
|
||||
if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip()
|
||||
}
|
||||
missing_fields: list[str] = []
|
||||
for item in request.missing_fields:
|
||||
key = str(item or "").strip()
|
||||
if request.task_type == "expense_application" and key in APPLICATION_NON_BLOCKING_FIELDS:
|
||||
continue
|
||||
if key in BUSINESS_CANONICAL_FIELDS and key not in missing_fields and not normalized_fields.get(key):
|
||||
missing_fields.append(key)
|
||||
return StewardSlotDecisionRequest(
|
||||
task_type=request.task_type,
|
||||
user_message=str(request.user_message or "").strip(),
|
||||
ontology_fields=normalized_fields,
|
||||
missing_fields=missing_fields,
|
||||
task_context=request.task_context if isinstance(request.task_context, dict) else {},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_messages(request: StewardSlotDecisionRequest) -> list[dict[str, Any]]:
|
||||
context_payload = {
|
||||
"task_type": request.task_type,
|
||||
"user_message": request.user_message,
|
||||
"ontology_fields": request.ontology_fields,
|
||||
"missing_fields_from_intent_agent": request.missing_fields,
|
||||
"field_catalog": {
|
||||
key: FIELD_CATALOG[key]
|
||||
for key in BUSINESS_CANONICAL_FIELD_ORDER
|
||||
if key in FIELD_CATALOG
|
||||
},
|
||||
"task_context": request.task_context,
|
||||
}
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是 X-Financial 小财管家的任务字段决策智能体。"
|
||||
"你必须通过 function calling 返回下一步动作。"
|
||||
"你的任务不是关键词匹配,而是结合用户意图、当前任务类型、canonical ontology 字段、"
|
||||
"上游意图识别给出的缺失字段和字段目录,判断现在应先追问用户,还是可以展示核对结果。"
|
||||
"所有 required_fields 和 missing_fields 只能使用 field_catalog 中的 canonical 字段。"
|
||||
"如果字段是内部提示、示例、系统指令或可选项,不能当作用户已经提供。"
|
||||
"费用申请场景中 amount 可由系统估算,不应作为用户必须手填字段。"
|
||||
"费用申请生成核对表阶段,attachments 不阻塞生成,可在报销或归档阶段补充;"
|
||||
"employee_no、department_name、employee_name 属于系统用户档案字段,必须从上下文读取,不能向用户追问。"
|
||||
"差旅申请通常只有 transport_mode 这类会影响费用测算的字段才需要先追问。"
|
||||
"如果缺失字段会影响后续测算、入库、附件归集或合规判断,应返回 ask_user;"
|
||||
"如果信息足以生成可核对但未提交的结果,应返回 render_preview。"
|
||||
"question 和 rationale 必须是面向用户的业务说明,不暴露内部推理链。"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(context_payload, ensure_ascii=False),
|
||||
},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_tool_schema() -> dict[str, Any]:
|
||||
canonical_fields = list(BUSINESS_CANONICAL_FIELD_ORDER)
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": STEWARD_SLOT_DECISION_FUNCTION_NAME,
|
||||
"description": "提交小财管家当前任务的字段缺口和下一步动作决策。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"next_action": {
|
||||
"type": "string",
|
||||
"enum": ["ask_user", "render_preview"],
|
||||
},
|
||||
"required_fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": canonical_fields},
|
||||
},
|
||||
"missing_fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": canonical_fields},
|
||||
},
|
||||
"question": {"type": "string"},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
"field_key": {"type": "string", "enum": canonical_fields},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["label", "value", "field_key"],
|
||||
},
|
||||
},
|
||||
"rationale": {"type": "string"},
|
||||
},
|
||||
"required": ["next_action", "required_fields", "missing_fields", "question", "options", "rationale"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _build_response_from_model_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
request: StewardSlotDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardSlotDecisionResponse | None:
|
||||
next_action = str(payload.get("next_action") or "").strip()
|
||||
if next_action not in {"ask_user", "render_preview"}:
|
||||
return None
|
||||
required_fields = self._sanitize_fields(payload.get("required_fields"))
|
||||
missing_fields = self._sanitize_fields(payload.get("missing_fields"))
|
||||
required_fields = self._filter_blocking_fields(required_fields, request.task_type)
|
||||
missing_fields = self._filter_blocking_fields(missing_fields, request.task_type)
|
||||
missing_fields = [
|
||||
key
|
||||
for key in missing_fields
|
||||
if key in required_fields or key in request.missing_fields
|
||||
]
|
||||
if next_action == "ask_user" and not missing_fields:
|
||||
missing_fields = list(request.missing_fields)
|
||||
if next_action == "ask_user" and not missing_fields:
|
||||
next_action = "render_preview"
|
||||
options = []
|
||||
question = ""
|
||||
rationale = "当前申请信息足以先生成核对结果;附件和员工编号不应作为用户补填项阻塞申请预览。"
|
||||
else:
|
||||
options = self._sanitize_options(payload.get("options"), missing_fields)
|
||||
question = self._clean_text(payload.get("question"))
|
||||
rationale = self._clean_text(payload.get("rationale"))
|
||||
return StewardSlotDecisionResponse(
|
||||
decision_source="llm_function_call",
|
||||
next_action=next_action, # type: ignore[arg-type]
|
||||
required_fields=required_fields,
|
||||
missing_fields=missing_fields,
|
||||
question=question,
|
||||
options=options,
|
||||
rationale=rationale,
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _filter_blocking_fields(fields: list[str], task_type: str) -> list[str]:
|
||||
if task_type != "expense_application":
|
||||
return fields
|
||||
return [field for field in fields if field not in APPLICATION_NON_BLOCKING_FIELDS]
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_fields(raw_fields: Any) -> list[str]:
|
||||
fields: list[str] = []
|
||||
if not isinstance(raw_fields, list):
|
||||
return fields
|
||||
for item in raw_fields:
|
||||
key = str(item or "").strip()
|
||||
if key in BUSINESS_CANONICAL_FIELDS and key not in fields:
|
||||
fields.append(key)
|
||||
return fields
|
||||
|
||||
def _sanitize_options(self, raw_options: Any, missing_fields: list[str]) -> list[StewardSlotOption]:
|
||||
options: list[StewardSlotOption] = []
|
||||
if isinstance(raw_options, list):
|
||||
for item in raw_options:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
field_key = str(item.get("field_key") or "").strip()
|
||||
label = self._clean_text(item.get("label"))
|
||||
value = self._clean_text(item.get("value")) or label
|
||||
if not field_key or field_key not in BUSINESS_CANONICAL_FIELDS or not label or not value:
|
||||
continue
|
||||
options.append(
|
||||
StewardSlotOption(
|
||||
field_key=field_key,
|
||||
label=label,
|
||||
value=value,
|
||||
description=self._clean_text(item.get("description")),
|
||||
)
|
||||
)
|
||||
if not options and missing_fields and missing_fields[0] == "transport_mode":
|
||||
options = [
|
||||
StewardSlotOption(field_key="transport_mode", label="火车", value="火车", description="选择火车或高铁出行。"),
|
||||
StewardSlotOption(field_key="transport_mode", label="飞机", value="飞机", description="选择飞机出行。"),
|
||||
StewardSlotOption(field_key="transport_mode", label="轮船", value="轮船", description="选择轮船出行。"),
|
||||
]
|
||||
return options[:6]
|
||||
|
||||
def _build_rule_fallback(
|
||||
self,
|
||||
request: StewardSlotDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardSlotDecisionResponse:
|
||||
missing_fields = list(request.missing_fields)
|
||||
if missing_fields:
|
||||
field = missing_fields[0]
|
||||
return StewardSlotDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="ask_user",
|
||||
required_fields=list(dict.fromkeys([*request.ontology_fields.keys(), *missing_fields])),
|
||||
missing_fields=missing_fields,
|
||||
question=self._build_fallback_question(field),
|
||||
options=self._sanitize_options([], [field]),
|
||||
rationale="模型字段决策暂不可用,我先按上游意图识别给出的本体缺口向你确认。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
return StewardSlotDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="render_preview",
|
||||
required_fields=list(request.ontology_fields.keys()),
|
||||
missing_fields=[],
|
||||
question="",
|
||||
options=[],
|
||||
rationale="当前任务没有上游标记的关键字段缺口,可以先生成核对结果供你确认。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_fallback_question(field: str) -> str:
|
||||
label = FIELD_CATALOG.get(field, {}).get("label") or field
|
||||
if field == "transport_mode":
|
||||
return "请问你这次打算怎么出行?可以选择火车、飞机或轮船。"
|
||||
return f"当前还缺少{label},请先补充后我再继续处理。"
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.agent_feedback import AgentOperationFeedback
|
||||
@@ -17,6 +18,7 @@ SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"}
|
||||
FAILED_STATUSES = {"failed", "failure", "error", "errored"}
|
||||
BLOCKED_STATUSES = {"blocked", "forbidden", "rejected"}
|
||||
RUNNING_STATUSES = {"running", "pending"}
|
||||
TOKEN_ESTIMATE_FALLBACK_TOTAL = 600
|
||||
|
||||
TOOL_BUCKETS = [
|
||||
{
|
||||
@@ -58,6 +60,32 @@ TOOL_BUCKETS = [
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _DashboardToolCall:
|
||||
id: str
|
||||
run_id: str
|
||||
tool_type: str | None
|
||||
tool_name: str | None
|
||||
status: str | None
|
||||
duration_ms: int | None
|
||||
error_message: str | None
|
||||
created_at: datetime | None
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
total_tokens: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _DashboardRun:
|
||||
run_id: str
|
||||
agent: str | None
|
||||
source: str | None
|
||||
user_id: str | None
|
||||
status: str | None
|
||||
started_at: datetime
|
||||
tool_calls: list[_DashboardToolCall] = field(default_factory=list)
|
||||
|
||||
|
||||
class SystemDashboardService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
@@ -116,16 +144,73 @@ class SystemDashboardService:
|
||||
def _ensure_storage_ready(self) -> None:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
|
||||
def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[AgentRun]:
|
||||
def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[_DashboardRun]:
|
||||
stmt = (
|
||||
select(AgentRun)
|
||||
.options(selectinload(AgentRun.tool_calls))
|
||||
select(
|
||||
AgentRun.run_id.label("run_id"),
|
||||
AgentRun.agent.label("agent"),
|
||||
AgentRun.source.label("source"),
|
||||
AgentRun.user_id.label("user_id"),
|
||||
AgentRun.status.label("run_status"),
|
||||
AgentRun.started_at.label("started_at"),
|
||||
AgentToolCall.id.label("tool_id"),
|
||||
AgentToolCall.run_id.label("tool_run_id"),
|
||||
AgentToolCall.tool_type.label("tool_type"),
|
||||
AgentToolCall.tool_name.label("tool_name"),
|
||||
AgentToolCall.status.label("tool_status"),
|
||||
AgentToolCall.duration_ms.label("duration_ms"),
|
||||
AgentToolCall.error_message.label("tool_error_message"),
|
||||
AgentToolCall.created_at.label("tool_created_at"),
|
||||
AgentToolCall.request_json["input_tokens"].as_integer().label("request_input_tokens"),
|
||||
AgentToolCall.request_json["prompt_tokens"].as_integer().label("request_prompt_tokens"),
|
||||
AgentToolCall.request_json["total_tokens"].as_integer().label("request_total_tokens"),
|
||||
AgentToolCall.response_json["input_tokens"].as_integer().label("response_input_tokens"),
|
||||
AgentToolCall.response_json["output_tokens"].as_integer().label("response_output_tokens"),
|
||||
AgentToolCall.response_json["completion_tokens"].as_integer().label("response_completion_tokens"),
|
||||
AgentToolCall.response_json["total_tokens"].as_integer().label("response_total_tokens"),
|
||||
)
|
||||
.outerjoin(AgentToolCall, AgentToolCall.run_id == AgentRun.run_id)
|
||||
.where(AgentRun.started_at >= start)
|
||||
.order_by(AgentRun.started_at.asc())
|
||||
.order_by(AgentRun.started_at.asc(), AgentToolCall.created_at.asc())
|
||||
)
|
||||
if before is not None:
|
||||
stmt = stmt.where(AgentRun.started_at < before)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
runs: dict[str, _DashboardRun] = {}
|
||||
for row in self.db.execute(stmt).all():
|
||||
run = runs.get(row.run_id)
|
||||
if run is None:
|
||||
run = _DashboardRun(
|
||||
run_id=row.run_id,
|
||||
agent=row.agent,
|
||||
source=row.source,
|
||||
user_id=row.user_id,
|
||||
status=row.run_status,
|
||||
started_at=row.started_at,
|
||||
)
|
||||
runs[row.run_id] = run
|
||||
|
||||
if row.tool_id is None:
|
||||
continue
|
||||
|
||||
input_tokens, output_tokens, total_tokens = self._token_counts_from_row(row)
|
||||
run.tool_calls.append(
|
||||
_DashboardToolCall(
|
||||
id=row.tool_id,
|
||||
run_id=row.tool_run_id or row.run_id,
|
||||
tool_type=row.tool_type,
|
||||
tool_name=row.tool_name,
|
||||
status=row.tool_status,
|
||||
duration_ms=row.duration_ms,
|
||||
error_message=row.tool_error_message,
|
||||
created_at=row.tool_created_at,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
)
|
||||
|
||||
return list(runs.values())
|
||||
|
||||
def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]:
|
||||
stmt = (
|
||||
@@ -143,7 +228,11 @@ class SystemDashboardService:
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _agent_daily_ratio(self, labels: list[str], tool_calls: list[AgentToolCall]) -> dict[str, Any]:
|
||||
def _agent_daily_ratio(
|
||||
self,
|
||||
labels: list[str],
|
||||
tool_calls: list[_DashboardToolCall],
|
||||
) -> dict[str, Any]:
|
||||
counts = {bucket["key"]: [0 for _ in labels] for bucket in TOOL_BUCKETS}
|
||||
label_index = {label: index for index, label in enumerate(labels)}
|
||||
for tool in tool_calls:
|
||||
@@ -231,7 +320,7 @@ class SystemDashboardService:
|
||||
for index, (user_id, value) in enumerate(rows)
|
||||
]
|
||||
|
||||
def _accuracy_comparison(self, tool_calls: list[AgentToolCall]) -> dict[str, Any]:
|
||||
def _accuracy_comparison(self, tool_calls: list[_DashboardToolCall]) -> dict[str, Any]:
|
||||
correct = {bucket["name"]: 0 for bucket in TOOL_BUCKETS}
|
||||
wrong = {bucket["name"]: 0 for bucket in TOOL_BUCKETS}
|
||||
for tool in tool_calls:
|
||||
@@ -297,7 +386,7 @@ class SystemDashboardService:
|
||||
|
||||
def _tool_detail_rows(
|
||||
self,
|
||||
tool_calls: list[AgentToolCall],
|
||||
tool_calls: list[_DashboardToolCall],
|
||||
records: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
token_by_tool = {str(record["tool_id"]): int(record["total"]) for record in records}
|
||||
@@ -331,14 +420,15 @@ class SystemDashboardService:
|
||||
)
|
||||
return rows
|
||||
|
||||
def _build_token_records(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
def _build_token_records(self, runs: list[_DashboardRun]) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for run in runs:
|
||||
for tool in run.tool_calls:
|
||||
input_tokens, output_tokens = self._extract_tool_tokens(tool)
|
||||
total = input_tokens + output_tokens
|
||||
input_tokens = int(tool.input_tokens or 0)
|
||||
output_tokens = int(tool.output_tokens or 0)
|
||||
total = int(tool.total_tokens or input_tokens + output_tokens)
|
||||
if total <= 0:
|
||||
total = self._estimate_tool_tokens(tool)
|
||||
total = self._estimate_tool_tokens(tool) if hasattr(tool, "request_json") else 0
|
||||
input_tokens = int(total * 0.62)
|
||||
output_tokens = total - input_tokens
|
||||
records.append(
|
||||
@@ -353,6 +443,42 @@ class SystemDashboardService:
|
||||
)
|
||||
return records
|
||||
|
||||
def _token_counts_from_row(self, row: Any) -> tuple[int, int, int]:
|
||||
input_tokens = self._first_positive_int(
|
||||
row.request_input_tokens,
|
||||
row.request_prompt_tokens,
|
||||
row.response_input_tokens,
|
||||
)
|
||||
output_tokens = self._first_positive_int(
|
||||
row.response_output_tokens,
|
||||
row.response_completion_tokens,
|
||||
)
|
||||
total_tokens = self._first_positive_int(
|
||||
row.request_total_tokens,
|
||||
row.response_total_tokens,
|
||||
)
|
||||
|
||||
if total_tokens and not input_tokens and not output_tokens:
|
||||
input_tokens = int(total_tokens * 0.62)
|
||||
output_tokens = total_tokens - input_tokens
|
||||
|
||||
if input_tokens + output_tokens <= 0 and total_tokens <= 0:
|
||||
total_tokens = TOKEN_ESTIMATE_FALLBACK_TOTAL
|
||||
input_tokens = int(total_tokens * 0.62)
|
||||
output_tokens = total_tokens - input_tokens
|
||||
|
||||
if total_tokens <= 0:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
return input_tokens, output_tokens, total_tokens
|
||||
|
||||
@staticmethod
|
||||
def _first_positive_int(*values: Any) -> int:
|
||||
for value in values:
|
||||
if isinstance(value, (int, float)) and value > 0:
|
||||
return int(value)
|
||||
return 0
|
||||
|
||||
def _extract_tool_tokens(self, tool: AgentToolCall) -> tuple[int, int]:
|
||||
payload = {
|
||||
"request": tool.request_json or {},
|
||||
@@ -392,7 +518,7 @@ class SystemDashboardService:
|
||||
return found
|
||||
return 0
|
||||
|
||||
def _tool_bucket(self, tool: AgentToolCall) -> dict[str, Any]:
|
||||
def _tool_bucket(self, tool: AgentToolCall | _DashboardToolCall) -> dict[str, Any]:
|
||||
text = f"{tool.tool_type or ''} {tool.tool_name or ''}".lower()
|
||||
if self._is_failed(tool.status) and ("timeout" in text or tool.error_message):
|
||||
return TOOL_BUCKETS[-1]
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.services.document_numbering import (
|
||||
)
|
||||
from app.services.user_agent_application_dates import (
|
||||
expand_application_time_with_days,
|
||||
resolve_application_date_range,
|
||||
resolve_application_days_from_time_range,
|
||||
)
|
||||
from app.services.user_agent_application_locations import normalize_application_location
|
||||
@@ -1143,8 +1144,19 @@ class UserAgentApplicationMixin:
|
||||
facts: dict[str, str],
|
||||
occurred_at: datetime,
|
||||
) -> bool:
|
||||
current_range = resolve_application_date_range(facts.get("time", ""))
|
||||
current_time = cls._normalize_application_time_identity(facts.get("time"))
|
||||
existing_detail = cls._extract_application_detail_from_claim(claim)
|
||||
existing_range = resolve_application_date_range(existing_detail.get("time"))
|
||||
if existing_range is None and claim.occurred_at is not None:
|
||||
existing_day = claim.occurred_at.date()
|
||||
existing_range = (existing_day, existing_day)
|
||||
if current_range is None and occurred_at is not None:
|
||||
current_day = occurred_at.date()
|
||||
current_range = (current_day, current_day)
|
||||
if current_range is not None and existing_range is not None:
|
||||
return current_range[0] <= existing_range[1] and existing_range[0] <= current_range[1]
|
||||
|
||||
existing_time = cls._normalize_application_time_identity(existing_detail.get("time"))
|
||||
if current_time and existing_time:
|
||||
return current_time == existing_time
|
||||
|
||||
@@ -45,7 +45,7 @@ def resolve_application_days_count(days_text: str) -> int:
|
||||
|
||||
def resolve_application_days_from_time_range(time_text: str) -> int:
|
||||
matches = re.findall(
|
||||
r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?",
|
||||
r"20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?",
|
||||
str(time_text or ""),
|
||||
)
|
||||
if len(matches) < 2:
|
||||
@@ -57,10 +57,29 @@ def resolve_application_days_from_time_range(time_text: str) -> int:
|
||||
return (end_date - start_date).days + 1
|
||||
|
||||
|
||||
def resolve_application_date_range(time_text: str) -> tuple[date, date] | None:
|
||||
matches = re.findall(
|
||||
r"20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?",
|
||||
str(time_text or ""),
|
||||
)
|
||||
dates = [
|
||||
parsed
|
||||
for parsed in (_parse_application_date(value) for value in matches)
|
||||
if parsed is not None
|
||||
]
|
||||
if not dates:
|
||||
return None
|
||||
start_date = dates[0]
|
||||
end_date = dates[-1] if len(dates) > 1 else start_date
|
||||
if end_date < start_date:
|
||||
start_date, end_date = end_date, start_date
|
||||
return start_date, end_date
|
||||
|
||||
|
||||
def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None:
|
||||
if time_text:
|
||||
match = re.search(
|
||||
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
|
||||
r"(?P<date>20\d{2}(?:[-/.年])\d{1,2}(?:[-/.月])\d{1,2}日?)",
|
||||
time_text,
|
||||
)
|
||||
if match:
|
||||
|
||||
@@ -36,10 +36,13 @@ from app.services import agent_foundation as agent_foundation_module
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
)
|
||||
from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.audit import AuditLogService
|
||||
@@ -62,6 +65,7 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
|
||||
for file_name in (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
):
|
||||
source_path = real_finance_rules / file_name
|
||||
if source_path.exists():
|
||||
@@ -181,8 +185,10 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||
communication_rule = next(
|
||||
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
|
||||
)
|
||||
preapproval_rule = next(item for item in rules if item.code == COMPANY_PREAPPROVAL_RULE_CODE)
|
||||
travel_config = travel_rule.config_json or {}
|
||||
communication_config = communication_rule.config_json or {}
|
||||
preapproval_config = preapproval_rule.config_json or {}
|
||||
|
||||
assert travel_rule.scenario_json == ["差旅费"]
|
||||
assert travel_config["scenario_category"] == "差旅费"
|
||||
@@ -190,6 +196,12 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||
assert communication_rule.scenario_json == ["通信费"]
|
||||
assert communication_config["scenario_category"] == "通信费"
|
||||
assert communication_config["ai_review_category"] == "通信费"
|
||||
assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
|
||||
assert preapproval_config["tag"] == "财务规则"
|
||||
assert preapproval_config["finance_rule_code"] == "expense.preapproval.policy"
|
||||
assert preapproval_config["finance_rule_sheet"] == "费用申请审批规则"
|
||||
assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"]
|
||||
assert preapproval_config["rule_document"]["file_name"] == COMPANY_PREAPPROVAL_RULE_FILENAME
|
||||
|
||||
|
||||
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
|
||||
|
||||
@@ -106,6 +106,68 @@ def test_agent_run_service_updates_existing_tool_call() -> None:
|
||||
assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"}
|
||||
|
||||
|
||||
def test_agent_run_list_uses_lightweight_preview_and_detail_keeps_full_payload() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
run = service.create_run(
|
||||
agent=AgentName.HERMES.value,
|
||||
source=AgentRunSource.SCHEDULE.value,
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
ontology_json={
|
||||
"scenario": "knowledge",
|
||||
"intent": "sync",
|
||||
"parse_strategy": "rule_fallback",
|
||||
"model_invocation_summary": {"tokens": 999},
|
||||
},
|
||||
route_json={
|
||||
"job_type": "knowledge_index_sync",
|
||||
"phase": "indexing",
|
||||
"progress": {
|
||||
"percent": 50,
|
||||
"total_documents": 2,
|
||||
"completed_documents": 1,
|
||||
"documents": [{"id": "doc-1", "text": "x" * 2000}],
|
||||
},
|
||||
"knowledge_ingest": {"documents": [{"id": "doc-1", "text": "x" * 2000}]},
|
||||
},
|
||||
)
|
||||
service.record_tool_call(
|
||||
run_id=run.run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="lightrag.index_documents",
|
||||
request_json={"prompt": "x" * 2000},
|
||||
response_json={"documents": [{"id": "doc-1", "text": "x" * 2000}]},
|
||||
status="succeeded",
|
||||
duration_ms=123,
|
||||
)
|
||||
|
||||
listed = next(item for item in service.list_runs(limit=20) if item.run_id == run.run_id)
|
||||
detail = service.get_run(run.run_id)
|
||||
|
||||
assert listed.ontology_json == {
|
||||
"scenario": "knowledge",
|
||||
"intent": "sync",
|
||||
"parse_strategy": "rule_fallback",
|
||||
}
|
||||
assert listed.route_json["job_type"] == "knowledge_index_sync"
|
||||
assert listed.route_json["phase"] == "indexing"
|
||||
assert listed.route_json["progress"] == {
|
||||
"percent": 50,
|
||||
"total_documents": 2,
|
||||
"completed_documents": 1,
|
||||
}
|
||||
assert "knowledge_ingest" not in listed.route_json
|
||||
assert len(listed.tool_calls) == 1
|
||||
assert listed.tool_calls[0].tool_name == "lightrag.index_documents"
|
||||
assert listed.tool_calls[0].request_json == {}
|
||||
assert listed.tool_calls[0].response_json == {}
|
||||
|
||||
assert detail is not None
|
||||
assert "knowledge_ingest" in detail.route_json
|
||||
assert detail.tool_calls[0].request_json["prompt"]
|
||||
assert detail.tool_calls[0].response_json["documents"]
|
||||
|
||||
|
||||
def test_agent_run_service_summarizes_model_and_tool_failures() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
@@ -147,7 +147,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
@@ -160,7 +160,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
|
||||
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
|
||||
and flag.get("route_decision", {}).get("requires_budget_review") is False
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
@@ -218,7 +218,7 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
@@ -285,7 +285,7 @@ def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
route_flag = [
|
||||
flag
|
||||
for flag in approved.risk_flags_json
|
||||
|
||||
@@ -319,6 +319,44 @@ def test_expense_application_pre_review_runs_stage_rules(tmp_path, monkeypatch)
|
||||
assert ai_pre_review["business_stage"] == "expense_application"
|
||||
|
||||
|
||||
def test_preapproval_amount_rules_run_from_rule_library() -> None:
|
||||
with build_session() as db:
|
||||
claim = _build_claim(claim_no="RE-PREAPPROVAL-MEAL", expense_type="meal")
|
||||
claim.amount = Decimal("501.00")
|
||||
|
||||
flags = ExpenseClaimService(db).evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="reimbursement",
|
||||
)["flags"]
|
||||
|
||||
meal_flags = [
|
||||
flag
|
||||
for flag in flags
|
||||
if isinstance(flag, dict)
|
||||
and flag.get("rule_code") == "risk.application.meal_high_value_without_preapproval"
|
||||
]
|
||||
assert len(meal_flags) == 1
|
||||
assert meal_flags[0]["finance_rule_code"] == "expense.preapproval.policy"
|
||||
assert "500" in meal_flags[0]["message"]
|
||||
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "application_link",
|
||||
"application_claim_id": "application-preapproval-ok",
|
||||
"application_claim_no": "AP-202606-OK",
|
||||
}
|
||||
]
|
||||
flags = ExpenseClaimService(db).evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="reimbursement",
|
||||
)["flags"]
|
||||
assert all(
|
||||
flag.get("rule_code") != "risk.application.meal_high_value_without_preapproval"
|
||||
for flag in flags
|
||||
if isinstance(flag, dict)
|
||||
)
|
||||
|
||||
|
||||
def test_reimbursement_item_sync_persists_rule_center_risk_preview(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.config import get_settings
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
@@ -31,11 +32,14 @@ from app.services.expense_claim_attachment_storage import ExpenseClaimAttachment
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
)
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.ocr import OcrService
|
||||
from app.services.receipt_folder import ReceiptFolderService
|
||||
|
||||
|
||||
def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
|
||||
@@ -3907,6 +3911,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
|
||||
approval_stage="审批完成",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525121000-ARCHIVED",
|
||||
employee_name="戊",
|
||||
department_name="E部",
|
||||
project_code="PRJ-E",
|
||||
expense_type="travel_application",
|
||||
reason="E 申请",
|
||||
location="广州",
|
||||
amount=Decimal("600.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage=APPLICATION_ARCHIVE_STAGE,
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525123000-HGFEDCBA",
|
||||
employee_name="丁",
|
||||
@@ -3933,7 +3954,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
|
||||
assert {claim.claim_no for claim in claims} == {
|
||||
"EXP-ARCH-101",
|
||||
"EXP-ARCH-PAID",
|
||||
"AP-20260525120000-ABCDEFGH",
|
||||
"AP-20260525121000-ARCHIVED",
|
||||
}
|
||||
|
||||
|
||||
@@ -4288,6 +4309,65 @@ def test_admin_can_delete_archived_claim() -> None:
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_admin_delete_claim_unlinks_receipt_folder_items(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
receipt_owner = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="Employee",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
admin_user = CurrentUserContext(
|
||||
username="superadmin",
|
||||
name="Admin",
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="Shanghai")
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
claim_no = claim.claim_no
|
||||
item_id = claim.items[0].id
|
||||
|
||||
receipt_service = ReceiptFolderService()
|
||||
receipt = receipt_service.save_receipt(
|
||||
filename="admin-delete-linked-receipt.pdf",
|
||||
content=b"%PDF-1.4 linked",
|
||||
media_type="application/pdf",
|
||||
current_user=receipt_owner,
|
||||
linked_claim_id=claim_id,
|
||||
linked_claim_no=claim_no,
|
||||
linked_item_id=item_id,
|
||||
document=OcrRecognizeDocumentRead(
|
||||
filename="admin-delete-linked-receipt.pdf",
|
||||
media_type="application/pdf",
|
||||
text="invoice number 123 amount 100",
|
||||
document_type="vat_invoice",
|
||||
document_type_label="invoice",
|
||||
scene_code="other",
|
||||
scene_label="receipt",
|
||||
),
|
||||
)
|
||||
assert receipt.status == "linked"
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, admin_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
unlinked_receipt = receipt_service.get_receipt(receipt.id, receipt_owner)
|
||||
assert unlinked_receipt.status == "unlinked"
|
||||
assert unlinked_receipt.linked_claim_id == ""
|
||||
assert unlinked_receipt.linked_claim_no == ""
|
||||
assert unlinked_receipt.linked_at is None
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-return@example.com",
|
||||
@@ -4842,7 +4922,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "审批完成"
|
||||
assert approved.approval_stage == "关联单据状态"
|
||||
archived_claims = ExpenseClaimService(db).list_archived_claims(
|
||||
CurrentUserContext(
|
||||
username="finance-archive@example.com",
|
||||
@@ -4851,7 +4931,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
is_admin=False,
|
||||
)
|
||||
)
|
||||
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
|
||||
assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims)
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
assert generated_draft.status == "draft"
|
||||
assert generated_draft.approval_stage == "待提交"
|
||||
@@ -4891,7 +4971,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
and flag.get("opinion") == "预算额度可承接,同意。"
|
||||
and flag.get("previous_approval_stage") == "预算管理者审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
and flag.get("next_approval_stage") == "关联单据状态"
|
||||
and flag.get("generated_draft_claim_id") == generated_draft.id
|
||||
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
|
||||
for flag in approved.risk_flags_json
|
||||
@@ -5002,7 +5082,7 @@ def test_application_routes_to_department_p8_executive_with_approver_name() -> N
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
|
||||
|
||||
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
|
||||
@@ -5147,7 +5227,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
@@ -5158,7 +5238,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
|
||||
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
|
||||
and flag.get("budget_approval_merged") is True
|
||||
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
|
||||
for flag in approved.risk_flags_json
|
||||
@@ -5235,7 +5315,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "审批完成"
|
||||
assert approved.approval_stage == "关联单据状态"
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
@@ -5250,7 +5330,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
|
||||
and flag.get("opinion") == "业务必要且预算可承接,同意申请。"
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
and flag.get("next_approval_stage") == "关联单据状态"
|
||||
and flag.get("budget_approval_merged") is True
|
||||
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
|
||||
for flag in approved.risk_flags_json
|
||||
@@ -5819,6 +5899,94 @@ def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_marking_linked_reimbursement_paid_archives_application_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-pay-linked-application@example.com",
|
||||
name="财务付款",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
application_claim = ExpenseClaim(
|
||||
claim_no="AP-202606050001-ARCHIVE",
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-APP",
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网部署",
|
||||
location="上海",
|
||||
amount=Decimal("3000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 6, 5, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage=APPROVAL_DONE_STAGE,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(application_claim)
|
||||
db.flush()
|
||||
|
||||
reimbursement_claim = ExpenseClaim(
|
||||
claim_no="RE-202606050001-ARCHIVE",
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-APP",
|
||||
expense_type="travel",
|
||||
reason="支撑国网部署报销",
|
||||
location="上海",
|
||||
amount=Decimal("3000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=2,
|
||||
occurred_at=datetime(2026, 6, 5, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 6, 6, 10, 0, tzinfo=UTC),
|
||||
status="pending_payment",
|
||||
approval_stage="待付款",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "application_handoff",
|
||||
"event_type": "expense_application_to_reimbursement_draft",
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": application_claim.claim_no,
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(reimbursement_claim)
|
||||
db.commit()
|
||||
|
||||
archived_before = ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
assert all(claim.claim_no != application_claim.claim_no for claim in archived_before)
|
||||
|
||||
paid = ExpenseClaimService(db).mark_claim_paid(reimbursement_claim.id, current_user)
|
||||
|
||||
assert paid is not None
|
||||
db.refresh(application_claim)
|
||||
assert application_claim.status == "approved"
|
||||
assert application_claim.approval_stage == APPLICATION_ARCHIVE_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "application_archive_sync"
|
||||
and flag.get("event_type") == "expense_application_archived_by_reimbursement"
|
||||
and flag.get("reimbursement_claim_no") == reimbursement_claim.claim_no
|
||||
and flag.get("next_approval_stage") == APPLICATION_ARCHIVE_STAGE
|
||||
for flag in application_claim.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "payment"
|
||||
and any(
|
||||
item.get("application_claim_no") == application_claim.claim_no
|
||||
for item in flag.get("archived_application_claims", [])
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
for flag in paid.risk_flags_json
|
||||
)
|
||||
|
||||
archived_after = ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
assert any(claim.claim_no == application_claim.claim_no for claim in archived_after)
|
||||
|
||||
|
||||
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-returned@example.com",
|
||||
|
||||
@@ -3,7 +3,8 @@ from app.services.expense_claim_status_registry import (
|
||||
normalize_expense_claim_state,
|
||||
)
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
@@ -40,7 +41,19 @@ def test_normalize_reimbursement_archive_stage_differs_from_application_done() -
|
||||
)
|
||||
|
||||
assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE
|
||||
assert application_state.approval_stage == APPROVAL_DONE_STAGE
|
||||
assert application_state.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
|
||||
|
||||
def test_normalize_application_archive_stage_is_distinct_from_approval_done() -> None:
|
||||
state = normalize_expense_claim_state(
|
||||
"approved",
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
claim_no="AP-20260602-0002",
|
||||
expense_type="travel_application",
|
||||
)
|
||||
|
||||
assert state.status == "approved"
|
||||
assert state.approval_stage == APPLICATION_ARCHIVE_STAGE
|
||||
|
||||
|
||||
def test_normalize_payment_stages_by_status() -> None:
|
||||
|
||||
@@ -117,3 +117,28 @@ def test_notification_state_endpoint_reads_and_updates_current_user_state() -> N
|
||||
assert payload["states"][0]["hidden_at"] is None
|
||||
assert payload["states"][0]["context_json"]["kind"] == "workbench"
|
||||
assert other_response.json()["states"] == []
|
||||
|
||||
|
||||
def test_notification_state_endpoint_accepts_document_center_bulk_read_state() -> None:
|
||||
client = build_client()
|
||||
headers = {"x-auth-username": "alice", "x-auth-name": "Alice"}
|
||||
states = [
|
||||
{
|
||||
"notification_id": f"document:owned:DOC-{index}",
|
||||
"read": True,
|
||||
"hidden": False,
|
||||
"context_json": {"kind": "document", "target_type": "documents-center"},
|
||||
}
|
||||
for index in range(150)
|
||||
]
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/notification-states",
|
||||
json={"states": states},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload["states"]) == 150
|
||||
assert all(item["read_at"] for item in payload["states"])
|
||||
|
||||
@@ -179,6 +179,64 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
assert recognized.lines[1].page_index == 1
|
||||
|
||||
|
||||
def test_ocr_service_reuses_cached_document_for_same_content(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
*,
|
||||
python_bin: str,
|
||||
worker_path: str,
|
||||
input_paths: list[Path],
|
||||
) -> dict:
|
||||
calls["count"] += 1
|
||||
return {
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"documents": [
|
||||
{
|
||||
"input_path": str(input_paths[0]),
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"text": "增值税电子发票 金额 20 元",
|
||||
"summary": "增值税电子发票,金额 20 元。",
|
||||
"avg_score": 0.97,
|
||||
"line_count": 1,
|
||||
"page_count": 1,
|
||||
"warnings": [],
|
||||
"lines": [
|
||||
{
|
||||
"text": "增值税电子发票 金额 20 元",
|
||||
"score": 0.97,
|
||||
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
|
||||
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||
OcrService._result_cache.clear()
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
first = OcrService().recognize_files([("first.png", b"same-image", "image/png")])
|
||||
second = OcrService().recognize_files([("second.png", b"same-image", "image/png")])
|
||||
finally:
|
||||
OcrService._result_cache.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
assert calls["count"] == 1
|
||||
assert first.documents[0].filename == "first.png"
|
||||
assert second.documents[0].filename == "second.png"
|
||||
assert second.documents[0].summary == first.documents[0].summary
|
||||
|
||||
|
||||
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.ocr import OcrRecognizeDocumentRead
|
||||
@@ -71,7 +69,7 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkeypatch, tmp_path) -> None:
|
||||
def test_receipt_folder_unlink_receipts_for_claim_marks_linked_receipts_unlinked(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
@@ -101,9 +99,17 @@ def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkey
|
||||
),
|
||||
)
|
||||
|
||||
assert service.get_receipt(receipt.id, current_user).linked_claim_id == "claim-1"
|
||||
assert service.delete_receipts_for_claim("claim-1") == 1
|
||||
with pytest.raises(FileNotFoundError):
|
||||
service.get_receipt(receipt.id, current_user)
|
||||
linked_detail = service.get_receipt(receipt.id, current_user)
|
||||
assert linked_detail.status == "linked"
|
||||
assert linked_detail.linked_claim_id == "claim-1"
|
||||
assert linked_detail.linked_claim_no == "RE-001"
|
||||
|
||||
assert service.unlink_receipts_for_claim("claim-1") == 1
|
||||
|
||||
unlinked_detail = service.get_receipt(receipt.id, current_user)
|
||||
assert unlinked_detail.status == "unlinked"
|
||||
assert unlinked_detail.linked_claim_id == ""
|
||||
assert unlinked_detail.linked_claim_no == ""
|
||||
assert unlinked_detail.linked_at is None
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import SERVER_DIR
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.services.risk_rule_dsl_examples import (
|
||||
get_risk_rule_dsl_example,
|
||||
@@ -166,6 +169,95 @@ def test_date_rule_uses_application_month_before_ticket_item_date() -> None:
|
||||
assert condition["outside_dates"] == ["2026-02-20"]
|
||||
|
||||
|
||||
def test_application_context_values_are_available_to_composite_rules() -> None:
|
||||
claim = _claim(amount=Decimal("3000.00"))
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "application_link",
|
||||
"application_claim_id": "application-ctx-1",
|
||||
"application_claim_no": "AP-202606-CTX",
|
||||
"application_detail": {
|
||||
"application_amount": "3000",
|
||||
"application_expense_type": "office",
|
||||
},
|
||||
}
|
||||
]
|
||||
manifest = {
|
||||
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
"params": {
|
||||
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
"conditions": [
|
||||
{
|
||||
"id": "application_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["application.id", "application.claim_no"],
|
||||
}
|
||||
],
|
||||
"hit_logic": "application_present",
|
||||
"condition_summary": "application exists",
|
||||
},
|
||||
}
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
|
||||
|
||||
assert result is not None
|
||||
condition = result["evidence"]["conditions"][0]
|
||||
assert condition["values"] == ["application-ctx-1", "AP-202606-CTX"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_name", "expense_type", "amount"),
|
||||
[
|
||||
("risk.application.meal_high_value_without_preapproval.json", "meal", Decimal("501.00")),
|
||||
("risk.application.office_bulk_without_purchase.json", "office", Decimal("2001.00")),
|
||||
("risk.application.large_expense_without_preapproval.json", "software", Decimal("2001.00")),
|
||||
],
|
||||
)
|
||||
def test_preapproval_amount_rules_hit_without_linked_application(
|
||||
file_name: str,
|
||||
expense_type: str,
|
||||
amount: Decimal,
|
||||
) -> None:
|
||||
claim = _claim(amount=amount)
|
||||
claim.expense_type = expense_type
|
||||
manifest = _load_rule_manifest(file_name)
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
|
||||
|
||||
assert result is not None
|
||||
assert result["evidence"]["condition_results"]["amount_exceeds_preapproval_threshold"] is True
|
||||
assert result["evidence"]["condition_results"]["application_present"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_name", "expense_type", "amount"),
|
||||
[
|
||||
("risk.application.meal_high_value_without_preapproval.json", "entertainment", Decimal("800.00")),
|
||||
("risk.application.office_bulk_without_purchase.json", "office", Decimal("2600.00")),
|
||||
("risk.application.large_expense_without_preapproval.json", "software", Decimal("2600.00")),
|
||||
],
|
||||
)
|
||||
def test_preapproval_amount_rules_skip_when_application_is_linked(
|
||||
file_name: str,
|
||||
expense_type: str,
|
||||
amount: Decimal,
|
||||
) -> None:
|
||||
claim = _claim(amount=amount)
|
||||
claim.expense_type = expense_type
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "application_link",
|
||||
"application_claim_id": "application-linked-ok",
|
||||
"application_claim_no": "AP-202606-OK",
|
||||
}
|
||||
]
|
||||
manifest = _load_rule_manifest(file_name)
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=[])
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-RISK-RULE-DSL",
|
||||
@@ -193,3 +285,8 @@ def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim:
|
||||
)
|
||||
]
|
||||
return claim
|
||||
|
||||
|
||||
def _load_rule_manifest(file_name: str) -> dict:
|
||||
path = Path(SERVER_DIR) / "rules" / "risk-rules" / file_name
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -93,6 +93,38 @@ class EntertainmentFunctionCallingIntentAgent:
|
||||
)
|
||||
|
||||
|
||||
class ApplicationFunctionCallingIntentAgent:
|
||||
def detect(self, request, *, base_date, canonical_fields):
|
||||
return StewardIntentAgentResult(
|
||||
payload={
|
||||
"thinking_events": [
|
||||
{
|
||||
"stage": "task_split",
|
||||
"title": "识别出差申请",
|
||||
"content": "模型识别到用户要发起北京出差申请,并且后续还有报销事项。",
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_type": "expense_application",
|
||||
"title": "北京出差申请",
|
||||
"summary": "明天前往北京出差3天,支撑国网仿生产部署。",
|
||||
"confidence": 0.94,
|
||||
"ontology_fields": {
|
||||
"time_range": "明天",
|
||||
"location": "北京",
|
||||
"expense_type": "差旅",
|
||||
"reason": "支撑国网仿生产部署",
|
||||
},
|
||||
"missing_fields": [],
|
||||
}
|
||||
],
|
||||
"attachment_groups": [],
|
||||
},
|
||||
model_call_traces=[],
|
||||
)
|
||||
|
||||
|
||||
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天客户现场沟通的交通费",
|
||||
@@ -136,6 +168,22 @@ def test_steward_planner_normalizes_llm_business_entertainment_expense_type() ->
|
||||
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
|
||||
|
||||
|
||||
def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="明天出差北京3天,支撑国网仿生产部署",
|
||||
client_now_iso="2026-06-04T09:30:00+08:00",
|
||||
)
|
||||
|
||||
result = StewardPlannerService(intent_agent=ApplicationFunctionCallingIntentAgent()).build_plan(payload)
|
||||
|
||||
assert result.planning_source == "llm_function_call"
|
||||
assert result.tasks[0].missing_fields == ["transport_mode"]
|
||||
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
|
||||
assert gap_events
|
||||
assert "没有说明出行方式" in gap_events[0].content
|
||||
assert "火车、飞机或轮船" in gap_events[0].content
|
||||
|
||||
|
||||
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
|
||||
payload = StewardPlanRequest(
|
||||
message="我要报销昨天的交通费",
|
||||
@@ -197,6 +245,10 @@ def test_steward_planner_treats_future_travel_without_apply_word_as_application(
|
||||
assert result.tasks[0].ontology_fields["location"] == "北京"
|
||||
assert result.tasks[0].ontology_fields["expense_type"] == "travel"
|
||||
assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署"
|
||||
assert result.tasks[0].missing_fields == ["transport_mode"]
|
||||
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
|
||||
assert gap_events
|
||||
assert "没有说明出行方式" in gap_events[0].content
|
||||
assert result.tasks[1].assigned_agent == "reimbursement_assistant"
|
||||
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
|
||||
assert result.tasks[1].ontology_fields["expense_type"] == "entertainment"
|
||||
|
||||
96
server/tests/test_steward_runtime_decision_agent.py
Normal file
96
server/tests/test_steward_runtime_decision_agent.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from app.schemas.steward import StewardRuntimeDecisionRequest
|
||||
from app.services.steward_runtime_decision_agent import (
|
||||
STEWARD_RUNTIME_DECISION_FUNCTION_NAME,
|
||||
StewardRuntimeDecisionAgent,
|
||||
)
|
||||
|
||||
|
||||
class _FakeToolCall:
|
||||
def __init__(self, name, arguments):
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
|
||||
class _FakeRuntimeResult:
|
||||
def __init__(self, tool_call=None):
|
||||
self.tool_call = tool_call
|
||||
|
||||
def calls_as_dicts(self):
|
||||
return [{"tool": self.tool_call.name if self.tool_call else ""}]
|
||||
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
self.last_messages = []
|
||||
self.last_tools = []
|
||||
self.last_tool_choice = None
|
||||
|
||||
def complete_with_tool_call(self, messages, tools, tool_choice, **kwargs):
|
||||
self.last_messages = messages
|
||||
self.last_tools = tools
|
||||
self.last_tool_choice = tool_choice
|
||||
if self.payload is None:
|
||||
return _FakeRuntimeResult()
|
||||
return _FakeRuntimeResult(_FakeToolCall(STEWARD_RUNTIME_DECISION_FUNCTION_NAME, self.payload))
|
||||
|
||||
|
||||
def test_steward_runtime_decision_uses_function_calling_context():
|
||||
runtime = _FakeRuntime(
|
||||
{
|
||||
"next_action": "submit_current_application",
|
||||
"target_task_id": "task-application-beijing",
|
||||
"target_message_id": "msg-application-preview",
|
||||
"field_key": "",
|
||||
"field_value": "",
|
||||
"confirmation_required": False,
|
||||
"question": "",
|
||||
"response_text": "",
|
||||
"rationale": "用户确认当前申请核对表无误。",
|
||||
}
|
||||
)
|
||||
|
||||
result = StewardRuntimeDecisionAgent(runtime).decide(
|
||||
StewardRuntimeDecisionRequest(
|
||||
user_message="确认",
|
||||
runtime_state={
|
||||
"waiting_for": "application_submit_confirmation",
|
||||
"pending_application": {
|
||||
"message_id": "msg-application-preview",
|
||||
"task_id": "task-application-beijing",
|
||||
"ready_to_submit": True,
|
||||
},
|
||||
"remaining_tasks": [
|
||||
{"task_id": "task-reimbursement-meal", "task_type": "reimbursement"}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decision_source == "llm_function_call"
|
||||
assert result.next_action == "submit_current_application"
|
||||
assert result.target_message_id == "msg-application-preview"
|
||||
assert result.target_task_id == "task-application-beijing"
|
||||
assert runtime.last_tool_choice["function"]["name"] == STEWARD_RUNTIME_DECISION_FUNCTION_NAME
|
||||
assert "runtime_state" in runtime.last_messages[-1]["content"]
|
||||
|
||||
|
||||
def test_steward_runtime_decision_fallback_keeps_current_context():
|
||||
runtime = _FakeRuntime(None)
|
||||
|
||||
result = StewardRuntimeDecisionAgent(runtime).decide(
|
||||
StewardRuntimeDecisionRequest(
|
||||
user_message="确认",
|
||||
runtime_state={
|
||||
"pending_steward_action": {
|
||||
"message_id": "msg-next-task",
|
||||
"target_task_id": "task-reimbursement-meal",
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decision_source == "rule_fallback"
|
||||
assert result.next_action == "continue_next_task"
|
||||
assert result.target_message_id == "msg-next-task"
|
||||
assert result.target_task_id == "task-reimbursement-meal"
|
||||
136
server/tests/test_steward_slot_decision_agent.py
Normal file
136
server/tests/test_steward_slot_decision_agent.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.steward import StewardSlotDecisionRequest
|
||||
from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatToolCall, RuntimeToolCallResult
|
||||
from app.services.steward_slot_decision_agent import (
|
||||
STEWARD_SLOT_DECISION_FUNCTION_NAME,
|
||||
StewardSlotDecisionAgent,
|
||||
)
|
||||
|
||||
|
||||
class FakeSlotRuntime:
|
||||
def __init__(self, arguments=None):
|
||||
self.arguments = arguments
|
||||
self.messages = None
|
||||
|
||||
def complete_with_tool_call(self, messages, **kwargs):
|
||||
self.messages = messages
|
||||
if self.arguments is None:
|
||||
return RuntimeToolCallResult(tool_call=None, calls=[])
|
||||
return RuntimeToolCallResult(
|
||||
tool_call=RuntimeChatToolCall(
|
||||
name=STEWARD_SLOT_DECISION_FUNCTION_NAME,
|
||||
arguments=self.arguments,
|
||||
),
|
||||
calls=[
|
||||
RuntimeChatCallTrace(
|
||||
slot="main",
|
||||
provider="OpenAI Compatible",
|
||||
model="fake",
|
||||
attempt=1,
|
||||
status="succeeded",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_steward_slot_decision_uses_function_calling_result() -> None:
|
||||
runtime = FakeSlotRuntime(
|
||||
{
|
||||
"next_action": "ask_user",
|
||||
"required_fields": ["expense_type", "time_range", "location", "reason", "transport_mode"],
|
||||
"missing_fields": ["transport_mode"],
|
||||
"question": "请问你这次打算怎么出行?",
|
||||
"options": [
|
||||
{"field_key": "transport_mode", "label": "飞机", "value": "飞机"},
|
||||
{"field_key": "transport_mode", "label": "火车", "value": "火车"},
|
||||
],
|
||||
"rationale": "出行方式会影响交通费用测算。",
|
||||
}
|
||||
)
|
||||
|
||||
result = StewardSlotDecisionAgent(runtime).decide(
|
||||
StewardSlotDecisionRequest(
|
||||
task_type="expense_application",
|
||||
user_message="明天出差北京3天,支撑国网仿生产部署",
|
||||
ontology_fields={
|
||||
"expense_type": "travel",
|
||||
"time_range": "2026-06-05 至 2026-06-07",
|
||||
"location": "北京",
|
||||
"reason": "支撑国网仿生产部署",
|
||||
},
|
||||
missing_fields=["transport_mode"],
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decision_source == "llm_function_call"
|
||||
assert result.next_action == "ask_user"
|
||||
assert result.missing_fields == ["transport_mode"]
|
||||
assert [item.value for item in result.options] == ["飞机", "火车"]
|
||||
assert "出行方式会影响" in result.rationale
|
||||
|
||||
|
||||
def test_steward_slot_decision_falls_back_to_intent_missing_fields_only() -> None:
|
||||
runtime = FakeSlotRuntime(arguments=None)
|
||||
|
||||
result = StewardSlotDecisionAgent(runtime).decide(
|
||||
StewardSlotDecisionRequest(
|
||||
task_type="expense_application",
|
||||
user_message="还需要补充:出行方式(例如高铁、飞机、自驾、出租车)",
|
||||
ontology_fields={
|
||||
"expense_type": "travel",
|
||||
"location": "北京",
|
||||
"reason": "支撑国网仿生产部署",
|
||||
},
|
||||
missing_fields=["transport_mode"],
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decision_source == "rule_fallback"
|
||||
assert result.next_action == "ask_user"
|
||||
assert result.missing_fields == ["transport_mode"]
|
||||
assert [item.value for item in result.options] == ["火车", "飞机", "轮船"]
|
||||
assert "高铁" not in result.required_fields
|
||||
|
||||
|
||||
def test_steward_slot_decision_does_not_ask_user_for_application_profile_or_attachments() -> None:
|
||||
runtime = FakeSlotRuntime(
|
||||
{
|
||||
"next_action": "ask_user",
|
||||
"required_fields": [
|
||||
"expense_type",
|
||||
"time_range",
|
||||
"location",
|
||||
"reason",
|
||||
"amount",
|
||||
"attachments",
|
||||
"employee_no",
|
||||
],
|
||||
"missing_fields": ["attachments", "employee_no"],
|
||||
"question": "请补充附件和员工编号。",
|
||||
"options": [],
|
||||
"rationale": "附件/凭证和员工编号为合规必需字段。",
|
||||
}
|
||||
)
|
||||
|
||||
result = StewardSlotDecisionAgent(runtime).decide(
|
||||
StewardSlotDecisionRequest(
|
||||
task_type="expense_application",
|
||||
user_message="明天出差北京3天,支撑国网仿生产部署",
|
||||
ontology_fields={
|
||||
"expense_type": "travel",
|
||||
"time_range": "2026-06-05 至 2026-06-07",
|
||||
"location": "北京",
|
||||
"reason": "支撑国网仿生产部署",
|
||||
},
|
||||
missing_fields=["attachments", "employee_no"],
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decision_source == "llm_function_call"
|
||||
assert result.next_action == "render_preview"
|
||||
assert result.missing_fields == []
|
||||
assert "attachments" not in result.required_fields
|
||||
assert "employee_no" not in result.required_fields
|
||||
assert result.options == []
|
||||
assert "合规必需字段" not in result.rationale
|
||||
@@ -693,6 +693,66 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
|
||||
assert second_response.draft_payload is None
|
||||
|
||||
|
||||
def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
existing_claim = ExpenseClaim(
|
||||
id="application-overlap-1",
|
||||
claim_no="AP-202606050001-OVERLAP",
|
||||
employee_name="pytest",
|
||||
department_name="技术部",
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网部署",
|
||||
location="北京",
|
||||
amount=Decimal("2700.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 6, 5, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "application_detail",
|
||||
"business_stage": "expense_application",
|
||||
"application_detail": {
|
||||
"application_type": "差旅费用申请",
|
||||
"time": "2026-06-05 至 2026-06-07",
|
||||
"location": "北京",
|
||||
"reason": "支撑国网部署",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(existing_claim)
|
||||
db.commit()
|
||||
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"确认提交",
|
||||
context_overrides={
|
||||
"manager_name": "向万红",
|
||||
"application_preview": {
|
||||
"fields": {
|
||||
"applicationType": "差旅费用申请",
|
||||
"time": "2026-06-06 至 2026-06-08",
|
||||
"location": "北京",
|
||||
"reason": "支撑国网仿生产部署",
|
||||
"days": "3天",
|
||||
"transportMode": "火车",
|
||||
"amount": "2700元",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
|
||||
assert len(claims) == 1
|
||||
assert "已存在申请单" in response.answer
|
||||
assert "系统没有重复创建" in response.answer
|
||||
assert existing_claim.claim_no in response.answer
|
||||
assert response.draft_payload is None
|
||||
|
||||
|
||||
def test_user_agent_application_edit_resubmits_returned_application_claim() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
Reference in New Issue
Block a user