Refine travel reimbursement steward flow

Align planner, runtime rules, and policy assets so travel guidance
matches the updated reimbursement workflow.
This commit is contained in:
caoxiaozhu
2026-06-15 22:55:18 +08:00
parent 792741709a
commit 9f7b8b46a3
85 changed files with 9496 additions and 2555 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,17 +1,19 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.large_expense_without_preapproval",
"name": "?????????",
"description": "???????? 2000 ?????????????",
"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": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -34,67 +36,67 @@
"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": "???ID",
"label": "申请单ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"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"
}
@@ -144,10 +146,10 @@
"meal",
"entertainment",
"office",
"????",
"??",
"????",
"??"
"业务招待",
"招待",
"办公用品",
"办公"
]
}
],
@@ -161,10 +163,12 @@
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "?????????????????? 2000 ????????????????",
"message_template": "?????? 2000 ?????????????????????????",
"condition_summary": "非业务招待、非办公用品的通用费用超过 2000 元且未关联费用申请时触发。",
"message_template": "通用大额费用超过 2000 元但未找到关联费用申请,请补充前置申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -177,14 +181,14 @@
"facts": [
{
"id": "A",
"label": "????",
"label": "报销金额",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"label": "关联申请",
"fields": [
"application.id",
"application.claim_no"
@@ -192,7 +196,15 @@
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -206,25 +218,43 @@
}
},
"metadata": {
"owner": "??????",
"owner": "财务制度管理组",
"stability": "platform",
"source_ref": "??????????",
"source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 86,
"risk_level": "high",
"rule_title": "?????????",
"rule_title": "通用大额费用无前置申请",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"all"
],
"budget_required": true
"budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "high",
"risk_score": 86,
"risk_level": "high"
"risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -1,17 +1,19 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.meal_high_value_without_preapproval",
"name": "??????????",
"description": "????????? 500 ?????????????",
"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": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -36,67 +38,67 @@
"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": "???ID",
"label": "申请单ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"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"
}
@@ -146,10 +148,12 @@
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "??????????? 500 ????????????????",
"message_template": "??????? 500 ?????????????????????????",
"condition_summary": "业务招待费超过 500 元且未关联已审批费用申请时触发。",
"message_template": "业务招待费超过 500 元但未找到关联费用申请,请补充前置申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -163,14 +167,14 @@
"facts": [
{
"id": "A",
"label": "????",
"label": "报销金额",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"label": "关联申请",
"fields": [
"application.id",
"application.claim_no"
@@ -178,7 +182,15 @@
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -192,16 +204,18 @@
}
},
"metadata": {
"owner": "??????",
"owner": "财务制度管理组",
"stability": "platform",
"source_ref": "??????????",
"source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 88,
"risk_level": "high",
"rule_title": "??????????",
"rule_title": "业务招待高金额无前置申请",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -209,9 +223,25 @@
"meal",
"entertainment"
],
"budget_required": true
"budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "high",
"risk_score": 88,
"risk_level": "high"
"risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -1,17 +1,19 @@
{
"schema_version": "2.0",
"rule_code": "risk.application.office_bulk_without_purchase",
"name": "???????????",
"description": "???????????????? 2000 ???????????",
"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": "composite_rule_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -34,67 +36,67 @@
"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": "???ID",
"label": "申请单ID",
"type": "text",
"source": "application"
},
{
"key": "application.claim_no",
"label": "????",
"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"
}
@@ -144,10 +146,12 @@
]
},
"formula": "amount > threshold AND NOT hasApplication",
"condition_summary": "???????????????? 2000 ????????????????",
"message_template": "??????? 2000 ??????????????????????????????",
"condition_summary": "办公用品费用超过 2000 元且未关联费用申请或采购审批时触发。",
"message_template": "办公用品费用超过 2000 元但未找到关联费用申请,请补充采购申请或审批说明。",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -160,14 +164,14 @@
"facts": [
{
"id": "A",
"label": "????",
"label": "报销金额",
"fields": [
"claim.amount"
]
},
{
"id": "B",
"label": "???",
"label": "关联申请",
"fields": [
"application.id",
"application.claim_no"
@@ -175,7 +179,15 @@
}
],
"hit_logic": "A > threshold AND NOT EXISTS(B)"
}
},
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -189,25 +201,43 @@
}
},
"metadata": {
"owner": "??????",
"owner": "财务制度管理组",
"stability": "platform",
"source_ref": "??????????",
"source_ref": "公司费用申请审批规则",
"created_at": "2026-06-05T00:00:00+08:00",
"created_by": "system",
"risk_score": 84,
"risk_level": "high",
"rule_title": "???????????",
"rule_title": "办公用品批量采购无前置申请",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "????????",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"office"
],
"budget_required": true
"budget_required": true,
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "high",
"risk_score": 84,
"risk_level": "high"
"risk_level": "high",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -10,8 +10,8 @@
"ontology_signal": "application_required",
"evaluator": "template_rule",
"template_key": "keyword_match_v1",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
@@ -119,15 +119,55 @@
"未申请"
],
"condition_summary": "差旅金额达到大额阈值且缺少有效出差申请时触发。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"travel"
],
"budget_required": true
"budget_required": true,
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"outcomes": {
"pass": {
@@ -149,17 +189,97 @@
"risk_score": 82,
"risk_level": "high",
"rule_title": "大额差旅未申请",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"reimbursement"
],
"expense_types": [
"travel"
],
"budget_required": true
"budget_required": true,
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"severity": "high",
"risk_score": 82,
"risk_level": "high"
"risk_level": "high",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
}

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_city_mismatch",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -105,7 +105,31 @@
"项目现场"
],
"condition_summary": "票据城市未覆盖申报目的地,或路线出现无法由本次票据起终点和申报目的地解释的额外城市且无合理说明。",
"message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。"
"message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -121,14 +145,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 90,
"risk_level": "high",
"rule_title": "差旅目的地与票据城市不一致高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -146,7 +170,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "high",
"risk_score": 90,
@@ -160,5 +206,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_date_outside_trip_window",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -102,7 +102,37 @@
],
"hit_logic": "ticket_date_outside_trip",
"condition_summary": "任一票据/明细日期早于出差开始日前 1 天或晚于结束日后 1 天。",
"message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。"
"message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"outcomes": {
"pass": {
@@ -118,14 +148,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 88,
"risk_level": "high",
"rule_title": "票据日期超出差旅行程高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -143,7 +173,35 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"severity": "high",
"risk_score": 88,
@@ -157,5 +215,33 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
}

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_personal_purpose",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -76,7 +76,37 @@
],
"condition_summary": "差旅事由或票据文本命中个人旅游/私人目的关键词。",
"message_template": "识别到个人旅游或非公务目的表达,请确认是否属于公司差旅范围。",
"template_key": "keyword_match_v1"
"template_key": "keyword_match_v1",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"outcomes": {
"pass": {
@@ -92,14 +122,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 86,
"risk_level": "high",
"rule_title": "个人旅游或非公务目的高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -117,7 +147,35 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"severity": "high",
"risk_score": 86,
@@ -131,5 +189,33 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
}

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-申请审批",
"ontology_signal": "travel_preapproval_absent",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application",
"reimbursement"
@@ -76,7 +76,19 @@
],
"condition_summary": "差旅申请/报销文本命中未申请、未审批或事后补申请关键词。",
"message_template": "识别到差旅未事前申请或事后补申请迹象,请补齐已审批的差旅申请后再提交。",
"template_key": "keyword_match_v1"
"template_key": "keyword_match_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -92,14 +104,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:费用申请审批规则",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 92,
"risk_level": "high",
"rule_title": "差旅未申请或事后补申请高风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application",
"reimbursement"
@@ -117,7 +129,17 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "high",
"risk_score": 92,
@@ -131,5 +153,15 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-申请信息",
"ontology_signal": "travel_application_fields_missing",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application"
],
@@ -80,7 +80,49 @@
],
"condition_summary": "差旅申请缺少事由、地点、起止时间或预计金额。",
"message_template": "差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。",
"template_key": "field_required_v1"
"template_key": "field_required_v1",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"outcomes": {
"pass": {
@@ -96,14 +138,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:费用申请审批规则、差旅住宿费标准、地区淡旺季映射表、交通工具等级标准、交通费用预估表、出差补助标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 42,
"risk_level": "low",
"rule_title": "差旅申请基础信息不完整低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application"
],
@@ -120,7 +162,47 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
},
"severity": "low",
"risk_score": 42,
@@ -134,5 +216,45 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
},
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
},
{
"code": "rule.expense.company_travel_transport_estimate",
"sheet": "交通费用预估表",
"name": "交通费用预估表",
"component": "transport_estimate"
},
{
"code": "rule.expense.company_travel_allowance_reimbursement",
"sheet": "出差补助标准",
"name": "出差补助报销标准",
"component": "allowance"
}
]
}

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_attachment_ocr_missing",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -50,7 +50,31 @@
],
"condition_summary": "差旅附件缺少可读取 OCR 文本。",
"message_template": "差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。",
"template_key": "field_required_v1"
"template_key": "field_required_v1",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -66,14 +90,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 38,
"risk_level": "low",
"rule_title": "差旅附件无法识别低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -91,7 +115,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "low",
"risk_score": 38,
@@ -105,5 +151,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-市内交通",
"ontology_signal": "travel_local_transport_detail_missing",
"evaluator": "template_rule",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "rule.expense.company_travel_transport_class",
"finance_rule_sheet": "交通工具等级标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -102,7 +102,19 @@
]
},
"condition_summary": "存在市内交通关键词,但文本中缺少起点、终点或路线说明。",
"message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。"
"message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。",
"finance_rule_code": "rule.expense.company_travel_transport_class",
"finance_rule_sheet": "交通工具等级标准",
"basic_rule_code": "rule.expense.company_travel_transport_class",
"basic_rule_sheet": "交通工具等级标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -118,14 +130,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 36,
"risk_level": "low",
"rule_title": "市内交通路线说明不足低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "rule.expense.company_travel_transport_class",
"finance_rule_sheet": "交通工具等级标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -143,7 +155,17 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_transport_class",
"basic_rule_sheet": "交通工具等级标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "low",
"risk_score": 36,
@@ -157,5 +179,15 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_transport_class",
"basic_rule_sheet": "交通工具等级标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_vague_ticket_content",
"evaluator": "vague_goods_description",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -49,7 +49,31 @@
},
"params": {
"condition_summary": "票据未识别为明确的酒店、交通等差旅票据,且商品或服务名称过于笼统,无法直接对应差旅事项。",
"message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。"
"message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -65,14 +89,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 34,
"risk_level": "low",
"rule_title": "差旅票据服务内容笼统低风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -90,7 +114,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "low",
"risk_score": 34,
@@ -103,5 +149,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_duplicate_ticket",
"evaluator": "duplicate_invoice",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -49,7 +49,31 @@
},
"params": {
"condition_summary": "票据号码在当前单据或历史报销中重复出现。",
"message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。"
"message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -65,14 +89,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 75,
"risk_level": "medium",
"rule_title": "差旅票据重复中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -90,7 +114,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "medium",
"risk_score": 75,
@@ -103,5 +149,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_multi_city_without_reason",
"evaluator": "multi_city_reason_required",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -67,7 +67,31 @@
},
"params": {
"condition_summary": "差旅行程涉及 3 个及以上城市,且事由未包含中转、多地、改签、绕行等说明。",
"message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。"
"message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -83,14 +107,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 72,
"risk_level": "medium",
"rule_title": "多城市行程缺少说明中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -108,7 +132,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "medium",
"risk_score": 72,
@@ -121,5 +167,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -9,8 +9,8 @@
"risk_category": "差旅费-事由完整性",
"ontology_signal": "travel_reason_too_brief",
"evaluator": "reason_too_brief",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application",
"reimbursement"
@@ -50,7 +50,19 @@
"params": {
"min_reason_length": 10,
"condition_summary": "合并申请/报销事由后有效字符少于 10 个。",
"message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。"
"message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"outcomes": {
"pass": {
@@ -66,14 +78,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:费用申请审批规则",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 68,
"risk_level": "medium",
"rule_title": "差旅事由过短中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_code": "expense.preapproval.policy",
"finance_rule_sheet": "费用申请审批规则",
"business_stage": [
"expense_application",
"reimbursement"
@@ -91,7 +103,17 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
},
"severity": "medium",
"risk_score": 68,
@@ -104,5 +126,15 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "expense.preapproval.policy",
"basic_rule_sheet": "费用申请审批规则",
"basic_rule_refs": [
{
"code": "expense.preapproval.policy",
"sheet": "费用申请审批规则",
"name": "公司费用申请审批规则",
"component": "preapproval"
}
]
}

View File

@@ -10,7 +10,7 @@
"ontology_signal": "travel_invoice_title_mismatch",
"evaluator": "identity_consistency",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -60,7 +60,31 @@
"远光软件"
],
"condition_summary": "票据抬头/购买方不包含报销人姓名,也不包含公司抬头关键词。",
"message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。"
"message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "差旅住宿费标准",
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"outcomes": {
"pass": {
@@ -76,14 +100,14 @@
"metadata": {
"owner": "admin",
"stability": "admin_configured",
"source_ref": "差旅费报销风险规则库 / admin 手工配置",
"source_ref": "拆分基础规则:差旅住宿费标准、地区淡旺季映射表、交通工具等级标准",
"created_at": "2026-05-26T07:06:27.746703+00:00",
"created_by": "admin",
"risk_score": 64,
"risk_level": "medium",
"rule_title": "差旅票据抬头不一致中风险",
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
"finance_rule_sheet": "公司差旅费报销规则",
"finance_rule_sheet": "差旅住宿费标准",
"business_stage": [
"expense_application",
"reimbursement"
@@ -101,7 +125,29 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
},
"severity": "medium",
"risk_score": 64,
@@ -114,5 +160,27 @@
"model": "risk_score_v3",
"source": "admin_manual_travel_risk_catalog",
"reason": "按差旅费报销高/中/低风险分层手工设定。"
}
},
"basic_rule_code": "rule.expense.company_travel_expense_reimbursement",
"basic_rule_sheet": "差旅住宿费标准",
"basic_rule_refs": [
{
"code": "rule.expense.company_travel_expense_reimbursement",
"sheet": "差旅住宿费标准",
"name": "差旅住宿报销标准",
"component": "lodging"
},
{
"code": "rule.expense.company_travel_season_mapping",
"sheet": "地区淡旺季映射表",
"name": "地区淡旺季映射表",
"component": "season_mapping"
},
{
"code": "rule.expense.company_travel_transport_class",
"sheet": "交通工具等级标准",
"name": "交通工具等级标准",
"component": "transport"
}
]
}

View File

@@ -20,7 +20,9 @@ from app.schemas.steward import (
StewardSlotDecisionResponse,
StewardThinkingEvent,
)
from app.services.agent_conversations import AgentConversationService
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_flow_state import StewardFlowStateService
from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_planner import StewardPlannerService
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
@@ -44,7 +46,8 @@ DbSession = Annotated[Session, Depends(get_db)]
)
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
try:
return _build_steward_planner(db).build_plan(payload)
plan = _build_steward_planner(db).build_plan(payload)
return _attach_conversation_state(db, payload, plan)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -72,7 +75,9 @@ def create_steward_runtime_decision(
payload: StewardRuntimeDecisionRequest,
db: DbSession,
) -> StewardRuntimeDecisionResponse:
return StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(payload)
hydrated_payload = _hydrate_runtime_decision_payload(db, payload)
decision = StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(hydrated_payload)
return _attach_runtime_conversation_state(db, hydrated_payload, decision)
@router.post(
@@ -82,7 +87,7 @@ def create_steward_runtime_decision(
)
async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse:
return StreamingResponse(
_iter_steward_plan_events(payload, _build_steward_planner(db)),
_iter_steward_plan_events(payload, _build_steward_planner(db), db),
media_type="application/x-ndjson",
)
@@ -90,6 +95,7 @@ async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> Str
async def _iter_steward_plan_events(
payload: StewardPlanRequest,
planner: StewardPlannerService,
db: Session,
) -> AsyncIterator[str]:
yield _encode_stream_event(
"thinking",
@@ -105,6 +111,7 @@ async def _iter_steward_plan_events(
try:
plan = planner.build_plan(payload)
plan = _attach_conversation_state(db, payload, plan)
except ValueError as exc:
yield _encode_stream_event("error", {"message": str(exc)})
return
@@ -124,3 +131,131 @@ def _build_steward_planner(db: Session) -> StewardPlannerService:
return StewardPlannerService(
intent_agent=StewardIntentAgent(RuntimeChatService(db)),
)
def _attach_conversation_state(
db: Session,
payload: StewardPlanRequest,
plan: StewardPlanResponse,
) -> StewardPlanResponse:
context_json = dict(payload.context_json or {})
context_json["session_type"] = str(context_json.get("session_type") or "steward").strip() or "steward"
conversation_service = AgentConversationService(db)
conversation = conversation_service.get_or_create_conversation(
conversation_id=_resolve_conversation_id(context_json),
user_id=payload.user_id,
source="user_message",
context_json=context_json,
)
current_state = _resolve_current_steward_state(conversation.state_json, context_json)
steward_state = StewardFlowStateService().merge_plan(current_state, plan)
conversation = conversation_service.update_state(
conversation_id=conversation.conversation_id,
run_id=None,
scenario="steward",
intent="plan",
context_json={
**context_json,
"steward_state": steward_state,
},
) or conversation
conversation_service.append_message(
conversation_id=conversation.conversation_id,
role="user",
content=payload.message,
message_json={"source": "steward_plan_request"},
)
conversation_service.append_message(
conversation_id=conversation.conversation_id,
role="assistant",
content=plan.summary,
message_json={
"source": "steward_plan_response",
"plan_id": plan.plan_id,
"steward_state": steward_state,
},
)
return plan.model_copy(
update={
"conversation_id": conversation.conversation_id,
"steward_state": steward_state,
}
)
def _attach_runtime_conversation_state(
db: Session,
payload: StewardRuntimeDecisionRequest,
decision: StewardRuntimeDecisionResponse,
) -> StewardRuntimeDecisionResponse:
steward_state = decision.steward_state
if not isinstance(steward_state, dict) or not steward_state:
return decision
context_json = dict(payload.context_json or {})
conversation_id = _resolve_conversation_id(context_json)
if not conversation_id:
return decision
conversation_service = AgentConversationService(db)
conversation_service.update_state(
conversation_id=conversation_id,
run_id=None,
scenario="steward",
intent="runtime_decision",
context_json={
**context_json,
"steward_state": steward_state,
},
)
return decision
def _hydrate_runtime_decision_payload(
db: Session,
payload: StewardRuntimeDecisionRequest,
) -> StewardRuntimeDecisionRequest:
context_json = dict(payload.context_json or {})
runtime_state = dict(payload.runtime_state or {})
if isinstance(runtime_state.get("steward_state"), dict) and runtime_state["steward_state"]:
return payload
if isinstance(context_json.get("steward_state"), dict) and context_json["steward_state"]:
return payload
conversation_id = _resolve_conversation_id(context_json)
if not conversation_id:
return payload
conversation = AgentConversationService(db).get_conversation(conversation_id)
stored_state = conversation.state_json.get("steward_state") if conversation and isinstance(conversation.state_json, dict) else None
if not isinstance(stored_state, dict) or not stored_state:
return payload
runtime_state["steward_state"] = stored_state
conversation_state = dict(context_json.get("conversation_state") or {})
conversation_state["steward_state"] = stored_state
context_json["conversation_state"] = conversation_state
return payload.model_copy(
update={
"runtime_state": runtime_state,
"context_json": context_json,
}
)
def _resolve_conversation_id(context_json: dict[str, Any]) -> str | None:
return str(
context_json.get("conversation_id")
or context_json.get("conversationId")
or ""
).strip() or None
def _resolve_current_steward_state(
conversation_state: dict[str, Any] | None,
context_json: dict[str, Any],
) -> dict[str, Any]:
state_json = conversation_state if isinstance(conversation_state, dict) else {}
stored_state = state_json.get("steward_state")
if isinstance(stored_state, dict) and stored_state:
return stored_state
incoming_state = context_json.get("steward_state") or context_json.get("stewardState")
return incoming_state if isinstance(incoming_state, dict) else {}

View File

@@ -198,6 +198,9 @@ class TravelReimbursementCalculatorRequest(BaseModel):
days: int = Field(ge=1, le=365)
location: str = Field(min_length=1, max_length=120)
grade: str | None = Field(default=None, max_length=30)
transport_mode: str | None = Field(default=None, max_length=30)
origin_location: str | None = Field(default=None, max_length=120)
travel_date: date | None = None
class TravelReimbursementCalculatorResponse(BaseModel):
@@ -215,6 +218,17 @@ class TravelReimbursementCalculatorResponse(BaseModel):
basic_allowance_rate: Decimal
total_allowance_rate: Decimal
allowance_amount: Decimal
transport_mode: str = ""
transport_origin: str = ""
transport_destination: str = ""
transport_estimated_amount: Decimal = Decimal("0.00")
transport_estimate_basis: str = ""
transport_estimate_confidence: str = ""
transport_estimate_source: str = ""
transport_estimate_rule_code: str = ""
transport_estimate_rule_name: str = ""
transport_estimate_rule_version: str = ""
travel_date: date | None = None
total_amount: Decimal
rule_name: str
rule_version: str

View File

@@ -8,11 +8,13 @@ from pydantic import BaseModel, Field
StewardTaskType = Literal["expense_application", "reimbursement"]
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
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",
"continue_selected_flow",
"submit_current_application",
"continue_next_task",
"fill_current_slot",
@@ -29,6 +31,8 @@ StewardTaskStatus = Literal[
"blocked",
]
StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"]
StewardFlowId = Literal["travel_application", "travel_reimbursement"]
StewardPendingFlowStatus = Literal["none", "pending", "confirmed", "rejected"]
class StewardAttachmentInput(BaseModel):
@@ -90,15 +94,39 @@ class StewardConfirmationAction(BaseModel):
payload: dict[str, Any] = Field(default_factory=dict, description="确认后继续执行所需载荷。")
class StewardCandidateFlow(BaseModel):
flow_id: StewardFlowId = Field(description="候选业务流程。")
label: str = Field(description="用户可见候选流程名称。")
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="候选流程置信度。")
reason: str = Field(default="", description="候选流程依据。")
ontology_fields: dict[str, str] = Field(default_factory=dict, description="候选流程可继承的 canonical ontology 字段。")
missing_fields: list[str] = Field(default_factory=list, description="候选流程仍缺失的 canonical ontology 字段。")
class StewardPendingFlowConfirmation(BaseModel):
status: StewardPendingFlowStatus = Field(default="none", description="候选流程确认状态。")
source_message: str = Field(default="", description="触发候选流程确认的用户原始输入。")
reason: str = Field(default="", description="需要确认流程方向的原因。")
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="候选业务流程。")
class StewardPlanResponse(BaseModel):
plan_id: str = Field(description="小财管家计划 ID。")
plan_status: str = Field(default="needs_confirmation", description="计划状态。")
planning_source: StewardPlanningSource = Field(default="rule_fallback", description="计划生成来源。")
next_action: StewardPlanNextAction = Field(default="confirm_task", description="计划完成后的下一步动作。")
conversation_id: str = Field(default="", description="持久化会话 ID。")
steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家跨轮业务状态。")
summary: str = Field(description="计划摘要。")
thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。")
tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。")
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
pending_flow_confirmation: StewardPendingFlowConfirmation = Field(
default_factory=StewardPendingFlowConfirmation,
description="申请/报销流程不明确时等待用户确认的候选流程。",
)
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="等待用户确认的候选流程快捷列表。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
@@ -146,4 +174,18 @@ class StewardRuntimeDecisionResponse(BaseModel):
question: str = Field(default="", description="需要追问用户时展示的问题。")
response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。")
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家更新后的跨轮业务状态。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
class StewardFlowStatePatch(BaseModel):
active_flow: StewardFlowId = Field(description="本轮对话正在推进的业务流程。")
flow_id: StewardFlowId = Field(description="需要合并字段的目标业务流程。")
intent: str = Field(default="", description="本轮识别出的业务意图。")
status: str = Field(default="collecting", description="流程状态。")
fields: dict[str, Any] = Field(default_factory=dict, description="待写入流程的本体字段 patch。")
missing_fields: list[str] = Field(default_factory=list, description="仍缺失的 canonical ontology 字段。")
application_claim_id: str = Field(default="", description="出差申请流程已生成的申请单 ID。")
linked_application_claim_id: str = Field(default="", description="报销流程关联的申请单 ID。")
attachments: list[dict[str, Any]] = Field(default_factory=list, description="流程关联附件摘要。")
evidence: list[dict[str, Any]] = Field(default_factory=list, description="字段来源证据。")

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from app.services.agent_asset_travel_spreadsheets import build_styled_workbook
def build_communication_expense_workbook() -> bytes:
return build_styled_workbook(
"通信费报销标准",
[
"序号",
"适用对象",
"岗位/职级范围",
"月度报销上限",
"票据要求",
"申请阶段预算口径",
"审批/例外说明",
"备注",
],
[
[
1,
"一线销售/客户成功",
"销售经理、客户成功经理、项目驻场岗位",
200,
"运营商通信费发票或电子账单",
"按月度上限占用预算",
"超出上限需直属领导审批并说明客户项目",
"仅覆盖因公通信支出",
],
[
2,
"项目交付/实施",
"实施顾问、项目经理、现场支持岗位",
150,
"运营商通信费发票或电子账单",
"按月度上限占用预算",
"长期驻场可按项目专项审批调整",
"需关联项目或客户",
],
[
3,
"管理岗位",
"部门负责人及以上",
120,
"运营商通信费发票或电子账单",
"按月度上限占用预算",
"超出上限需补充业务说明",
"按自然月核算",
],
[
4,
"普通员工",
"未单列岗位",
80,
"运营商通信费发票或电子账单",
"按月度上限占用预算",
"原则上不支持超额报销",
"特殊岗位需先维护适用对象",
],
],
column_widths=[8, 22, 30, 16, 30, 24, 38, 28],
)

View File

@@ -14,6 +14,16 @@ from zipfile import ZIP_DEFLATED, ZipFile
from openpyxl import load_workbook
from app.core.config import SERVER_DIR, get_settings
from app.services.agent_asset_finance_spreadsheets import build_communication_expense_workbook
from app.services.agent_asset_travel_spreadsheets import (
build_travel_allowance_workbook,
build_travel_grade_mapping_workbook,
build_travel_lodging_workbook_from_source,
build_travel_season_mapping_workbook,
build_travel_transport_class_workbook,
build_travel_transport_estimate_workbook,
build_xlsx_bytes_from_source_sheet,
)
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
@@ -21,11 +31,29 @@ RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
)
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "差旅住宿费标准.xlsx"
COMPANY_TRAVEL_SOURCE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE = "rule.expense.company_travel_allowance_reimbursement"
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME = "出差补助标准.xlsx"
COMPANY_TRAVEL_TRANSPORT_RULE_CODE = "rule.expense.company_travel_transport_class"
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME = "交通工具等级标准.xlsx"
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE = "rule.expense.company_travel_transport_estimate"
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME = "交通费用预估表.xlsx"
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE = "rule.expense.company_travel_grade_mapping"
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME = "差旅职级映射表.xlsx"
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE = "rule.expense.company_travel_season_mapping"
COMPANY_TRAVEL_SEASON_MAPPING_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"
TRAVEL_SPREADSHEET_RULE_CODES = {
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
}
FINANCE_RULES_LIBRARY = "finance-rules"
RISK_RULES_LIBRARY = "risk-rules"
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
@@ -284,65 +312,79 @@ class AgentAssetSpreadsheetManager:
@staticmethod
def build_company_travel_rule_template() -> bytes:
standard_rows = [
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
[
"长途交通",
"飞机、高铁、火车等跨城出行",
"行程单、车票、发票",
"据实报销",
"超预算需直属领导审批",
"优先选择公共交通",
],
[
"住宿费",
"出差住宿",
"酒店发票、入住清单",
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
"超标需总监审批",
"协议酒店优先",
],
[
"市内交通",
"出租车、网约车、地铁、公交",
"发票或电子行程单",
"150/天",
"超限需补充说明",
"夜间或无公共交通场景可豁免",
],
[
"餐补",
"出差期间日常补助",
"无需票据",
"120/天",
"系统自动核定",
"当天往返默认不享受",
],
[
"招待餐费",
"客户接待或项目宴请",
"餐饮发票、参与人清单",
"300/人",
"需业务负责人审批",
"需关联客户或项目",
],
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
@staticmethod
def build_travel_lodging_rule_template() -> bytes:
lodging_rows = [
["地区(城市)", "城市级别", "P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "备注"],
["北京", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"],
["上海", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"],
["广州", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "广交会期间可按例外流程说明"],
["深圳", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "旺季需补充超标说明"],
["杭州", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
["南京", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
["成都", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
["武汉", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
["其他地区", "其他地区", 320, 320, 320, 320, 380, 380, 380, 450, 450, "未单列城市按其他地区执行"],
]
instruction_rows = [
["字段", "填写说明"],
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
["备注", "记录豁免条件、灰度口径或制度来源。"],
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
]
return _build_xlsx_bytes(
[
("差旅报销标准", standard_rows),
("填表说明", instruction_rows),
]
source_path = (
SERVER_DIR
/ "rules"
/ FINANCE_RULES_LIBRARY
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
)
return build_travel_lodging_workbook_from_source(source_path, lodging_rows)
@staticmethod
def build_travel_allowance_rule_template() -> bytes:
return build_travel_allowance_workbook()
@staticmethod
def build_travel_transport_rule_template() -> bytes:
return build_travel_transport_class_workbook()
@staticmethod
def build_travel_grade_mapping_template() -> bytes:
return build_travel_grade_mapping_workbook()
@staticmethod
def build_travel_season_mapping_template() -> bytes:
source_path = (
SERVER_DIR
/ "rules"
/ FINANCE_RULES_LIBRARY
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
)
return build_travel_season_mapping_workbook(source_path)
@staticmethod
def build_travel_transport_estimate_rule_template() -> bytes:
return build_travel_transport_estimate_workbook()
@staticmethod
def build_company_communication_rule_template() -> bytes:
return build_communication_expense_workbook()
@staticmethod
def _build_travel_source_sheet(
sheet_name: str,
*,
fallback_rows: list[list[object]],
) -> bytes:
source_path = (
SERVER_DIR
/ "rules"
/ FINANCE_RULES_LIBRARY
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
)
if source_path.exists():
try:
return build_xlsx_bytes_from_source_sheet(source_path, sheet_name)
except (OSError, ValueError):
pass
return _build_xlsx_bytes([(sheet_name, fallback_rows)])
@staticmethod
def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
@@ -350,7 +392,17 @@ class AgentAssetSpreadsheetManager:
@staticmethod
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
return _build_xlsx_bytes([(sheet_name, [[""]])])
return _build_xlsx_bytes(
[
(
sheet_name,
[
["规则项", "适用条件", "标准/阈值", "所需材料", "审批要求", "备注"],
["", "", "", "", "", ""],
],
)
]
)
@staticmethod
def rebuild_from_uploaded_content(content: bytes) -> bytes:
@@ -360,23 +412,20 @@ class AgentAssetSpreadsheetManager:
try:
workbook = load_workbook(
filename=BytesIO(content),
read_only=True,
read_only=False,
data_only=False,
)
except Exception as exc: # noqa: BLE001
raise ValueError("无法解析上传的 Excel 表格。") from exc
sheets: list[tuple[str, list[list[object]]]] = []
for worksheet in workbook.worksheets:
rows = [
list(row)
for row in worksheet.iter_rows(values_only=True)
]
sheets.append((worksheet.title, _trim_empty_table(rows)))
if not sheets:
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
return _build_xlsx_bytes(sheets)
try:
if not workbook.worksheets:
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
rebuilt_buffer = BytesIO()
workbook.save(rebuilt_buffer)
return rebuilt_buffer.getvalue()
finally:
workbook.close()
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
@@ -544,7 +593,7 @@ def _build_styles_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
'<fonts count="1"><font><sz val="13"/><name val="Microsoft YaHei"/></font></fonts>'
'<fills count="2"><fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="gray125"/></fill></fills>'
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
@@ -562,6 +611,14 @@ def _build_styles_xml() -> str:
def _build_sheet_xml(rows: list[list[object]]) -> str:
normalized_rows = rows or [[""]]
max_column_count = max((len(row) for row in normalized_rows), default=1)
column_widths = _build_sheet_column_widths(normalized_rows, max_column_count)
column_xml = "".join(
(
f'<col min="{index}" max="{index}" width="{width}" '
'customWidth="1" bestFit="1"/>'
)
for index, width in enumerate(column_widths, start=1)
)
worksheet_rows: list[str] = []
for row_index, row in enumerate(normalized_rows, start=1):
@@ -573,15 +630,18 @@ def _build_sheet_xml(rows: list[list[object]]) -> str:
cells.append(
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>'
)
worksheet_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
worksheet_rows.append(
f'<row r="{row_index}" ht="25" customHeight="1">{"".join(cells)}</row>'
)
dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<dimension ref="{dimension}"/>'
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>"
"<sheetFormatPr defaultRowHeight=\"18\"/>"
'<sheetViews><sheetView workbookViewId="0" zoomScale="120" zoomScaleNormal="120"/></sheetViews>'
"<sheetFormatPr defaultRowHeight=\"25\" customHeight=\"1\"/>"
f"<cols>{column_xml}</cols>"
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
"</worksheet>"
)
@@ -596,6 +656,31 @@ def _column_letter(index: int) -> str:
return result
def _build_sheet_column_widths(
rows: list[list[object]],
max_column_count: int,
) -> list[str]:
widths: list[str] = []
for column_index in range(max_column_count):
max_text_width = 0.0
for row in rows[:120]:
value = row[column_index] if column_index < len(row) else ""
text = "" if value is None else str(value)
if not text:
continue
max_text_width = max(max_text_width, _estimate_display_width(text))
width = min(max(max_text_width + 4, 16), 42)
widths.append(f"{width:.1f}")
return widths
def _estimate_display_width(text: str) -> float:
width = 0.0
for char in text:
width += 2.0 if ord(char) > 127 else 1.0
return width
def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
normalized_rows = [list(row) for row in rows]
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):

View File

@@ -13,8 +13,18 @@ from app.schemas.agent_asset import (
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RULE_LIBRARY_NAMES,
SPREADSHEET_MIME_TYPE,
@@ -133,7 +143,7 @@ class AgentAssetSpreadsheetHelperMixin:
}
if config_json.get("rule_document") != expected_document:
config_json["detail_mode"] = "spreadsheet"
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
config_json["rule_library"] = library
config_json["rule_document"] = expected_document
asset.config_json = config_json
@@ -160,7 +170,7 @@ class AgentAssetSpreadsheetHelperMixin:
)
config_json = dict(asset.config_json or {})
config_json["detail_mode"] = "spreadsheet"
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
config_json["rule_library"] = library
config_json["rule_document"] = {
**self.spreadsheet_manager.build_rule_document_config(
@@ -187,6 +197,16 @@ class AgentAssetSpreadsheetHelperMixin:
return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE:
return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_ALLOWANCE_RULE_CODE:
return COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_TRANSPORT_RULE_CODE:
return COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE:
return COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE:
return COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
if asset.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE:
return COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
fallback = Path(str(asset.name or "规则表").strip()).name
return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx"

View File

@@ -0,0 +1,554 @@
from __future__ import annotations
import re
from copy import copy
from io import BytesIO
from pathlib import Path
from typing import Any
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
LODGING_SHEET_NAME = "差旅住宿费标准"
ALLOWANCE_SHEET_NAME = "出差补助标准"
TRANSPORT_CLASS_SHEET_NAME = "交通工具等级标准"
TRANSPORT_ESTIMATE_SHEET_NAME = "交通费用预估表"
TRAVEL_GRADE_LABELS = {
"P0": "实习/见习",
"P1": "基础员工",
"P2": "初级员工",
"P3": "普通员工",
"P4": "资深员工/主管",
"P5": "基层经理",
"P6": "中层经理",
"P7": "高层经理",
"P8": "董事会",
}
def build_travel_lodging_workbook_from_source(
source_path: Path,
fallback_rows: list[list[object]],
) -> bytes:
rows: list[list[object]] = []
if source_path.exists():
workbook = load_workbook(source_path, read_only=True, data_only=True)
try:
if LODGING_SHEET_NAME in workbook.sheetnames:
rows = _extract_lodging_rows(
list(workbook[LODGING_SHEET_NAME].iter_rows(values_only=True))
)
finally:
workbook.close()
if not rows:
rows = _fallback_lodging_rows(fallback_rows)
return build_styled_workbook(
LODGING_SHEET_NAME,
["序号", "地区", "地区(城市)", *TRAVEL_GRADE_KEYS, "常规超标限额"],
[
[
row[0],
row[1],
row[2],
*_expand_lodging_grade_amounts(row),
row[7],
]
for row in rows
],
column_widths=[8, 14, 28, *([12] * len(TRAVEL_GRADE_KEYS)), 16],
)
def build_travel_grade_mapping_workbook() -> bytes:
return build_styled_workbook(
"差旅职级映射表",
["序号", "职级", "职级名称", "住宿标准列", "交通标准行", "适用说明", "备注"],
[
[index, grade, TRAVEL_GRADE_LABELS[grade], grade, grade, _grade_usage_note(grade), ""]
for index, grade in enumerate(TRAVEL_GRADE_KEYS, start=1)
],
column_widths=[8, 14, 28, 14, 14, 32, 32],
)
def build_travel_allowance_workbook() -> bytes:
return build_styled_workbook(
ALLOWANCE_SHEET_NAME,
["序号", "补助区域", "伙食补助/天", "基本补助/天", "补助合计/天", "适用说明", "备注"],
[
[1, "直辖市/特区", 65, 35, 100, "北京、上海、天津、重庆、深圳等地区", "按出差自然日计算"],
[2, "其他地区", 55, 35, 90, "未单列的境内城市和地区", "申请阶段用于预算占用"],
[3, "新疆-乌鲁木齐", 75, 45, 120, "乌鲁木齐市", "按高原/远途地区补助口径执行"],
[4, "新疆-其他", 65, 40, 105, "新疆除乌鲁木齐外地区", "按远途地区补助口径执行"],
[5, "西藏", 80, 50, 130, "西藏自治区", "按高原地区补助口径执行"],
[6, "港澳台", 120, 80, 200, "香港、澳门、台湾地区", "需按出入境及外币票据要求补充材料"],
[7, "国外", 180, 120, 300, "境外国家和地区", "外币折算按财务汇率口径执行"],
],
column_widths=[8, 18, 16, 16, 16, 34, 34],
)
def build_travel_transport_class_workbook() -> bytes:
return build_styled_workbook(
TRANSPORT_CLASS_SHEET_NAME,
[
"序号",
"职级",
"职级说明",
"飞机标准",
"火车标准",
"轮船标准",
"适用说明",
"超标处理",
"备注",
],
[
[
index,
grade,
TRAVEL_GRADE_LABELS[grade],
"经济舱",
"二等座/硬卧/硬座" if grade != "P8" else "二等座/软卧/硬卧",
"二等舱",
"按已审批出差申请执行" if grade in {"P6", "P7", "P8"} else "优先选择火车或高铁;确需飞机时按经济舱执行",
"超出标准需说明原因并走审批" if grade != "P8" else "超出标准需董事会或授权审批确认",
"申请阶段按交通费用预估表占用预算" if grade != "P8" else "P8 为董事会级别",
]
for index, grade in enumerate(TRAVEL_GRADE_KEYS, start=1)
],
column_widths=[8, 18, 34, 14, 22, 14, 42, 34, 34],
)
def build_travel_season_mapping_workbook(source_path: Path) -> bytes:
rows: list[list[object]] = []
if source_path.exists():
workbook = load_workbook(source_path, read_only=True, data_only=True)
try:
if LODGING_SHEET_NAME in workbook.sheetnames:
lodging_rows = _extract_lodging_rows(
list(workbook[LODGING_SHEET_NAME].iter_rows(values_only=True))
)
rows = [
[row[0], row[1], row[2], row[3], row[7], row[8]]
for row in lodging_rows
]
finally:
workbook.close()
if not rows:
rows = [[1, "北京", "北京", "", 500, ""]]
return build_styled_workbook(
"地区淡旺季映射表",
["序号", "地区", "地区(城市)", "旺季期间(月)", "常规超标限额", "旺季超标限额"],
rows,
column_widths=[8, 14, 28, 18, 16, 16],
)
def build_travel_transport_estimate_workbook() -> bytes:
return build_styled_workbook(
TRANSPORT_ESTIMATE_SHEET_NAME,
[
"序号",
"出发城市",
"目的地",
"目的地范围",
"交通方式",
"单程预估金额",
"往返预估金额",
"置信度",
"预算占用口径",
"来源说明",
],
[
[1, "武汉", "北京", "高频城市", "火车", 520, 1040, "基础规则", "往返二等座/硬卧预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[2, "武汉", "北京", "高频城市", "飞机", 700, 1400, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
[3, "武汉", "上海", "高频城市", "火车", 360, 720, "基础规则", "往返二等座预估", "参考历史票据样例与 12306 公布票价查询口径"],
[4, "武汉", "上海", "高频城市", "飞机", 600, 1200, "基础规则", "往返经济舱预估", "参考高频航线公开往返价格,按申请预算保守占用"],
[5, "武汉", "广州", "高频城市", "火车", 470, 940, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[6, "武汉", "广州", "高频城市", "飞机", 650, 1300, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
[7, "武汉", "深圳", "高频城市", "火车", 540, 1080, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[8, "武汉", "深圳", "高频城市", "飞机", 700, 1400, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
[9, "武汉", "杭州", "高频城市", "火车", 330, 660, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[10, "武汉", "南京", "高频城市", "火车", 260, 520, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[11, "武汉", "成都", "普通城市", "火车", 350, 700, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[12, "武汉", "成都", "普通城市", "飞机", 600, 1200, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
[13, "武汉", "西安", "普通城市", "火车", 300, 600, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[14, "武汉", "厦门", "沿海城市", "火车", 450, 900, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
[15, "武汉", "厦门", "沿海城市", "飞机", 650, 1300, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和沿海航线公开价格"],
[16, "武汉", "三亚", "远途地区", "飞机", 900, 1800, "基础规则", "往返经济舱预估", "参考旅游/远途航线公开价格,申请阶段占用预算用"],
[17, "武汉", "乌鲁木齐", "远途地区", "飞机", 1600, 3200, "基础规则", "往返经济舱预估", "远途航线按预算保守占用"],
[18, "武汉", "拉萨", "远途地区", "飞机", 1800, 3600, "基础规则", "往返经济舱预估", "远途航线按预算保守占用"],
[19, "*", "", "高频城市", "火车", 520, 1040, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
[20, "*", "", "高频城市", "飞机", 650, 1300, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
[21, "*", "", "沿海城市", "火车", 520, 1040, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
[22, "*", "", "沿海城市", "飞机", 700, 1400, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
[23, "*", "", "远途地区", "火车", 900, 1800, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
[24, "*", "", "远途地区", "飞机", 1600, 3200, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
[25, "*", "", "普通城市", "火车", 360, 720, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
[26, "*", "", "普通城市", "飞机", 600, 1200, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
[27, "*", "", "普通城市", "轮船", 320, 640, "兜底", "往返二等舱预估", "水路交通暂无实时接口时使用"],
],
column_widths=[8, 14, 18, 16, 12, 16, 16, 12, 24, 42],
)
def build_xlsx_bytes_from_source_sheet(source_path: Path, sheet_name: str) -> bytes:
source_workbook = load_workbook(source_path, read_only=False, data_only=False)
try:
if sheet_name not in source_workbook.sheetnames:
raise ValueError("原始规则表中没有对应工作表。")
source_sheet = source_workbook[sheet_name]
target_workbook = Workbook()
target_sheet = target_workbook.active
target_sheet.title = sheet_name
_copy_worksheet(source_sheet, target_sheet)
_clarify_travel_source_sheet_headers(sheet_name, target_sheet)
_remove_redundant_title_row(target_sheet, sheet_name)
target_sheet.sheet_view.zoomScale = 120
target_sheet.sheet_view.zoomScaleNormal = 120
workbook_buffer = BytesIO()
target_workbook.save(workbook_buffer)
target_workbook.close()
return workbook_buffer.getvalue()
finally:
source_workbook.close()
def build_styled_workbook(
sheet_name: str,
headers: list[str],
rows: list[list[object]],
*,
column_widths: list[int],
) -> bytes:
workbook = Workbook()
worksheet = workbook.active
worksheet.title = sheet_name
header_fill = PatternFill(fill_type="solid", fgColor="FFD9EAF7")
thin_side = Side(style="thin", color="FF7F9DB9")
table_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
for column_index, header in enumerate(headers, start=1):
cell = worksheet.cell(row=1, column=column_index, value=header)
cell.font = Font(name="Microsoft YaHei", size=12, bold=True, color="FF0F172A")
cell.fill = header_fill
cell.border = table_border
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
worksheet.row_dimensions[1].height = 30
for row_index, row in enumerate(rows, start=2):
for column_index, value in enumerate(row, start=1):
cell = worksheet.cell(row=row_index, column=column_index, value=value)
cell.font = Font(name="Microsoft YaHei", size=11, color="FF0F172A")
cell.border = table_border
cell.alignment = Alignment(vertical="center", wrap_text=True)
worksheet.row_dimensions[row_index].height = 30
for column_index, width in enumerate(column_widths, start=1):
worksheet.column_dimensions[_column_letter(column_index)].width = width
worksheet.freeze_panes = "A2"
worksheet.sheet_view.zoomScale = 120
worksheet.sheet_view.zoomScaleNormal = 120
workbook_buffer = BytesIO()
workbook.save(workbook_buffer)
workbook.close()
return workbook_buffer.getvalue()
def _extract_lodging_rows(source_rows: list[tuple[Any, ...]]) -> list[list[object]]:
header_index = -1
indexes: dict[str, int] = {}
expected_headers = {
"seq": "序号",
"region": "地区",
"city": "地区(城市)",
"peak_period": "旺季期间",
"p7": "公司级管理人员、高层经理P7及以上",
"p4": "中层经理、基层经理P4-P6、外聘专家",
"p1": "其他员工",
"regular_limit": "超标限额",
"peak_limit": "旺季超标限额",
}
for row_index, row in enumerate(source_rows[:10]):
values = [str(value or "").strip() for value in row]
if "地区(城市)" not in values:
continue
for key, label in expected_headers.items():
if label in values:
indexes[key] = values.index(label)
header_index = row_index
break
if header_index < 0 or "city" not in indexes:
return []
rows: list[list[object]] = []
for row in source_rows[header_index + 1 :]:
region = _row_value(row, indexes.get("region", -1))
raw_city = _row_value(row, indexes.get("city", -1))
cities = _split_location_names(raw_city)
if not cities:
continue
period_by_city, shared_period = _parse_peak_periods(
_row_value(row, indexes.get("peak_period", -1))
)
for city in cities:
period = period_by_city.get(_normalize_period_key(city), shared_period)
rows.append(
[
_row_value(row, indexes.get("seq", -1)),
region,
city,
period,
_row_value(row, indexes.get("p7", -1)),
_row_value(row, indexes.get("p4", -1)),
_row_value(row, indexes.get("p1", -1)),
_row_value(row, indexes.get("regular_limit", -1)),
_row_value(row, indexes.get("peak_limit", -1)) if period else "",
]
)
return rows
def _fallback_lodging_rows(fallback_rows: list[list[object]]) -> list[list[object]]:
rows: list[list[object]] = []
for index, row in enumerate(fallback_rows[1:], start=1):
if len(row) >= 11:
junior_amount = row[5]
manager_amount = row[8]
executive_amount = row[10]
else:
junior_amount = row[2] if len(row) > 2 else ""
manager_amount = row[3] if len(row) > 3 else ""
executive_amount = row[4] if len(row) > 4 else ""
rows.append(
[
index,
"",
row[0] if len(row) > 0 else "",
"",
executive_amount,
manager_amount,
junior_amount,
executive_amount,
"",
]
)
return rows
def _expand_lodging_grade_amounts(row: list[object]) -> list[object]:
executive_amount = row[4] if len(row) > 4 else ""
manager_amount = row[5] if len(row) > 5 else ""
junior_amount = row[6] if len(row) > 6 else ""
return [
junior_amount,
junior_amount,
junior_amount,
junior_amount,
manager_amount,
manager_amount,
manager_amount,
executive_amount,
executive_amount,
]
def _grade_usage_note(grade: str) -> str:
if grade == "P8":
return "最高职级,适用于董事会"
if grade in {"P6", "P7"}:
return "适用于中高层管理人员"
if grade in {"P4", "P5"}:
return "适用于主管及基层管理人员"
return "适用于员工序列"
def _split_location_names(value: object) -> list[str]:
text = str(value or "").strip()
if not text:
return []
text = re.sub(r"[(].*?[)]", "", text)
text = re.sub(r"^\s*\d+\s*个中心城区[、,]?", "", text)
text = re.sub(r"[;,/]+", "", text)
names: list[str] = []
for part in text.split(""):
cleaned = _normalize_location_name(part)
if not cleaned or cleaned == "中心城区":
continue
names.append(cleaned)
return list(dict.fromkeys(names))
def _parse_peak_periods(value: object) -> tuple[dict[str, str], str]:
text = str(value or "").strip()
if not text:
return ({}, "")
period_by_city: dict[str, str] = {}
for part in re.split(r"[;]", text):
if "" not in part and ":" not in part:
continue
city, period = re.split(r"[:]", part, maxsplit=1)
normalized_city = _normalize_period_key(city)
normalized_period = _normalize_peak_period(period)
if normalized_city and normalized_period:
period_by_city[normalized_city] = normalized_period
if period_by_city:
return (period_by_city, "")
return ({}, _normalize_peak_period(text))
def _normalize_peak_period(value: object) -> str:
text = str(value or "").strip()
text = re.sub(r"\s+", "", text)
text = re.sub(r"(月|上旬|中旬|下旬)", "", text)
text = re.sub(r"[、,;;]+", ",", text)
text = re.sub(r"[^0-9,\-]", "", text)
text = re.sub(r",{2,}", ",", text).strip(",")
return text
def _normalize_period_key(value: object) -> str:
return _normalize_location_name(value).removesuffix("")
def _normalize_location_name(value: object) -> str:
text = str(value or "").strip()
text = re.sub(r"\s+", "", text)
text = text.removesuffix("")
if text != "其他地区":
text = text.removesuffix("地区")
return text
def _row_value(row: tuple[Any, ...], index: int) -> object:
if index < 0 or index >= len(row):
return ""
return "" if row[index] is None else row[index]
def _copy_worksheet(source_sheet, target_sheet) -> None:
target_sheet.freeze_panes = source_sheet.freeze_panes
target_sheet.sheet_format = copy(source_sheet.sheet_format)
target_sheet.sheet_properties = copy(source_sheet.sheet_properties)
target_sheet.page_margins = copy(source_sheet.page_margins)
target_sheet.page_setup = copy(source_sheet.page_setup)
target_sheet.print_options = copy(source_sheet.print_options)
for row in source_sheet.iter_rows():
for source_cell in row:
target_cell = target_sheet[source_cell.coordinate]
target_cell.value = source_cell.value
if source_cell.has_style:
target_cell.font = copy(source_cell.font)
target_cell.fill = copy(source_cell.fill)
target_cell.border = copy(source_cell.border)
target_cell.alignment = copy(source_cell.alignment)
target_cell.protection = copy(source_cell.protection)
target_cell.number_format = source_cell.number_format
if source_cell.hyperlink:
target_cell._hyperlink = copy(source_cell.hyperlink)
if source_cell.comment:
target_cell.comment = copy(source_cell.comment)
for merged_range in source_sheet.merged_cells.ranges:
target_sheet.merge_cells(str(merged_range))
for key, source_dimension in source_sheet.column_dimensions.items():
target_dimension = target_sheet.column_dimensions[key]
target_dimension.width = source_dimension.width
target_dimension.hidden = source_dimension.hidden
target_dimension.bestFit = source_dimension.bestFit
target_dimension.outlineLevel = source_dimension.outlineLevel
target_dimension.collapsed = source_dimension.collapsed
for index, source_dimension in source_sheet.row_dimensions.items():
target_dimension = target_sheet.row_dimensions[index]
target_dimension.height = source_dimension.height
target_dimension.hidden = source_dimension.hidden
target_dimension.outlineLevel = source_dimension.outlineLevel
target_dimension.collapsed = source_dimension.collapsed
def _clarify_travel_source_sheet_headers(sheet_name: str, worksheet) -> None:
if sheet_name == "交通工具等级标准":
worksheet["A4"] = "P5+"
worksheet["A5"] = "P1-P4"
worksheet.row_dimensions[4].height = max(worksheet.row_dimensions[4].height or 0, 42)
worksheet.row_dimensions[5].height = max(worksheet.row_dimensions[5].height or 0, 42)
worksheet.column_dimensions["A"].width = max(worksheet.column_dimensions["A"].width or 0, 18)
def _remove_redundant_title_row(worksheet, title: str) -> None:
first_cell_value = str(worksheet["A1"].value or "").strip()
if first_cell_value != str(title or "").strip():
return
has_other_first_row_values = any(
str(worksheet.cell(row=1, column=column_index).value or "").strip()
for column_index in range(2, worksheet.max_column + 1)
)
if has_other_first_row_values:
return
shifted_merged_ranges: list[tuple[int, int, int, int]] = []
for merged_range in list(worksheet.merged_cells.ranges):
range_text = str(merged_range)
min_col = merged_range.min_col
min_row = merged_range.min_row
max_col = merged_range.max_col
max_row = merged_range.max_row
worksheet.unmerge_cells(range_text)
if min_row <= 1:
continue
shifted_merged_ranges.append((min_col, min_row - 1, max_col, max_row - 1))
old_freeze_panes = worksheet.freeze_panes
worksheet.delete_rows(1, 1)
for min_col, min_row, max_col, max_row in shifted_merged_ranges:
worksheet.merge_cells(
start_row=min_row,
start_column=min_col,
end_row=max_row,
end_column=max_col,
)
worksheet.freeze_panes = _shift_freeze_panes_after_deleted_first_row(old_freeze_panes)
def _shift_freeze_panes_after_deleted_first_row(freeze_panes: object) -> str | None:
if not freeze_panes:
return None
coordinate = str(freeze_panes)
match = re.fullmatch(r"([A-Z]+)([0-9]+)", coordinate)
if not match:
return coordinate
column, row_text = match.groups()
row_index = int(row_text)
if row_index <= 1:
return None
return f"{column}{row_index - 1}"
def _column_letter(index: int) -> str:
value = max(1, int(index))
result = ""
while value > 0:
value, remainder = divmod(value - 1, 26)
result = f"{chr(65 + remainder)}{result}"
return result

View File

@@ -74,7 +74,7 @@ class AgentAssetService(
) -> list[AgentAssetListItem]:
self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library()
self.sync_rule_assets_from_libraries()
assets = self.repository.list(
asset_type=asset_type, status=status, domain=domain, keyword=keyword
)
@@ -94,7 +94,7 @@ class AgentAssetService(
) -> PageResult[AgentAssetListItem]:
self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library()
self.sync_rule_assets_from_libraries()
assets = self.repository.list(
asset_type=asset_type,
status=status,
@@ -552,6 +552,13 @@ class AgentAssetService(
self.db.commit()
return manifest_count
def sync_rule_assets_from_libraries(self) -> int:
foundation = AgentFoundationService(self.db)
synced_count = foundation.sync_finance_rule_assets_from_catalog()
synced_count += foundation.sync_platform_risk_rules_from_library()
self.db.commit()
return synced_count
def _validate_version_payload(
self, asset: AgentAsset, payload: AgentAssetVersionCreate
) -> None:

View File

@@ -19,6 +19,7 @@ STATEFUL_CONTEXT_KEYS = (
"ocr_summary",
"ocr_documents",
"review_form_values",
"steward_state",
"business_time_context",
)
REVIEW_FLOW_CONTEXT_KEYS = {

View File

@@ -270,7 +270,7 @@ class AgentFoundationAssetSeedMixin:
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -296,7 +296,7 @@ class AgentFoundationAssetSeedMixin:
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -320,7 +320,7 @@ class AgentFoundationAssetSeedMixin:
config_json={
"severity": "high",
"enabled": True,
"tag": "财务规则",
"tag": "申请规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
@@ -729,7 +729,7 @@ class AgentFoundationAssetSeedMixin:
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC),
),
AgentAssetReview(
@@ -737,7 +737,7 @@ class AgentFoundationAssetSeedMixin:
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC),
),
]

View File

@@ -368,7 +368,7 @@ class AgentFoundationAssetTopUpMixin:
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -391,7 +391,7 @@ class AgentFoundationAssetTopUpMixin:
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -415,7 +415,7 @@ class AgentFoundationAssetTopUpMixin:
config_json={
"severity": "high",
"enabled": True,
"tag": "财务规则",
"tag": "申请规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
@@ -453,7 +453,7 @@ class AgentFoundationAssetTopUpMixin:
**(company_travel_rule.config_json or {}),
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
@@ -489,7 +489,7 @@ class AgentFoundationAssetTopUpMixin:
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC),
)
@@ -523,7 +523,7 @@ class AgentFoundationAssetTopUpMixin:
**(company_communication_rule.config_json or {}),
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"tag": "基础规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
@@ -569,7 +569,7 @@ class AgentFoundationAssetTopUpMixin:
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=datetime.now(UTC),
)
@@ -591,7 +591,7 @@ class AgentFoundationAssetTopUpMixin:
**(company_preapproval_rule.config_json or {}),
"severity": "high",
"enabled": True,
"tag": "财务规则",
"tag": "申请规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
@@ -640,7 +640,7 @@ class AgentFoundationAssetTopUpMixin:
version=COMPANY_PREAPPROVAL_RULE_VERSION,
reviewer="顾承宣",
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版费用申请审批规则表已确认,可作为财务规则使用。",
review_note="首版费用申请审批规则表已确认,可作为申请规则使用。",
reviewed_at=datetime.now(UTC),
)

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
def build_preapproval_rule_workbook_sheets() -> list[tuple[str, list[list[object]]]]:
return [
(
"费用申请审批规则",
[
[
"费用类型代码",
"费用类型",
"触发条件",
"阈值金额",
"前置要求",
"审批要求",
"风险动作",
"备注",
],
[
"meal/entertainment",
"业务招待费",
"单次费用金额大于 500 元",
500,
"必须先提交费用申请单,并说明客户、参与人和招待事由",
"申请单需按审批链完成审批后方可报销",
"报销阶段未关联已通过申请单时标记高风险",
"适配 meal 与 entertainment 两个本体费用类型",
],
[
"office",
"办公用品费",
"单次或批量采购金额大于 2000 元",
2000,
"必须先提交办公采购或费用申请单",
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
"报销阶段未关联已通过申请单时标记高风险",
"覆盖办公用品、办公耗材、低值易耗品等场景",
],
[
"all",
"通用大额费用",
"任意费用金额大于 2000 元",
2000,
"必须进入费用申请和审批流程",
"至少完成直属领导审批;按预算和基础规则继续流转",
"报销阶段未关联已通过申请单时标记高风险",
"差旅、通信等已有专项规则时可同时适用专项规则",
],
],
),
(
"字段说明",
[
["字段", "说明"],
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
["审批要求", "说明申请单进入审批链后的最低审批要求"],
["风险动作", "说明报销阶段未满足规则时的系统处理"],
],
),
]

View File

@@ -5,6 +5,10 @@ from pathlib import Path
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetType,
AgentReviewStatus,
AgentAssetStatus,
)
from app.core.logging import get_logger
@@ -12,22 +16,38 @@ 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_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
AgentAssetSpreadsheetManager,
)
from app.services.agent_foundation_constants import (
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,
)
from app.services.finance_rule_catalog import (
DEPRECATED_FINANCE_RULE_CODES,
DEPRECATED_FINANCE_RULE_REPLACEMENTS,
)
from app.services.agent_foundation_preapproval_spreadsheet import (
build_preapproval_rule_workbook_sheets,
)
logger = get_logger("app.services.agent_foundation")
@@ -44,25 +64,131 @@ class AgentFoundationSpreadsheetMixin:
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="差旅住宿报销标准",
description="按地区和职级维护差旅住宿费报销上限。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="差旅住宿费标准",
expense_types=["travel", "hotel", "transport"],
expense_types=["hotel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_lodging_rule_template(),
rule_template_label="差旅住宿 Excel 模板",
travel_policy_component="lodging",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
name="出差补助报销标准",
description="按地区维护伙食补助、基本出差补贴和补助合计。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="出差补助标准",
expense_types=["travel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_allowance_rule_template(),
rule_template_label="出差补助 Excel 模板",
travel_policy_component="allowance",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
name="交通工具等级标准",
description="按员工职级维护飞机、火车等长途交通工具等级。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="交通工具等级标准",
expense_types=["travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_rule_template(),
rule_template_label="交通工具等级 Excel 模板",
travel_policy_component="transport",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
name="交通费用预估表",
description="按出发城市、目的地和交通方式维护申请阶段预算占用的交通费用预估金额。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="交通费用预估表",
expense_types=["travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_estimate_rule_template(),
rule_template_label="交通费用预估 Excel 模板",
travel_policy_component="transport_estimate",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
name="差旅职级映射表",
description="明确 P0-P8 九级职级与住宿、交通规则列之间的对应关系,其中 P8 为董事会。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="差旅职级映射表",
expense_types=["hotel", "travel", "transport"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_grade_mapping_template(),
rule_template_label="差旅职级映射 Excel 模板",
travel_policy_component="grade_mapping",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
name="地区淡旺季映射表",
description="明确住宿标准中旺季地区、旺季月份和旺季超标限额的对应关系。",
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="地区淡旺季映射表",
expense_types=["hotel", "travel"],
version=COMPANY_TRAVEL_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_travel_season_mapping_template(),
rule_template_label="地区淡旺季映射 Excel 模板",
travel_policy_component="season_mapping",
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
finance_rule_sheet="通信费报销标准",
expense_types=["communication"],
version=COMPANY_COMMUNICATION_RULE_VERSION,
reviewer="顾承宇",
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
workbook_content=AgentAssetSpreadsheetManager.build_company_communication_rule_template(),
rule_template_label="通信费报销 Excel 模板",
finance_rule_code="expense.communication.policy",
refresh_workbook_content=True,
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_PREAPPROVAL_RULE_CODE,
name="公司费用申请审批规则",
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="费用申请审批规则",
expense_types=["meal", "entertainment", "office", "all"],
version=COMPANY_PREAPPROVAL_RULE_VERSION,
reviewer="顾承宣",
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
workbook_content=None,
rule_template_label="费用申请审批 Excel 模板",
finance_rule_code="expense.preapproval.policy",
tag="申请规则",
)
)
return synced_count
@@ -71,30 +197,183 @@ class AgentFoundationSpreadsheetMixin:
self,
*,
code: str,
name: str,
description: str,
scenario_category: str,
finance_rule_sheet: str,
expense_types: list[str],
version: str,
reviewer: str,
file_name: str,
workbook_content: bytes | None,
rule_template_label: str,
finance_rule_code: str | None = None,
travel_policy_component: str = "",
tag: str = "基础规则",
refresh_workbook_content: bool = False,
) -> bool:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
created_asset = asset is None
if asset is None:
return False
asset = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=code,
name=name,
description=description,
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=[scenario_category],
owner="财务制度管理组",
reviewer=reviewer,
status=AgentAssetStatus.ACTIVE.value,
current_version=version,
config_json={
"severity": "medium",
"enabled": True,
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": scenario_category,
"ai_review_category": scenario_category,
"finance_rule_code": code,
"finance_rule_sheet": finance_rule_sheet,
"expense_types": expense_types,
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": rule_template_label,
},
)
else:
asset.name = name
asset.description = description
asset.owner = asset.owner or "财务制度管理组"
asset.reviewer = asset.reviewer or reviewer
if not str(asset.current_version or "").strip():
asset.current_version = version
if not str(asset.working_version or "").strip():
asset.working_version = asset.current_version
if not str(asset.published_version or "").strip():
asset.published_version = asset.current_version
if not str(asset.status or "").strip() or asset.status == AgentAssetStatus.DISABLED.value:
asset.status = AgentAssetStatus.ACTIVE.value
asset.scenario_json = [scenario_category]
asset.config_json = {
config_json = {
**(asset.config_json or {}),
"enabled": True,
"tag": "财务规则",
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": scenario_category,
"ai_review_category": scenario_category,
"finance_rule_code": code,
"finance_rule_code": finance_rule_code or code,
"finance_rule_sheet": finance_rule_sheet,
"expense_types": expense_types,
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
"rule_template_label": rule_template_label,
}
if travel_policy_component:
config_json["travel_policy_component"] = travel_policy_component
asset.config_json = config_json
rule_document = (asset.config_json or {}).get("rule_document")
has_rule_document = isinstance(rule_document, dict) and bool(
str(rule_document.get("storage_key") or "").strip()
)
if workbook_content is not None and (
created_asset or not has_rule_document or refresh_workbook_content
):
self._ensure_finance_rule_asset_document(
asset,
version=version,
reviewer=reviewer,
file_name=file_name,
content=workbook_content,
force_live_document=refresh_workbook_content,
)
return True
def _ensure_finance_rule_asset_document(
self,
asset: AgentAsset,
*,
version: str,
reviewer: str,
file_name: str,
content: bytes,
force_live_document: bool = False,
) -> None:
manager = AgentAssetSpreadsheetManager()
manager.ensure_rule_library_dirs()
rule_document = (asset.config_json or {}).get("rule_document")
storage_key = (
str(rule_document.get("storage_key") or "").strip()
if isinstance(rule_document, dict)
else ""
)
should_seed_file = force_live_document or not storage_key
if storage_key:
try:
current_path = manager.resolve_storage_path(storage_key)
except FileNotFoundError:
current_path = None
should_seed_file = should_seed_file or current_path is None or not current_path.exists()
if should_seed_file:
metadata = manager.store_rule_library_spreadsheet(
library=FINANCE_RULES_LIBRARY,
file_name=file_name,
content=content,
actor_name="系统初始化",
source="rule-library",
)
asset.config_json = {
**(asset.config_json or {}),
"rule_document": {
**AgentAssetSpreadsheetManager.build_rule_document_config(
metadata,
asset_version=version,
),
"storage_key": metadata.storage_key,
},
}
else:
metadata = manager.store_rule_library_spreadsheet_snapshot(
library=FINANCE_RULES_LIBRARY,
asset_id=asset.id,
version=version,
file_name=file_name,
content=content,
actor_name="系统初始化",
source="rule-library-version",
)
self._ensure_asset_version(
asset,
version=version,
content=AgentAssetSpreadsheetManager.build_version_markdown(
rule_name=asset.name,
version=version,
metadata=metadata,
),
content_type=AgentAssetContentType.MARKDOWN.value,
change_note=f"初始化{asset.name} Excel 规则表。",
created_by="系统初始化",
)
self._ensure_asset_review(
asset,
version=version,
reviewer=reviewer,
review_status=AgentReviewStatus.APPROVED.value,
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
reviewed_at=None,
)
def _hide_deprecated_finance_rule_assets(self) -> None:
for code in DEPRECATED_FINANCE_RULE_CODES:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
@@ -105,16 +384,16 @@ class AgentFoundationSpreadsheetMixin:
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
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 {}),
@@ -196,7 +475,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet",
"tag": "财务规则",
"tag": "基础规则",
"rule_tag": "基础规则",
"tags": ["基础规则"],
"rule_tags": ["基础规则"],
"rule_library": FINANCE_RULES_LIBRARY,
@@ -224,7 +506,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet",
"tag": "财务规则",
"tag": "基础规则",
"rule_tag": "基础规则",
"tags": ["基础规则"],
"rule_tags": ["基础规则"],
"rule_library": FINANCE_RULES_LIBRARY,
@@ -299,65 +584,9 @@ class AgentFoundationSpreadsheetMixin:
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
fallback_sheet_name="费用申请审批规则",
tag="申请规则",
workbook_sheets=[
(
"费用申请审批规则",
[
[
"费用类型代码",
"费用类型",
"触发条件",
"阈值金额",
"前置要求",
"审批要求",
"风险动作",
"备注",
],
[
"meal/entertainment",
"业务招待费",
"单次费用金额大于 500 元",
500,
"必须先提交费用申请单,并说明客户、参与人和招待事由",
"申请单需按审批链完成审批后方可报销",
"报销阶段未关联已通过申请单时标记高风险",
"适配 meal 与 entertainment 两个本体费用类型",
],
[
"office",
"办公用品费",
"单次或批量采购金额大于 2000 元",
2000,
"必须先提交办公采购或费用申请单",
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
"报销阶段未关联已通过申请单时标记高风险",
"覆盖办公用品、办公耗材、低值易耗品等场景",
],
[
"all",
"通用大额费用",
"任意费用金额大于 2000 元",
2000,
"必须进入费用申请和审批流程",
"至少完成直属领导审批;按预算和财务规则继续流转",
"报销阶段未关联已通过申请单时标记高风险",
"差旅、通信等已有专项规则时可同时适用专项规则",
],
],
),
(
"字段说明",
[
["字段", "说明"],
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
["审批要求", "说明申请单进入审批链后的最低审批要求"],
["风险动作", "说明报销阶段未满足规则时的系统处理"],
],
),
],
workbook_sheets=build_preapproval_rule_workbook_sheets(),
)
@@ -385,7 +614,7 @@ class AgentFoundationSpreadsheetMixin:
return live_path.read_bytes()
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
def _ensure_finance_rule_spreadsheet_seed(
@@ -404,6 +633,7 @@ class AgentFoundationSpreadsheetMixin:
fallback_sheet_name: str,
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
tag: str = "基础规则",
):
@@ -473,7 +703,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet",
"tag": "财务规则",
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"rule_library": FINANCE_RULES_LIBRARY,
@@ -501,7 +734,10 @@ class AgentFoundationSpreadsheetMixin:
"detail_mode": "spreadsheet",
"tag": "财务规则",
"tag": tag,
"rule_tag": tag,
"tags": [tag],
"rule_tags": [tag],
"rule_library": FINANCE_RULES_LIBRARY,

View File

@@ -11,6 +11,33 @@ from app.services.expense_claim_risk_stage import (
)
def _normalize_basic_rule_refs(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
refs: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for item in value:
if not isinstance(item, dict):
continue
code = str(item.get("code") or item.get("rule_code") or "").strip()
sheet = str(item.get("sheet") or item.get("rule_sheet") or "").strip()
if not code and not sheet:
continue
key = (code, sheet)
if key in seen:
continue
seen.add(key)
refs.append(
{
"code": code,
"sheet": sheet,
"name": str(item.get("name") or "").strip(),
"component": str(item.get("component") or "").strip(),
}
)
return refs
def build_platform_risk_flag(
manifest: dict[str, Any],
*,
@@ -55,6 +82,42 @@ def build_platform_risk_flag(
metadata.get("actionability") or manifest.get("actionability"),
default_actionability,
)
finance_rule_code = str(
manifest.get("finance_rule_code")
or metadata.get("finance_rule_code")
or manifest.get("basic_rule_code")
or metadata.get("basic_rule_code")
or ""
).strip()
finance_rule_sheet = str(
manifest.get("finance_rule_sheet")
or metadata.get("finance_rule_sheet")
or manifest.get("basic_rule_sheet")
or metadata.get("basic_rule_sheet")
or ""
).strip()
basic_rule_code = str(
manifest.get("basic_rule_code")
or metadata.get("basic_rule_code")
or finance_rule_code
).strip()
basic_rule_sheet = str(
manifest.get("basic_rule_sheet")
or metadata.get("basic_rule_sheet")
or finance_rule_sheet
).strip()
basic_rule_refs = _normalize_basic_rule_refs(
manifest.get("basic_rule_refs") or metadata.get("basic_rule_refs")
)
if not basic_rule_refs and (basic_rule_code or basic_rule_sheet):
basic_rule_refs = [
{
"code": basic_rule_code,
"sheet": basic_rule_sheet,
"name": "",
"component": "",
}
]
return with_risk_business_stage(
{
@@ -63,8 +126,11 @@ 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(),
"basic_rule_code": basic_rule_code,
"basic_rule_sheet": basic_rule_sheet,
"basic_rule_refs": basic_rule_refs,
"finance_rule_code": finance_rule_code,
"finance_rule_sheet": finance_rule_sheet,
"severity": severity,
"action": action,
"label": label,

View File

@@ -14,7 +14,9 @@ from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetT
from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.services.agent_asset_spreadsheet import (
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
AgentAssetSpreadsheetManager,
TRAVEL_SPREADSHEET_RULE_CODES,
)
from app.services.expense_rule_runtime_defaults import (
DEFAULT_SCENE_MATRIX_CONFIG,
@@ -39,6 +41,14 @@ from app.services.expense_rule_runtime_standards import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
)
from app.services.expense_rule_runtime_spreadsheet_extractors import (
extract_hotel_season_limits,
extract_normalized_transport_class_limits,
extract_normalized_travel_allowance_limits,
map_transport_grade_row_to_bands,
transport_class_level_for_text,
)
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
class ExpenseRuleRuntimeService:
def __init__(self, db: Session) -> None:
@@ -59,16 +69,18 @@ class ExpenseRuleRuntimeService:
assets = []
asset_ids = {asset.id for asset in assets}
travel_spreadsheet_asset = self.db.scalar(
select(AgentAsset)
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
.limit(1)
travel_spreadsheet_assets = list(
self.db.scalars(
select(AgentAsset)
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
.where(AgentAsset.code.in_(TRAVEL_SPREADSHEET_RULE_CODES))
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
).all()
)
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids:
assets.append(travel_spreadsheet_asset)
for travel_spreadsheet_asset in travel_spreadsheet_assets:
if travel_spreadsheet_asset.id not in asset_ids:
assets.append(travel_spreadsheet_asset)
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
for asset in assets:
@@ -76,7 +88,7 @@ class ExpenseRuleRuntimeService:
if version is None:
continue
is_travel_spreadsheet_asset = (
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE
str(asset.code or "").strip() in TRAVEL_SPREADSHEET_RULE_CODES
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
)
runtime_payload = self._extract_runtime_payload(
@@ -173,7 +185,7 @@ class ExpenseRuleRuntimeService:
asset: AgentAsset,
version: AgentAssetVersion,
) -> None:
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE:
if str(asset.code or "").strip() not in TRAVEL_SPREADSHEET_RULE_CODES:
return
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
return
@@ -183,7 +195,9 @@ class ExpenseRuleRuntimeService:
rule_document = (asset.config_json or {}).get("rule_document")
if not isinstance(rule_document, dict):
rule_document = {}
storage_key = str(metadata.storage_key if metadata is not None else "").strip()
storage_key = str(rule_document.get("storage_key") or "").strip()
if not storage_key and metadata is not None:
storage_key = str(metadata.storage_key or "").strip()
if storage_key:
try:
workbook_path = manager.resolve_storage_path(storage_key)
@@ -217,24 +231,48 @@ class ExpenseRuleRuntimeService:
try:
standards = self._extract_travel_amount_standards_from_workbook(workbook)
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
hotel_season_limits = self._extract_hotel_season_limits_from_workbook(workbook)
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
transport_estimates = self._extract_transport_estimates_from_workbook(workbook)
finally:
workbook.close()
standard_rule_version = str(
rule_document.get("asset_version") or asset.current_version or version.version
).strip()
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None:
if (
hotel_city_limits
or hotel_season_limits.get("hotel_peak_periods")
or hotel_season_limits.get("hotel_peak_city_limits")
or allowance_limits
or transport_limits
or transport_estimates
) and catalog.travel_policy is not None:
payload = catalog.travel_policy.model_dump()
payload["standard_rule_code"] = asset.code
payload["standard_rule_name"] = asset.name
payload["standard_rule_version"] = standard_rule_version
if str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
payload["standard_rule_code"] = asset.code
payload["standard_rule_name"] = asset.name
payload["standard_rule_version"] = standard_rule_version
if str(asset.code or "").strip() == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE:
payload["transport_estimate_rule_code"] = asset.code
payload["transport_estimate_rule_name"] = asset.name
payload["transport_estimate_rule_version"] = standard_rule_version
if hotel_city_limits:
payload["hotel_city_limits"] = {
**payload.get("hotel_city_limits", {}),
**hotel_city_limits,
}
if hotel_season_limits.get("hotel_peak_periods"):
payload["hotel_peak_periods"] = {
**payload.get("hotel_peak_periods", {}),
**hotel_season_limits["hotel_peak_periods"],
}
if hotel_season_limits.get("hotel_peak_city_limits"):
payload["hotel_peak_city_limits"] = {
**payload.get("hotel_peak_city_limits", {}),
**hotel_season_limits["hotel_peak_city_limits"],
}
if allowance_limits:
payload["allowance_limits"] = {
**payload.get("allowance_limits", {}),
@@ -245,6 +283,12 @@ class ExpenseRuleRuntimeService:
**payload.get("transport_limits", {}),
**transport_limits,
}
if transport_estimates:
existing_estimates = list(payload.get("transport_estimates") or [])
payload["transport_estimates"] = [
*existing_estimates,
*transport_estimates,
]
catalog.travel_policy = RuntimeTravelPolicy(**payload)
for expense_type, amount in standards.items():
@@ -317,6 +361,10 @@ class ExpenseRuleRuntimeService:
continue
for column_index, header in enumerate(values):
compact = re.sub(r"\s+", "", header)
grade_key = ExpenseRuleRuntimeService._extract_exact_grade_header(compact)
if grade_key:
band_indexes[grade_key] = column_index
continue
if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
band_indexes["junior"] = column_index
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
@@ -347,6 +395,17 @@ class ExpenseRuleRuntimeService:
city_entry[band] = amount
return city_limits
@staticmethod
def _extract_exact_grade_header(value: str) -> str:
compact = re.sub(r"\s+", "", str(value or "").upper())
if not compact or any(token in compact for token in ("-", "+", "及以上")):
return ""
match = re.match(r"^(P[0-8])(?:级|董事会)?$", compact)
if match is None:
return ""
grade_key = match.group(1)
return grade_key if grade_key in TRAVEL_GRADE_KEYS else ""
@staticmethod
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
allowance_limits: dict[str, dict[str, Decimal]] = {}
@@ -355,6 +414,12 @@ class ExpenseRuleRuntimeService:
if not rows:
continue
normalized_limits = extract_normalized_travel_allowance_limits(rows)
if normalized_limits:
for allowance_key, region_amounts in normalized_limits.items():
allowance_limits.setdefault(allowance_key, {}).update(region_amounts)
continue
header_index = -1
type_index = -1
region_indexes: dict[str, int] = {}
@@ -393,6 +458,19 @@ class ExpenseRuleRuntimeService:
allowance_limits[allowance_key] = entry
return allowance_limits
@staticmethod
def _extract_hotel_season_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Any]]:
peak_periods: dict[str, str] = {}
peak_limits: dict[str, Decimal] = {}
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
limits = extract_hotel_season_limits(rows)
peak_periods.update(limits.get("hotel_peak_periods") or {})
peak_limits.update(limits.get("hotel_peak_city_limits") or {})
return {"hotel_peak_periods": peak_periods, "hotel_peak_city_limits": peak_limits}
@staticmethod
def _map_allowance_type_to_key(value: str) -> str:
normalized = re.sub(r"\s+", "", str(value or ""))
@@ -412,6 +490,12 @@ class ExpenseRuleRuntimeService:
if not rows:
continue
normalized_limits = extract_normalized_transport_class_limits(rows)
if normalized_limits:
for grade_band, transport_levels in normalized_limits.items():
limits.setdefault(grade_band, {}).update(transport_levels)
continue
employee_index = -1
flight_index = -1
train_index = -1
@@ -434,11 +518,11 @@ class ExpenseRuleRuntimeService:
for row in rows:
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text)
bands = map_transport_grade_row_to_bands(employee_text)
if not bands:
continue
flight_level = (
ExpenseRuleRuntimeService._transport_class_level_for_text(
transport_class_level_for_text(
row[flight_index] if len(row) > flight_index else None,
kind="flight",
)
@@ -446,7 +530,7 @@ class ExpenseRuleRuntimeService:
else None
)
train_level = (
ExpenseRuleRuntimeService._transport_class_level_for_text(
transport_class_level_for_text(
row[train_index] if len(row) > train_index else None,
kind="train",
)
@@ -462,39 +546,121 @@ class ExpenseRuleRuntimeService:
return limits
@staticmethod
def _map_transport_grade_row_to_bands(value: str) -> list[str]:
normalized = re.sub(r"\s+", "", str(value or "").upper())
if not normalized or normalized.startswith(""):
return []
bands: list[str] = []
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")):
bands.extend(["junior", "mid"])
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")):
bands.extend(["mid", "senior", "manager", "executive"])
return list(dict.fromkeys(bands))
def _extract_transport_estimates_from_workbook(workbook: Any) -> list[dict[str, object]]:
estimates: list[dict[str, object]] = []
for sheet in workbook.worksheets:
rows = list(sheet.iter_rows(values_only=True))
if not rows:
continue
header_index = -1
indexes: dict[str, int] = {}
header_aliases = {
"origin_city": ("出发城市", "出发地", "起点城市"),
"destination_city": ("目的地", "到达城市", "目的城市"),
"location_band": ("目的地范围", "城市范围", "地区范围"),
"transport_mode": ("交通方式", "出行方式", "交通工具"),
"one_way_amount": ("单程预估金额", "单程金额", "单程费用"),
"round_trip_amount": ("往返预估金额", "往返金额", "往返费用", "预算占用金额"),
"confidence": ("置信度", "匹配级别"),
"basis": ("预算占用口径", "预估依据", "口径说明"),
}
for index, row in enumerate(rows[:10]):
values = [str(value or "").strip() for value in row]
if "交通方式" not in values and "出行方式" not in values:
continue
for key, aliases in header_aliases.items():
for alias in aliases:
if alias in values:
indexes[key] = values.index(alias)
break
if "transport_mode" in indexes and (
"round_trip_amount" in indexes or "one_way_amount" in indexes
):
header_index = index
break
if header_index < 0:
continue
for row in rows[header_index + 1 :]:
mode = ExpenseRuleRuntimeService._map_transport_mode_text(
ExpenseRuleRuntimeService._row_text(row, indexes.get("transport_mode", -1))
)
if not mode:
continue
one_way_amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
row[indexes["one_way_amount"]]
if "one_way_amount" in indexes and len(row) > indexes["one_way_amount"]
else None
)
round_trip_amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
row[indexes["round_trip_amount"]]
if "round_trip_amount" in indexes and len(row) > indexes["round_trip_amount"]
else None
)
if round_trip_amount is None and one_way_amount is not None:
round_trip_amount = (one_way_amount * Decimal("2")).quantize(Decimal("0.01"))
if round_trip_amount is None or round_trip_amount <= Decimal("0.00"):
continue
estimates.append(
{
"origin_city": ExpenseRuleRuntimeService._row_text(
row, indexes.get("origin_city", -1)
),
"destination_city": ExpenseRuleRuntimeService._row_text(
row, indexes.get("destination_city", -1)
),
"location_band": ExpenseRuleRuntimeService._map_location_band_text(
ExpenseRuleRuntimeService._row_text(
row, indexes.get("location_band", -1)
)
),
"transport_mode": mode,
"one_way_amount": one_way_amount or Decimal("0.00"),
"round_trip_amount": round_trip_amount,
"confidence": ExpenseRuleRuntimeService._row_text(
row, indexes.get("confidence", -1)
)
or "basic_rule",
"basis": ExpenseRuleRuntimeService._row_text(
row, indexes.get("basis", -1)
),
}
)
return estimates
@staticmethod
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
def _row_text(row: Any, index: int) -> str:
if index < 0 or len(row) <= index:
return ""
return str(row[index] or "").strip()
@staticmethod
def _map_transport_mode_text(value: str) -> str:
normalized = re.sub(r"\s+", "", str(value or ""))
if any(keyword in normalized for keyword in ("飞机", "机票", "航班", "经济舱")):
return "飞机"
if any(keyword in normalized for keyword in ("火车", "高铁", "动车", "铁路", "二等座", "硬卧")):
return "火车"
if any(keyword in normalized for keyword in ("轮船", "船票", "客轮", "渡轮", "邮轮")):
return "轮船"
return normalized if normalized in {"飞机", "火车", "轮船"} else ""
@staticmethod
def _map_location_band_text(value: str) -> str:
normalized = re.sub(r"\s+", "", str(value or ""))
if not normalized:
return None
if kind == "flight":
if any(keyword in normalized for keyword in ("头等舱",)):
return 4
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
return 3
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
return 2
if "经济舱" in normalized:
return 1
if kind == "train":
if "商务座" in normalized:
return 3
if any(keyword in normalized for keyword in ("一等座", "软卧")):
return 2
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
return 1
return None
return ""
if any(keyword in normalized for keyword in ("高频", "一线", "核心", "重点")):
return "premium"
if any(keyword in normalized for keyword in ("远途", "偏远", "新疆", "西藏", "海南", "港澳台", "海外")):
return "remote"
if any(keyword in normalized for keyword in ("沿海", "海滨", "港口")):
return "coastal"
if any(keyword in normalized for keyword in ("普通", "默认", "其他")):
return "default"
return normalized
@staticmethod
def _extract_city_names_from_cell(value: str) -> list[str]:

View File

@@ -230,11 +230,15 @@ DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = {
"晚到店",
],
"band_labels": {
"junior": "P1-P3",
"mid": "P4-P5",
"senior": "P6-P7",
"manager": "M1-M2",
"executive": "M3及以上 / D序列",
"P0": "P0 实习/见习",
"P1": "P1 基础员工",
"P2": "P2 初级员工",
"P3": "P3 普通员工",
"P4": "P4 资深员工/主管",
"P5": "P5 基层经理",
"P6": "P6 中层经理",
"P7": "P7 高层经理",
"P8": "P8 董事会",
},
"city_tiers": {
"北京": "tier_1",
@@ -267,18 +271,26 @@ DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = {
"佛山": "tier_2",
},
"hotel_limits": {
"junior": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"mid": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
"senior": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"},
"manager": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"},
"executive": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"},
"P0": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"P1": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"P2": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"P3": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
"P4": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
"P5": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
"P6": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"},
"P7": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"},
"P8": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"},
},
"transport_limits": {
"junior": {"flight": 1, "train": 1},
"mid": {"flight": 1, "train": 1},
"senior": {"flight": 2, "train": 2},
"manager": {"flight": 3, "train": 3},
"executive": {"flight": 4, "train": 3},
"P0": {"flight": 1, "train": 1},
"P1": {"flight": 1, "train": 1},
"P2": {"flight": 1, "train": 1},
"P3": {"flight": 1, "train": 1},
"P4": {"flight": 1, "train": 1},
"P5": {"flight": 1, "train": 1},
"P6": {"flight": 2, "train": 2},
"P7": {"flight": 3, "train": 3},
"P8": {"flight": 4, "train": 3},
},
"flight_classes": [
{"keyword": "头等舱", "level": 4},

View File

@@ -46,6 +46,17 @@ class TravelClassConfig(BaseModel):
level: int
class TravelTransportEstimateConfig(BaseModel):
origin_city: str = ""
destination_city: str = ""
location_band: str = ""
transport_mode: str
one_way_amount: Decimal = Decimal("0")
round_trip_amount: Decimal = Decimal("0")
confidence: str = "basic_rule"
basis: str = ""
class TravelPolicyConfig(BaseModel):
kind: Literal["travel_policy"]
version: int = 1
@@ -57,11 +68,17 @@ class TravelPolicyConfig(BaseModel):
city_tiers: dict[str, str] = Field(default_factory=dict)
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
hotel_peak_periods: dict[str, str] = Field(default_factory=dict)
hotel_peak_city_limits: dict[str, Decimal] = Field(default_factory=dict)
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
standard_rule_code: str = ""
standard_rule_name: str = ""
standard_rule_version: str = ""
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
transport_estimates: list[TravelTransportEstimateConfig] = Field(default_factory=list)
transport_estimate_rule_code: str = ""
transport_estimate_rule_name: str = ""
transport_estimate_rule_version: str = ""
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
train_classes: list[TravelClassConfig] = Field(default_factory=list)

View File

@@ -0,0 +1,241 @@
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
def extract_normalized_travel_allowance_limits(
rows: list[tuple[Any, ...]],
) -> dict[str, dict[str, Decimal]]:
header_aliases = {
"region": ("补助区域", "适用区域", "地区", "区域"),
"meal": ("伙食补助/天", "伙食补助", "餐补", "餐费补助"),
"basic": ("基本补助/天", "基本出差补贴", "基本补助"),
"total": ("补助合计/天", "补助合计", "合计", "总计"),
}
indexes = _find_header_indexes(rows, header_aliases)
header_index = indexes.pop("__header_index__", -1)
if header_index < 0 or "region" not in indexes:
return {}
allowance_limits: dict[str, dict[str, Decimal]] = {}
for row in rows[header_index + 1 :]:
region = _row_text(row, indexes["region"])
if not region:
continue
for allowance_key in ("meal", "basic", "total"):
column_index = indexes.get(allowance_key)
if column_index is None:
continue
amount = _coerce_decimal_cell(row[column_index] if len(row) > column_index else None)
if amount is not None:
allowance_limits.setdefault(allowance_key, {})[region] = amount
return allowance_limits
def extract_normalized_transport_class_limits(
rows: list[tuple[Any, ...]],
) -> dict[str, dict[str, int]]:
header_aliases = {
"employee": ("职级范围", "员工职级", "职级"),
"flight": ("飞机标准", "飞机", "航班标准"),
"train": ("火车标准", "火车", "铁路标准"),
}
indexes = _find_header_indexes(rows, header_aliases)
header_index = indexes.pop("__header_index__", -1)
if header_index < 0 or "employee" not in indexes:
return {}
limits: dict[str, dict[str, int]] = {}
for row in rows[header_index + 1 :]:
employee_text = _row_text(row, indexes["employee"])
bands = map_transport_grade_row_to_bands(employee_text)
if not bands:
continue
flight_level = (
transport_class_level_for_text(
row[indexes["flight"]] if len(row) > indexes["flight"] else None,
kind="flight",
)
if "flight" in indexes
else None
)
train_level = (
transport_class_level_for_text(
row[indexes["train"]] if len(row) > indexes["train"] else None,
kind="train",
)
if "train" in indexes
else None
)
for band in bands:
entry = limits.setdefault(band, {})
if flight_level is not None:
entry["flight"] = flight_level
if train_level is not None:
entry["train"] = train_level
return limits
def extract_hotel_season_limits(rows: list[tuple[Any, ...]]) -> dict[str, dict[str, Any]]:
header_aliases = {
"city": ("地区(城市)", "城市", "地区"),
"peak_period": ("旺季期间(月)", "旺季期间", "旺季月份"),
"peak_limit": ("旺季超标限额", "旺季住宿上限", "旺季限额"),
}
indexes = _find_header_indexes_for_aliases(rows, header_aliases, required=("city", "peak_period", "peak_limit"))
header_index = indexes.pop("__header_index__", -1)
if header_index < 0:
return {"hotel_peak_periods": {}, "hotel_peak_city_limits": {}}
peak_periods: dict[str, str] = {}
peak_limits: dict[str, Decimal] = {}
for row in rows[header_index + 1 :]:
period = _normalize_peak_period_text(_row_text(row, indexes["peak_period"]))
peak_limit = _coerce_decimal_cell(row[indexes["peak_limit"]] if len(row) > indexes["peak_limit"] else None)
if not period or peak_limit is None:
continue
for city in _split_city_cell(_row_text(row, indexes["city"])):
peak_periods[city] = period
peak_limits[city] = peak_limit
return {"hotel_peak_periods": peak_periods, "hotel_peak_city_limits": peak_limits}
def map_transport_grade_row_to_bands(value: str) -> list[str]:
normalized = re.sub(r"\s+", "", str(value or "").upper())
if not normalized or normalized.startswith(""):
return []
if "-" not in normalized and "+" not in normalized and "及以上" not in normalized:
exact_match = re.search(r"P\s*([0-8])", normalized)
if exact_match:
grade_key = f"P{int(exact_match.group(1))}"
return [grade_key] if grade_key in TRAVEL_GRADE_KEYS else []
bands: list[str] = []
if any(
keyword in normalized
for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")
):
bands.extend(["junior", "mid"])
if any(
keyword in normalized
for keyword in (
"P5",
"P6",
"P7",
"P5及以上",
"中层经理",
"高层经理",
"公司级",
"M3",
"外聘专家",
)
):
bands.extend(["mid", "senior", "manager", "executive"])
return list(dict.fromkeys(bands))
def transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
normalized = re.sub(r"\s+", "", str(value or ""))
if not normalized:
return None
if kind == "flight":
if any(keyword in normalized for keyword in ("头等舱",)):
return 4
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
return 3
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
return 2
if "经济舱" in normalized:
return 1
if kind == "train":
if "商务座" in normalized:
return 3
if any(keyword in normalized for keyword in ("一等座", "软卧")):
return 2
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
return 1
return None
def _find_header_indexes(
rows: list[tuple[Any, ...]],
header_aliases: dict[str, tuple[str, ...]],
) -> dict[str, int]:
for index, row in enumerate(rows[:10]):
indexes: dict[str, int] = {}
values = [str(value or "").strip() for value in row]
for key, aliases in header_aliases.items():
for alias in aliases:
if alias in values:
indexes[key] = values.index(alias)
break
if _has_required_normalized_header(indexes):
indexes["__header_index__"] = index
return indexes
return {}
def _find_header_indexes_for_aliases(
rows: list[tuple[Any, ...]],
header_aliases: dict[str, tuple[str, ...]],
*,
required: tuple[str, ...],
) -> dict[str, int]:
for index, row in enumerate(rows[:10]):
indexes: dict[str, int] = {}
values = [str(value or "").strip() for value in row]
for key, aliases in header_aliases.items():
for alias in aliases:
if alias in values:
indexes[key] = values.index(alias)
break
if all(key in indexes for key in required):
indexes["__header_index__"] = index
return indexes
return {}
def _has_required_normalized_header(indexes: dict[str, int]) -> bool:
return (
"region" in indexes
and any(key in indexes for key in ("meal", "basic", "total"))
) or ("employee" in indexes and any(key in indexes for key in ("flight", "train")))
def _row_text(row: tuple[Any, ...], index: int) -> str:
if index < 0 or len(row) <= index:
return ""
return str(row[index] or "").strip()
def _coerce_decimal_cell(value: Any) -> Decimal | None:
if value is None:
return None
try:
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
except (ArithmeticError, ValueError):
return None
def _split_city_cell(value: str) -> list[str]:
normalized = re.sub(r"[;,、/]+", "", str(value or "").strip())
if not normalized:
return []
names: list[str] = []
for part in normalized.split(""):
cleaned = re.sub(r"\s+", "", part)
cleaned = re.sub(r"[(].*?[)]", "", cleaned)
if cleaned and len(cleaned) <= 12:
names.append(cleaned.removesuffix(""))
return list(dict.fromkeys(names))
def _normalize_peak_period_text(value: str) -> str:
text = re.sub(r"\s+", "", str(value or ""))
text = re.sub(r"(月|上旬|中旬|下旬)", "", text)
text = re.sub(r"[、,;;]+", ",", text)
text = re.sub(r"[^0-9,\-]", "", text)
return re.sub(r",{2,}", ",", text).strip(",")

View File

@@ -34,6 +34,57 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"applicationAmount",
"application_amount_label",
"applicationAmountLabel",
"application_budget_occupied_amount",
"applicationBudgetOccupiedAmount",
"application_policy_total_amount",
"applicationPolicyTotalAmount",
),
"transport_estimated_amount": (
"application_transport_estimated_amount",
"applicationTransportEstimatedAmount",
"transportEstimatedAmount",
"transport_estimate_amount",
"transportEstimateAmount",
),
"train_estimated_amount": (
"application_train_estimated_amount",
"applicationTrainEstimatedAmount",
"trainEstimatedAmount",
),
"flight_estimated_amount": (
"application_flight_estimated_amount",
"applicationFlightEstimatedAmount",
"flightEstimatedAmount",
),
"hotel_amount": (
"application_hotel_amount",
"applicationHotelAmount",
"lodging_amount",
"lodgingAmount",
"hotelAmount",
),
"allowance_amount": (
"application_allowance_amount",
"applicationAllowanceAmount",
"subsidy_amount",
"subsidyAmount",
"allowanceAmount",
),
"policy_total_amount": (
"application_policy_total_amount",
"applicationPolicyTotalAmount",
"application_budget_occupied_amount",
"applicationBudgetOccupiedAmount",
"policyTotalAmount",
"budget_occupied_amount",
"budgetOccupiedAmount",
),
"reimbursement_amount": (
"application_reimbursement_amount",
"applicationReimbursementAmount",
"actual_reimbursement_amount",
"actualReimbursementAmount",
"reimbursementAmount",
),
"transport_mode": (
"transport_type",
@@ -42,6 +93,15 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"application_transport_mode",
"applicationTransportMode",
),
"document_type": ("documentType", "ocr_document_type", "ocrDocumentType"),
"invoice_no": ("invoiceNo", "invoice_number", "invoiceNumber", "ocr_invoice_no", "ocrInvoiceNo"),
"invoice_date": ("invoiceDate", "issue_date", "issueDate", "ocr_invoice_date", "ocrInvoiceDate"),
"ticket_no": ("ticketNo", "ticket_number", "ticketNumber", "ocr_ticket_no", "ocrTicketNo"),
"ticket_type": ("ticketType", "ocr_ticket_type", "ocrTicketType"),
"origin_location": ("originLocation", "departure_location", "departureLocation", "from_city", "fromCity"),
"destination_location": ("destinationLocation", "arrival_location", "arrivalLocation", "to_city", "toCity"),
"hotel_name": ("hotelName", "ocr_hotel_name", "ocrHotelName"),
"hotel_nights": ("hotelNights", "stay_nights", "stayNights"),
"attachments": ("attachment_names", "attachmentNames"),
"customer_name": ("customerName",),
"merchant_name": ("merchantName",),
@@ -54,6 +114,10 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"manager_name": ("managerName", "direct_manager_name", "directManagerName"),
"finance_owner_name": ("financeOwnerName",),
"finance_approver_name": ("financeApproverName",),
"basic_rule_code": ("basicRuleCode", "finance_rule_code", "financeRuleCode"),
"basic_rule_sheet": ("basicRuleSheet", "finance_rule_sheet", "financeRuleSheet"),
"basic_rule_name": ("basicRuleName", "finance_rule_name", "financeRuleName"),
"basic_rule_version": ("basicRuleVersion", "finance_rule_version", "financeRuleVersion"),
}
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
@@ -69,14 +133,36 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"employee_location",
"employee_risk_profile",
"document_id",
"invoice_no",
"invoice_date",
"ticket_no",
"ticket_type",
"origin_location",
"destination_location",
"hotel_name",
"hotel_nights",
"application_claim_id",
"application_claim_no",
"application_status",
"application_amount",
"application_approved_amount",
"application_budget_occupied_amount",
"application_reimbursement_amount",
"application_expense_type",
"application_days",
"application_date",
"application_required",
"preapproval_required",
"application_lodging_daily_cap",
"application_subsidy_daily_cap",
"application_transport_policy",
"application_policy_estimate",
"application_transport_estimated_amount",
"application_train_estimated_amount",
"application_flight_estimated_amount",
"application_hotel_amount",
"application_allowance_amount",
"application_policy_total_amount",
"application_rule_name",
"application_rule_version",
"original_amount",

View File

@@ -2,6 +2,18 @@ from __future__ import annotations
from app.schemas.ontology import OntologyParseResult
PREAPPROVAL_RULE_CODES = [
"risk.application.meal_high_value_without_preapproval",
"risk.application.office_bulk_without_purchase",
"risk.application.large_expense_without_preapproval",
]
APPLICATION_REQUIRED_RULE_CODES = [
*PREAPPROVAL_RULE_CODES,
"risk.application.travel_large_without_preapproval",
"risk.application.marketing_without_campaign",
]
RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
"location_mismatch": ["risk.travel.destination_receipt_location"],
"base_location_overlap": ["risk.travel.base_location_overlap"],
@@ -18,6 +30,9 @@ RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
"meal_as_travel": ["risk.expense.meal_localized_as_travel"],
"consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"],
"reason_too_brief": ["risk.expense.reason_too_brief"],
"application_required": APPLICATION_REQUIRED_RULE_CODES,
"application_absent": APPLICATION_REQUIRED_RULE_CODES,
"preapproval_absent": PREAPPROVAL_RULE_CODES,
}
TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
@@ -32,6 +47,29 @@ TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
"meal_as_travel": ("餐费", "差旅餐", "本地餐"),
"consecutive_transport_receipts": ("连续交通", "多张车票", "打车"),
"reason_too_brief": ("事由", "说明太短", "理由不足"),
"application_required": (
"前置申请",
"事前申请",
"事前审批",
"费用申请",
"申请审批",
"无申请",
"未申请",
"缺少申请",
"没有申请",
"未审批",
),
"preapproval_absent": (
"无前置申请",
"未做申请",
"未提交申请",
"未走审批",
"大额费用",
"业务招待",
"招待费",
"办公采购",
"办公用品",
),
}

View File

@@ -0,0 +1,378 @@
from __future__ import annotations
from typing import Any
from app.schemas.steward import (
StewardCandidateFlow,
StewardFlowStatePatch,
StewardPendingFlowConfirmation,
StewardPlanResponse,
StewardTask,
)
from app.services.ontology_field_registry import (
CANONICAL_ONTOLOGY_FIELDS,
normalize_ontology_form_values,
)
class StewardFlowStateService:
"""维护小财管家跨轮对话的本体业务状态。"""
EVENT_LIMIT = 80
def merge_state(
self,
current_state: dict[str, Any] | None,
patch: StewardFlowStatePatch,
) -> dict[str, Any]:
state = self._normalize_state(current_state)
flow = dict(state["flows"].get(patch.flow_id) or {})
existing_fields = dict(flow.get("fields") or {})
next_fields = {
**existing_fields,
**self._normalize_fields(patch.fields),
}
flow.update(
{
"flow_id": patch.flow_id,
"status": str(patch.status or "collecting").strip() or "collecting",
"intent": str(patch.intent or flow.get("intent") or "").strip(),
"fields": next_fields,
"missing_fields": self._normalize_missing_fields(patch.missing_fields),
}
)
if patch.application_claim_id:
flow["application_claim_id"] = patch.application_claim_id
if patch.linked_application_claim_id:
flow["linked_application_claim_id"] = patch.linked_application_claim_id
if patch.attachments:
flow["attachments"] = self._merge_attachments(flow.get("attachments"), patch.attachments)
state["active_flow"] = patch.active_flow
state["flows"][patch.flow_id] = flow
state["events"] = self._append_event(state.get("events"), patch, flow)
return state
def merge_plan(
self,
current_state: dict[str, Any] | None,
plan: StewardPlanResponse,
) -> dict[str, Any]:
state = self._normalize_state(current_state)
if plan.pending_flow_confirmation.status == "pending":
state = self._merge_pending_flow_confirmation(
state,
plan.pending_flow_confirmation,
next_action=plan.next_action,
)
for task in plan.tasks:
state = self.merge_state(
state,
self._build_patch_from_task(
task,
linked_application_claim_id=self._resolve_application_claim_id(state),
attachments=self._resolve_task_attachments(plan, task.task_id),
),
)
return state
@staticmethod
def _normalize_state(current_state: dict[str, Any] | None) -> dict[str, Any]:
source = current_state if isinstance(current_state, dict) else {}
flows = source.get("flows") if isinstance(source.get("flows"), dict) else {}
events = source.get("events") if isinstance(source.get("events"), list) else []
return {
"version": str(source.get("version") or "steward.flow_state.v2"),
"active_flow": str(source.get("active_flow") or "").strip(),
"flows": dict(flows),
"pending_flow_confirmation": (
dict(source.get("pending_flow_confirmation"))
if isinstance(source.get("pending_flow_confirmation"), dict)
else {
"status": "none",
"source_message": "",
"reason": "",
"candidate_flows": [],
}
),
"next_action": str(source.get("next_action") or "").strip(),
"events": list(events),
}
def confirm_flow(
self,
current_state: dict[str, Any],
flow_id: str,
) -> dict[str, Any]:
state = self._normalize_state(current_state)
if flow_id not in {"travel_application", "travel_reimbursement"}:
return state
flows = state["flows"] if isinstance(state.get("flows"), dict) else {}
flow = dict(flows.get(flow_id) or {})
flow["flow_id"] = flow_id
flow["status"] = "collecting" if flow.get("missing_fields") else "ready_for_confirmation"
flows[flow_id] = flow
state["flows"] = flows
state["active_flow"] = flow_id
state["next_action"] = "continue_selected_flow"
pending = dict(state.get("pending_flow_confirmation") or {})
pending["status"] = "confirmed"
pending["confirmed_flow_id"] = flow_id
state["pending_flow_confirmation"] = pending
state["events"] = self._append_flow_confirmation_event(state.get("events"), flow_id)
return state
def _merge_pending_flow_confirmation(
self,
state: dict[str, Any],
pending: StewardPendingFlowConfirmation,
*,
next_action: str,
) -> dict[str, Any]:
candidate_flows = [
self._serialize_candidate_flow(candidate)
for candidate in pending.candidate_flows
]
state["version"] = "steward.flow_state.v2"
state["active_flow"] = ""
state["next_action"] = str(next_action or "confirm_flow")
state["pending_flow_confirmation"] = {
"status": pending.status,
"source_message": pending.source_message,
"reason": pending.reason,
"candidate_flows": candidate_flows,
}
flows = state["flows"] if isinstance(state.get("flows"), dict) else {}
for candidate in pending.candidate_flows:
flow = dict(flows.get(candidate.flow_id) or {})
flow.update(
{
"flow_id": candidate.flow_id,
"intent": self._resolve_candidate_intent(candidate.flow_id),
"status": "pending_flow_confirmation",
"fields": self._normalize_fields(candidate.ontology_fields),
"missing_fields": self._normalize_missing_fields(candidate.missing_fields),
"confidence": candidate.confidence,
"evidence": [
{
"source": "pending_flow_confirmation",
"field": key,
"text": candidate.reason or pending.reason,
}
for key in candidate.ontology_fields
],
}
)
flows[candidate.flow_id] = flow
state["flows"] = flows
state["events"] = self._append_pending_flow_event(state.get("events"), pending)
return state
@staticmethod
def _serialize_candidate_flow(candidate: StewardCandidateFlow) -> dict[str, Any]:
return {
"flow_id": candidate.flow_id,
"label": candidate.label,
"confidence": candidate.confidence,
"reason": candidate.reason,
"ontology_fields": dict(candidate.ontology_fields),
"missing_fields": list(candidate.missing_fields),
}
@staticmethod
def _resolve_candidate_intent(flow_id: str) -> str:
return (
"travel_application_create"
if flow_id == "travel_application"
else "travel_reimbursement_draft"
)
@staticmethod
def _build_patch_from_task(
task: StewardTask,
*,
linked_application_claim_id: str = "",
attachments: list[dict[str, Any]] | None = None,
) -> StewardFlowStatePatch:
if task.task_type == "expense_application":
flow_id = "travel_application"
intent = "travel_application_create"
link_id = ""
else:
flow_id = "travel_reimbursement"
intent = "travel_reimbursement_draft"
link_id = linked_application_claim_id
return StewardFlowStatePatch(
active_flow=flow_id,
flow_id=flow_id,
intent=intent,
status="collecting" if task.missing_fields else "ready_for_confirmation",
fields=task.ontology_fields,
missing_fields=task.missing_fields,
linked_application_claim_id=link_id,
attachments=attachments or [],
evidence=[
{
"source": "steward_plan",
"field": key,
"text": task.summary,
}
for key in task.ontology_fields
],
)
@staticmethod
def _resolve_application_claim_id(state: dict[str, Any]) -> str:
flows = state.get("flows") if isinstance(state.get("flows"), dict) else {}
application_flow = flows.get("travel_application") if isinstance(flows, dict) else {}
if not isinstance(application_flow, dict):
return ""
return str(application_flow.get("application_claim_id") or "").strip()
@staticmethod
def _resolve_task_attachments(
plan: StewardPlanResponse,
task_id: str,
) -> list[dict[str, Any]]:
attachments: list[dict[str, Any]] = []
for group in plan.attachment_groups:
if group.target_task_id != task_id:
continue
for name in group.attachment_names:
normalized = str(name or "").strip()
if normalized:
attachments.append({"name": normalized, "source": "steward_attachment_group"})
return attachments
@staticmethod
def _normalize_fields(fields: dict[str, Any]) -> dict[str, str]:
normalized = normalize_ontology_form_values(fields)
return {
key: value
for key, value in normalized.items()
if key in CANONICAL_ONTOLOGY_FIELDS and str(value or "").strip()
}
@staticmethod
def _normalize_missing_fields(fields: list[str]) -> list[str]:
normalized: list[str] = []
for field in fields:
key = str(field or "").strip()
if key in CANONICAL_ONTOLOGY_FIELDS and key not in normalized:
normalized.append(key)
return normalized
@staticmethod
def _merge_attachments(
current_attachments: Any,
incoming_attachments: list[dict[str, Any]],
) -> list[dict[str, Any]]:
attachments = [
dict(item)
for item in current_attachments
if isinstance(item, dict)
] if isinstance(current_attachments, list) else []
seen = {
str(item.get("file_id") or item.get("name") or "").strip()
for item in attachments
if str(item.get("file_id") or item.get("name") or "").strip()
}
for item in incoming_attachments:
if not isinstance(item, dict):
continue
key = str(item.get("file_id") or item.get("name") or "").strip()
if key and key in seen:
continue
attachments.append(dict(item))
if key:
seen.add(key)
return attachments
def _append_event(
self,
current_events: Any,
patch: StewardFlowStatePatch,
flow: dict[str, Any],
) -> list[dict[str, Any]]:
events = [
dict(item)
for item in current_events
if isinstance(item, dict)
] if isinstance(current_events, list) else []
events.append(
{
"sequence": len(events) + 1,
"flow_id": patch.flow_id,
"active_flow": patch.active_flow,
"intent": str(patch.intent or flow.get("intent") or "").strip(),
"status": str(flow.get("status") or "").strip(),
"fields": self._normalize_fields(patch.fields),
"missing_fields": list(flow.get("missing_fields") or []),
"evidence": [
dict(item)
for item in patch.evidence
if isinstance(item, dict)
],
}
)
return events[-self.EVENT_LIMIT :]
def _append_pending_flow_event(
self,
current_events: Any,
pending: StewardPendingFlowConfirmation,
) -> list[dict[str, Any]]:
events = [
dict(item)
for item in current_events
if isinstance(item, dict)
] if isinstance(current_events, list) else []
events.append(
{
"sequence": len(events) + 1,
"flow_id": "",
"active_flow": "",
"intent": "pending_flow_confirmation",
"status": pending.status,
"fields": {},
"missing_fields": [],
"candidate_flows": [
self._serialize_candidate_flow(candidate)
for candidate in pending.candidate_flows
],
"evidence": [
{
"source": "steward_plan",
"text": pending.source_message,
}
],
}
)
return events[-self.EVENT_LIMIT :]
def _append_flow_confirmation_event(
self,
current_events: Any,
flow_id: str,
) -> list[dict[str, Any]]:
events = [
dict(item)
for item in current_events
if isinstance(item, dict)
] if isinstance(current_events, list) else []
events.append(
{
"sequence": len(events) + 1,
"flow_id": flow_id,
"active_flow": flow_id,
"intent": "flow_confirmed",
"status": "confirmed",
"fields": {},
"missing_fields": [],
"evidence": [{"source": "runtime_user_selection", "text": flow_id}],
}
)
return events[-self.EVENT_LIMIT :]

View File

@@ -108,6 +108,9 @@ class StewardIntentAgent:
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。"
"如果用户只描述出差时间、地点和事由,但没有明确申请、报销、提交、保存草稿等动作,"
"且无法从上下文判断流程方向,必须返回 pending_flow_confirmation.status=pending"
"candidate_flows 同时给出 travel_application 和 travel_reimbursementtasks 保持空数组。"
"所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields"
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
"相对日期必须以 base_date 为准转换为明确日期。"
@@ -180,6 +183,56 @@ class StewardIntentAgent:
],
},
},
"pending_flow_confirmation": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["none", "pending"],
},
"source_message": {"type": "string"},
"reason": {"type": "string"},
"candidate_flows": {
"type": "array",
"items": {
"type": "object",
"properties": {
"flow_id": {
"type": "string",
"enum": ["travel_application", "travel_reimbursement"],
},
"label": {"type": "string"},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
},
"reason": {"type": "string"},
"ontology_fields": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"missing_fields": {
"type": "array",
"items": {
"type": "string",
"enum": canonical_fields,
},
},
},
"required": [
"flow_id",
"label",
"confidence",
"reason",
"ontology_fields",
"missing_fields",
],
},
},
},
"required": ["status", "source_message", "reason", "candidate_flows"],
},
"attachment_groups": {
"type": "array",
"items": {

View File

@@ -8,6 +8,8 @@ from typing import Any
from app.schemas.steward import (
StewardAttachmentGroup,
StewardAttachmentInput,
StewardCandidateFlow,
StewardPendingFlowConfirmation,
StewardPlanRequest,
StewardPlanResponse,
StewardTask,
@@ -31,7 +33,18 @@ class StewardModelPlanBuilder:
request: StewardPlanRequest,
base_date: date,
) -> StewardPlanResponse | None:
pending_flow_confirmation = self._build_pending_flow_confirmation(
intent_result.payload,
request=request,
base_date=base_date,
)
tasks = self._build_tasks_from_model_payload(intent_result.payload, request, base_date)
if not tasks and pending_flow_confirmation.status == "pending":
return self._build_pending_flow_plan(
pending_flow_confirmation,
intent_result,
request=request,
)
if not tasks:
return None
@@ -54,11 +67,33 @@ class StewardModelPlanBuilder:
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
planning_source="llm_function_call",
next_action="confirm_task" if confirmation_groups else "delegate_task",
summary=self.planner._build_summary(tasks, attachment_groups),
thinking_events=thinking_events,
tasks=tasks,
attachment_groups=attachment_groups,
confirmation_groups=confirmation_groups,
pending_flow_confirmation=pending_flow_confirmation,
candidate_flows=pending_flow_confirmation.candidate_flows,
model_call_traces=intent_result.model_call_traces,
)
def _build_pending_flow_plan(
self,
pending_flow_confirmation: StewardPendingFlowConfirmation,
intent_result: StewardIntentAgentResult,
*,
request: StewardPlanRequest,
) -> StewardPlanResponse:
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="needs_flow_confirmation",
planning_source="llm_function_call",
next_action="confirm_flow",
summary=self._build_pending_flow_summary(pending_flow_confirmation),
thinking_events=self._build_pending_flow_thinking_events(intent_result.payload, request),
pending_flow_confirmation=pending_flow_confirmation,
candidate_flows=pending_flow_confirmation.candidate_flows,
model_call_traces=intent_result.model_call_traces,
)
@@ -144,6 +179,134 @@ class StewardModelPlanBuilder:
return tasks
def _build_pending_flow_confirmation(
self,
payload: dict[str, Any],
*,
request: StewardPlanRequest,
base_date: date,
) -> StewardPendingFlowConfirmation:
raw_pending = payload.get("pending_flow_confirmation")
raw_candidates = payload.get("candidate_flows")
if isinstance(raw_pending, dict):
raw_candidates = raw_pending.get("candidate_flows", raw_candidates)
status = self.planner._clean_text(raw_pending.get("status")) or "pending"
source_message = self.planner._clean_text(raw_pending.get("source_message")) or request.message
reason = self.planner._clean_text(raw_pending.get("reason"))
else:
status = "pending" if isinstance(raw_candidates, list) and raw_candidates else "none"
source_message = request.message
reason = ""
candidates = self._build_candidate_flows(raw_candidates, request=request, base_date=base_date)
if status != "pending" or not candidates:
return StewardPendingFlowConfirmation()
return StewardPendingFlowConfirmation(
status="pending",
source_message=source_message,
reason=reason or "当前话术同时可能进入申请或报销流程,需要先请用户确认。",
candidate_flows=candidates,
)
def _build_candidate_flows(
self,
raw_candidates: Any,
*,
request: StewardPlanRequest,
base_date: date,
) -> list[StewardCandidateFlow]:
if not isinstance(raw_candidates, list):
return []
candidates: list[StewardCandidateFlow] = []
for raw_candidate in raw_candidates:
if not isinstance(raw_candidate, dict):
continue
flow_id = self.planner._clean_text(raw_candidate.get("flow_id"))
if flow_id not in {"travel_application", "travel_reimbursement"}:
continue
task_type = "expense_application" if flow_id == "travel_application" else "reimbursement"
fields = self._sanitize_model_ontology_fields(
raw_candidate.get("ontology_fields"),
request=request,
base_date=base_date,
)
if not fields:
fields = self.planner._extract_ontology_fields(
request.message,
task_type,
base_date,
request,
)
missing_fields = self._sanitize_model_missing_fields(
raw_candidate.get("missing_fields"),
task_type=task_type,
fields=fields,
)
label = self.planner._clean_text(raw_candidate.get("label")) or (
"补办出差申请" if flow_id == "travel_application" else "发起费用报销"
)
candidates.append(
StewardCandidateFlow(
flow_id=flow_id, # type: ignore[arg-type]
label=label,
confidence=self._clamp_confidence(raw_candidate.get("confidence"), default=0.5),
reason=self.planner._clean_text(raw_candidate.get("reason")),
ontology_fields=fields,
missing_fields=missing_fields,
)
)
return candidates[:2]
def _build_pending_flow_thinking_events(
self,
payload: dict[str, Any],
request: StewardPlanRequest,
) -> list[StewardThinkingEvent]:
events = [
StewardThinkingEvent(
event_id="intent_agent_function_call",
stage="llm_function_call",
title="识别财务事项",
content="我识别到这句话包含出差事项,但还需要确认你要进入申请流程还是报销流程。",
)
]
raw_events = payload.get("thinking_events")
if isinstance(raw_events, list):
for raw_event in raw_events[:4]:
if not isinstance(raw_event, dict):
continue
title = self.planner._clean_text(raw_event.get("title"))
content = self.planner._clean_text(raw_event.get("content"))
if not title or not content:
continue
events.append(
StewardThinkingEvent(
event_id=f"intent_agent_model_{len(events):03d}",
stage=self.planner._clean_text(raw_event.get("stage")) or "flow_confirmation",
title=title,
content=content,
)
)
if len(events) == 1:
events.append(
StewardThinkingEvent(
event_id="intent_agent_pending_flow",
stage="flow_confirmation",
title="等待确认流程方向",
content=f"当前输入“{request.message}”缺少明确动作词,需要先由你选择补办出差申请或发起费用报销。",
)
)
return events
@staticmethod
def _build_pending_flow_summary(pending_flow_confirmation: StewardPendingFlowConfirmation) -> str:
candidate_labels = [item.label for item in pending_flow_confirmation.candidate_flows if item.label]
if len(candidate_labels) >= 2:
return (
f"我识别到这是一次财务事项,但还不能确定你要做的是"
f"**{candidate_labels[0]}**还是**{candidate_labels[1]}**。请先选择一个方向。"
)
return "我识别到这是一次财务事项,但还需要先确认具体流程方向。"
def _sanitize_model_ontology_fields(
self,
raw_fields: Any,

View File

@@ -9,7 +9,9 @@ from typing import Any
from app.schemas.steward import (
StewardAttachmentGroup,
StewardAttachmentInput,
StewardCandidateFlow,
StewardConfirmationAction,
StewardPendingFlowConfirmation,
StewardPlanRequest,
StewardPlanResponse,
StewardTask,
@@ -107,7 +109,7 @@ class StewardPlannerService:
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
model_call_traces: list[dict[str, Any]] = []
fallback_reason = ""
if self.intent_agent is not None:
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
try:
intent_result = self.intent_agent.detect(
request,
@@ -122,6 +124,17 @@ class StewardPlannerService:
base_date=base_date,
)
if llm_plan is not None:
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return self._build_pending_flow_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=(
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
"服务端已改为候选流程确认,避免误入申请流程。"
),
planning_source="llm_function_call",
)
return llm_plan
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
@@ -136,6 +149,16 @@ class StewardPlannerService:
fallback_reason=fallback_reason,
)
def _should_use_model_intent_recognition(
self,
message: str,
base_date: date,
request: StewardPlanRequest,
) -> bool:
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return False
return self._has_multiple_financial_demands(message)
def _build_rule_fallback_plan(
self,
request: StewardPlanRequest,
@@ -145,6 +168,13 @@ class StewardPlannerService:
fallback_reason: str = "",
) -> StewardPlanResponse:
message = self._clean_text(request.message)
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return self._build_pending_flow_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=fallback_reason,
)
task_drafts = self._extract_task_drafts(message)
tasks = [self._build_task(draft, base_date, request) for draft in task_drafts]
if not tasks:
@@ -169,6 +199,7 @@ class StewardPlannerService:
plan_id=plan_id,
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
planning_source="rule_fallback",
next_action="confirm_task" if confirmation_groups else "delegate_task",
summary=self._build_summary(tasks, attachment_groups),
thinking_events=thinking_events,
tasks=tasks,
@@ -177,6 +208,91 @@ class StewardPlannerService:
model_call_traces=model_call_traces or [],
)
def _build_pending_flow_fallback_plan(
self,
request: StewardPlanRequest,
*,
base_date: date,
model_call_traces: list[dict[str, Any]] | None = None,
fallback_reason: str = "",
planning_source: str = "rule_fallback",
) -> StewardPlanResponse:
candidates = self._build_rule_candidate_flows(request, base_date)
pending = StewardPendingFlowConfirmation(
status="pending",
source_message=request.message,
reason="当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。",
candidate_flows=candidates,
)
thinking_events = []
if fallback_reason:
thinking_events.append(
StewardThinkingEvent(
event_id="intent_agent_rule_fallback",
stage="rule_fallback",
title="意图识别智能体进入兜底模式",
content=fallback_reason,
)
)
thinking_events.append(
StewardThinkingEvent(
event_id="intent_pending_flow_confirmation",
stage="flow_confirmation",
title="需要确认流程方向",
content="我识别到时间、地点和出差事由,但没有识别到明确的申请或报销动作,需要先请你选择流程方向。",
)
)
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="needs_flow_confirmation",
planning_source=planning_source, # type: ignore[arg-type]
next_action="confirm_flow",
summary=(
"我识别到这是一次出差事项,但还不能确定你要做的是"
"**补办出差申请**还是**发起费用报销**。请先选择一个方向。"
),
thinking_events=thinking_events,
pending_flow_confirmation=pending,
candidate_flows=candidates,
model_call_traces=model_call_traces or [],
)
def _build_rule_candidate_flows(
self,
request: StewardPlanRequest,
base_date: date,
) -> list[StewardCandidateFlow]:
application_fields = self._extract_ontology_fields(
request.message,
"expense_application",
base_date,
request,
)
reimbursement_fields = self._extract_ontology_fields(
request.message,
"reimbursement",
base_date,
request,
)
return [
StewardCandidateFlow(
flow_id="travel_application",
label="补办出差申请",
confidence=0.52,
reason="用户描述了出差时间、地点和事由,但没有明确说要报销。",
ontology_fields=application_fields,
missing_fields=self._resolve_missing_fields("expense_application", application_fields),
),
StewardCandidateFlow(
flow_id="travel_reimbursement",
label="发起费用报销",
confidence=0.48,
reason="用户描述的也可能是已发生出差事项,需要进入报销材料整理。",
ontology_fields=reimbursement_fields,
missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields),
),
]
def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]:
drafts: list[PlannedTaskDraft] = []
first_reimbursement = self._find_first_reimbursement_index(message)
@@ -202,6 +318,24 @@ class StewardPlannerService:
return drafts
def _has_multiple_financial_demands(self, message: str) -> bool:
task_drafts = self._extract_task_drafts(message)
if len(task_drafts) > 1:
return True
compact = re.sub(r"\s+", "", message)
if not compact:
return False
application_signal = self._looks_like_application(compact) or self._looks_like_future_travel_application(compact)
reimbursement_signal = self._find_first_reimbursement_index(compact) >= 0
if application_signal and reimbursement_signal:
return True
connector_signal = re.search(r"并且|同时|另外|还有|还要|以及|再", compact)
repeated_reimbursement_signal = len(list(REIMBURSEMENT_PATTERN.finditer(compact))) > 1
return bool(connector_signal and repeated_reimbursement_signal)
@staticmethod
def _find_first_reimbursement_index(message: str) -> int:
candidates = [message.find(item) for item in ("我要报销", "还需要报销", "需要报销", "报销")]
@@ -238,6 +372,35 @@ class StewardPlannerService:
)
return bool((business_signal or route_signal) and (time_signal or planned_route_signal))
def _looks_like_ambiguous_travel_flow(
self,
text: str,
base_date: date,
request: StewardPlanRequest,
) -> bool:
compact = re.sub(r"\s+", "", text)
if not compact or request.attachments:
return False
if re.search(r"申请|报销|草稿|提交|审批|保存|发起|创建", compact):
return False
if not re.search(r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", compact):
return False
if not self._extract_time_range(compact, base_date):
return False
if not self._extract_location(compact):
return False
return not self._is_future_or_current_time_range(compact, base_date)
def _is_future_or_current_time_range(self, segment: str, base_date: date) -> bool:
normalized = self._extract_time_range(segment, base_date)
if not normalized:
return False
try:
parsed = date.fromisoformat(normalized)
except ValueError:
return False
return parsed >= base_date
def _build_task(
self,
draft: PlannedTaskDraft,

View File

@@ -1,19 +1,23 @@
from __future__ import annotations
import json
import re
from typing import Any
from app.schemas.steward import (
StewardFlowStatePatch,
StewardRuntimeDecisionRequest,
StewardRuntimeDecisionResponse,
)
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_flow_state import StewardFlowStateService
STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision"
RUNTIME_NEXT_ACTIONS = {
"plan_new_tasks",
"continue_selected_flow",
"submit_current_application",
"continue_next_task",
"fill_current_slot",
@@ -22,6 +26,16 @@ RUNTIME_NEXT_ACTIONS = {
"no_op",
}
FIELD_LABELS = {
"transport_mode": "出行方式",
"expense_type": "费用类型",
"time_range": "时间",
"location": "地点",
"reason": "事由",
"amount": "金额",
"attachments": "附件",
}
class StewardRuntimeDecisionAgent:
"""用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。"""
@@ -31,6 +45,9 @@ class StewardRuntimeDecisionAgent:
def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse:
normalized_request = self._normalize_request(request)
selected_flow_decision = self._build_selected_flow_decision(normalized_request, [])
if selected_flow_decision is not None:
return selected_flow_decision
result = self.runtime_chat_service.complete_with_tool_call(
self._build_messages(normalized_request),
tools=[self._build_tool_schema()],
@@ -47,18 +64,104 @@ class StewardRuntimeDecisionAgent:
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)
return self._attach_updated_steward_state(response, normalized_request)
return self._attach_updated_steward_state(
self._build_rule_fallback(normalized_request, traces),
normalized_request,
)
def _build_selected_flow_decision(
self,
request: StewardRuntimeDecisionRequest,
traces: list[dict[str, Any]],
) -> StewardRuntimeDecisionResponse | None:
selected_flow_id = self._resolve_selected_pending_flow_id(
request.runtime_state,
request.user_message,
)
if not selected_flow_id:
return None
next_state = StewardFlowStateService().confirm_flow(
request.runtime_state.get("steward_state") if isinstance(request.runtime_state.get("steward_state"), dict) else {},
selected_flow_id,
)
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="continue_selected_flow",
target_task_id=selected_flow_id,
response_text=self._build_selected_flow_response_text(selected_flow_id),
rationale="已按你选择的候选流程继续处理。",
steward_state=next_state,
model_call_traces=traces,
)
@staticmethod
def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest:
context_json = request.context_json if isinstance(request.context_json, dict) else {}
runtime_state = request.runtime_state if isinstance(request.runtime_state, dict) else {}
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 {},
runtime_state=StewardRuntimeDecisionAgent._hydrate_runtime_state(runtime_state, context_json),
context_json=context_json,
)
@staticmethod
def _hydrate_runtime_state(
runtime_state: dict[str, Any],
context_json: dict[str, Any],
) -> dict[str, Any]:
hydrated = dict(runtime_state or {})
steward_state = StewardRuntimeDecisionAgent._resolve_steward_state(context_json)
if steward_state:
hydrated.setdefault("steward_state", steward_state)
if StewardRuntimeDecisionAgent._has_runtime_anchor(hydrated) or not steward_state:
return hydrated
active_flow = str(steward_state.get("active_flow") or "").strip()
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
flow = flows.get(active_flow) if isinstance(flows, dict) else None
if not isinstance(flow, dict):
return hydrated
missing_fields = [
str(item or "").strip()
for item in list(flow.get("missing_fields") or [])
if str(item or "").strip()
]
hydrated["current_task"] = {
"task_id": active_flow,
"task_type": "expense_application" if active_flow == "travel_application" else "reimbursement",
"ontology_fields": dict(flow.get("fields") or {}),
"missing_fields": missing_fields,
}
if missing_fields:
hydrated["waiting_for"] = "steward_flow_field_completion"
else:
hydrated["waiting_for"] = "steward_flow_confirmation"
return hydrated
@staticmethod
def _resolve_steward_state(context_json: dict[str, Any]) -> dict[str, Any]:
direct_state = context_json.get("steward_state") or context_json.get("stewardState")
if isinstance(direct_state, dict) and direct_state:
return direct_state
conversation_state = context_json.get("conversation_state")
if isinstance(conversation_state, dict):
nested_state = conversation_state.get("steward_state") or conversation_state.get("stewardState")
if isinstance(nested_state, dict) and nested_state:
return nested_state
return {}
@staticmethod
def _has_runtime_anchor(runtime_state: dict[str, Any]) -> bool:
if str(runtime_state.get("waiting_for") or "").strip():
return True
for key in ("pending_application", "pending_steward_action", "pending_slot_action", "current_task"):
if isinstance(runtime_state.get(key), dict) and runtime_state[key]:
return True
return bool(runtime_state.get("remaining_tasks") or runtime_state.get("completed_tasks"))
@staticmethod
def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]:
payload = {
@@ -177,6 +280,34 @@ class StewardRuntimeDecisionAgent:
rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。",
model_call_traces=traces,
)
if waiting_for == "steward_flow_field_completion":
current_task = state.get("current_task") if isinstance(state.get("current_task"), dict) else {}
missing_fields = [
str(item or "").strip()
for item in list(current_task.get("missing_fields") or [])
if str(item or "").strip()
]
field_key = missing_fields[0] if missing_fields else ""
if field_key and request.user_message:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="fill_current_slot",
target_task_id=str(current_task.get("task_id") or ""),
field_key=field_key,
field_value=request.user_message,
rationale="模型运行时决策暂不可用,我先把你的补充写入当前小财管家流程字段。",
model_call_traces=traces,
)
if field_key:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
next_action="ask_user",
target_task_id=str(current_task.get("task_id") or ""),
field_key=field_key,
question=f"请补充{FIELD_LABELS.get(field_key, field_key)}",
rationale="当前小财管家流程仍缺少必要字段。",
model_call_traces=traces,
)
if waiting_for:
return StewardRuntimeDecisionResponse(
decision_source="rule_fallback",
@@ -192,6 +323,104 @@ class StewardRuntimeDecisionAgent:
model_call_traces=traces,
)
@staticmethod
def _resolve_selected_pending_flow_id(runtime_state: dict[str, Any], user_message: str) -> str:
steward_state = runtime_state.get("steward_state")
if not isinstance(steward_state, dict):
return ""
pending = steward_state.get("pending_flow_confirmation")
if not isinstance(pending, dict) or pending.get("status") != "pending":
return ""
message = re.sub(r"\s+", "", str(user_message or ""))
if not message:
return ""
candidates = pending.get("candidate_flows") if isinstance(pending.get("candidate_flows"), list) else []
for candidate in candidates:
if not isinstance(candidate, dict):
continue
flow_id = str(candidate.get("flow_id") or "").strip()
label = re.sub(r"\s+", "", str(candidate.get("label") or ""))
if flow_id == "travel_application" and (
message in {"补办出差申请", "出差申请", "申请", "补申请"}
or (label and message == label)
):
return flow_id
if flow_id == "travel_reimbursement" and (
message in {"发起费用报销", "费用报销", "报销", "发起报销"}
or (label and message == label)
):
return flow_id
return ""
@staticmethod
def _build_selected_flow_response_text(flow_id: str) -> str:
if flow_id == "travel_application":
return "已确认按 **补办出差申请** 继续,我会基于当前出差信息整理申请材料。"
return "已确认按 **发起费用报销** 继续,我会基于当前出差信息整理报销材料。"
@staticmethod
def _clean_text(value: Any) -> str:
return str(value or "").strip()
def _attach_updated_steward_state(
self,
response: StewardRuntimeDecisionResponse,
request: StewardRuntimeDecisionRequest,
) -> StewardRuntimeDecisionResponse:
steward_state = request.runtime_state.get("steward_state")
if not isinstance(steward_state, dict) or not steward_state:
return response
if response.next_action == "continue_selected_flow":
flow_id = self._resolve_target_flow_id(response, steward_state)
if flow_id:
next_state = StewardFlowStateService().confirm_flow(steward_state, flow_id)
return response.model_copy(update={"steward_state": next_state})
return response.model_copy(update={"steward_state": steward_state})
if response.next_action != "fill_current_slot" or not response.field_key:
return response.model_copy(update={"steward_state": steward_state})
flow_id = self._resolve_target_flow_id(response, steward_state)
if not flow_id:
return response.model_copy(update={"steward_state": steward_state})
current_flow = self._resolve_flow(steward_state, flow_id)
remaining_missing_fields = [
key
for key in list(current_flow.get("missing_fields") or [])
if str(key or "").strip() and str(key or "").strip() != response.field_key
]
next_state = StewardFlowStateService().merge_state(
steward_state,
StewardFlowStatePatch(
active_flow=flow_id, # type: ignore[arg-type]
flow_id=flow_id, # type: ignore[arg-type]
intent=str(current_flow.get("intent") or "").strip(),
status="collecting" if remaining_missing_fields else "ready_for_confirmation",
fields={response.field_key: response.field_value},
missing_fields=remaining_missing_fields,
evidence=[
{
"source": "runtime_user_message",
"field": response.field_key,
"text": request.user_message,
}
],
),
)
return response.model_copy(update={"steward_state": next_state})
@staticmethod
def _resolve_target_flow_id(
response: StewardRuntimeDecisionResponse,
steward_state: dict[str, Any],
) -> str:
target = str(response.target_task_id or "").strip()
if target in {"travel_application", "travel_reimbursement"}:
return target
active_flow = str(steward_state.get("active_flow") or "").strip()
return active_flow if active_flow in {"travel_application", "travel_reimbursement"} else ""
@staticmethod
def _resolve_flow(steward_state: dict[str, Any], flow_id: str) -> dict[str, Any]:
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
flow = flows.get(flow_id) if isinstance(flows, dict) else {}
return dict(flow) if isinstance(flow, dict) else {}

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import re
TRAVEL_GRADE_KEYS = tuple(f"P{level}" for level in range(9))
def resolve_travel_policy_grade_key(grade: str | None) -> str | None:
normalized = str(grade or "").strip().upper()
if not normalized:
return None
if "董事会" in normalized:
return "P8"
p_match = re.search(r"P\s*([0-8])", normalized)
if p_match:
return f"P{int(p_match.group(1))}"
level_match = re.search(r"(?<!\d)([0-8])\s*级", normalized)
if level_match:
return f"P{int(level_match.group(1))}"
m_match = re.search(r"M\s*(\d+)", normalized)
if m_match:
level = int(m_match.group(1))
if level <= 1:
return "P5"
if level <= 2:
return "P6"
return "P7"
if normalized.startswith("D"):
return "P8"
if any(keyword in normalized for keyword in ("外聘专家", "专家")):
return "P6"
if "高层经理" in normalized or "公司级" in normalized:
return "P7"
if "中层经理" in normalized:
return "P6"
if "基层经理" in normalized:
return "P5"
if "主管" in normalized or "资深" in normalized:
return "P4"
return None
def travel_policy_grade_key_candidates(grade_key: str | None) -> list[str]:
raw_key = str(grade_key or "").strip()
normalized = raw_key.upper()
if not normalized:
return []
candidates = [normalized]
legacy_key = raw_key.lower()
if legacy_key in {"junior", "mid", "senior", "manager", "executive"}:
candidates.append(legacy_key)
legacy = _legacy_grade_band(normalized)
if legacy and legacy not in candidates:
candidates.append(legacy)
return candidates
def _legacy_grade_band(grade_key: str) -> str:
match = re.fullmatch(r"P([0-8])", grade_key)
if not match:
return ""
level = int(match.group(1))
if level <= 3:
return "junior"
if level <= 5:
return "mid"
if level <= 7:
return "senior"
return "executive"

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import re
from datetime import date
from decimal import Decimal
from sqlalchemy import func, or_, select
@@ -16,326 +17,12 @@ from app.schemas.reimbursement import (
from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService
OTHER_REGION_LOCATION_KEYWORDS = {
"河北",
"石家庄",
"唐山",
"秦皇岛",
"邯郸",
"邢台",
"保定",
"张家口",
"承德",
"沧州",
"廊坊",
"衡水",
"山西",
"太原",
"大同",
"长治",
"晋城",
"晋中",
"运城",
"临汾",
"吕梁",
"内蒙古",
"呼和浩特",
"包头",
"赤峰",
"通辽",
"鄂尔多斯",
"辽宁",
"鞍山",
"抚顺",
"本溪",
"丹东",
"锦州",
"营口",
"盘锦",
"吉林",
"长春",
"吉林市",
"四平",
"通化",
"白山",
"松原",
"延边",
"黑龙江",
"哈尔滨",
"齐齐哈尔",
"牡丹江",
"佳木斯",
"大庆",
"江苏",
"常州",
"南通",
"连云港",
"淮安",
"盐城",
"扬州",
"镇江",
"泰州",
"宿迁",
"浙江",
"温州",
"嘉兴",
"湖州",
"绍兴",
"金华",
"衢州",
"舟山",
"台州",
"丽水",
"安徽",
"芜湖",
"蚌埠",
"淮南",
"马鞍山",
"淮北",
"铜陵",
"安庆",
"黄山",
"滁州",
"阜阳",
"宿州",
"六安",
"亳州",
"池州",
"宣城",
"福建",
"泉州",
"漳州",
"莆田",
"三明",
"南平",
"龙岩",
"宁德",
"江西",
"南昌",
"景德镇",
"萍乡",
"九江",
"新余",
"鹰潭",
"赣州",
"吉安",
"宜春",
"抚州",
"上饶",
"山东",
"淄博",
"枣庄",
"东营",
"烟台",
"潍坊",
"济宁",
"泰安",
"威海",
"日照",
"临沂",
"德州",
"聊城",
"滨州",
"菏泽",
"河南",
"洛阳",
"开封",
"平顶山",
"安阳",
"鹤壁",
"新乡",
"焦作",
"濮阳",
"许昌",
"漯河",
"三门峡",
"南阳",
"商丘",
"信阳",
"周口",
"驻马店",
"湖北",
"黄石",
"十堰",
"宜昌",
"襄阳",
"鄂州",
"荆门",
"孝感",
"荆州",
"黄冈",
"咸宁",
"随州",
"恩施",
"湖南",
"株洲",
"湘潭",
"衡阳",
"邵阳",
"岳阳",
"常德",
"张家界",
"益阳",
"郴州",
"永州",
"怀化",
"娄底",
"湘西",
"广东",
"惠州",
"江门",
"湛江",
"茂名",
"肇庆",
"梅州",
"汕尾",
"河源",
"阳江",
"清远",
"潮州",
"揭阳",
"云浮",
"广西",
"南宁",
"柳州",
"桂林",
"梧州",
"北海",
"防城港",
"钦州",
"贵港",
"玉林",
"百色",
"贺州",
"河池",
"来宾",
"崇左",
"海南",
"儋州",
"四川",
"自贡",
"攀枝花",
"泸州",
"德阳",
"绵阳",
"广元",
"遂宁",
"内江",
"乐山",
"南充",
"眉山",
"宜宾",
"广安",
"达州",
"雅安",
"巴中",
"资阳",
"阿坝",
"甘孜",
"凉山",
"贵州",
"贵阳",
"遵义",
"六盘水",
"安顺",
"毕节",
"铜仁",
"黔东南",
"黔南",
"黔西南",
"云南",
"曲靖",
"玉溪",
"保山",
"昭通",
"丽江",
"普洱",
"临沧",
"楚雄",
"红河",
"文山",
"西双版纳",
"大理",
"德宏",
"怒江",
"迪庆",
"陕西",
"宝鸡",
"咸阳",
"铜川",
"渭南",
"延安",
"汉中",
"榆林",
"安康",
"商洛",
"甘肃",
"兰州",
"嘉峪关",
"金昌",
"白银",
"天水",
"武威",
"张掖",
"平凉",
"酒泉",
"庆阳",
"定西",
"陇南",
"临夏",
"甘南",
"青海",
"西宁",
"海东",
"海北",
"黄南",
"海南州",
"果洛",
"玉树",
"海西",
"宁夏",
"银川",
"石嘴山",
"吴忠",
"固原",
"中卫",
}
OTHER_REGION_PROVINCE_KEYWORDS = {
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"四川",
"贵州",
"云南",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"西藏",
"台湾",
"香港",
"澳门",
}
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}
from app.services.travel_policy_grades import travel_policy_grade_key_candidates
from app.services.travel_reimbursement_regions import (
AMBIGUOUS_PROVINCE_CITY_NAMES,
OTHER_REGION_LOCATION_KEYWORDS,
OTHER_REGION_PROVINCE_KEYWORDS,
)
class TravelReimbursementCalculatorService:
@@ -359,40 +46,76 @@ class TravelReimbursementCalculatorService:
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
if not grade_band:
raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位")
raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销职级")
matched_city = self._resolve_city(location, policy)
matched_other_region = "" if matched_city else self._resolve_other_region(location)
if not matched_city and not matched_other_region:
raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。")
city_tier = policy.city_tiers.get(matched_city, "tier_3") if matched_city else "tier_3"
hotel_rate = self._resolve_hotel_rate(policy, grade_band, matched_city, city_tier)
hotel_rate = self._resolve_hotel_rate(
policy,
grade_band,
matched_city,
city_tier,
payload.travel_date,
)
allowance_region = self._resolve_allowance_region(location, matched_city or matched_other_region)
meal_rate = self._resolve_allowance_rate(policy, "meal", allowance_region)
basic_rate = self._resolve_allowance_rate(policy, "basic", allowance_region)
total_allowance_rate = self._resolve_total_allowance_rate(policy, allowance_region, meal_rate, basic_rate)
origin_city = self._resolve_origin_city(payload, current_user, policy)
transport_mode = self._normalize_transport_mode(payload.transport_mode)
transport_estimate = self._resolve_transport_estimate(
policy,
origin_city=origin_city,
destination_city=matched_city or matched_other_region,
destination_text=location,
transport_mode=transport_mode,
)
transport_estimated_amount = Decimal(
transport_estimate.get("amount") or Decimal("0.00")
).quantize(Decimal("0.01"))
hotel_amount = hotel_rate * Decimal(days)
allowance_amount = total_allowance_rate * Decimal(days)
total_amount = hotel_amount + allowance_amount
total_amount = hotel_amount + allowance_amount + transport_estimated_amount
band_label = policy.band_labels.get(grade_band, grade_band)
rule_name = policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则"
rule_version = policy.standard_rule_version or policy.rule_version or ""
display_city = matched_city or self._format_other_region_display(matched_other_region)
formula_text = (
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
f"{self._format_money(total_amount)}"
)
if transport_estimated_amount > Decimal("0.00"):
formula_text = (
f"交通 {self._format_money(transport_estimated_amount)} + "
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
f"{self._format_money(total_amount)}"
)
summary_tail = (
f"交通费用按“{transport_estimate.get('basis') or '交通费用预估表'}"
f"预估 {self._format_money(transport_estimated_amount)} 元。"
f"{days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
f"补贴合计 {self._format_money(allowance_amount)} 元,"
f"申请预算占用参考总金额为 {self._format_money(total_amount)} 元。"
)
else:
formula_text = (
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
f"{self._format_money(total_amount)}"
)
summary_tail = (
f"{days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
f"补贴合计 {self._format_money(allowance_amount)} 元,"
f"参考可报销总金额为 {self._format_money(total_amount)} 元。"
)
summary_text = (
f"按《{rule_name}{f'{rule_version}' if rule_version else ''}测算:"
f"当前职级 {grade} 对应 {band_label},出差地点“{location}”匹配为“{display_city}”,"
f"当前职级 {grade} 对应 {band_label},出差地点“{location}”匹配为“{display_city}”,"
f"住宿标准 {self._format_money(hotel_rate)} 元/天,补贴区域为“{allowance_region}”,"
f"补贴标准 {self._format_money(total_allowance_rate)} 元/天"
f"(伙食 {self._format_money(meal_rate)} + 基本 {self._format_money(basic_rate)})。"
f"{days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
f"补贴合计 {self._format_money(allowance_amount)} 元,"
f"参考可报销总金额为 {self._format_money(total_amount)} 元。"
f"{summary_tail}"
)
return TravelReimbursementCalculatorResponse(
@@ -410,6 +133,19 @@ class TravelReimbursementCalculatorService:
basic_allowance_rate=basic_rate,
total_allowance_rate=total_allowance_rate,
allowance_amount=allowance_amount,
transport_mode=transport_mode or str(transport_estimate.get("transport_mode") or "").strip(),
transport_origin=str(transport_estimate.get("origin_city") or origin_city or "").strip(),
transport_destination=str(
transport_estimate.get("destination_city") or display_city or location
).strip(),
transport_estimated_amount=transport_estimated_amount,
transport_estimate_basis=str(transport_estimate.get("basis") or "").strip(),
transport_estimate_confidence=str(transport_estimate.get("confidence") or "").strip(),
transport_estimate_source=str(transport_estimate.get("source") or "").strip(),
transport_estimate_rule_code=str(policy.transport_estimate_rule_code or "").strip(),
transport_estimate_rule_name=str(policy.transport_estimate_rule_name or "").strip(),
transport_estimate_rule_version=str(policy.transport_estimate_rule_version or "").strip(),
travel_date=payload.travel_date,
total_amount=total_amount,
rule_name=rule_name,
rule_version=rule_version,
@@ -510,6 +246,152 @@ class TravelReimbursementCalculatorService:
return matches[0]
return None
def _resolve_origin_city(
self,
payload: TravelReimbursementCalculatorRequest,
current_user: CurrentUserContext,
policy: RuntimeTravelPolicy,
) -> str:
origin_location = str(payload.origin_location or "").strip()
if not origin_location:
employee = self._resolve_current_employee(current_user)
origin_location = str(employee.location or "").strip() if employee is not None else ""
if not origin_location:
origin_location = "武汉"
return (
self._resolve_city(origin_location, policy)
or self._resolve_other_region(origin_location)
or origin_location
)
@staticmethod
def _normalize_transport_mode(value: str | None) -> str:
normalized = re.sub(r"\s+", "", str(value or ""))
if any(keyword in normalized for keyword in ("飞机", "机票", "航班", "乘机", "坐飞机")):
return "飞机"
if any(keyword in normalized for keyword in ("火车", "高铁", "动车", "铁路", "列车")):
return "火车"
if any(keyword in normalized for keyword in ("轮船", "船票", "客轮", "渡轮", "邮轮", "坐船")):
return "轮船"
return normalized if normalized in {"飞机", "火车", "轮船"} else ""
def _resolve_transport_estimate(
self,
policy: RuntimeTravelPolicy,
*,
origin_city: str,
destination_city: str,
destination_text: str,
transport_mode: str,
) -> dict[str, object]:
if self._normalize_city_key(origin_city) == self._normalize_city_key(destination_city):
return {}
location_band = self._resolve_transport_location_band(
destination_city or destination_text
)
candidate_modes = [transport_mode] if transport_mode else ["火车", "飞机", "轮船"]
matched = None
matched_mode = ""
for candidate_mode in candidate_modes:
candidates: list[tuple[int, object]] = []
for estimate in policy.transport_estimates:
if str(estimate.transport_mode or "").strip() != candidate_mode:
continue
origin_score = self._transport_origin_match_score(
str(estimate.origin_city or ""), origin_city
)
if origin_score <= 0:
continue
destination_score = self._transport_destination_match_score(
str(estimate.destination_city or ""),
destination_city or destination_text,
str(estimate.location_band or ""),
location_band,
)
if destination_score <= 0:
continue
candidates.append((origin_score + destination_score, estimate))
if candidates:
_, matched = sorted(candidates, key=lambda item: item[0], reverse=True)[0]
matched_mode = candidate_mode
break
if matched is None:
return {}
amount = Decimal(matched.round_trip_amount or Decimal("0.00")).quantize(Decimal("0.01"))
if amount <= Decimal("0.00"):
return {}
origin_label = str(matched.origin_city or "").strip()
if origin_label in {"*", "默认", "通用"}:
origin_label = origin_city
destination_label = str(matched.destination_city or "").strip() or (
destination_city or self._transport_location_band_label(location_band)
)
basis = str(matched.basis or "").strip()
if not basis:
basis = f"{origin_label}-{destination_label}{matched_mode}往返预估"
return {
"amount": amount,
"origin_city": origin_label,
"destination_city": destination_label,
"transport_mode": matched_mode,
"basis": basis,
"confidence": str(matched.confidence or "basic_rule").strip(),
"source": "basic_rule_transport_estimate",
}
@staticmethod
def _normalize_city_key(value: str) -> str:
return re.sub(r"(省|市|区|县|自治州|特别行政区)$", "", str(value or "").strip())
def _transport_origin_match_score(self, configured_origin: str, origin_city: str) -> int:
normalized = self._normalize_city_key(configured_origin)
if not normalized or normalized in {"*", "默认", "通用"}:
return 10
origin_key = self._normalize_city_key(origin_city)
return 30 if normalized == origin_key or normalized in origin_key or origin_key in normalized else 0
def _transport_destination_match_score(
self,
configured_destination: str,
destination_city: str,
configured_band: str,
location_band: str,
) -> int:
destination_key = self._normalize_city_key(destination_city)
configured_key = self._normalize_city_key(configured_destination)
if configured_key and (
configured_key == destination_key
or configured_key in destination_key
or destination_key in configured_key
):
return 70
if configured_key:
return 0
if configured_band and configured_band == location_band:
return 40
return 0
@staticmethod
def _resolve_transport_location_band(location: str) -> str:
text = str(location or "").strip()
if any(keyword in text for keyword in ("新疆", "西藏", "青海", "甘肃", "宁夏", "内蒙古", "海南", "三亚", "海口", "香港", "澳门", "台湾", "海外", "国外")):
return "remote"
if any(keyword in text for keyword in ("北京", "上海", "广州", "深圳", "杭州", "南京", "苏州", "成都", "重庆", "天津")):
return "premium"
if any(keyword in text for keyword in ("厦门", "福州", "青岛", "大连", "宁波", "舟山")):
return "coastal"
return "default"
@staticmethod
def _transport_location_band_label(location_band: str) -> str:
return {
"premium": "高频城市",
"remote": "远途地区",
"coastal": "沿海城市",
"default": "普通城市",
}.get(str(location_band or "").strip(), "普通城市")
@staticmethod
def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str:
normalized = str(location or "").strip()
@@ -536,17 +418,67 @@ class TravelReimbursementCalculatorService:
grade_band: str,
matched_city: str,
city_tier: str,
travel_date: date | None = None,
) -> Decimal:
city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {}
if city_limits.get(grade_band) is not None:
return Decimal(city_limits[grade_band])
base_rate = Decimal("0")
for candidate in travel_policy_grade_key_candidates(grade_band):
if city_limits.get(candidate) is not None:
base_rate = Decimal(city_limits[candidate])
break
band_limits = policy.hotel_limits.get(grade_band, {})
if band_limits.get(city_tier) is not None:
return Decimal(band_limits[city_tier])
if band_limits.get("tier_3") is not None:
return Decimal(band_limits["tier_3"])
return Decimal("0")
if base_rate <= Decimal("0"):
for candidate in travel_policy_grade_key_candidates(grade_band):
band_limits = policy.hotel_limits.get(candidate, {})
if band_limits.get(city_tier) is not None:
base_rate = Decimal(band_limits[city_tier])
break
if band_limits.get("tier_3") is not None:
base_rate = Decimal(band_limits["tier_3"])
break
peak_rate = TravelReimbursementCalculatorService._resolve_peak_hotel_rate(
policy,
matched_city,
travel_date,
)
return max(base_rate, peak_rate)
@staticmethod
def _resolve_peak_hotel_rate(
policy: RuntimeTravelPolicy,
matched_city: str,
travel_date: date | None,
) -> Decimal:
if not matched_city or travel_date is None:
return Decimal("0")
period = (getattr(policy, "hotel_peak_periods", {}) or {}).get(matched_city, "")
if not period or not TravelReimbursementCalculatorService._month_in_peak_period(travel_date.month, period):
return Decimal("0")
peak_rate = (getattr(policy, "hotel_peak_city_limits", {}) or {}).get(matched_city)
return Decimal(peak_rate or Decimal("0"))
@staticmethod
def _month_in_peak_period(month: int, period: str) -> bool:
for part in re.split(r"[,,、;]+", str(period or "")):
if not part:
continue
if "-" not in part:
try:
if int(part) == month:
return True
except ValueError:
continue
continue
start_text, end_text = part.split("-", 1)
try:
start, end = int(start_text), int(end_text)
except ValueError:
continue
if start <= end and start <= month <= end:
return True
if start > end and (month >= start or month <= end):
return True
return False
@staticmethod
def _resolve_allowance_region(location: str, matched_city: str) -> str:

View File

@@ -0,0 +1,321 @@
from __future__ import annotations
OTHER_REGION_LOCATION_KEYWORDS = {
"河北",
"石家庄",
"唐山",
"秦皇岛",
"邯郸",
"邢台",
"保定",
"张家口",
"承德",
"沧州",
"廊坊",
"衡水",
"山西",
"太原",
"大同",
"长治",
"晋城",
"晋中",
"运城",
"临汾",
"吕梁",
"内蒙古",
"呼和浩特",
"包头",
"赤峰",
"通辽",
"鄂尔多斯",
"辽宁",
"鞍山",
"抚顺",
"本溪",
"丹东",
"锦州",
"营口",
"盘锦",
"吉林",
"长春",
"吉林市",
"四平",
"通化",
"白山",
"松原",
"延边",
"黑龙江",
"哈尔滨",
"齐齐哈尔",
"牡丹江",
"佳木斯",
"大庆",
"江苏",
"常州",
"南通",
"连云港",
"淮安",
"盐城",
"扬州",
"镇江",
"泰州",
"宿迁",
"浙江",
"温州",
"嘉兴",
"湖州",
"绍兴",
"金华",
"衢州",
"舟山",
"台州",
"丽水",
"安徽",
"芜湖",
"蚌埠",
"淮南",
"马鞍山",
"淮北",
"铜陵",
"安庆",
"黄山",
"滁州",
"阜阳",
"宿州",
"六安",
"亳州",
"池州",
"宣城",
"福建",
"泉州",
"漳州",
"莆田",
"三明",
"南平",
"龙岩",
"宁德",
"江西",
"南昌",
"景德镇",
"萍乡",
"九江",
"新余",
"鹰潭",
"赣州",
"吉安",
"宜春",
"抚州",
"上饶",
"山东",
"淄博",
"枣庄",
"东营",
"烟台",
"潍坊",
"济宁",
"泰安",
"威海",
"日照",
"临沂",
"德州",
"聊城",
"滨州",
"菏泽",
"河南",
"洛阳",
"开封",
"平顶山",
"安阳",
"鹤壁",
"新乡",
"焦作",
"濮阳",
"许昌",
"漯河",
"三门峡",
"南阳",
"商丘",
"信阳",
"周口",
"驻马店",
"湖北",
"黄石",
"十堰",
"宜昌",
"襄阳",
"鄂州",
"荆门",
"孝感",
"荆州",
"黄冈",
"咸宁",
"随州",
"恩施",
"湖南",
"株洲",
"湘潭",
"衡阳",
"邵阳",
"岳阳",
"常德",
"张家界",
"益阳",
"郴州",
"永州",
"怀化",
"娄底",
"湘西",
"广东",
"惠州",
"江门",
"湛江",
"茂名",
"肇庆",
"梅州",
"汕尾",
"河源",
"阳江",
"清远",
"潮州",
"揭阳",
"云浮",
"广西",
"南宁",
"柳州",
"桂林",
"梧州",
"北海",
"防城港",
"钦州",
"贵港",
"玉林",
"百色",
"贺州",
"河池",
"来宾",
"崇左",
"海南",
"儋州",
"四川",
"自贡",
"攀枝花",
"泸州",
"德阳",
"绵阳",
"广元",
"遂宁",
"内江",
"乐山",
"南充",
"眉山",
"宜宾",
"广安",
"达州",
"雅安",
"巴中",
"资阳",
"阿坝",
"甘孜",
"凉山",
"贵州",
"贵阳",
"遵义",
"六盘水",
"安顺",
"毕节",
"铜仁",
"黔东南",
"黔南",
"黔西南",
"云南",
"曲靖",
"玉溪",
"保山",
"昭通",
"丽江",
"普洱",
"临沧",
"楚雄",
"红河",
"文山",
"西双版纳",
"大理",
"德宏",
"怒江",
"迪庆",
"陕西",
"宝鸡",
"咸阳",
"铜川",
"渭南",
"延安",
"汉中",
"榆林",
"安康",
"商洛",
"甘肃",
"兰州",
"嘉峪关",
"金昌",
"白银",
"天水",
"武威",
"张掖",
"平凉",
"酒泉",
"庆阳",
"定西",
"陇南",
"临夏",
"甘南",
"青海",
"西宁",
"海东",
"海北",
"黄南",
"海南州",
"果洛",
"玉树",
"海西",
"宁夏",
"银川",
"石嘴山",
"吴忠",
"固原",
"中卫",
}
OTHER_REGION_PROVINCE_KEYWORDS = {
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"四川",
"贵州",
"云南",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"西藏",
"台湾",
"香港",
"澳门",
}
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}

View File

@@ -38,8 +38,14 @@ from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
)
from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
@@ -64,6 +70,9 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY
for file_name in (
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_FILENAME,
):
@@ -197,12 +206,36 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
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 travel_config["tag"] == "基础规则"
assert communication_config["tag"] == "基础规则"
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
grade_mapping_rule = next(
item for item in rules if item.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE
)
season_mapping_rule = next(
item for item in rules if item.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE
)
transport_estimate_rule = next(
item for item in rules if item.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE
)
assert grade_mapping_rule.config_json["tag"] == "基础规则"
assert grade_mapping_rule.config_json["rule_document"]["file_name"] == (
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
)
assert season_mapping_rule.config_json["tag"] == "基础规则"
assert season_mapping_rule.config_json["rule_document"]["file_name"] == (
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
)
assert transport_estimate_rule.config_json["tag"] == "基础规则"
assert transport_estimate_rule.config_json["rule_document"]["file_name"] == (
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
)
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
with build_session() as db:
@@ -743,15 +776,15 @@ def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -
assert catalog.travel_policy is not None
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则"
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500
assert catalog.travel_policy.standard_rule_name == "差旅住宿报销标准"
assert catalog.travel_policy.hotel_city_limits["北京"]["P0"] == 450
assert catalog.travel_policy.hotel_city_limits["北京"]["P4"] == 450
assert catalog.travel_policy.hotel_city_limits["北京"]["P8"] == 500
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1
assert catalog.travel_policy.transport_limits["P7"]["flight"] == 1
assert catalog.travel_policy.transport_limits["P8"]["train"] == 2
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
@@ -777,18 +810,23 @@ def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> N
),
)
assert result.rule_name == "公司差旅费报销规则"
assert result.rule_name == "差旅住宿报销标准"
assert result.grade == "P4"
assert result.grade_band == "mid"
assert result.grade_band == "P4"
assert result.matched_city == "北京"
assert result.hotel_rate == 450
assert result.hotel_amount == 1350
assert result.allowance_region == "直辖市/特区"
assert result.total_allowance_rate == 100
assert result.allowance_amount == 300
assert result.total_amount == 1650
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text
assert "参考可报销总金额为 1650.00 元" in result.summary_text
assert result.transport_estimated_amount == 1040
assert result.transport_estimate_source == "basic_rule_transport_estimate"
assert result.total_amount == 2690
assert (
"交通 1040.00 + 住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 2690.00"
== result.formula_text
)
assert "申请预算占用参考总金额为 2690.00 元" in result.summary_text
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
@@ -821,7 +859,8 @@ def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_lo
assert result.allowance_region == "其他地区"
assert result.total_allowance_rate == 90
assert result.allowance_amount == 180
assert result.total_amount == 940
assert result.transport_estimated_amount == 720
assert result.total_amount == 1660
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:

View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from app.schemas.steward import (
StewardCandidateFlow,
StewardFlowStatePatch,
StewardPendingFlowConfirmation,
StewardPlanResponse,
)
from app.services.steward_flow_state import StewardFlowStateService
def test_state_merge_keeps_application_and_reimbursement_flows() -> None:
service = StewardFlowStateService()
state = service.merge_state(
{},
StewardFlowStatePatch(
active_flow="travel_application",
flow_id="travel_application",
intent="travel_application_create",
fields={"expense_type": "travel", "location": "上海", "reason": "客户现场支撑"},
missing_fields=["transport_mode"],
),
)
state = service.merge_state(
state,
StewardFlowStatePatch(
active_flow="travel_reimbursement",
flow_id="travel_reimbursement",
intent="travel_reimbursement_draft",
fields={"amount": "708.00", "invoice_no": "NO-1"},
linked_application_claim_id="claim-app-001",
),
)
assert state["active_flow"] == "travel_reimbursement"
assert state["flows"]["travel_application"]["fields"]["location"] == "上海"
assert state["flows"]["travel_application"]["missing_fields"] == ["transport_mode"]
assert state["flows"]["travel_reimbursement"]["fields"]["amount"] == "708.00"
assert state["flows"]["travel_reimbursement"]["linked_application_claim_id"] == "claim-app-001"
def test_state_merge_filters_non_ontology_fields() -> None:
service = StewardFlowStateService()
state = service.merge_state(
{},
StewardFlowStatePatch(
active_flow="travel_application",
flow_id="travel_application",
intent="travel_application_create",
fields={
"location": "上海",
"invented_field": "x",
"occurred_date": "2026-06-15",
},
),
)
assert state["flows"]["travel_application"]["fields"] == {
"location": "上海",
"time_range": "2026-06-15",
}
def test_state_merge_appends_traceable_events() -> None:
service = StewardFlowStateService()
state = service.merge_state(
{},
StewardFlowStatePatch(
active_flow="travel_application",
flow_id="travel_application",
intent="travel_application_create",
fields={"location": "北京"},
evidence=[{"source": "user_message", "field": "location", "text": "去北京出差"}],
),
)
assert len(state["events"]) == 1
assert state["events"][0]["flow_id"] == "travel_application"
assert state["events"][0]["intent"] == "travel_application_create"
assert state["events"][0]["fields"] == {"location": "北京"}
assert state["events"][0]["evidence"][0]["text"] == "去北京出差"
def test_state_merge_plan_keeps_pending_flow_confirmation() -> None:
service = StewardFlowStateService()
state = service.merge_plan(
{},
StewardPlanResponse(
plan_id="steward_plan_pending",
plan_status="needs_flow_confirmation",
planning_source="llm_function_call",
next_action="confirm_flow",
summary="需要先确认是申请还是报销。",
pending_flow_confirmation=StewardPendingFlowConfirmation(
status="pending",
source_message="2月20-23日去上海出差辅助国网仿生产环境部署",
reason="缺少申请或报销动作词。",
candidate_flows=[
StewardCandidateFlow(
flow_id="travel_application",
label="补办出差申请",
confidence=0.52,
reason="可能是补办申请。",
ontology_fields={
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
missing_fields=["transport_mode"],
),
StewardCandidateFlow(
flow_id="travel_reimbursement",
label="发起费用报销",
confidence=0.48,
reason="可能是发起报销。",
ontology_fields={
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
),
],
),
),
)
assert state["active_flow"] == ""
assert state["pending_flow_confirmation"]["status"] == "pending"
assert state["flows"]["travel_application"]["status"] == "pending_flow_confirmation"
assert state["flows"]["travel_application"]["fields"]["location"] == "上海"
assert state["flows"]["travel_application"]["missing_fields"] == ["transport_mode"]
assert state["flows"]["travel_reimbursement"]["fields"]["time_range"] == "2026-02-20"

View File

@@ -0,0 +1,30 @@
from app.services.steward_intent_agent import (
STEWARD_INTENT_FUNCTION_NAME,
StewardIntentAgent,
)
def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None:
schema = StewardIntentAgent._build_intent_tool_schema(
["expense_type", "time_range", "location", "reason", "transport_mode"]
)
function_schema = schema["function"]
assert function_schema["name"] == STEWARD_INTENT_FUNCTION_NAME
properties = function_schema["parameters"]["properties"]
pending_schema = properties["pending_flow_confirmation"]
candidate_schema = pending_schema["properties"]["candidate_flows"]["items"]
assert "pending_flow_confirmation" in properties
assert pending_schema["properties"]["status"]["enum"] == ["none", "pending"]
assert candidate_schema["properties"]["flow_id"]["enum"] == [
"travel_application",
"travel_reimbursement",
]
assert candidate_schema["properties"]["missing_fields"]["items"]["enum"] == [
"expense_type",
"time_range",
"location",
"reason",
"transport_mode",
]

View File

@@ -63,6 +63,24 @@ class FakeFunctionCallingIntentAgent:
)
class CountingFunctionCallingIntentAgent(FakeFunctionCallingIntentAgent):
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return super().detect(request, base_date=base_date, canonical_fields=canonical_fields)
class CountingNoResultIntentAgent:
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return None
class EmptyFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return None
@@ -125,9 +143,92 @@ class ApplicationFunctionCallingIntentAgent:
)
class PendingFlowFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "flow_confirmation",
"title": "识别到出差事项但动作不明确",
"content": "用户提供了时间、地点和事由,但没有明确要补办申请还是发起报销。",
}
],
"pending_flow_confirmation": {
"status": "pending",
"source_message": request.message,
"reason": "缺少申请或报销动作词,需要用户确认流程方向。",
"candidate_flows": [
{
"flow_id": "travel_application",
"label": "补办出差申请",
"confidence": 0.52,
"reason": "这句话可以理解为补办出差申请。",
"ontology_fields": {
"time_range": "2月20日",
"location": "上海",
"expense_type": "差旅",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": ["transport_mode"],
},
{
"flow_id": "travel_reimbursement",
"label": "发起费用报销",
"confidence": 0.48,
"reason": "这句话也可能是在为已发生出差发起报销。",
"ontology_fields": {
"time_range": "2月20日",
"location": "上海",
"expense_type": "差旅",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": [],
},
],
},
"tasks": [],
"attachment_groups": [],
},
model_call_traces=[],
)
class AmbiguousApplicationFunctionCallingIntentAgent:
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": "2月20-23日去上海出差辅助国网仿生产环境部署。",
"confidence": 0.9,
"ontology_fields": {
"time_range": "2月20日",
"location": "上海",
"expense_type": "差旅",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": ["transport_mode"],
}
],
"attachment_groups": [],
},
model_call_traces=[{"status": "succeeded"}],
)
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费",
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u5ba2\u6237\u73b0\u573a\u6c9f\u901a\u7684\u4ea4\u901a\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00",
attachments=[
StewardAttachmentInput(name="出租车票.png"),
@@ -157,7 +258,7 @@ def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None
def test_steward_planner_normalizes_llm_business_entertainment_expense_type() -> None:
payload = StewardPlanRequest(
message="报销昨天业务招待费",
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u62a5\u9500\u6628\u5929\u4e1a\u52a1\u62db\u5f85\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00",
)
@@ -170,7 +271,7 @@ def test_steward_planner_normalizes_llm_business_entertainment_expense_type() ->
def test_steward_planner_enforces_application_transport_gap_after_function_calling() -> None:
payload = StewardPlanRequest(
message="明天出差北京3天支撑国网仿生产部署",
message="\u6211\u60f3\u7533\u8bf7\u660e\u5929\u51fa\u5dee\u5317\u4eac\u0033\u5929\uff0c\u652f\u6491\u56fd\u7f51\u4eff\u751f\u4ea7\u90e8\u7f72\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00",
)
@@ -184,19 +285,114 @@ def test_steward_planner_enforces_application_transport_gap_after_function_calli
assert "火车、飞机或轮船" in gap_events[0].content
def test_steward_planner_returns_pending_flow_confirmation_from_llm() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产环境部署",
client_now_iso="2026-06-15T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=PendingFlowFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
assert result.pending_flow_confirmation.status == "pending"
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
assert result.candidate_flows[0].ontology_fields["time_range"] == "2026-02-20"
assert result.candidate_flows[0].ontology_fields["location"] == "上海"
assert "申请" in result.summary and "报销" in result.summary
def test_steward_planner_skips_llm_for_single_ambiguous_travel_flow() -> None:
payload = StewardPlanRequest(
message="\u0032\u6708\u0032\u0030-\u0032\u0033\u65e5\u53bb\u4e0a\u6d77\u51fa\u5dee\u8f85\u52a9\u56fd\u7f51\u4eff\u751f\u4ea7\u73af\u5883\u90e8\u7f72",
client_now_iso="2026-06-15T09:30:00+08:00",
)
intent_agent = CountingNoResultIntentAgent()
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
assert intent_agent.calls == 0
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
assert result.model_call_traces == []
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
def test_steward_planner_uses_llm_for_multi_financial_demands() -> None:
payload = StewardPlanRequest(
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00",
)
intent_agent = CountingFunctionCallingIntentAgent()
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
assert intent_agent.calls == 1
assert result.planning_source == "llm_function_call"
assert result.model_call_traces[0]["status"] == "succeeded"
def test_steward_planner_overrides_llm_direct_application_for_ambiguous_travel_flow() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产环境部署",
client_now_iso="2026-06-15T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=AmbiguousApplicationFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
assert result.tasks == []
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
payload = StewardPlanRequest(
message="我要报销昨天的交通费",
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u7684\u4ea4\u901a\u8d39",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
assert [task.task_type for task in result.tasks] == ["expense_application", "reimbursement"]
assert result.tasks[0].ontology_fields["time_range"] == "2026-07-02"
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
assert result.thinking_events[0].stage == "rule_fallback"
def test_steward_planner_rule_fallback_confirms_ambiguous_travel_flow() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产环境部署",
client_now_iso="2026-06-15T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.pending_flow_confirmation.status == "pending"
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
assert result.tasks == []
assert result.confirmation_groups == []
def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
payload = StewardPlanRequest(
message=(
@@ -326,3 +522,28 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
assert events[0]["data"]["stage"] == "stream_start"
assert events[-1]["event"] == "plan"
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"
def test_steward_plan_endpoint_persists_application_and_reimbursement_state() -> None:
client = TestClient(create_app())
response = client.post(
"/api/v1/steward/plans",
json={
"message": "我想申请7月2日去北京出差并且我要报销昨天的交通费",
"user_id": "u-steward-state",
"client_now_iso": "2026-06-04T09:30:00+08:00",
"context_json": {"session_type": "steward", "entry_source": "personal_workbench"},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["conversation_id"].startswith("conv_")
state = payload["steward_state"]
assert state["active_flow"] == "travel_reimbursement"
assert state["flows"]["travel_application"]["fields"]["location"] == "北京"
assert state["flows"]["travel_application"]["fields"]["time_range"] == "2026-07-02"
assert state["flows"]["travel_reimbursement"]["fields"]["time_range"] == "2026-06-03"
assert state["flows"]["travel_reimbursement"]["fields"]["expense_type"] == "transport"
assert all("invented_field" not in flow["fields"] for flow in state["flows"].values())

View File

@@ -94,3 +94,154 @@ def test_steward_runtime_decision_fallback_keeps_current_context():
assert result.next_action == "continue_next_task"
assert result.target_message_id == "msg-next-task"
assert result.target_task_id == "task-reimbursement-meal"
def test_steward_runtime_decision_fallback_reads_persisted_steward_state():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="我坐高铁",
runtime_state={},
context_json={
"conversation_state": {
"steward_state": {
"active_flow": "travel_application",
"flows": {
"travel_application": {
"flow_id": "travel_application",
"intent": "travel_application_create",
"fields": {
"expense_type": "travel",
"time_range": "2026-07-02",
"location": "北京",
"reason": "客户现场支撑",
},
"missing_fields": ["transport_mode"],
}
},
}
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "fill_current_slot"
assert result.target_task_id == "travel_application"
assert result.field_key == "transport_mode"
assert result.field_value == "我坐高铁"
assert result.steward_state["flows"]["travel_application"]["fields"]["transport_mode"] == "我坐高铁"
assert result.steward_state["flows"]["travel_application"]["missing_fields"] == []
def test_steward_runtime_decision_fallback_confirms_selected_flow():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="补办出差申请",
runtime_state={},
context_json={
"conversation_state": {
"steward_state": {
"version": "steward.flow_state.v2",
"active_flow": "",
"pending_flow_confirmation": {
"status": "pending",
"source_message": "2月20-23日去上海出差辅助国网仿生产环境部署",
"reason": "缺少申请或报销动作词。",
"candidate_flows": [
{
"flow_id": "travel_application",
"label": "补办出差申请",
"confidence": 0.52,
},
{
"flow_id": "travel_reimbursement",
"label": "发起费用报销",
"confidence": 0.48,
},
],
},
"flows": {
"travel_application": {
"flow_id": "travel_application",
"intent": "travel_application_create",
"status": "pending_flow_confirmation",
"fields": {
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": ["transport_mode"],
},
"travel_reimbursement": {
"flow_id": "travel_reimbursement",
"intent": "travel_reimbursement_draft",
"status": "pending_flow_confirmation",
"fields": {
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": [],
},
},
}
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_selected_flow"
assert result.target_task_id == "travel_application"
assert result.steward_state["active_flow"] == "travel_application"
assert result.steward_state["pending_flow_confirmation"]["status"] == "confirmed"
assert result.steward_state["flows"]["travel_application"]["status"] == "collecting"
def test_steward_runtime_decision_fallback_confirms_reimbursement_flow():
runtime = _FakeRuntime(None)
result = StewardRuntimeDecisionAgent(runtime).decide(
StewardRuntimeDecisionRequest(
user_message="发起费用报销",
runtime_state={
"steward_state": {
"version": "steward.flow_state.v2",
"active_flow": "",
"pending_flow_confirmation": {
"status": "pending",
"candidate_flows": [
{"flow_id": "travel_application", "label": "补办出差申请"},
{"flow_id": "travel_reimbursement", "label": "发起费用报销"},
],
},
"flows": {
"travel_reimbursement": {
"flow_id": "travel_reimbursement",
"intent": "travel_reimbursement_draft",
"status": "pending_flow_confirmation",
"fields": {
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": [],
}
},
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_selected_flow"
assert result.target_task_id == "travel_reimbursement"
assert result.steward_state["active_flow"] == "travel_reimbursement"
assert result.steward_state["pending_flow_confirmation"]["status"] == "confirmed"