diff --git a/document/development/expense-control-demo-data/01_finance-rules.md b/document/development/expense-control-demo-data/01_finance-rules.md new file mode 100644 index 0000000..e5ade37 --- /dev/null +++ b/document/development/expense-control-demo-data/01_finance-rules.md @@ -0,0 +1,35 @@ +# 财务规则表补齐开发记录 + +## 目标 + +财务规则中心只维护真正具备制度标准、且需要按职级/职务或明确人均标准执行的规则表。没有实际金额分档的费用类型,不在财务规则中心单独生成 Excel 表;其额度控制进入预算中心,申请前置和材料完整性进入风险规则。 + +## 本次范围调整 + +- 保留《公司差旅费报销规则》。 +- 保留《公司通信费报销规则》。 +- 删除独立《公司交通住宿费细分规则》,交通/住宿标准统一并入差旅规则。 +- 删除业务招待费、市场推广费、会务费、办公用品费、培训费、软件服务费、福利费这 7 张口径型规则表。 +- 不再为“申请、附件、合同/验收、预算归集口径”单独创建财务规则表。 +- 规则中心中如已存在上述口径型资产,统一标记为废弃规则,不再作为财务规则展示。 + +## 字段口径 + +- 金额标准:只在真实制度表中维护。 +- 职级/职务分档:没有实际标准时不造字段、不造表。 +- 预算额度:进入预算中心和预算执行规则。 +- 申请前置:进入风险规则的申请前置类。 +- 附件/合同/验收:进入风险规则的材料完整性类。 +- 费用类型归类:进入风险规则或本体费用类型映射,不通过财务规则表承载。 + +## 当前交付物 + +- `server/rules/finance-rules/公司差旅费报销规则.xlsx` +- `server/rules/finance-rules/公司通信费报销规则.xlsx` + +## 验证方式 + +- 规则中心只展示真实财务标准表。 +- 被删除的口径型规则资产不会被重新创建。 +- 历史口径型规则资产如已存在,会被同步为 `废弃规则`。 +- 风险规则不再引用已删除的口径型财务规则表 code。 diff --git a/document/development/expense-control-demo-data/02_risk-rules.md b/document/development/expense-control-demo-data/02_risk-rules.md new file mode 100644 index 0000000..bfff5b1 --- /dev/null +++ b/document/development/expense-control-demo-data/02_risk-rules.md @@ -0,0 +1,39 @@ +# 风险规则补齐开发记录 + +## 目标 + +补齐预算、申请前置、报销偏差、费用标准、材料完整性类风险规则,让后续 demo 数据可以形成“预算-申请-报销-风控”的闭环。 + +## 本次范围 + +- 第一批新增 30 条左右平台 JSON 风险规则。 +- 风险规则必须能通过现有 `risk-rules` JSON 规则库同步到规则中心。 +- 规则中保留口径引用字段;只有存在真实职级/职务金额分档的费用才引用财务规则表。 +- 没有独立财务标准表的费用,引用申请制度、材料完整性、预算执行或费用归类口径。 +- 规则中心的适用场景必须来自 `expense_types`,展示为具体费用类型,而不是统一显示通用。 +- 预算类规则先预留预算字段和口径,不在本阶段新增预算流水表。 + +## 规则分类 + +- 预算类:预算不足、80% 预警、100% 超预算、冻结预算、跨部门预算、跨季度预算。 +- 申请前置类:大额费用无申请,推广/培训/会务/软件/办公采购/招待无事前申请。 +- 申请报销偏差类:金额超申请、超 10%、科目不一致、部门不一致、周期不一致、重复报销。 +- 费用标准类:差旅、通信等真实标准;其他费用不伪造职级限额。 +- 费用归类类:固定资产伪装为办公用品等科目错配风险。 +- 材料完整性类:合同、方案、验收、签到、参与人、客户说明等材料缺失。 + +## 风险规则扩展字段 + +- `finance_rule_code`:可指向真实财务规则表,也可指向申请/预算/材料/归类制度口径。 +- `finance_rule_sheet`:真实表时记录工作表名称,制度口径时记录口径名称。 +- `business_stage` +- `expense_types`:用于意图识别后的费用类型匹配,也是规则中心适用场景的来源。 +- `budget_required` + +## 验证方式 + +- `AgentFoundationRiskRuleMixin` 能同步新增 JSON 规则。 +- 新增规则不被识别为自然语言生成草稿并跳过。 +- 规则资产的 `config_json` 能保留口径引用字段,且不指向已删除的口径型财务规则表。 +- 规则资产的 `scenario_json` 能从 `expense_types` 生成具体费用场景。 +- 至少验证预算类、申请前置类、费用标准类、材料完整性类各有规则同步成功。 diff --git a/document/development/expense-control-demo-data/TODO.md b/document/development/expense-control-demo-data/TODO.md new file mode 100644 index 0000000..0872a70 --- /dev/null +++ b/document/development/expense-control-demo-data/TODO.md @@ -0,0 +1,20 @@ +# 费用管控 Demo 数据规则补齐 TODO + +## 2026-05-26 + +- [x] 建立开发记录目录。 +- [x] 编写财务规则表开发记录。 +- [x] 编写风险规则开发记录。 +- [x] 设计费用类型财务规则定义。 +- [x] 生成第一版财务规则 Excel 文件。 +- [x] 让第一版财务规则表进入规则中心资产同步。 +- [x] 补充规则中心同步测试。 +- [x] 新增预算/申请/报销风险 JSON 规则。 +- [x] 补充风险规则同步测试。 +- [x] 补充财务规则资产同步脚本并同步演示库。 +- [x] 纠正财务规则表口径:删除独立交通住宿细分表,非制度标准费用不再维护限额表。 +- [x] 按真实职务金额分档口径二次纠正:删除 7 张没有实际金额分档的口径型财务规则表。 +- [x] 调整风险规则引用,避免指向已删除的口径型财务规则表。 +- [x] 修正规则中心适用场景:按 `expense_types` 展示具体费用类型,不再统一落为通用。 +- [x] 运行后端定向测试。 +- [x] 核对交付物和 TODO。 diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json b/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json deleted file mode 100644 index c86d4ee..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260525155459576686.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260525155459576686", - "name": "住宿日期与差旅行程不匹配", - "description": "面向业务用户的说明", - "enabled": true, - "requires_attachment": true, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "claim.reason" - ], - "condition_summary": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "natural_language": "差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。", - "required_fields": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "claim.reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 77 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-25T15:54:59.576686+00:00", - "created_by": "admin", - "requires_attachment": true, - "rule_title": "住宿日期与差旅行程不匹配", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。", - "business_explanation": "面向业务用户的说明", - "condition_summary": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "flow": { - "start": "提交业务单据", - "evidence": "读取住宿城市、申报地点、行程城市", - "decision": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "pass": "继续流转", - "fail": "提示风险" - }, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_level_updated_at": "2026-05-25T16:05:15.691638+00:00", - "risk_score": 77, - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 77, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 77, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": true - } - } - }, - "flow_diagram_svg": "", - "severity": "high", - "risk_score": 77, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 77, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 77, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": true - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json deleted file mode 100644 index 613b00e..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101144234987.json +++ /dev/null @@ -1,180 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101144234987", - "name": "差旅目的地与票据城市不一致", - "description": "当差旅费业务满足“差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "enabled": true, - "requires_attachment": false, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_compare_v1", - "semantic_type": null, - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "item.item_location", - "label": "明细地点", - "type": "text", - "source": "item" - } - ] - }, - "params": { - "template_key": "field_compare_v1", - "field_keys": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "item.item_location" - ], - "condition_summary": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集", - "natural_language": "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。", - "conditions": [ - { - "left": "attachment.hotel_city", - "operator": "overlap", - "right": "claim.location" - } - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 78 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:11:44.234987+08:00", - "created_by": "admin", - "requires_attachment": false, - "risk_score": 78, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 78, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 80, - "evidence": 82, - "exception": 66, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 78, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_compare_v1", - "field_count": 4, - "condition_count": 1, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - }, - "rule_title": "差旅目的地与票据城市不一致", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。", - "business_explanation": "当差旅费业务满足“差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,要求补充行程说明或退回修改。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集", - "rule_ir": {}, - "flow": { - "start": "差旅费单据提交", - "evidence": "读取住宿城市、申报地点、行程城市", - "decision": "对比住宿城市、申报地点、行程城市之间是否一致或存在交集", - "pass": "未命中风险,继续业务流转", - "fail": "命中高风险,提示复核" - } - }, - "flow_diagram_svg": "", - "severity": "high", - "risk_score": 78, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 78, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 80, - "evidence": 82, - "exception": 66, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 78, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_compare_v1", - "field_count": 4, - "condition_count": 1, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json deleted file mode 100644 index 0e1ef18..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101531166364.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101531166364", - "name": "住宿日期与差旅行程不匹配", - "description": "当差旅费业务满足“差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "enabled": true, - "requires_attachment": false, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "semantic_type": null, - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "claim.reason" - ], - "condition_summary": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "natural_language": "差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。", - "required_fields": [ - "attachment.hotel_city", - "claim.location", - "attachment.route_cities", - "claim.reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 77 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:15:31.166364+08:00", - "created_by": "admin", - "requires_attachment": false, - "risk_score": 77, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 77, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 77, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - }, - "rule_title": "住宿日期与差旅行程不匹配", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。", - "business_explanation": "当差旅费业务满足“差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "rule_ir": {}, - "flow": { - "start": "差旅费单据提交", - "evidence": "读取住宿城市、申报地点、行程城市", - "decision": "检查住宿城市、申报地点、行程城市是否满足必填和完整性要求", - "pass": "未命中风险,继续业务流转", - "fail": "命中高风险,提示复核" - } - }, - "flow_diagram_svg": "", - "severity": "high", - "risk_score": 77, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 77, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 78, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 77, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json deleted file mode 100644 index 86f2947..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101826456519.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101826456519", - "name": "差旅事由过于笼统", - "description": "当差旅费业务满足“差旅费报销时,先读取报销事由、明细事由、申报目的地、费用类型和明细地点;再判断是否能说明出差目的、客户或项目背景、发生城市和费用构成。若报销事由只填写“出差”“差旅”“项目支持”“工作安排”等笼统描述,且明细事由也无法补充业务背景,则标记为高风险,提示经办人补充出差目的、项目名称或业务对象。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "enabled": true, - "requires_attachment": false, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "semantic_type": null, - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "item.item_type", - "label": "费用类型", - "type": "enum", - "source": "item" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - }, - { - "key": "item.item_reason", - "label": "明细事由", - "type": "text", - "source": "item" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "claim.location", - "item.item_type", - "claim.reason", - "item.item_reason" - ], - "condition_summary": "检查申报地点、费用类型、报销事由是否满足必填和完整性要求", - "natural_language": "差旅费报销时,先读取报销事由、明细事由、申报目的地、费用类型和明细地点;再判断是否能说明出差目的、客户或项目背景、发生城市和费用构成。若报销事由只填写“出差”“差旅”“项目支持”“工作安排”等笼统描述,且明细事由也无法补充业务背景,则标记为中风险,提示经办人补充出差目的、项目名称或业务对象。", - "required_fields": [ - "claim.location", - "item.item_type", - "claim.reason", - "item.item_reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 72 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:18:26.456519+08:00", - "created_by": "admin", - "requires_attachment": false, - "risk_score": 72, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 72, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 62, - "exception": 35, - "action": 65, - "sensitivity": 70 - }, - "calibration": { - "raw_score": 72, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - }, - "rule_title": "差旅事由过于笼统", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅费报销时,先读取报销事由、明细事由、申报目的地、费用类型和明细地点;再判断是否能说明出差目的、客户或项目背景、发生城市和费用构成。若报销事由只填写“出差”“差旅”“项目支持”“工作安排”等笼统描述,且明细事由也无法补充业务背景,则标记为中风险,提示经办人补充出差目的、项目名称或业务对象。", - "business_explanation": "当差旅费业务满足“差旅费报销时,先读取报销事由、明细事由、申报目的地、费用类型和明细地点;再判断是否能说明出差目的、客户或项目背景、发生城市和费用构成。若报销事由只填写“出差”“差旅”“项目支持”“工作安排”等笼统描述,且明细事由也无法补充业务背景,则标记为高风险,提示经办人补充出差目的、项目名称或业务对象。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "检查申报地点、费用类型、报销事由是否满足必填和完整性要求", - "rule_ir": {}, - "flow": { - "start": "差旅费单据提交", - "evidence": "读取申报地点、费用类型、报销事由", - "decision": "检查申报地点、费用类型、报销事由是否满足必填和完整性要求", - "pass": "未命中风险,继续业务流转", - "fail": "命中高风险,提示复核" - } - }, - "flow_diagram_svg": "", - "severity": "high", - "risk_score": 72, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 72, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 62, - "exception": 35, - "action": 65, - "sensitivity": 70 - }, - "calibration": { - "raw_score": 72, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json deleted file mode 100644 index e8b76a4..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101912249313.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101912249313", - "name": "差旅基础字段缺失提醒", - "description": "检查差旅报销的报销事由、申报目的地、明细发生地点和明细事由是否填写完整,确保能说明出差目的、发生城市和费用内容。缺少这些字段但无其他风险迹象时标记为中风险,提示补齐。", - "enabled": true, - "requires_attachment": false, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "semantic_type": "travel_info_completeness", - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "claim.location", - "label": "申报地点", - "type": "text", - "source": "claim" - }, - { - "key": "claim.reason", - "label": "报销事由", - "type": "text", - "source": "claim" - }, - { - "key": "item.item_location", - "label": "明细地点", - "type": "text", - "source": "item" - }, - { - "key": "item.item_reason", - "label": "明细事由", - "type": "text", - "source": "item" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "claim.location", - "claim.reason", - "item.item_location", - "item.item_reason" - ], - "condition_summary": "检查申报地点、报销事由、明细地点是否满足必填和完整性要求", - "natural_language": "差旅费报销提交时,先读取报销事由、申报目的地、费用类型、明细发生地点和明细事由;再判断这些字段是否能完整说明出差目的、发生城市和费用内容。若缺少申报目的地、明细地点或明细事由,但暂未发现票据城市冲突、金额异常或重复报销迹象,则标记为低风险,提示经办人补齐基础差旅信息后继续提交。", - "semantic_type": "travel_info_completeness", - "required_fields": [ - "claim.location", - "claim.reason", - "item.item_location", - "item.item_reason" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "low", - "action": "manual_review", - "risk_score": 30 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:19:12.249313+08:00", - "created_by": "admin", - "requires_attachment": false, - "risk_score": 30, - "risk_level": "low", - "risk_level_label": "低风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 30, - "level": "low", - "level_label": "低风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 48, - "certainty": 86, - "evidence": 62, - "exception": 35, - "action": 35, - "sensitivity": 70 - }, - "calibration": { - "raw_score": 58, - "rules": [ - { - "name": "explicit_low_control_cap", - "score_before": 58, - "score_after": 30, - "reason": "规则语义明确为低风险,且控制动作仅为提醒、提示、补齐或补充说明。" - } - ] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - }, - "rule_title": "差旅基础字段缺失提醒", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅费报销提交时,先读取报销事由、申报目的地、费用类型、明细发生地点和明细事由;再判断这些字段是否能完整说明出差目的、发生城市和费用内容。若缺少申报目的地、明细地点或明细事由,但暂未发现票据城市冲突、金额异常或重复报销迹象,则标记为低风险,提示经办人补齐基础差旅信息后继续提交。", - "business_explanation": "检查差旅报销的报销事由、申报目的地、明细发生地点和明细事由是否填写完整,确保能说明出差目的、发生城市和费用内容。缺少这些字段但无其他风险迹象时标记为中风险,提示补齐。", - "condition_summary": "检查申报地点、报销事由、明细地点是否满足必填和完整性要求", - "rule_ir": { - "facts": [ - "A = claim.reason (报销事由)", - "B = claim.location (申报目的地)", - "C = item.item_location (明细发生地点)", - "D = item.item_reason (明细事由)" - ], - "conditions": [ - { - "id": "missing_travel_info", - "operator": "not_exists_any", - "fields": [ - "B", - "C", - "D" - ] - } - ], - "hit_logic": "missing_travel_info AND (A exists) → 低风险,提示补齐申报目的地、明细地点或明细事由" - }, - "flow": { - "start": "提交差旅报销单", - "evidence": "读取申报地点、报销事由、明细地点", - "decision": "检查申报地点、报销事由、明细地点是否满足必填和完整性要求", - "pass": "所有基础差旅信息完整,无风险提示", - "fail": "缺少申报目的地/明细发生地点/明细事三者之一,标记为中风险,提示经办人补齐后继续提交" - } - }, - "flow_diagram_svg": "", - "severity": "low", - "risk_score": 30, - "risk_level": "low", - "risk_level_label": "低风险", - "risk_score_detail": { - "score": 30, - "level": "low", - "level_label": "低风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 48, - "certainty": 86, - "evidence": 62, - "exception": 35, - "action": 35, - "sensitivity": 70 - }, - "calibration": { - "raw_score": 58, - "rules": [ - { - "name": "explicit_low_control_cap", - "score_before": 58, - "score_after": 30, - "reason": "规则语义明确为低风险,且控制动作仅为提醒、提示、补齐或补充说明。" - } - ] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": false - } - } -} diff --git a/server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json b/server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json deleted file mode 100644 index e176945..0000000 --- a/server/rules/risk-rules/risk.expense.travel.generated_20260526101948535257.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "schema_version": "2.0", - "rule_code": "risk.expense.travel.generated_20260526101948535257", - "name": "差旅附件要素不完整提示", - "description": "当差旅费业务满足“差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为高风险,提醒补齐票据要素或重新上传清晰附件。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "enabled": false, - "requires_attachment": true, - "risk_dimension": "natural_language_rule", - "risk_category": "差旅费", - "ontology_signal": "natural_language_risk", - "evaluator": "template_rule", - "template_key": "field_required_v1", - "semantic_type": null, - "applies_to": { - "domains": [ - "expense" - ], - "expense_categories": [ - "travel" - ] - }, - "inputs": { - "fields": [ - { - "key": "attachment.hotel_city", - "label": "住宿城市", - "type": "text", - "source": "attachment" - }, - { - "key": "attachment.route_cities", - "label": "行程城市", - "type": "list", - "source": "attachment" - }, - { - "key": "attachment.invoice_no", - "label": "发票号码", - "type": "text", - "source": "attachment" - }, - { - "key": "attachment.goods_name", - "label": "商品服务名称", - "type": "text", - "source": "attachment" - } - ] - }, - "params": { - "template_key": "field_required_v1", - "field_keys": [ - "attachment.hotel_city", - "attachment.route_cities", - "attachment.invoice_no", - "attachment.goods_name" - ], - "condition_summary": "检查住宿城市、行程城市、发票号码是否满足必填和完整性要求", - "natural_language": "差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为低风险,提醒补齐票据要素或重新上传清晰附件。", - "required_fields": [ - "attachment.hotel_city", - "attachment.route_cities", - "attachment.invoice_no", - "attachment.goods_name" - ] - }, - "outcomes": { - "pass": { - "severity": "none", - "action": "continue" - }, - "fail": { - "severity": "high", - "action": "manual_review", - "risk_score": 76 - } - }, - "metadata": { - "owner": "admin", - "stability": "generated_draft", - "source_ref": "自然语言风险规则", - "created_at": "2026-05-26T10:19:48.535257+08:00", - "created_by": "admin", - "requires_attachment": true, - "risk_score": 76, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_model": "risk_score_v3", - "risk_score_detail": { - "score": 76, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 65, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 76, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": true - } - }, - "rule_title": "差旅附件要素不完整提示", - "expense_category": "travel", - "expense_category_label": "差旅费", - "natural_language": "差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为低风险,提醒补齐票据要素或重新上传清晰附件。", - "business_explanation": "当差旅费业务满足“差旅报销时,先检查是否上传了交通票据、住宿票据或其他差旅附件;再读取发票号码、购买方名称、商品服务名称、票据全文、报销人和部门。若票据已上传但发票号码、购买方名称或商品服务名称缺失,且报销事由、人员和部门信息能够说明费用归属,则标记为高风险,提醒补齐票据要素或重新上传清晰附件。”时,系统会按高风险进行提示,并要求经办人或审核人补充核对依据。", - "condition_summary": "检查住宿城市、行程城市、发票号码是否满足必填和完整性要求", - "rule_ir": {}, - "flow": { - "start": "差旅费单据提交", - "evidence": "读取住宿城市、行程城市、发票号码", - "decision": "检查住宿城市、行程城市、发票号码是否满足必填和完整性要求", - "pass": "未命中风险,继续业务流转", - "fail": "命中高风险,提示复核" - } - }, - "flow_diagram_svg": "", - "severity": "high", - "risk_score": 76, - "risk_level": "high", - "risk_level_label": "高风险", - "risk_score_detail": { - "score": 76, - "level": "high", - "level_label": "高风险", - "model": "risk_score_v3", - "weights": { - "impact": 0.35, - "certainty": 0.25, - "evidence": 0.15, - "exception": 0.1, - "action": 0.1, - "sensitivity": 0.05 - }, - "components": { - "impact": 78, - "certainty": 86, - "evidence": 82, - "exception": 35, - "action": 65, - "sensitivity": 88 - }, - "calibration": { - "raw_score": 76, - "rules": [] - }, - "ai_evidence": {}, - "basis": { - "template_key": "field_required_v1", - "field_count": 4, - "condition_count": 0, - "expense_category": "travel", - "expense_category_label": "差旅费", - "requires_attachment": true - } - } -} diff --git a/server/rules/risk-rules/risk.travel.high.city_mismatch.json b/server/rules/risk-rules/risk.travel.high.city_mismatch.json new file mode 100644 index 0000000..c00f055 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.high.city_mismatch.json @@ -0,0 +1,173 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.high.city_mismatch", + "name": "差旅目的地与票据城市不一致高风险", + "description": "交通票、住宿票识别出的城市与申报目的地或明细地点不一致,且事由未说明绕行、多地拜访或改签原因。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-行程一致性", + "ontology_signal": "travel_city_mismatch", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.location", + "label": "申报地点", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_location", + "label": "明细发生地点", + "type": "text", + "source": "item" + }, + { + "key": "employee.location", + "label": "员工常驻地", + "type": "text", + "source": "employee" + }, + { + "key": "attachment.route_cities", + "label": "交通票行程城市", + "type": "list", + "source": "attachment" + }, + { + "key": "attachment.hotel_city", + "label": "住宿城市", + "type": "text", + "source": "attachment" + }, + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + } + ] + }, + "params": { + "template_key": "field_compare_v1", + "semantic_type": "travel_route_city_consistency", + "field_keys": [ + "claim.location", + "item.item_location", + "employee.location", + "attachment.route_cities", + "attachment.hotel_city", + "claim.reason", + "item.item_reason" + ], + "reference_city_fields": [ + "claim.location", + "item.item_location" + ], + "attachment_city_fields": [ + "attachment.route_cities", + "attachment.hotel_city" + ], + "home_city_fields": [ + "employee.location" + ], + "exception_fields": [ + "claim.reason", + "item.item_reason" + ], + "exception_keywords": [ + "中转", + "改签", + "绕行", + "多地", + "临时变更", + "客户拜访", + "项目现场" + ], + "condition_summary": "票据城市未覆盖申报目的地,或路线出现常驻地/目的地以外城市且无合理说明。", + "message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "block", + "risk_score": 90 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "高风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 90, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "high", + "risk_score": 90, + "risk_level": "high", + "template_key": "field_compare_v1", + "risk_level_label": "高风险", + "risk_score_detail": { + "score": 90, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.high.date_outside_trip.json b/server/rules/risk-rules/risk.travel.high.date_outside_trip.json new file mode 100644 index 0000000..65ffdba --- /dev/null +++ b/server/rules/risk-rules/risk.travel.high.date_outside_trip.json @@ -0,0 +1,161 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.high.date_outside_trip", + "name": "票据日期超出差旅行程高风险", + "description": "票据日期、住宿日期或明细发生日期超出申报出差起止日期,允许 1 天交通衔接容差。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-日期一致性", + "ontology_signal": "travel_date_outside_trip_window", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.trip_start_date", + "label": "出差开始日期", + "type": "date", + "source": "claim" + }, + { + "key": "claim.trip_end_date", + "label": "出差结束日期", + "type": "date", + "source": "claim" + }, + { + "key": "item.item_date", + "label": "明细发生日期", + "type": "date", + "source": "item" + }, + { + "key": "attachment.issue_date", + "label": "票据日期", + "type": "date", + "source": "attachment" + }, + { + "key": "attachment.stay_start_date", + "label": "住宿开始日期", + "type": "date", + "source": "attachment" + }, + { + "key": "attachment.stay_end_date", + "label": "住宿结束日期", + "type": "date", + "source": "attachment" + } + ] + }, + "params": { + "template_key": "composite_rule_v1", + "field_keys": [ + "claim.trip_start_date", + "claim.trip_end_date", + "item.item_date", + "attachment.issue_date", + "attachment.stay_start_date", + "attachment.stay_end_date" + ], + "conditions": [ + { + "id": "ticket_date_outside_trip", + "operator": "date_outside_range", + "date_fields": [ + "item.item_date", + "attachment.issue_date", + "attachment.stay_start_date", + "attachment.stay_end_date" + ], + "range_start_fields": [ + "claim.trip_start_date" + ], + "range_end_fields": [ + "claim.trip_end_date" + ], + "tolerance_days": 1 + } + ], + "hit_logic": "ticket_date_outside_trip", + "condition_summary": "任一票据/明细日期早于出差开始日前 1 天或晚于结束日后 1 天。", + "message_template": "票据日期超出申报差旅行程,请补充改签/延期说明或更正行程日期。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "block", + "risk_score": 88 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "高风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 88, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "high", + "risk_score": 88, + "risk_level": "high", + "template_key": "composite_rule_v1", + "risk_level_label": "高风险", + "risk_score_detail": { + "score": 88, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.high.personal_purpose.json b/server/rules/risk-rules/risk.travel.high.personal_purpose.json new file mode 100644 index 0000000..b13c80b --- /dev/null +++ b/server/rules/risk-rules/risk.travel.high.personal_purpose.json @@ -0,0 +1,135 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.high.personal_purpose", + "name": "个人旅游或非公务目的高风险", + "description": "差旅申请或报销文本中出现旅游、探亲、休假、私人行程等非公务目的表达。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-真实性", + "ontology_signal": "travel_personal_purpose", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "field_keys": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "search_fields": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "keywords": [ + "旅游", + "探亲", + "休假", + "度假", + "私人", + "个人行程", + "家属", + "亲友" + ], + "condition_summary": "差旅事由或票据文本命中个人旅游/私人目的关键词。", + "message_template": "识别到个人旅游或非公务目的表达,请确认是否属于公司差旅范围。", + "template_key": "keyword_match_v1" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "block", + "risk_score": 86 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "高风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 86, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "high", + "risk_score": 86, + "risk_level": "high", + "template_key": "keyword_match_v1", + "risk_level_label": "高风险", + "risk_score_detail": { + "score": 86, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.high.preapproval_absent.json b/server/rules/risk-rules/risk.travel.high.preapproval_absent.json new file mode 100644 index 0000000..2017331 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.high.preapproval_absent.json @@ -0,0 +1,135 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.high.preapproval_absent", + "name": "差旅未申请或事后补申请高风险", + "description": "申请或报销事由出现未申请、未审批、先报销后补申请等表达时,判定为差旅事前审批缺失风险。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-申请审批", + "ontology_signal": "travel_preapproval_absent", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "field_keys": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "search_fields": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "keywords": [ + "未申请", + "无申请", + "未审批", + "未批准", + "先报销", + "事后申请", + "补申请", + "补报" + ], + "condition_summary": "差旅申请/报销文本命中未申请、未审批或事后补申请关键词。", + "message_template": "识别到差旅未事前申请或事后补申请迹象,请补齐已审批的差旅申请后再提交。", + "template_key": "keyword_match_v1" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "high", + "action": "block", + "risk_score": 92 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "高风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 92, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "high", + "risk_score": 92, + "risk_level": "high", + "template_key": "keyword_match_v1", + "risk_level_label": "高风险", + "risk_score_detail": { + "score": 92, + "level": "high", + "level_label": "高风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.low.application_fields_missing.json b/server/rules/risk-rules/risk.travel.low.application_fields_missing.json new file mode 100644 index 0000000..0cc8e8b --- /dev/null +++ b/server/rules/risk-rules/risk.travel.low.application_fields_missing.json @@ -0,0 +1,138 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.low.application_fields_missing", + "name": "差旅申请基础信息不完整低风险", + "description": "费用申请环节缺少差旅地点、事由、起止时间或预计金额等基础信息时,提示经办人补充。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-申请信息", + "ontology_signal": "travel_application_fields_missing", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "claim.location", + "label": "申报地点", + "type": "text", + "source": "claim" + }, + { + "key": "claim.trip_start_date", + "label": "出差开始日期", + "type": "date", + "source": "claim" + }, + { + "key": "claim.trip_end_date", + "label": "出差结束日期", + "type": "date", + "source": "claim" + }, + { + "key": "claim.amount", + "label": "申报金额", + "type": "number", + "source": "claim" + } + ] + }, + "params": { + "field_keys": [ + "claim.reason", + "claim.location", + "claim.trip_start_date", + "claim.trip_end_date", + "claim.amount" + ], + "required_fields": [ + "claim.reason", + "claim.location", + "claim.trip_start_date", + "claim.trip_end_date", + "claim.amount" + ], + "condition_summary": "差旅申请缺少事由、地点、起止时间或预计金额。", + "message_template": "差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。", + "template_key": "field_required_v1" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "low", + "action": "warning", + "risk_score": 42 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "低风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 42, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "low", + "risk_score": 42, + "risk_level": "low", + "template_key": "field_required_v1", + "risk_level_label": "低风险", + "risk_score_detail": { + "score": 42, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json b/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json new file mode 100644 index 0000000..8a2bf1c --- /dev/null +++ b/server/rules/risk-rules/risk.travel.low.attachment_ocr_missing.json @@ -0,0 +1,109 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.low.attachment_ocr_missing", + "name": "差旅附件无法识别低风险", + "description": "差旅报销已上传附件但没有可读取的 OCR 文本或关键票据信息,提醒人工补录或重新上传。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-附件质量", + "ontology_signal": "travel_attachment_ocr_missing", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "field_keys": [ + "attachment.ocr_text" + ], + "required_fields": [ + "attachment.ocr_text" + ], + "condition_summary": "差旅附件缺少可读取 OCR 文本。", + "message_template": "差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。", + "template_key": "field_required_v1" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "low", + "action": "warning", + "risk_score": 38 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "低风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 38, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "low", + "risk_score": 38, + "risk_level": "low", + "template_key": "field_required_v1", + "risk_level_label": "低风险", + "risk_score_detail": { + "score": 38, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json b/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json new file mode 100644 index 0000000..5c0fcef --- /dev/null +++ b/server/rules/risk-rules/risk.travel.low.local_transport_detail_missing.json @@ -0,0 +1,161 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.low.local_transport_detail_missing", + "name": "市内交通路线说明不足低风险", + "description": "差旅行程中出现打车、网约车、出租车等市内交通表达,但未说明起点、终点或路线。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-市内交通", + "ontology_signal": "travel_local_transport_detail_missing", + "evaluator": "template_rule", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "template_key": "composite_rule_v1", + "field_keys": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "conditions": [ + { + "id": "has_local_transport", + "operator": "contains_any", + "fields": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "keywords": [ + "打车", + "出租车", + "网约车", + "滴滴", + "市内交通" + ] + }, + { + "id": "missing_route_detail", + "operator": "not_contains_any", + "fields": [ + "claim.reason", + "item.item_reason", + "attachment.ocr_text" + ], + "keywords": [ + "起点", + "终点", + "路线", + "从", + "到", + "往返" + ] + } + ], + "hit_logic": { + "all": [ + "has_local_transport", + "missing_route_detail" + ] + }, + "condition_summary": "存在市内交通关键词,但文本中缺少起点、终点或路线说明。", + "message_template": "市内交通路线说明不足,请补充起点、终点或业务地点。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "low", + "action": "warning", + "risk_score": 36 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "低风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 36, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "low", + "risk_score": 36, + "risk_level": "low", + "template_key": "composite_rule_v1", + "risk_level_label": "低风险", + "risk_score_detail": { + "score": 36, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json b/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json new file mode 100644 index 0000000..3bf5027 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.low.vague_ticket_content.json @@ -0,0 +1,107 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.low.vague_ticket_content", + "name": "差旅票据服务内容笼统低风险", + "description": "票据商品或服务名称过于笼统,例如仅写服务费、其他、详见清单等,提醒补充明细。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-票据明细", + "ontology_signal": "travel_vague_ticket_content", + "evaluator": "vague_goods_description", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "attachment.goods_name", + "label": "商品或服务名称", + "type": "text", + "source": "attachment" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "condition_summary": "票据商品或服务名称过于笼统,无法直接对应差旅事项。", + "message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "low", + "action": "warning", + "risk_score": 34 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "低风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 34, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "low", + "risk_score": 34, + "risk_level": "low", + "risk_level_label": "低风险", + "risk_score_detail": { + "score": 34, + "level": "low", + "level_label": "低风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json b/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json new file mode 100644 index 0000000..669cec4 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.medium.duplicate_ticket.json @@ -0,0 +1,107 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.medium.duplicate_ticket", + "name": "差旅票据重复中风险", + "description": "同一张交通票、住宿票或发票号码在当前单据内重复,或与历史报销附件重复。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-票据重复", + "ontology_signal": "travel_duplicate_ticket", + "evaluator": "duplicate_invoice", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "attachment.invoice_no", + "label": "票据号码", + "type": "text", + "source": "attachment" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "condition_summary": "票据号码在当前单据或历史报销中重复出现。", + "message_template": "发现疑似重复票据,请核对是否已经报销或重复上传。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 75 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 75, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "medium", + "risk_score": 75, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 75, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json b/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json new file mode 100644 index 0000000..f4d986a --- /dev/null +++ b/server/rules/risk-rules/risk.travel.medium.multi_city_no_reason.json @@ -0,0 +1,125 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.medium.multi_city_no_reason", + "name": "多城市行程缺少说明中风险", + "description": "票据或明细识别到多个城市,但申请/报销事由未说明中转、多地拜访、改签或绕行原因。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-行程说明", + "ontology_signal": "travel_multi_city_without_reason", + "evaluator": "multi_city_reason_required", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "item.item_location", + "label": "明细发生地点", + "type": "text", + "source": "item" + }, + { + "key": "attachment.route_cities", + "label": "交通票行程城市", + "type": "list", + "source": "attachment" + }, + { + "key": "attachment.hotel_city", + "label": "住宿城市", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "condition_summary": "差旅行程涉及 3 个及以上城市,且事由未包含中转、多地、改签、绕行等说明。", + "message_template": "识别到多城市差旅行程,请补充中转、多地拜访或改签原因。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 72 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 72, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "medium", + "risk_score": 72, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 72, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json b/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json new file mode 100644 index 0000000..55a61c6 --- /dev/null +++ b/server/rules/risk-rules/risk.travel.medium.reason_too_brief.json @@ -0,0 +1,108 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.medium.reason_too_brief", + "name": "差旅事由过短中风险", + "description": "差旅申请或报销事由有效描述不足,无法支撑目的、客户/项目、行程必要性的判断。", + "enabled": true, + "requires_attachment": false, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-事由完整性", + "ontology_signal": "travel_reason_too_brief", + "evaluator": "reason_too_brief", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.reason", + "label": "报销/申请事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + } + ] + }, + "params": { + "min_reason_length": 10, + "condition_summary": "合并申请/报销事由后有效字符少于 10 个。", + "message_template": "差旅事由描述过短,请补充项目、客户、地点和出差目的。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 68 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 68, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "medium", + "risk_score": 68, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 68, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/rules/risk-rules/risk.travel.medium.title_mismatch.json b/server/rules/risk-rules/risk.travel.medium.title_mismatch.json new file mode 100644 index 0000000..5461f0a --- /dev/null +++ b/server/rules/risk-rules/risk.travel.medium.title_mismatch.json @@ -0,0 +1,118 @@ +{ + "schema_version": "2.0", + "rule_code": "risk.travel.medium.title_mismatch", + "name": "差旅票据抬头不一致中风险", + "description": "票据抬头、乘车人或购买方与报销人不一致,且不属于公司抬头或允许例外。", + "enabled": true, + "requires_attachment": true, + "risk_dimension": "travel_reimbursement_control", + "risk_category": "差旅费-票据主体", + "ontology_signal": "travel_invoice_title_mismatch", + "evaluator": "identity_consistency", + "finance_rule_code": "rule.expense.company_travel_expense_reimbursement", + "finance_rule_sheet": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "applies_to": { + "domains": [ + "expense", + "travel" + ], + "expense_types": [ + "travel" + ], + "business_stages": [ + "expense_application", + "reimbursement" + ] + }, + "inputs": { + "fields": [ + { + "key": "claim.employee_name", + "label": "申请/报销人", + "type": "text", + "source": "claim" + }, + { + "key": "attachment.buyer_name", + "label": "票据抬头/购买方", + "type": "text", + "source": "attachment" + }, + { + "key": "attachment.ocr_text", + "label": "票据 OCR 全文", + "type": "text", + "source": "attachment" + } + ] + }, + "params": { + "allow_keywords": [ + "公司", + "远光", + "远光软件" + ], + "condition_summary": "票据抬头/购买方不包含报销人姓名,也不包含公司抬头关键词。", + "message_template": "票据抬头或乘车人与报销人不一致,请补充代订、同行或公司抬头说明。" + }, + "outcomes": { + "pass": { + "severity": "none", + "action": "continue" + }, + "fail": { + "severity": "medium", + "action": "manual_review", + "risk_score": 64 + } + }, + "metadata": { + "owner": "admin", + "stability": "admin_configured", + "source_ref": "差旅费报销风险规则库 / admin 手工配置", + "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": "公司差旅费报销规则", + "business_stage": [ + "expense_application", + "reimbursement" + ], + "expense_types": [ + "travel" + ], + "implementation_note": "使用当前规则中心 JSON 风险规则执行器可识别的字段与模板配置。", + "risk_level_label": "中风险", + "risk_score_model": "risk_score_v3", + "risk_score_detail": { + "score": 64, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } + }, + "severity": "medium", + "risk_score": 64, + "risk_level": "medium", + "risk_level_label": "中风险", + "risk_score_detail": { + "score": 64, + "level": "medium", + "level_label": "中风险", + "model": "risk_score_v3", + "source": "admin_manual_travel_risk_catalog", + "reason": "按差旅费报销高/中/低风险分层手工设定。" + } +} diff --git a/server/scripts/build_expense_control_demo_risk_rules.py b/server/scripts/build_expense_control_demo_risk_rules.py new file mode 100644 index 0000000..20aabe1 --- /dev/null +++ b/server/scripts/build_expense_control_demo_risk_rules.py @@ -0,0 +1,704 @@ +#!/usr/bin/env python3 +"""Build expense-control demo risk rule JSON manifests.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path + +SERVER_DIR = Path(__file__).resolve().parents[1] +RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules" + + +BUDGET_EXPENSE_TYPES = ( + "travel", + "hotel", + "transport", + "meal", + "meeting", + "marketing", + "office", + "training", + "software", + "communication", + "welfare", +) + + +FIELD_LABELS = { + "claim.amount": ("报销金额", "number", "claim"), + "claim.expense_type": ("费用类型", "enum", "claim"), + "claim.department_name": ("部门", "text", "claim"), + "claim.reason": ("事由", "text", "claim"), + "item.item_reason": ("明细说明", "text", "item"), + "application.id": ("申请单", "text", "application"), + "application.status": ("申请状态", "enum", "application"), + "application.approved_amount": ("申请审批金额", "number", "application"), + "application.expense_type": ("申请费用类型", "enum", "application"), + "application.department_name": ("申请部门", "text", "application"), + "application.start_date": ("申请开始日期", "date", "application"), + "application.end_date": ("申请结束日期", "date", "application"), + "budget.line_id": ("预算行", "text", "budget"), + "budget.available_amount": ("预算可用金额", "number", "budget"), + "budget.used_rate": ("预算使用率", "number", "budget"), + "budget.status": ("预算状态", "enum", "budget"), + "budget.department_name": ("预算部门", "text", "budget"), + "budget.quarter": ("预算季度", "text", "budget"), + "budget.project_code": ("预算项目", "text", "budget"), + "material.attachment_count": ("附件数量", "number", "material"), + "material.contract_uploaded": ("合同已上传", "boolean", "material"), + "material.acceptance_uploaded": ("验收材料已上传", "boolean", "material"), + "material.plan_uploaded": ("方案已上传", "boolean", "material"), + "material.attendee_list_uploaded": ("参与人清单已上传", "boolean", "material"), + "material.invoice_uploaded": ("发票已上传", "boolean", "material"), +} + + +BASE_FIELDS = ( + "claim.amount", + "claim.expense_type", + "claim.department_name", + "claim.reason", + "item.item_reason", +) +BUDGET_FIELDS = BASE_FIELDS + ( + "budget.line_id", + "budget.available_amount", + "budget.used_rate", + "budget.status", + "budget.department_name", + "budget.quarter", + "budget.project_code", +) +APPLICATION_FIELDS = BASE_FIELDS + ( + "application.id", + "application.status", + "application.approved_amount", + "application.expense_type", + "application.department_name", +) +MATERIAL_FIELDS = BASE_FIELDS + ( + "material.attachment_count", + "material.contract_uploaded", + "material.acceptance_uploaded", + "material.plan_uploaded", + "material.attendee_list_uploaded", + "material.invoice_uploaded", +) + + +@dataclass(frozen=True, slots=True) +class DemoRiskRule: + code: str + name: str + description: str + category: str + ontology_signal: str + finance_rule_code: str + finance_rule_sheet: str + business_stage: tuple[str, ...] + expense_types: tuple[str, ...] + condition_summary: str + keywords: tuple[str, ...] + severity: str + action: str + risk_score: int + risk_level: str + budget_required: bool = True + requires_attachment: bool = False + field_keys: tuple[str, ...] = field(default_factory=lambda: BASE_FIELDS) + + +def _budget_rule( + code: str, + name: str, + description: str, + condition_summary: str, + keywords: tuple[str, ...], + severity: str, + action: str, + risk_score: int, +) -> DemoRiskRule: + return DemoRiskRule( + code=code, + name=name, + description=description, + category="预算管控", + ontology_signal="budget_over_limit", + finance_rule_code="budget.execution.policy", + finance_rule_sheet="预算执行规则", + business_stage=("expense_application", "reimbursement", "budget_execution"), + expense_types=BUDGET_EXPENSE_TYPES, + condition_summary=condition_summary, + keywords=keywords, + severity=severity, + action=action, + risk_score=risk_score, + risk_level="high" if risk_score >= 80 else "medium", + field_keys=BUDGET_FIELDS, + ) + + +def _rule_payload(rule: DemoRiskRule) -> dict[str, object]: + now = datetime.now(UTC).isoformat() + fields = [ + { + "key": key, + "label": FIELD_LABELS[key][0], + "type": FIELD_LABELS[key][1], + "source": FIELD_LABELS[key][2], + } + for key in rule.field_keys + ] + return { + "schema_version": "2.0", + "rule_code": rule.code, + "name": rule.name, + "description": rule.description, + "enabled": True, + "requires_attachment": rule.requires_attachment, + "risk_dimension": "expense_control_demo", + "risk_category": rule.category, + "ontology_signal": rule.ontology_signal, + "evaluator": "template_rule", + "template_key": "keyword_match_v1", + "finance_rule_code": rule.finance_rule_code, + "finance_rule_sheet": rule.finance_rule_sheet, + "business_stage": list(rule.business_stage), + "expense_types": list(rule.expense_types), + "budget_required": rule.budget_required, + "applies_to": { + "domains": ["expense"], + "expense_types": list(rule.expense_types), + "business_stages": list(rule.business_stage), + }, + "inputs": {"fields": fields}, + "params": { + "template_key": "keyword_match_v1", + "field_keys": list(rule.field_keys), + "search_fields": [ + "claim.reason", + "item.item_reason", + "claim.expense_type", + ], + "keywords": list(rule.keywords), + "condition_summary": rule.condition_summary, + "finance_rule_code": rule.finance_rule_code, + "finance_rule_sheet": rule.finance_rule_sheet, + "business_stage": list(rule.business_stage), + "expense_types": list(rule.expense_types), + "budget_required": rule.budget_required, + }, + "outcomes": { + "pass": {"severity": "none", "action": "continue"}, + "fail": { + "severity": rule.severity, + "action": rule.action, + "risk_score": rule.risk_score, + }, + }, + "metadata": { + "owner": "风控与审计部", + "stability": "platform", + "source_ref": "费用管控 Demo 风险规则库", + "created_at": now, + "created_by": "system", + "risk_score": rule.risk_score, + "risk_level": rule.risk_level, + "rule_title": rule.name, + "finance_rule_code": rule.finance_rule_code, + "finance_rule_sheet": rule.finance_rule_sheet, + "business_stage": list(rule.business_stage), + "expense_types": list(rule.expense_types), + "budget_required": rule.budget_required, + }, + "severity": rule.severity, + "risk_score": rule.risk_score, + "risk_level": rule.risk_level, + } + + +RULES: tuple[DemoRiskRule, ...] = ( + _budget_rule( + "risk.budget.available_balance_insufficient", + "预算可用余额不足", + "提交后预算余额为负,或当前可用预算不足以覆盖本次申请/报销金额。", + "预算可用金额小于本次金额时触发。", + ("预算不足", "可用余额不足", "超预算"), + "high", + "manual_review", + 88, + ), + _budget_rule( + "risk.budget.usage_warning_80", + "预算使用率达到 80% 预警", + "报销或申请通过后,部门/项目/费用类型预算使用率达到 80% 以上。", + "预算使用率大于等于 80% 且低于 100% 时触发。", + ("预算预警", "80%", "使用率过高"), + "medium", + "warning", + 70, + ), + _budget_rule( + "risk.budget.usage_over_100", + "预算使用率超过 100% 管控", + "报销或申请通过后,预算使用率超过 100%,需要阻断或升级审批。", + "预算使用率超过 100% 时触发。", + ("预算超支", "超过100%", "禁止提交"), + "critical", + "block_submit", + 96, + ), + _budget_rule( + "risk.budget.frozen_or_closed_used", + "使用冻结或关闭预算", + "单据引用了已冻结、已关闭或已作废的预算行。", + "预算状态不是启用时触发。", + ("冻结预算", "关闭预算", "预算作废"), + "high", + "block_submit", + 90, + ), + _budget_rule( + "risk.budget.missing_budget_line", + "缺少预算口径", + "需要预算管控的费用未关联年度、季度、部门、项目或费用类型预算。", + "费用类型要求预算管控但预算行为空时触发。", + ("无预算", "预算口径缺失", "未关联预算"), + "high", + "manual_review", + 82, + ), + _budget_rule( + "risk.budget.cross_department_without_authorization", + "跨部门预算未授权", + "报销部门与预算归属部门不一致,且没有跨部门预算授权。", + "单据部门与预算部门不一致且无授权说明时触发。", + ("跨部门预算", "部门不一致", "未授权"), + "high", + "manual_review", + 86, + ), + _budget_rule( + "risk.budget.cross_quarter_without_explanation", + "跨季度预算未说明", + "单据发生期间与预算季度不一致,且缺少跨季度使用说明。", + "发生季度与预算季度不一致且未说明时触发。", + ("跨季度预算", "季度不一致", "未说明"), + "medium", + "manual_review", + 76, + ), + _budget_rule( + "risk.budget.project_department_mismatch", + "项目预算与部门不匹配", + "单据引用的项目预算不属于当前部门或当前成本中心。", + "项目预算归属与报销部门不一致时触发。", + ("项目预算", "成本中心不匹配", "部门不匹配"), + "high", + "manual_review", + 84, + ), + _budget_rule( + "risk.budget.duplicate_reserve", + "重复占用预算", + "同一申请、项目或合同已占用预算,本次单据再次占用同一预算口径。", + "相同业务标识存在未释放预算占用时触发。", + ("重复占用", "预算锁定", "重复申请"), + "medium", + "manual_review", + 74, + ), + _budget_rule( + "risk.budget.consume_without_release", + "预算占用未释放", + "申请取消、退回或驳回后,预算占用未释放导致后续可用预算失真。", + "申请非有效状态但仍存在预算占用时触发。", + ("占用未释放", "退回未释放", "预算释放"), + "medium", + "manual_review", + 72, + ), + DemoRiskRule( + "risk.application.large_expense_without_preapproval", + "大额费用未事前申请", + "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。", + "申请前置", + "application_required", + "finance.preapproval.policy", + "费用申请前置规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "金额达到大额阈值且缺少已通过申请单时触发。", + ("大额费用", "未申请", "先申请后报销"), + "high", + "manual_review", + 86, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.application.marketing_without_campaign", + "市场推广费无活动申请", + "市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("marketing",), + "市场推广费报销缺少活动申请或方案时触发。", + ("市场推广", "活动申请", "投放方案"), + "high", + "manual_review", + 84, + "high", + field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",), + ), + DemoRiskRule( + "risk.application.training_without_plan", + "培训费无培训计划", + "培训费报销缺少年度培训计划、专项培训申请或审批记录。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("training",), + "培训费用没有匹配到培训计划或事前申请时触发。", + ("培训计划", "培训申请", "未申请"), + "high", + "manual_review", + 83, + "high", + field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",), + ), + DemoRiskRule( + "risk.application.meeting_without_application", + "会务费无会议申请", + "会务场地、物料、会务服务等费用缺少会议申请或会议预算审批。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("meeting",), + "会务费报销缺少会议申请、会议预算或会议通知时触发。", + ("会议申请", "会议预算", "会务费"), + "high", + "manual_review", + 82, + "high", + field_keys=APPLICATION_FIELDS + ("material.plan_uploaded",), + ), + DemoRiskRule( + "risk.application.software_without_purchase", + "软件服务费无采购申请", + "软件订阅、SaaS 服务或系统实施费用缺少采购申请或审批链。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("software",), + "软件服务费缺少采购申请或合同审批时触发。", + ("软件采购", "SaaS", "采购申请"), + "high", + "manual_review", + 85, + "high", + field_keys=APPLICATION_FIELDS + ("material.contract_uploaded",), + ), + DemoRiskRule( + "risk.application.office_bulk_without_purchase", + "办公用品大额采购未申请", + "批量办公用品或设备采购达到阈值但未走采购申请。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("office",), + "办公用品单次金额达到采购阈值且缺少采购申请时触发。", + ("办公采购", "大额办公用品", "采购申请"), + "medium", + "manual_review", + 78, + "medium", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.application.meal_high_value_without_preapproval", + "大额业务招待未申请", + "业务招待金额或人均金额超过制度阈值但未事前审批。", + "申请前置", + "application_required", + "expense.application.policy", + "费用申请前置规则", + ("reimbursement",), + ("meal",), + "业务招待金额超过申请阈值且没有通过申请时触发。", + ("业务招待", "人均超标", "未申请"), + "high", + "manual_review", + 84, + "high", + field_keys=APPLICATION_FIELDS + ("material.attendee_list_uploaded",), + ), + DemoRiskRule( + "risk.application.travel_large_without_preapproval", + "大额差旅未申请", + "多人出差、长周期出差或高金额差旅报销缺少出差申请。", + "申请前置", + "application_required", + "rule.expense.company_travel_expense_reimbursement", + "差旅住宿费标准", + ("reimbursement",), + ("travel", "hotel", "transport"), + "差旅金额达到大额阈值且缺少有效出差申请时触发。", + ("差旅申请", "大额差旅", "未申请"), + "high", + "manual_review", + 82, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.amount_over_application", + "报销金额超过申请金额", + "报销总金额超过已审批申请金额,需要按偏差规则复核。", + "报销偏差", + "amount_over_application", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "报销金额大于申请审批金额时触发。", + ("超过申请金额", "报销偏差", "申请金额"), + "medium", + "manual_review", + 76, + "medium", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.amount_over_application_10pct", + "报销金额超过申请金额 10%", + "报销金额比申请审批金额高出 10% 以上,需要升级审批或禁止提交。", + "报销偏差", + "amount_over_application", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "报销金额超过申请审批金额 10% 以上时触发。", + ("超过申请10%", "金额偏差", "升级审批"), + "high", + "manual_review", + 88, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.expense_type_mismatch_application", + "报销费用类型与申请不一致", + "报销单费用类型与关联申请单费用类型不一致。", + "报销偏差", + "expense_type_mismatch", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "报销费用类型与申请费用类型不一致时触发。", + ("费用类型不一致", "申请报销不匹配", "类型偏差"), + "medium", + "manual_review", + 74, + "medium", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.department_mismatch_application", + "报销部门与申请部门不一致", + "报销部门、成本中心与关联申请单不一致,且缺少调整说明。", + "报销偏差", + "department_mismatch", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "报销部门与申请部门不一致时触发。", + ("部门不一致", "成本中心偏差", "申请部门"), + "medium", + "manual_review", + 72, + "medium", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.period_outside_application", + "报销发生期间超出申请期间", + "费用发生日期不在已审批申请的起止日期范围内。", + "报销偏差", + "period_outside_application", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "发生日期超出申请有效期间时触发。", + ("期间不一致", "超出申请期间", "日期偏差"), + "medium", + "manual_review", + 70, + "medium", + field_keys=APPLICATION_FIELDS + ("application.start_date", "application.end_date"), + ), + DemoRiskRule( + "risk.reimbursement.rejected_application_claimed", + "已驳回申请被用于报销", + "报销单关联的申请单为驳回、撤回或已取消状态。", + "报销偏差", + "invalid_application_status", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "关联申请状态不是已通过或已完成时触发。", + ("申请驳回", "申请撤回", "无效申请"), + "high", + "block_submit", + 92, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.reimbursement.duplicate_against_application", + "同一申请重复报销", + "同一申请单或同一合同/项目存在多笔疑似重复报销。", + "报销偏差", + "duplicate_reimbursement", + "application.reimbursement.linkage.policy", + "申请报销关联规则", + ("reimbursement",), + BUDGET_EXPENSE_TYPES, + "同一申请的已报销金额与本次金额超过申请金额时触发。", + ("重复报销", "同一申请", "重复占用"), + "high", + "manual_review", + 86, + "high", + field_keys=APPLICATION_FIELDS, + ), + DemoRiskRule( + "risk.standard.meal_participants_missing", + "业务招待缺少参与人清单", + "业务招待费要求提供客户名称、参与人清单和招待说明。", + "材料完整性", + "material_missing", + "expense.material.policy", + "材料完整性规则", + ("reimbursement",), + ("meal",), + "业务招待费缺少参与人清单或客户信息时触发。", + ("参与人清单", "客户信息", "业务招待"), + "medium", + "manual_review", + 72, + "medium", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), + DemoRiskRule( + "risk.standard.software_contract_missing", + "软件服务费缺少合同", + "软件服务费、SaaS 订阅或系统实施费用缺少合同、订单或验收材料。", + "材料完整性", + "contract_missing", + "expense.material.policy", + "材料完整性规则", + ("expense_application", "reimbursement"), + ("software",), + "软件服务费要求合同或订单但未提供时触发。", + ("合同缺失", "软件服务", "验收材料"), + "high", + "manual_review", + 84, + "high", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), + DemoRiskRule( + "risk.standard.marketing_acceptance_missing", + "市场推广费缺少验收材料", + "市场推广活动缺少投放截图、活动复盘、验收单或结案材料。", + "材料完整性", + "acceptance_missing", + "expense.material.policy", + "材料完整性规则", + ("reimbursement",), + ("marketing",), + "市场推广费要求验收或结案材料但未提供时触发。", + ("验收材料", "活动结案", "投放截图"), + "high", + "manual_review", + 82, + "high", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), + DemoRiskRule( + "risk.standard.office_fixed_asset_as_office", + "固定资产伪装为办公用品费", + "办公用品费明细疑似包含固定资产、电子设备或应走采购入库的物品。", + "费用标准", + "expense_type_mismatch", + "expense.classification.policy", + "费用类型归类规则", + ("reimbursement",), + ("office",), + "办公用品费包含固定资产关键词或超过采购阈值时触发。", + ("固定资产", "电脑", "显示器", "办公设备"), + "medium", + "manual_review", + 78, + "medium", + field_keys=BASE_FIELDS, + ), + DemoRiskRule( + "risk.standard.meeting_attendee_list_missing", + "会务费缺少参会名单", + "会务费报销缺少参会名单、会议通知、会议照片或会议纪要。", + "材料完整性", + "material_missing", + "expense.material.policy", + "材料完整性规则", + ("reimbursement",), + ("meeting",), + "会务费要求参会名单或会议材料但未提供时触发。", + ("参会名单", "会议通知", "会议纪要"), + "medium", + "manual_review", + 74, + "medium", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), +) + + +def main() -> None: + RISK_RULE_DIR.mkdir(parents=True, exist_ok=True) + for rule in RULES: + path = RISK_RULE_DIR / f"{rule.code}.json" + payload = _rule_payload(rule) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"Generated {len(RULES)} expense-control demo risk rule manifest(s).") + + +if __name__ == "__main__": + main() diff --git a/server/scripts/sync_finance_rule_assets.py b/server/scripts/sync_finance_rule_assets.py new file mode 100644 index 0000000..b7fe7f6 --- /dev/null +++ b/server/scripts/sync_finance_rule_assets.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Sync finance rule spreadsheet assets from the built-in catalog.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +SERVER_SRC = Path(__file__).resolve().parents[1] / "src" +if str(SERVER_SRC) not in sys.path: + sys.path.insert(0, str(SERVER_SRC)) + +from app.db.session import get_session_factory # noqa: E402 +from app.services.agent_foundation import AgentFoundationService # noqa: E402 + + +def main() -> None: + db = get_session_factory()() + try: + count = AgentFoundationService(db).sync_finance_rule_assets_from_catalog() + db.commit() + print(f"Synced {count} finance rule asset(s) from catalog.") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py index a7fc866..2831e30 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -1,30 +1,31 @@ -from collections.abc import Generator -from dataclasses import dataclass -from typing import Annotated - -from fastapi import Depends, Header, HTTPException, status -from sqlalchemy.orm import Session - -from app.db.session import get_session_factory - - -def get_db() -> Generator[Session, None, None]: - db = get_session_factory()() - try: - yield db - finally: - db.close() - - -@dataclass(slots=True) +from collections.abc import Generator +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.session import get_session_factory + + +def get_db() -> Generator[Session, None, None]: + db = get_session_factory()() + try: + yield db + finally: + db.close() + + +@dataclass(slots=True) class CurrentUserContext: username: str name: str role_codes: list[str] is_admin: bool department_name: str = "" - - + cost_center: str = "" + + def get_current_user( x_auth_username: Annotated[ str | None, @@ -46,34 +47,75 @@ def get_current_user( str | None, Header(description="当前登录人的所属部门。"), ] = None, + x_auth_cost_center: Annotated[ + str | None, + Header(description="当前登录人的成本中心。"), + ] = None, ) -> CurrentUserContext: - role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()] - is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"} - - username = (x_auth_username or "").strip() - name = (x_auth_name or username).strip() - - if not username and not name: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="请先登录后再访问知识库。", - ) - - return CurrentUserContext( - username=username or name, + role_codes = [ + _normalize_role_code(item) + for item in (x_auth_role_codes or "").split(",") + if _normalize_role_code(item) + ] + username = (x_auth_username or "").strip() + name = (x_auth_name or username).strip() + is_admin = _resolve_platform_admin_flag( + username=username, + name=name, + role_codes=role_codes, + header_value=x_auth_is_admin, + ) + + if not username and not name: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录后再访问知识库。", + ) + + return CurrentUserContext( + username=username or name, name=name or username, role_codes=role_codes, is_admin=is_admin, department_name=(x_auth_department or "").strip(), + cost_center=(x_auth_cost_center or "").strip(), ) - - + + +def _normalize_role_code(value: str | None) -> str: + role_code = str(value or "").strip().lower() + if role_code == "auditor": + return "budget_monitor" + return role_code + + +def _current_user_role_codes(current_user: CurrentUserContext) -> set[str]: + return {_normalize_role_code(item) for item in current_user.role_codes if _normalize_role_code(item)} + + +def _resolve_platform_admin_flag( + *, + username: str, + name: str, + role_codes: list[str], + header_value: str | None, +) -> bool: + if str(header_value or "").strip().lower() in {"1", "true", "yes", "on"}: + return True + + identities = { + str(username or "").strip().lower(), + str(name or "").strip().lower(), + } + return "admin" in identities or "admin" in {_normalize_role_code(item) for item in role_codes} + + def require_admin_user( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> CurrentUserContext: - if current_user.is_admin or "manager" in current_user.role_codes: + if current_user.is_admin or "manager" in _current_user_role_codes(current_user): return current_user - + raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="只有管理员可以上传、删除或修改知识库文件。", @@ -95,24 +137,58 @@ def require_platform_admin_user( def require_rule_editor_user( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> CurrentUserContext: - role_codes = {item.strip() for item in current_user.role_codes} + role_codes = _current_user_role_codes(current_user) if current_user.is_admin or "manager" in role_codes or "finance" in role_codes: return current_user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="只有财务人员或高级管理人员可以编辑规则草稿。", + detail="只有财务人员或高级财务人员可以编辑规则草稿。", ) def require_rule_reviewer_user( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> CurrentUserContext: - role_codes = {item.strip() for item in current_user.role_codes} + role_codes = _current_user_role_codes(current_user) if current_user.is_admin or "manager" in role_codes: return current_user raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="只有高级管理人员或 admin 管理员可以执行该操作。", + detail="只有高级财务人员或 admin 管理员可以执行该操作。", ) + + +def require_budget_viewer_user( + current_user: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> CurrentUserContext: + role_codes = _current_user_role_codes(current_user) + if current_user.is_admin or role_codes & {"budget_monitor", "executive"}: + return current_user + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有预算监控员或高级财务人员可以查看预算中心。", + ) + + +def require_budget_editor_user( + current_user: Annotated[CurrentUserContext, Depends(get_current_user)], +) -> CurrentUserContext: + role_codes = _current_user_role_codes(current_user) + if current_user.is_admin or "executive" in role_codes: + return current_user + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有 admin 管理员或高级财务人员可以维护预算额度。", + ) + + +def is_budget_scope_limited_user(current_user: CurrentUserContext) -> bool: + if current_user.is_admin: + return False + + role_codes = _current_user_role_codes(current_user) + return "budget_monitor" in role_codes and "executive" not in role_codes diff --git a/server/src/app/api/v1/endpoints/agent_assets.py b/server/src/app/api/v1/endpoints/agent_assets.py index e37ba0a..9d7cb9b 100644 --- a/server/src/app/api/v1/endpoints/agent_assets.py +++ b/server/src/app/api/v1/endpoints/agent_assets.py @@ -695,9 +695,9 @@ def create_agent_asset_review( role_codes = {item.strip() for item in current_user.role_codes} if payload.review_status.value == "pending": if not (current_user.is_admin or "manager" in role_codes or "finance" in role_codes): - raise PermissionError("只有财务人员或高级管理人员可以提交审核。") + raise PermissionError("只有财务人员或高级财务人员可以提交审核。") elif not (current_user.is_admin or "manager" in role_codes): - raise PermissionError("只有高级管理人员可以审核规则。") + raise PermissionError("只有高级财务人员可以审核规则。") return AgentAssetService(db).create_review( asset_id, payload, @@ -746,7 +746,7 @@ def activate_agent_asset( response_model=AgentAssetRead, summary="设置风险规则启用状态", description=( - "高级管理人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。" + "高级财务人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。" ), ) def set_agent_asset_risk_rule_enabled( @@ -797,7 +797,7 @@ def set_agent_asset_risk_rule_level( "/{asset_id}/return", response_model=AgentAssetRiskRuleLatestTestSummary, summary="回退待审核风险规则", - description="高级管理人员将待审核风险规则回退到草稿,并记录回退原因。", + description="高级财务人员将待审核风险规则回退到草稿,并记录回退原因。", ) def return_agent_asset_risk_rule( asset_id: str, @@ -822,7 +822,7 @@ def return_agent_asset_risk_rule( "/{asset_id}/publish", response_model=AgentAssetRead, summary="审核并发布风险规则", - description="高级管理人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。", + description="高级财务人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。", ) def publish_agent_asset_risk_rule( asset_id: str, diff --git a/server/src/app/api/v1/endpoints/budgets.py b/server/src/app/api/v1/endpoints/budgets.py new file mode 100644 index 0000000..c91ed2e --- /dev/null +++ b/server/src/app/api/v1/endpoints/budgets.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, or_, select +from sqlalchemy.orm import selectinload +from sqlalchemy.orm import Session + +from app.api.deps import ( + CurrentUserContext, + get_current_user, + get_db, + is_budget_scope_limited_user, + require_budget_editor_user, + require_budget_viewer_user, +) +from app.models.employee import Employee +from app.models.budget import BudgetAllocation +from app.schemas.budget import ( + BudgetAllocationCreate, + BudgetAllocationRead, + BudgetCheckRead, + BudgetCheckRequest, + BudgetOperationRead, + BudgetOperationRequest, + BudgetSummaryRead, + BudgetTransactionRead, +) +from app.schemas.common import ErrorResponse +from app.services.budget import BudgetControlError, BudgetService + +router = APIRouter(prefix="/budgets") +DbSession = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] +BudgetViewer = Annotated[CurrentUserContext, Depends(require_budget_viewer_user)] +BudgetEditor = Annotated[CurrentUserContext, Depends(require_budget_editor_user)] + + +@router.get( + "/summary", + response_model=BudgetSummaryRead, + summary="读取预算中心汇总", +) +def get_budget_summary( + db: DbSession, + current_user: BudgetViewer, + fiscal_year: Annotated[int | None, Query(alias="year")] = None, + period_key: Annotated[str | None, Query(alias="period")] = None, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, +) -> BudgetSummaryRead: + scope = _resolve_budget_query_scope( + db, + current_user, + department_id=department_id, + department_name=department_name, + cost_center=cost_center, + ) + return BudgetService(db).get_summary( + fiscal_year=fiscal_year, + period_key=period_key, + **scope, + ) + + +@router.get( + "/allocations", + response_model=list[BudgetAllocationRead], + summary="查询预算额度列表", +) +def list_budget_allocations( + db: DbSession, + current_user: BudgetViewer, + fiscal_year: Annotated[int | None, Query(alias="year")] = None, + period_key: Annotated[str | None, Query(alias="period")] = None, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, +) -> list[BudgetAllocationRead]: + scope = _resolve_budget_query_scope( + db, + current_user, + department_id=department_id, + department_name=department_name, + cost_center=cost_center, + ) + return BudgetService(db).list_allocations( + fiscal_year=fiscal_year, + period_key=period_key, + **scope, + ) + + +@router.post( + "/allocations", + response_model=BudgetAllocationRead, + status_code=status.HTTP_201_CREATED, + summary="创建或更新预算额度", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def create_budget_allocation( + payload: BudgetAllocationCreate, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetAllocationRead: + try: + allocation = BudgetService(db).create_or_update_allocation( + payload, + operator=current_user.name or current_user.username, + ) + db.commit() + return allocation + except ValueError as error: + db.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + +@router.get( + "/allocations/{allocation_id}/transactions", + response_model=list[BudgetTransactionRead], + summary="读取预算交易台账", +) +def list_budget_transactions( + allocation_id: str, + db: DbSession, + current_user: BudgetViewer, +) -> list[BudgetTransactionRead]: + allocation = BudgetService(db).get_allocation_row(allocation_id) + if allocation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预算额度不存在。") + if not _allocation_visible_to_user(db, current_user, allocation): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能查看其他部门预算流水。") + return BudgetService(db).list_transactions(allocation_id) + + +@router.post( + "/check", + response_model=BudgetCheckRead, + summary="校验预算可用余额", +) +def check_budget(payload: BudgetCheckRequest, db: DbSession, current_user: BudgetViewer) -> BudgetCheckRead: + scope = _resolve_budget_query_scope( + db, + current_user, + department_id=payload.department_id, + department_name=payload.department_name, + cost_center=payload.cost_center, + ) + scoped_payload = payload.model_copy(update=scope) + return BudgetService(db).check(scoped_payload) + + +@router.post( + "/reserve", + response_model=BudgetOperationRead, + summary="记录预算预占台账", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def reserve_budget( + payload: BudgetOperationRequest, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetOperationRead: + return _execute_budget_operation( + payload, + db=db, + current_user=current_user, + transaction_type="reserve", + ) + + +@router.post( + "/release", + response_model=BudgetOperationRead, + summary="记录预算释放台账", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def release_budget( + payload: BudgetOperationRequest, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetOperationRead: + return _execute_budget_operation( + payload, + db=db, + current_user=current_user, + transaction_type="release", + ) + + +@router.post( + "/consume", + response_model=BudgetOperationRead, + summary="记录预算核销台账", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def consume_budget( + payload: BudgetOperationRequest, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetOperationRead: + return _execute_budget_operation( + payload, + db=db, + current_user=current_user, + transaction_type="consume", + ) + + +@router.post( + "/rollback", + response_model=BudgetOperationRead, + summary="记录预算核销回滚台账", + responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}}, +) +def rollback_budget( + payload: BudgetOperationRequest, + db: DbSession, + current_user: BudgetEditor, +) -> BudgetOperationRead: + return _execute_budget_operation( + payload, + db=db, + current_user=current_user, + transaction_type="rollback", + ) + + +def _execute_budget_operation( + payload: BudgetOperationRequest, + *, + db: Session, + current_user: CurrentUserContext, + transaction_type: str, +) -> BudgetOperationRead: + try: + result = BudgetService(db).execute_operation( + payload, + transaction_type=transaction_type, + operator=current_user.name or current_user.username, + ) + db.commit() + return result + except BudgetControlError as error: + db.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + except ValueError as error: + db.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + +def _resolve_budget_query_scope( + db: Session, + current_user: CurrentUserContext, + *, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, +) -> dict[str, str | None]: + if not is_budget_scope_limited_user(current_user): + return { + "department_id": _blank_to_none(department_id), + "department_name": _blank_to_none(department_name), + "cost_center": _blank_to_none(cost_center), + } + + employee = _resolve_current_employee(db, current_user) + scoped_cost_center = ( + _blank_to_none(current_user.cost_center) + or _blank_to_none(getattr(employee, "cost_center", None)) + or _blank_to_none(getattr(getattr(employee, "organization_unit", None), "cost_center", None)) + ) + scoped_department_name = ( + _blank_to_none(current_user.department_name) + or _blank_to_none(getattr(getattr(employee, "organization_unit", None), "name", None)) + ) + + if not scoped_cost_center and not scoped_department_name: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="预算监控员缺少部门归属,不能查看预算中心。", + ) + + return { + "department_id": None, + "department_name": None if scoped_cost_center else scoped_department_name, + "cost_center": scoped_cost_center, + } + + +def _allocation_visible_to_user( + db: Session, + current_user: CurrentUserContext, + allocation: BudgetAllocation, +) -> bool: + if not is_budget_scope_limited_user(current_user): + return True + + scope = _resolve_budget_query_scope(db, current_user) + scoped_cost_center = scope.get("cost_center") + scoped_department_name = scope.get("department_name") + if scoped_cost_center: + return str(allocation.cost_center or "").strip() == scoped_cost_center + if scoped_department_name: + return str(allocation.department_name or "").strip() == scoped_department_name + return False + + +def _resolve_current_employee(db: Session, current_user: CurrentUserContext) -> Employee | None: + identities = [ + str(current_user.username or "").strip(), + str(current_user.name or "").strip(), + ] + identities = [item for item in dict.fromkeys(identities) if item] + if not identities: + return None + + lowered = [item.lower() for item in identities] + stmt = ( + select(Employee) + .options(selectinload(Employee.organization_unit)) + .where( + or_( + func.lower(Employee.email).in_(lowered), + Employee.employee_no.in_(identities), + Employee.name.in_(identities), + ) + ) + .limit(1) + ) + return db.scalars(stmt).first() + + +def _blank_to_none(value: str | None) -> str | None: + text = str(value or "").strip() + return text or None diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 38f2eca..65b002c 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -505,7 +505,7 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser "/claims/{claim_id}/return", response_model=ExpenseClaimRead, summary="退回报销单", - description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。", + description="财务人员、高级财务人员或当前审批人可将可见报销单退回到待提交状态。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, @@ -571,7 +571,7 @@ def approve_expense_claim( "/claims/{claim_id}", response_model=ExpenseClaimActionResponse, summary="删除报销单", - description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。", + description="申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 391d2cb..5dbab1c 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -5,6 +5,7 @@ from app.api.v1.endpoints.agent_runs import router as agent_runs_router from app.api.v1.endpoints.audit_logs import router as audit_logs_router from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.bootstrap import router as bootstrap_router +from app.api.v1.endpoints.budgets import router as budgets_router from app.api.v1.endpoints.employees import router as employees_router from app.api.v1.endpoints.health import router as health_router from app.api.v1.endpoints.knowledge import router as knowledge_router @@ -19,6 +20,7 @@ router = APIRouter() router.include_router(health_router, tags=["health"]) router.include_router(bootstrap_router, tags=["bootstrap"]) router.include_router(auth_router, tags=["auth"]) +router.include_router(budgets_router, tags=["budgets"]) router.include_router(agent_assets_router, tags=["agent-assets"]) router.include_router(agent_runs_router, tags=["agent-runs"]) router.include_router(audit_logs_router, tags=["audit-logs"]) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index fddcc46..a886a5f 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -4,6 +4,7 @@ from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestR from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee_change_log import EmployeeChangeLog from app.models.employee import Employee from app.models.financial_record import ( @@ -33,6 +34,9 @@ __all__ = [ "AgentToolCall", "ApprovalRecord", "AuditLog", + "BudgetAllocation", + "BudgetReservation", + "BudgetTransaction", "Employee", "EmployeeChangeLog", "ExpenseClaim", diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index f3b07a1..b14eb61 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersi from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee_change_log import EmployeeChangeLog from app.models.employee import Employee from app.models.financial_record import ( @@ -32,6 +33,9 @@ __all__ = [ "AgentToolCall", "ApprovalRecord", "AuditLog", + "BudgetAllocation", + "BudgetReservation", + "BudgetTransaction", "Employee", "EmployeeChangeLog", "ExpenseClaim", diff --git a/server/src/app/models/budget.py b/server/src/app/models/budget.py new file mode 100644 index 0000000..13e1fb1 --- /dev/null +++ b/server/src/app/models/budget.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class BudgetAllocation(Base): + __tablename__ = "budget_allocations" + __table_args__ = ( + UniqueConstraint( + "fiscal_year", + "period_key", + "department_id", + "cost_center", + "project_code", + "subject_code", + name="uq_budget_allocation_dimension", + ), + Index("ix_budget_allocations_dimension", "fiscal_year", "period_key", "subject_code"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + budget_no: Mapped[str] = mapped_column(String(50), unique=True, index=True) + fiscal_year: Mapped[int] = mapped_column(index=True) + period_type: Mapped[str] = mapped_column(String(20), default="quarter", index=True) + period_key: Mapped[str] = mapped_column(String(30), index=True) + department_id: Mapped[str | None] = mapped_column( + ForeignKey("organization_units.id"), nullable=True, index=True + ) + department_name: Mapped[str] = mapped_column(String(100), index=True) + cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + project_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + subject_code: Mapped[str] = mapped_column(String(50), index=True) + subject_name: Mapped[str] = mapped_column(String(100)) + original_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00")) + adjusted_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00")) + status: Mapped[str] = mapped_column(String(30), default="active", index=True) + warning_threshold: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("80.00")) + control_action: Mapped[str] = mapped_column(String(30), default="block") + description: Mapped[str | None] = mapped_column(Text(), nullable=True) + created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + transactions = relationship("BudgetTransaction", back_populates="allocation") + reservations = relationship("BudgetReservation", back_populates="allocation") + + +class BudgetReservation(Base): + __tablename__ = "budget_reservations" + __table_args__ = ( + Index("ix_budget_reservations_source", "source_type", "source_id"), + Index("ix_budget_reservations_status", "allocation_id", "source_status"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + reservation_no: Mapped[str] = mapped_column(String(50), unique=True, index=True) + allocation_id: Mapped[str] = mapped_column(ForeignKey("budget_allocations.id"), index=True) + source_type: Mapped[str] = mapped_column(String(40), index=True) + source_id: Mapped[str] = mapped_column(String(64), index=True) + source_no: Mapped[str] = mapped_column(String(80), index=True) + source_status: Mapped[str] = mapped_column(String(30), default="active", index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2)) + consumed_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00")) + released_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00")) + context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + allocation = relationship("BudgetAllocation", back_populates="reservations") + transactions = relationship("BudgetTransaction", back_populates="reservation") + + +class BudgetTransaction(Base): + __tablename__ = "budget_transactions" + __table_args__ = ( + Index("ix_budget_transactions_allocation_created", "allocation_id", "created_at"), + Index("ix_budget_transactions_source", "source_type", "source_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + transaction_no: Mapped[str] = mapped_column(String(50), unique=True, index=True) + allocation_id: Mapped[str] = mapped_column(ForeignKey("budget_allocations.id"), index=True) + reservation_id: Mapped[str | None] = mapped_column( + ForeignKey("budget_reservations.id"), nullable=True, index=True + ) + source_type: Mapped[str] = mapped_column(String(40), index=True) + source_id: Mapped[str] = mapped_column(String(64), index=True) + source_no: Mapped[str] = mapped_column(String(80), index=True) + transaction_type: Mapped[str] = mapped_column(String(30), index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2)) + before_available_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2)) + after_available_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2)) + operator: Mapped[str | None] = mapped_column(String(100), nullable=True) + reason: Mapped[str | None] = mapped_column(Text(), nullable=True) + context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + allocation = relationship("BudgetAllocation", back_populates="transactions") + reservation = relationship("BudgetReservation", back_populates="transactions") diff --git a/server/src/app/schemas/budget.py b/server/src/app/schemas/budget.py new file mode 100644 index 0000000..ecbebf0 --- /dev/null +++ b/server/src/app/schemas/budget.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + + +class BudgetAllocationCreate(BaseModel): + fiscal_year: int = Field(ge=2000, le=2100) + period_type: str = Field(default="quarter", max_length=20) + period_key: str = Field(min_length=1, max_length=30) + department_id: str | None = Field(default=None, max_length=36) + department_name: str = Field(min_length=1, max_length=100) + cost_center: str | None = Field(default=None, max_length=50) + project_code: str | None = Field(default=None, max_length=50) + subject_code: str = Field(min_length=1, max_length=50) + subject_name: str = Field(min_length=1, max_length=100) + original_amount: Decimal = Field(ge=0) + warning_threshold: Decimal = Field(default=Decimal("80.00"), ge=0, le=100) + control_action: str = Field(default="block", max_length=30) + description: str | None = Field(default=None, max_length=500) + + +class BudgetCheckRequest(BaseModel): + fiscal_year: int | None = Field(default=None, ge=2000, le=2100) + period_key: str | None = Field(default=None, max_length=30) + department_id: str | None = Field(default=None, max_length=36) + department_name: str | None = Field(default=None, max_length=100) + cost_center: str | None = Field(default=None, max_length=50) + project_code: str | None = Field(default=None, max_length=50) + subject_code: str = Field(min_length=1, max_length=50) + amount: Decimal = Field(ge=0) + + +class BudgetOperationRequest(BudgetCheckRequest): + source_type: str = Field(min_length=1, max_length=40) + source_id: str = Field(min_length=1, max_length=64) + source_no: str = Field(min_length=1, max_length=80) + reason: str | None = Field(default=None, max_length=500) + + +class BudgetBalanceRead(BaseModel): + total_amount: Decimal + reserved_amount: Decimal + consumed_amount: Decimal + available_amount: Decimal + usage_rate: Decimal + + +class BudgetAllocationRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + budget_no: str + fiscal_year: int + period_type: str + period_key: str + department_id: str | None + department_name: str + cost_center: str | None + project_code: str | None + subject_code: str + subject_name: str + original_amount: Decimal + adjusted_amount: Decimal + status: str + warning_threshold: Decimal + control_action: str + description: str | None = None + balance: BudgetBalanceRead + created_at: datetime + updated_at: datetime + + +class BudgetTransactionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + transaction_no: str + allocation_id: str + reservation_id: str | None + source_type: str + source_id: str + source_no: str + transaction_type: str + amount: Decimal + before_available_amount: Decimal + after_available_amount: Decimal + operator: str | None + reason: str | None + created_at: datetime + + +class BudgetSummaryRead(BaseModel): + fiscal_year: int | None = None + period_key: str | None = None + total_amount: Decimal + reserved_amount: Decimal + consumed_amount: Decimal + available_amount: Decimal + warning_count: int + over_budget_count: int + allocations: list[BudgetAllocationRead] = Field(default_factory=list) + + +class BudgetCheckRead(BaseModel): + passed: bool + blocking_reasons: list[str] = Field(default_factory=list) + flags: list[dict] = Field(default_factory=list) + allocation: BudgetAllocationRead | None = None + + +class BudgetOperationRead(BaseModel): + ok: bool + message: str + reservation_id: str | None = None + allocation: BudgetAllocationRead | None = None + transaction: BudgetTransactionRead | None = None + flags: list[dict] = Field(default_factory=list) diff --git a/server/src/app/schemas/settings.py b/server/src/app/schemas/settings.py index a6e93d1..a3554ac 100644 --- a/server/src/app/schemas/settings.py +++ b/server/src/app/schemas/settings.py @@ -23,7 +23,7 @@ class SettingsCompanyForm(BaseModel): class SettingsAdminForm(BaseModel): adminAccount: str = Field(min_length=1, max_length=120) - adminEmail: str = Field(min_length=1, max_length=255) + adminEmail: str = Field(default="", max_length=255) newPassword: str = Field(default="", max_length=128) confirmPassword: str = Field(default="", max_length=128) sessionTimeout: int = Field(default=30, ge=5, le=240) diff --git a/server/src/app/services/agent_asset_spreadsheet.py b/server/src/app/services/agent_asset_spreadsheet.py index bbc477f..fbbaa60 100644 --- a/server/src/app/services/agent_asset_spreadsheet.py +++ b/server/src/app/services/agent_asset_spreadsheet.py @@ -342,6 +342,10 @@ class AgentAssetSpreadsheetManager: ] ) + @staticmethod + def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes: + return _build_xlsx_bytes(sheets) + @staticmethod def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes: return _build_xlsx_bytes([(sheet_name, [[""]])]) diff --git a/server/src/app/services/agent_foundation_asset_seed.py b/server/src/app/services/agent_foundation_asset_seed.py index 5708d75..70b8dda 100644 --- a/server/src/app/services/agent_foundation_asset_seed.py +++ b/server/src/app/services/agent_foundation_asset_seed.py @@ -1,12 +1,8 @@ from __future__ import annotations -import hashlib -import json -from datetime import UTC, date, datetime -from decimal import Decimal -from pathlib import Path +from datetime import UTC, datetime -from sqlalchemy import inspect, select, text +from sqlalchemy import select from app.core.agent_enums import ( AgentAssetContentType, @@ -14,34 +10,15 @@ from app.core.agent_enums import ( AgentAssetStatus, AgentAssetType, AgentName, - AgentPermissionLevel, AgentReviewStatus, - AgentRunSource, - AgentRunStatus, - AgentToolType, ) +from app.core.logging import get_logger from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion -from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog -from app.models.audit_log import AuditLog -from app.models.financial_record import ( - AccountsPayableRecord, - AccountsReceivableRecord, - ExpenseClaim, - ExpenseClaimItem, -) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import ( - AgentAssetSpreadsheetManager, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, - COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, - RISK_RULES_LIBRARY, -) -from app.services.expense_rule_runtime import ( - build_scene_submission_standard_markdown, - build_travel_risk_control_standard_markdown, + AgentAssetSpreadsheetManager, ) from app.services.agent_foundation_constants import ( ATTACHMENT_RULE_ASSET_CODE, @@ -50,13 +27,7 @@ from app.services.agent_foundation_constants import ( COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, - DEMO_EXPENSE_CLAIM_SIGNATURES, - DEMO_PAYABLE_SIGNATURES, - DEMO_RECEIVABLE_SIGNATURES, - LEGACY_RULE_CODES, - PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) -from app.core.logging import get_logger logger = get_logger("app.services.agent_foundation") @@ -352,6 +323,8 @@ class AgentFoundationAssetSeedMixin: actor_name="系统初始化", ) + self._hide_deprecated_finance_rule_assets() + self.db.add_all( [ AgentAssetVersion( diff --git a/server/src/app/services/agent_foundation_asset_topup.py b/server/src/app/services/agent_foundation_asset_topup.py index 9694ca5..711b490 100644 --- a/server/src/app/services/agent_foundation_asset_topup.py +++ b/server/src/app/services/agent_foundation_asset_topup.py @@ -1,12 +1,8 @@ from __future__ import annotations -import hashlib -import json -from datetime import UTC, date, datetime -from decimal import Decimal -from pathlib import Path +from datetime import UTC, datetime -from sqlalchemy import inspect, select, text +from sqlalchemy import select from app.core.agent_enums import ( AgentAssetContentType, @@ -14,34 +10,15 @@ from app.core.agent_enums import ( AgentAssetStatus, AgentAssetType, AgentName, - AgentPermissionLevel, AgentReviewStatus, - AgentRunSource, - AgentRunStatus, - AgentToolType, ) -from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion -from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog -from app.models.audit_log import AuditLog -from app.models.financial_record import ( - AccountsPayableRecord, - AccountsReceivableRecord, - ExpenseClaim, - ExpenseClaimItem, -) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.core.logging import get_logger +from app.models.agent_asset import AgentAsset from app.services.agent_asset_spreadsheet import ( - AgentAssetSpreadsheetManager, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, - COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, - RISK_RULES_LIBRARY, -) -from app.services.expense_rule_runtime import ( - build_scene_submission_standard_markdown, - build_travel_risk_control_standard_markdown, + AgentAssetSpreadsheetManager, ) from app.services.agent_foundation_constants import ( ATTACHMENT_RULE_ASSET_CODE, @@ -50,13 +27,7 @@ from app.services.agent_foundation_constants import ( COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, COMPANY_TRAVEL_RULE_VERSION, - DEMO_EXPENSE_CLAIM_SIGNATURES, - DEMO_PAYABLE_SIGNATURES, - DEMO_RECEIVABLE_SIGNATURES, - LEGACY_RULE_CODES, - PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) -from app.core.logging import get_logger logger = get_logger("app.services.agent_foundation") @@ -509,6 +480,8 @@ class AgentFoundationAssetTopUpMixin: reviewed_at=datetime.now(UTC), ) + self._hide_deprecated_finance_rule_assets() + if "skill.ar.aging_summary" not in existing_codes: asset = self._create_seed_asset( diff --git a/server/src/app/services/agent_foundation_constants.py b/server/src/app/services/agent_foundation_constants.py index 9eb1663..706fbef 100644 --- a/server/src/app/services/agent_foundation_constants.py +++ b/server/src/app/services/agent_foundation_constants.py @@ -82,9 +82,9 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0" -COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",) +COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅费",) -COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("费用科目",) +COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",) ATTACHMENT_RULE_RUNTIME_CONFIG = { diff --git a/server/src/app/services/agent_foundation_risk_rules.py b/server/src/app/services/agent_foundation_risk_rules.py index af24651..fa9dd7d 100644 --- a/server/src/app/services/agent_foundation_risk_rules.py +++ b/server/src/app/services/agent_foundation_risk_rules.py @@ -23,6 +23,21 @@ from app.services.agent_foundation_constants import ( logger = get_logger("app.services.agent_foundation") +EXPENSE_TYPE_SCENARIO_LABELS = { + "travel": "差旅费", + "hotel": "住宿费", + "transport": "交通费", + "meal": "业务招待费", + "meeting": "会务费", + "marketing": "市场推广费", + "office": "办公用品费", + "training": "培训费", + "software": "软件服务费", + "communication": "通信费", + "welfare": "福利费", +} + + class AgentFoundationRiskRuleMixin: def _iter_platform_risk_manifests(self) -> list[tuple[str, dict[str, object]]]: @@ -123,8 +138,54 @@ class AgentFoundationRiskRuleMixin: return "通用" + @staticmethod + def _resolve_manifest_expense_types(manifest: dict[str, object]) -> list[str]: + def _collect(value: object) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, (list, tuple, set)): + return [str(item or "").strip() for item in value] + return [] + + candidates: list[str] = [] + candidates.extend(_collect(manifest.get("expense_types"))) + + applies_to = ( + manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {} + ) + candidates.extend(_collect(applies_to.get("expense_types"))) + + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + candidates.extend(_collect(metadata.get("expense_types"))) + + normalized: list[str] = [] + seen: set[str] = set() + for item in candidates: + value = item.strip().lower() + if not value or value in seen: + continue + seen.add(value) + normalized.append(value) + return normalized + + @staticmethod + def _expense_type_scenario_labels(expense_types: list[str]) -> list[str]: + labels: list[str] = [] + seen: set[str] = set() + for expense_type in expense_types: + label = EXPENSE_TYPE_SCENARIO_LABELS.get(expense_type) + if not label or label in seen: + continue + seen.add(label) + labels.append(label) + return labels + def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]: + labels = self._expense_type_scenario_labels(self._resolve_manifest_expense_types(manifest)) + if labels: + return labels + category = self._resolve_platform_risk_category(manifest) return [category] if category else ["通用"] @@ -139,7 +200,7 @@ class AgentFoundationRiskRuleMixin: risk_category = self._resolve_platform_risk_category(manifest) - return { + config = { "severity": str(fail_outcome.get("severity") or "medium"), @@ -176,6 +237,20 @@ class AgentFoundationRiskRuleMixin: ), } + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + for key in ( + "finance_rule_code", + "finance_rule_sheet", + "business_stage", + "expense_types", + "budget_required", + ): + value = manifest.get(key) + if value is None and isinstance(metadata, dict): + value = metadata.get(key) + if value is not None: + config[key] = value + return config def _build_platform_risk_seed_assets(self) -> list[AgentAsset]: @@ -242,6 +317,12 @@ class AgentFoundationRiskRuleMixin: before_count = len(existing_codes) self._ensure_platform_risk_rules_from_library(existing_codes) + manifest_codes = { + str(manifest.get("rule_code") or "").strip() + for _, manifest in self._iter_platform_risk_manifests() + if str(manifest.get("rule_code") or "").strip() + } + self._hide_stale_demo_risk_rules(manifest_codes) self.db.flush() @@ -265,6 +346,25 @@ class AgentFoundationRiskRuleMixin: return manifest_count + def _hide_stale_demo_risk_rules(self, manifest_codes: set[str]) -> None: + assets = self.db.scalars( + select(AgentAsset).where(AgentAsset.asset_type == AgentAssetType.RULE.value) + ).all() + for asset in assets: + config = asset.config_json if isinstance(asset.config_json, dict) else {} + if config.get("source_ref") != "费用管控 Demo 风险规则库": + continue + if asset.code in manifest_codes: + continue + asset.status = AgentAssetStatus.DISABLED.value + asset.config_json = { + **config, + "enabled": False, + "tag": "废弃风险规则", + "deprecated": True, + "deprecated_reason": "对应风险规则 JSON 已删除,不再参与费用管控 Demo。", + } + def _ensure_platform_risk_rules_from_library(self, existing_codes: set[str]) -> None: for file_name, manifest in self._iter_platform_risk_manifests(): diff --git a/server/src/app/services/agent_foundation_spreadsheets.py b/server/src/app/services/agent_foundation_spreadsheets.py index 8701695..16d43a3 100644 --- a/server/src/app/services/agent_foundation_spreadsheets.py +++ b/server/src/app/services/agent_foundation_spreadsheets.py @@ -1,66 +1,115 @@ from __future__ import annotations -import hashlib -import json -from datetime import UTC, date, datetime -from decimal import Decimal from pathlib import Path -from sqlalchemy import inspect, select, text +from sqlalchemy import select from app.core.agent_enums import ( - AgentAssetContentType, - AgentAssetDomain, AgentAssetStatus, - AgentAssetType, - AgentName, - AgentPermissionLevel, - AgentReviewStatus, - AgentRunSource, - AgentRunStatus, - AgentToolType, ) -from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion -from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog -from app.models.audit_log import AuditLog -from app.models.financial_record import ( - AccountsPayableRecord, - AccountsReceivableRecord, - ExpenseClaim, - ExpenseClaimItem, -) -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.core.logging import get_logger +from app.models.agent_asset import AgentAsset from app.services.agent_asset_spreadsheet import ( - AgentAssetSpreadsheetManager, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, - RISK_RULES_LIBRARY, -) -from app.services.expense_rule_runtime import ( - build_scene_submission_standard_markdown, - build_travel_risk_control_standard_markdown, + AgentAssetSpreadsheetManager, ) from app.services.agent_foundation_constants import ( - ATTACHMENT_RULE_ASSET_CODE, - ATTACHMENT_RULE_RUNTIME_CONFIG, COMPANY_COMMUNICATION_RULE_SCENARIO_JSON, - COMPANY_COMMUNICATION_RULE_VERSION, COMPANY_TRAVEL_RULE_SCENARIO_JSON, - COMPANY_TRAVEL_RULE_VERSION, - DEMO_EXPENSE_CLAIM_SIGNATURES, - DEMO_PAYABLE_SIGNATURES, - DEMO_RECEIVABLE_SIGNATURES, - LEGACY_RULE_CODES, - PLATFORM_DESTINATION_LOCATION_RULE_FILENAME, ) -from app.core.logging import get_logger +from app.services.finance_rule_catalog import ( + DEPRECATED_FINANCE_RULE_CODES, + DEPRECATED_FINANCE_RULE_REPLACEMENTS, +) logger = get_logger("app.services.agent_foundation") + class AgentFoundationSpreadsheetMixin: + def sync_finance_rule_assets_from_catalog(self) -> int: + synced_count = self._ensure_core_finance_rule_asset_metadata() + self._hide_deprecated_finance_rule_assets() + self.db.flush() + return synced_count + + def _ensure_core_finance_rule_asset_metadata(self) -> int: + synced_count = 0 + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + finance_rule_sheet="差旅住宿费标准", + expense_types=["travel", "hotel", "transport"], + ) + ) + synced_count += int( + self._ensure_core_finance_rule_asset( + code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], + finance_rule_sheet="通信费报销标准", + expense_types=["communication"], + ) + ) + return synced_count + + def _ensure_core_finance_rule_asset( + self, + *, + code: str, + scenario_category: str, + finance_rule_sheet: str, + expense_types: list[str], + ) -> bool: + asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code)) + if asset is None: + return False + asset.scenario_json = [scenario_category] + asset.config_json = { + **(asset.config_json or {}), + "enabled": True, + "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, + } + return True + + 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)) + if asset is None: + continue + asset.status = AgentAssetStatus.DISABLED.value + asset.scenario_json = ["已废弃"] + replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code) + deprecated_reason = ( + "交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。" + if replacement + else ( + "该费用类型没有独立职务金额分档,额度控制转入预算中心," + "不再作为独立财务规则表展示。" + ) + ) + asset.config_json = { + **(asset.config_json or {}), + "enabled": False, + "tag": "废弃规则", + "deprecated": True, + "deprecated_reason": deprecated_reason, + } + if replacement: + asset.config_json["replaced_by"] = replacement + def _ensure_company_travel_rule_spreadsheet_seed( self, @@ -251,6 +300,8 @@ class AgentFoundationSpreadsheetMixin: fallback_sheet_name: str, + workbook_sheets: list[tuple[str, list[list[object]]]] | None = None, + ): manager = AgentAssetSpreadsheetManager() @@ -271,6 +322,8 @@ class AgentFoundationSpreadsheetMixin: fallback_sheet_name=fallback_sheet_name, + workbook_sheets=workbook_sheets, + ), actor_name=actor_name, @@ -379,6 +432,8 @@ class AgentFoundationSpreadsheetMixin: fallback_sheet_name: str, + workbook_sheets: list[tuple[str, list[list[object]]]] | None = None, + ) -> bytes: live_key = ( @@ -397,4 +452,8 @@ class AgentFoundationSpreadsheetMixin: return live_path.read_bytes() + if workbook_sheets is not None: + + return AgentAssetSpreadsheetManager.build_rule_workbook(workbook_sheets) + return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name) diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index 13fad71..7d14e01 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -22,9 +22,10 @@ logger = get_logger("app.services.auth") ROLE_LABELS = { "manager": "管理员", "finance": "财务人员", - "executive": "高级管理人员", + "executive": "高级财务人员", "approver": "审批负责人", - "auditor": "审计观察员", + "budget_monitor": "预算监控员", + "auditor": "预算监控员", "user": "使用者", } diff --git a/server/src/app/services/budget.py b/server/src/app/services/budget.py new file mode 100644 index 0000000..23004bf --- /dev/null +++ b/server/src/app/services/budget.py @@ -0,0 +1,776 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.budget import BudgetAllocation, BudgetReservation +from app.models.financial_record import ExpenseClaim +from app.schemas.budget import ( + BudgetAllocationCreate, + BudgetAllocationRead, + BudgetCheckRead, + BudgetCheckRequest, + BudgetOperationRequest, + BudgetOperationRead, + BudgetSummaryRead, + BudgetTransactionRead, +) +from app.services.budget_support import BudgetSupportMixin +from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES + + +class BudgetService(BudgetSupportMixin): + def __init__(self, db: Session) -> None: + self.db = db + + def ensure_budget_ready(self) -> None: + Base.metadata.create_all(bind=self.db.get_bind()) + exists = self.db.scalar(select(BudgetAllocation.id).limit(1)) + if exists: + return + self._seed_default_allocations() + + def list_allocations( + self, + *, + fiscal_year: int | None = None, + period_key: str | None = None, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, + ) -> list[BudgetAllocationRead]: + self.ensure_budget_ready() + stmt = select(BudgetAllocation).order_by( + BudgetAllocation.fiscal_year.desc(), + BudgetAllocation.period_key.asc(), + BudgetAllocation.department_name.asc(), + BudgetAllocation.subject_code.asc(), + ).where(BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES)) + if fiscal_year is not None: + stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year) + if period_key: + stmt = stmt.where(BudgetAllocation.period_key == period_key) + if department_id: + stmt = stmt.where(BudgetAllocation.department_id == department_id) + if department_name: + stmt = stmt.where(BudgetAllocation.department_name == department_name) + if cost_center: + stmt = stmt.where(BudgetAllocation.cost_center == cost_center) + return [self.serialize_allocation(row) for row in self.db.scalars(stmt).all()] + + def get_summary( + self, + *, + fiscal_year: int | None = None, + period_key: str | None = None, + department_id: str | None = None, + department_name: str | None = None, + cost_center: str | None = None, + ) -> BudgetSummaryRead: + allocations = self.list_allocations( + fiscal_year=fiscal_year, + period_key=period_key, + department_id=department_id, + department_name=department_name, + cost_center=cost_center, + ) + total_amount = sum((item.balance.total_amount for item in allocations), Decimal("0.00")) + reserved_amount = sum((item.balance.reserved_amount for item in allocations), Decimal("0.00")) + consumed_amount = sum((item.balance.consumed_amount for item in allocations), Decimal("0.00")) + available_amount = sum((item.balance.available_amount for item in allocations), Decimal("0.00")) + warning_count = sum( + 1 + for item in allocations + if item.balance.usage_rate >= item.warning_threshold + and item.balance.available_amount >= Decimal("0.00") + ) + over_budget_count = sum( + 1 for item in allocations if item.balance.available_amount < Decimal("0.00") + ) + return BudgetSummaryRead( + fiscal_year=fiscal_year, + period_key=period_key, + total_amount=total_amount, + reserved_amount=reserved_amount, + consumed_amount=consumed_amount, + available_amount=available_amount, + warning_count=warning_count, + over_budget_count=over_budget_count, + allocations=allocations, + ) + + def create_or_update_allocation( + self, + payload: BudgetAllocationCreate, + *, + operator: str, + ) -> BudgetAllocationRead: + self.ensure_budget_ready() + subject_code = self._normalize_subject_code(payload.subject_code) + if not self._is_supported_budget_subject(subject_code): + raise ValueError("demo 阶段预算中心只维护差旅、通信、招待费、办公用品四类预算。") + period_key = self._normalize_period_key(payload.fiscal_year, payload.period_key) + existing = self._find_exact_allocation( + fiscal_year=payload.fiscal_year, + period_key=period_key, + department_id=payload.department_id, + department_name=payload.department_name, + cost_center=payload.cost_center, + project_code=payload.project_code, + subject_code=subject_code, + ) + if existing is None: + allocation = BudgetAllocation( + budget_no=self._make_no("BUD"), + fiscal_year=payload.fiscal_year, + period_type=self._normalize_period_type(payload.period_type), + period_key=period_key, + department_id=self._blank_to_none(payload.department_id), + department_name=payload.department_name.strip(), + cost_center=self._blank_to_none(payload.cost_center), + project_code=self._blank_to_none(payload.project_code), + subject_code=subject_code, + subject_name=payload.subject_name.strip(), + original_amount=self._money(payload.original_amount), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=self._percent(payload.warning_threshold), + control_action=self._normalize_control_action(payload.control_action), + description=self._blank_to_none(payload.description), + created_by=operator, + updated_by=operator, + ) + self.db.add(allocation) + self.db.flush() + self._record_transaction( + allocation=allocation, + transaction_type="init", + amount=allocation.original_amount, + before_available=Decimal("0.00"), + after_available=self.get_balance(allocation).available_amount, + source_type="budget_allocation", + source_id=allocation.id, + source_no=allocation.budget_no, + operator=operator, + reason="初始化预算额度", + ) + self.db.flush() + return self.serialize_allocation(allocation) + + before_balance = self.get_balance(existing) + original_before = self._money(existing.original_amount) + existing.period_type = self._normalize_period_type(payload.period_type) + existing.department_name = payload.department_name.strip() + existing.cost_center = self._blank_to_none(payload.cost_center) + existing.project_code = self._blank_to_none(payload.project_code) + existing.subject_name = payload.subject_name.strip() + existing.original_amount = self._money(payload.original_amount) + existing.warning_threshold = self._percent(payload.warning_threshold) + existing.control_action = self._normalize_control_action(payload.control_action) + existing.description = self._blank_to_none(payload.description) + existing.updated_by = operator + self.db.flush() + amount_delta = self._money(existing.original_amount) - original_before + if amount_delta: + self._record_transaction( + allocation=existing, + transaction_type="adjust", + amount=amount_delta, + before_available=before_balance.available_amount, + after_available=self.get_balance(existing).available_amount, + source_type="budget_allocation", + source_id=existing.id, + source_no=existing.budget_no, + operator=operator, + reason="调整预算额度", + ) + self.db.flush() + return self.serialize_allocation(existing) + + def check(self, payload: BudgetCheckRequest) -> BudgetCheckRead: + self.ensure_budget_ready() + subject_code = self._normalize_subject_code(payload.subject_code) + if not self._is_supported_budget_subject(subject_code): + flag = self._build_budget_flag( + event_type="budget_control_skipped", + severity="low", + label="预算暂不管控", + message="demo 阶段该费用类型暂不纳入预算计算。", + amount=self._money(payload.amount), + extra={"subject_code": subject_code}, + ) + return BudgetCheckRead(passed=True, flags=[flag]) + allocation = self._find_allocation_for_dimension( + fiscal_year=payload.fiscal_year, + period_key=payload.period_key, + department_id=payload.department_id, + department_name=payload.department_name, + cost_center=payload.cost_center, + project_code=payload.project_code, + subject_code=subject_code, + ) + amount = self._money(payload.amount) + if allocation is None: + flag = self._build_budget_flag( + event_type="budget_missing", + severity="high", + label="预算归属缺失", + message="未找到匹配的预算额度,当前单据不能进入费用控制闭环。", + amount=amount, + ) + return BudgetCheckRead(passed=False, blocking_reasons=[flag["message"]], flags=[flag]) + + review = self._review_allocation_amount(allocation, amount) + return BudgetCheckRead( + passed=not review["blocking_reasons"], + blocking_reasons=review["blocking_reasons"], + flags=review["flags"], + allocation=self.serialize_allocation(allocation), + ) + + def review_claim_budget(self, claim: ExpenseClaim) -> dict[str, list[Any]]: + self.ensure_budget_ready() + if not self._claim_uses_budget_control(claim): + return {"flags": [], "blocking_reasons": []} + allocation = self._find_allocation_for_claim(claim) + amount = self._money(claim.amount or Decimal("0.00")) + if allocation is None: + if self._budget_table_empty(): + allocation = self._create_fallback_allocation_for_claim(claim) + else: + flag = self._build_budget_flag( + event_type="budget_missing", + severity="high", + label="预算归属缺失", + message=f"单据 {claim.claim_no} 未找到预算池额度,请先在预算中心建立预算。", + amount=amount, + ) + return {"flags": [flag], "blocking_reasons": [flag["message"]]} + review = self._review_allocation_amount(allocation, amount) + return {"flags": review["flags"], "blocking_reasons": review["blocking_reasons"]} + + def reserve_for_claim( + self, + claim: ExpenseClaim, + *, + source_type: str, + operator: str, + ) -> list[dict[str, Any]]: + self.ensure_budget_ready() + if not self._claim_uses_budget_control(claim): + return [] + existing = self._find_active_reservation(source_type=source_type, source_id=claim.id) + if existing is not None: + return [ + self._build_operation_flag( + existing.allocation, + event_type="budget_reserve_reused", + label="预算预占已存在", + message=f"单据 {claim.claim_no} 已存在有效预算预占,本次提交复用原预占。", + amount=existing.amount, + reservation_id=existing.id, + ) + ] + + allocation = self._find_allocation_for_claim(claim) + if allocation is None and self._budget_table_empty(): + allocation = self._create_fallback_allocation_for_claim(claim) + if allocation is None: + raise BudgetControlError([f"单据 {claim.claim_no} 未找到预算池额度,请先建立预算。"]) + + amount = self._money(claim.amount or Decimal("0.00")) + review = self._review_allocation_amount(allocation, amount) + if review["blocking_reasons"]: + raise BudgetControlError(review["blocking_reasons"], flags=review["flags"]) + + before_balance = self.get_balance(allocation) + reservation = BudgetReservation( + reservation_no=self._make_no("BRS"), + allocation_id=allocation.id, + source_type=source_type, + source_id=claim.id, + source_no=claim.claim_no, + source_status="active", + amount=amount, + context_json=self._claim_context(claim), + ) + self.db.add(reservation) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="reserve", + amount=amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=source_type, + source_id=claim.id, + source_no=claim.claim_no, + operator=operator, + reason="单据提交预算预占", + ) + self.db.flush() + flag = self._build_operation_flag( + allocation, + event_type="budget_reserved", + label="预算已预占", + message=f"已为单据 {claim.claim_no} 预占预算 {amount} 元。", + amount=amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + return [*review["flags"], flag] + + def release_for_claim( + self, + claim: ExpenseClaim, + *, + source_type: str, + operator: str, + reason: str, + ) -> list[dict[str, Any]]: + self.ensure_budget_ready() + reservations = self._find_active_reservations(source_type=source_type, source_id=claim.id) + flags: list[dict[str, Any]] = [] + for reservation in reservations: + allocation = reservation.allocation + before_balance = self.get_balance(allocation) + amount = self._money(reservation.amount) + reservation.source_status = "released" + reservation.released_amount = amount + reservation.released_at = datetime.now(UTC) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="release", + amount=amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=source_type, + source_id=claim.id, + source_no=claim.claim_no, + operator=operator, + reason=reason, + ) + flags.append( + self._build_operation_flag( + allocation, + event_type="budget_released", + label="预算预占已释放", + message=f"单据 {claim.claim_no} 已释放预算预占 {amount} 元。", + amount=amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + ) + self.db.flush() + return flags + + def consume_for_claim( + self, + claim: ExpenseClaim, + *, + operator: str, + reason: str, + ) -> dict[str, Any] | None: + self.ensure_budget_ready() + if not self._claim_uses_budget_control(claim): + return None + reservations = self._find_active_reservations(source_type="claim", source_id=claim.id) + amount = self._money(claim.amount or Decimal("0.00")) + if reservations: + reservation = reservations[0] + allocation = reservation.allocation + before_balance = self.get_balance(allocation) + reserved_amount = self._money(reservation.amount) + consume_amount = min(amount, reserved_amount) + release_amount = max(reserved_amount - consume_amount, Decimal("0.00")) + reservation.source_status = "consumed" + reservation.consumed_amount = consume_amount + reservation.released_amount = release_amount + reservation.consumed_at = datetime.now(UTC) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="consume", + amount=consume_amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type="claim", + source_id=claim.id, + source_no=claim.claim_no, + operator=operator, + reason=reason, + ) + self.db.flush() + return self._build_operation_flag( + allocation, + event_type="budget_consumed", + label="预算已核销", + message=f"单据 {claim.claim_no} 已核销预算 {consume_amount} 元。", + amount=consume_amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + + allocation = self._find_allocation_for_claim(claim) + if allocation is None: + return self._build_budget_flag( + event_type="budget_consume_skipped", + severity="low", + label="预算未核销", + message=f"单据 {claim.claim_no} 未找到预算池额度,按存量单据兼容放行。", + amount=amount, + ) + review = self._review_allocation_amount(allocation, amount) + if review["blocking_reasons"]: + raise BudgetControlError(review["blocking_reasons"], flags=review["flags"]) + before_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + transaction_type="consume", + amount=amount, + before_available=before_balance.available_amount, + after_available=before_balance.available_amount - amount, + source_type="claim", + source_id=claim.id, + source_no=claim.claim_no, + operator=operator, + reason=reason, + ) + self.db.flush() + return self._build_operation_flag( + allocation, + event_type="budget_consumed", + label="预算已核销", + message=f"单据 {claim.claim_no} 已核销预算 {amount} 元。", + amount=amount, + transaction_id=transaction.id, + ) + + def transfer_application_reservation( + self, + *, + application_claim: ExpenseClaim, + draft_claim: ExpenseClaim, + operator: str, + ) -> dict[str, Any] | None: + self.ensure_budget_ready() + reservation = self._find_active_reservation( + source_type="application", + source_id=application_claim.id, + ) + if reservation is None: + return None + allocation = reservation.allocation + before_balance = self.get_balance(allocation) + reservation.source_type = "claim" + reservation.source_id = draft_claim.id + reservation.source_no = draft_claim.claim_no + context = dict(reservation.context_json or {}) + context["application_claim_id"] = application_claim.id + context["application_claim_no"] = application_claim.claim_no + context["draft_claim_id"] = draft_claim.id + context["draft_claim_no"] = draft_claim.claim_no + reservation.context_json = context + self.db.flush() + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="transfer", + amount=Decimal("0.00"), + before_available=before_balance.available_amount, + after_available=self.get_balance(allocation).available_amount, + source_type="claim", + source_id=draft_claim.id, + source_no=draft_claim.claim_no, + operator=operator, + reason="申请审批通过后预算预占转入报销草稿", + ) + self.db.flush() + return self._build_operation_flag( + allocation, + event_type="budget_reservation_transferred", + label="预算预占已转入报销", + message=f"申请 {application_claim.claim_no} 的预算预占已转入报销草稿 {draft_claim.claim_no}。", + amount=reservation.amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + + def execute_operation( + self, + payload: BudgetOperationRequest, + *, + transaction_type: str, + operator: str, + ) -> BudgetOperationRead: + self.ensure_budget_ready() + normalized_type = str(transaction_type or "").strip().lower() + subject_code = self._normalize_subject_code(payload.subject_code) + if not self._is_supported_budget_subject(subject_code): + raise BudgetControlError(["demo 阶段该费用类型暂不纳入预算计算。"]) + allocation = self._find_allocation_for_dimension( + fiscal_year=payload.fiscal_year, + period_key=payload.period_key, + department_id=payload.department_id, + department_name=payload.department_name, + cost_center=payload.cost_center, + project_code=payload.project_code, + subject_code=subject_code, + ) + if allocation is None: + raise BudgetControlError(["未找到匹配的预算额度。"]) + amount = self._money(payload.amount) + + if normalized_type == "reserve": + return self._execute_manual_reserve_operation(payload, allocation, amount, operator) + if normalized_type == "release": + return self._execute_manual_release_operation(payload, operator) + if normalized_type == "consume": + return self._execute_manual_consume_operation(payload, allocation, amount, operator) + + before_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + transaction_type=normalized_type, + amount=amount, + before_available=before_balance.available_amount, + after_available=before_balance.available_amount - amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + return BudgetOperationRead( + ok=True, + message="预算操作已记录。", + allocation=self.serialize_allocation(allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + ) + + def _execute_manual_reserve_operation( + self, + payload: BudgetOperationRequest, + allocation: BudgetAllocation, + amount: Decimal, + operator: str, + ) -> BudgetOperationRead: + existing = self._find_active_reservation( + source_type=payload.source_type, + source_id=payload.source_id, + ) + if existing is not None: + flag = self._build_operation_flag( + existing.allocation, + event_type="budget_reserve_reused", + label="预算预占已存在", + message=f"来源 {payload.source_no} 已存在有效预算预占。", + amount=existing.amount, + reservation_id=existing.id, + ) + return BudgetOperationRead( + ok=True, + message="预算预占已存在。", + reservation_id=existing.id, + allocation=self.serialize_allocation(existing.allocation), + flags=[flag], + ) + + review = self._review_allocation_amount(allocation, amount) + if review["blocking_reasons"]: + raise BudgetControlError(review["blocking_reasons"], flags=review["flags"]) + + before_balance = self.get_balance(allocation) + reservation = BudgetReservation( + reservation_no=self._make_no("BRS"), + allocation_id=allocation.id, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + source_status="active", + amount=amount, + context_json={"manual_operation": True}, + ) + self.db.add(reservation) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="reserve", + amount=amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + flag = self._build_operation_flag( + allocation, + event_type="budget_reserved", + label="预算已预占", + message=f"来源 {payload.source_no} 已预占预算 {amount} 元。", + amount=amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + return BudgetOperationRead( + ok=True, + message="预算预占已记录。", + reservation_id=reservation.id, + allocation=self.serialize_allocation(allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + flags=[*review["flags"], flag], + ) + + def _execute_manual_release_operation( + self, + payload: BudgetOperationRequest, + operator: str, + ) -> BudgetOperationRead: + reservation = self._find_active_reservation( + source_type=payload.source_type, + source_id=payload.source_id, + ) + if reservation is None: + raise BudgetControlError(["未找到可释放的预算预占。"]) + + allocation = reservation.allocation + before_balance = self.get_balance(allocation) + amount = self._money(reservation.amount) + reservation.source_status = "released" + reservation.released_amount = amount + reservation.released_at = datetime.now(UTC) + self.db.flush() + after_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + reservation=reservation, + transaction_type="release", + amount=amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + flag = self._build_operation_flag( + allocation, + event_type="budget_released", + label="预算预占已释放", + message=f"来源 {payload.source_no} 已释放预算预占 {amount} 元。", + amount=amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + return BudgetOperationRead( + ok=True, + message="预算释放已记录。", + reservation_id=reservation.id, + allocation=self.serialize_allocation(allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + flags=[flag], + ) + + def _execute_manual_consume_operation( + self, + payload: BudgetOperationRequest, + allocation: BudgetAllocation, + amount: Decimal, + operator: str, + ) -> BudgetOperationRead: + reservation = self._find_active_reservation( + source_type=payload.source_type, + source_id=payload.source_id, + ) + if reservation is None: + return self._record_direct_operation(payload, allocation, "consume", amount, operator) + + before_balance = self.get_balance(reservation.allocation) + reserved_amount = self._money(reservation.amount) + consume_amount = min(amount, reserved_amount) + reservation.source_status = "consumed" + reservation.consumed_amount = consume_amount + reservation.released_amount = max(reserved_amount - consume_amount, Decimal("0.00")) + reservation.consumed_at = datetime.now(UTC) + self.db.flush() + after_balance = self.get_balance(reservation.allocation) + transaction = self._record_transaction( + allocation=reservation.allocation, + reservation=reservation, + transaction_type="consume", + amount=consume_amount, + before_available=before_balance.available_amount, + after_available=after_balance.available_amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + flag = self._build_operation_flag( + reservation.allocation, + event_type="budget_consumed", + label="预算已核销", + message=f"来源 {payload.source_no} 已核销预算 {consume_amount} 元。", + amount=consume_amount, + reservation_id=reservation.id, + transaction_id=transaction.id, + ) + return BudgetOperationRead( + ok=True, + message="预算核销已记录。", + reservation_id=reservation.id, + allocation=self.serialize_allocation(reservation.allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + flags=[flag], + ) + + def _record_direct_operation( + self, + payload: BudgetOperationRequest, + allocation: BudgetAllocation, + transaction_type: str, + amount: Decimal, + operator: str, + ) -> BudgetOperationRead: + before_balance = self.get_balance(allocation) + transaction = self._record_transaction( + allocation=allocation, + transaction_type=transaction_type, + amount=amount, + before_available=before_balance.available_amount, + after_available=before_balance.available_amount - amount, + source_type=payload.source_type, + source_id=payload.source_id, + source_no=payload.source_no, + operator=operator, + reason=payload.reason, + ) + self.db.flush() + return BudgetOperationRead( + ok=True, + message="预算操作已记录。", + allocation=self.serialize_allocation(allocation), + transaction=BudgetTransactionRead.model_validate(transaction), + ) diff --git a/server/src/app/services/budget_support.py b/server/src/app/services/budget_support.py new file mode 100644 index 0000000..f909f1c --- /dev/null +++ b/server/src/app/services/budget_support.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import select + +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction +from app.models.financial_record import ExpenseClaim +from app.models.organization import OrganizationUnit +from app.schemas.budget import BudgetAllocationRead, BudgetTransactionRead +from app.services.budget_types import ( + BUDGET_SUBJECT_LABELS, + BudgetBalance, + DEFAULT_SUBJECT_AMOUNTS, + SUBJECT_CODE_ALIASES, + SUPPORTED_BUDGET_SUBJECT_CODES, +) +from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS +from app.services.expense_type_keywords import resolve_expense_type_code_from_text + + +class BudgetSupportMixin: + def serialize_allocation(self, allocation: BudgetAllocation) -> BudgetAllocationRead: + return BudgetAllocationRead( + id=allocation.id, + budget_no=allocation.budget_no, + fiscal_year=allocation.fiscal_year, + period_type=allocation.period_type, + period_key=allocation.period_key, + department_id=allocation.department_id, + department_name=allocation.department_name, + cost_center=allocation.cost_center, + project_code=allocation.project_code, + subject_code=allocation.subject_code, + subject_name=allocation.subject_name, + original_amount=self._money(allocation.original_amount), + adjusted_amount=self._money(allocation.adjusted_amount), + status=allocation.status, + warning_threshold=self._percent(allocation.warning_threshold), + control_action=allocation.control_action, + description=allocation.description, + balance=self.get_balance(allocation).to_read(), + created_at=allocation.created_at, + updated_at=allocation.updated_at, + ) + + def get_balance(self, allocation: BudgetAllocation) -> BudgetBalance: + reservations = self.db.scalars( + select(BudgetReservation).where( + BudgetReservation.allocation_id == allocation.id, + BudgetReservation.source_status == "active", + ) + ).all() + transactions = self.db.scalars( + select(BudgetTransaction).where(BudgetTransaction.allocation_id == allocation.id) + ).all() + reserved_amount = sum((self._money(item.amount) for item in reservations), Decimal("0.00")) + consumed_amount = Decimal("0.00") + for transaction in transactions: + transaction_type = str(transaction.transaction_type or "").strip().lower() + amount = self._money(transaction.amount) + if transaction_type == "consume": + consumed_amount += amount + elif transaction_type == "rollback": + consumed_amount -= amount + total_amount = self._money(allocation.original_amount) + self._money(allocation.adjusted_amount) + available_amount = total_amount - reserved_amount - consumed_amount + usage_amount = reserved_amount + consumed_amount + usage_rate = Decimal("0.00") + if total_amount > Decimal("0.00"): + usage_rate = ((usage_amount / total_amount) * Decimal("100")).quantize(Decimal("0.01")) + return BudgetBalance( + total_amount=total_amount, + reserved_amount=reserved_amount, + consumed_amount=consumed_amount, + available_amount=available_amount, + usage_rate=usage_rate, + ) + + def list_transactions(self, allocation_id: str) -> list[BudgetTransactionRead]: + self.ensure_budget_ready() + rows = self.db.scalars( + select(BudgetTransaction) + .where(BudgetTransaction.allocation_id == allocation_id) + .order_by(BudgetTransaction.created_at.desc()) + ).all() + return [BudgetTransactionRead.model_validate(row) for row in rows] + + def get_allocation_row(self, allocation_id: str) -> BudgetAllocation | None: + self.ensure_budget_ready() + return self.db.get(BudgetAllocation, allocation_id) + + def _review_allocation_amount( + self, + allocation: BudgetAllocation, + amount: Decimal, + ) -> dict[str, list[Any]]: + balance = self.get_balance(allocation) + flags: list[dict[str, Any]] = [] + blocking_reasons: list[str] = [] + if str(allocation.status or "").strip().lower() == "frozen": + message = f"预算 {allocation.budget_no} 已冻结,不能继续占用。" + flags.append( + self._build_operation_flag( + allocation, + event_type="budget_frozen", + label="预算已冻结", + message=message, + severity="high", + amount=amount, + ) + ) + blocking_reasons.append(message) + return {"flags": flags, "blocking_reasons": blocking_reasons} + + if amount > balance.available_amount: + over_amount = amount - balance.available_amount + message = ( + f"预算 {allocation.budget_no} 可用余额 {balance.available_amount} 元," + f"当前单据金额 {amount} 元,超出 {over_amount} 元。" + ) + flags.append( + self._build_operation_flag( + allocation, + event_type="budget_insufficient", + label="预算余额不足", + message=message, + severity="high", + amount=amount, + extra={"available_amount": str(balance.available_amount), "over_budget_amount": str(over_amount)}, + ) + ) + blocking_reasons.append(message) + return {"flags": flags, "blocking_reasons": blocking_reasons} + + after_usage = balance.reserved_amount + balance.consumed_amount + amount + usage_rate = Decimal("0.00") + if balance.total_amount > Decimal("0.00"): + usage_rate = ((after_usage / balance.total_amount) * Decimal("100")).quantize(Decimal("0.01")) + if usage_rate >= self._percent(allocation.warning_threshold): + flags.append( + self._build_operation_flag( + allocation, + event_type="budget_warning", + label="预算接近预警线", + message=( + f"预算 {allocation.budget_no} 本次占用后使用率预计达到 {usage_rate}%," + f"已达到预警线 {allocation.warning_threshold}%。" + ), + severity="medium", + amount=amount, + extra={"usage_rate": str(usage_rate)}, + ) + ) + return {"flags": flags, "blocking_reasons": blocking_reasons} + + def build_claim_budget_context(self, claim: ExpenseClaim) -> dict[str, Any]: + self.ensure_budget_ready() + amount = self._money(claim.amount or Decimal("0.00")) + fiscal_year, period_key = self._period_from_claim(claim) + subject_code = self._subject_code_from_claim(claim) + if not self._is_supported_budget_subject(subject_code): + return { + "matched": False, + "budget_applicable": False, + "skip_reason": "demo_budget_subject_not_enabled", + "claim_amount": str(amount), + "fiscal_year": fiscal_year, + "period_key": period_key, + "subject_code": subject_code, + "department_id": claim.department_id, + "department_name": claim.department_name, + "cost_center": self._resolve_claim_cost_center(claim), + } + + allocation = self._find_allocation_for_claim(claim) + if allocation is None: + return { + "matched": False, + "budget_applicable": True, + "claim_amount": str(amount), + "fiscal_year": fiscal_year, + "period_key": period_key, + "subject_code": subject_code, + "department_id": claim.department_id, + "department_name": claim.department_name, + "cost_center": self._resolve_claim_cost_center(claim), + } + + balance = self.get_balance(allocation) + over_budget_amount = max(amount - balance.available_amount, Decimal("0.00")) + return { + "matched": True, + "budget_applicable": True, + "allocation_id": allocation.id, + "budget_no": allocation.budget_no, + "claim_amount": str(amount), + "total_amount": str(balance.total_amount), + "reserved_amount": str(balance.reserved_amount), + "consumed_amount": str(balance.consumed_amount), + "available_amount": str(balance.available_amount), + "usage_rate": str(balance.usage_rate), + "over_budget_amount": str(over_budget_amount), + "warning_threshold": str(allocation.warning_threshold), + "control_action": allocation.control_action, + "fiscal_year": allocation.fiscal_year, + "period_key": allocation.period_key, + "subject_code": allocation.subject_code, + "subject_name": allocation.subject_name, + "department_id": allocation.department_id, + "department_name": allocation.department_name, + "cost_center": allocation.cost_center, + "project_code": allocation.project_code, + } + + def _find_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation | None: + fiscal_year, period_key = self._period_from_claim(claim) + return self._find_allocation_for_dimension( + fiscal_year=fiscal_year, + period_key=period_key, + department_id=claim.department_id, + department_name=claim.department_name, + cost_center=self._resolve_claim_cost_center(claim), + project_code=claim.project_code, + subject_code=self._subject_code_from_claim(claim), + ) + + def _find_allocation_for_dimension( + self, + *, + fiscal_year: int | None, + period_key: str | None, + department_id: str | None, + department_name: str | None, + cost_center: str | None, + project_code: str | None, + subject_code: str, + ) -> BudgetAllocation | None: + now = datetime.now(UTC) + year = fiscal_year or now.year + key = self._normalize_period_key(year, period_key or self._quarter_key(year, now.month)) + normalized_subject = self._normalize_subject_code(subject_code) + candidates = list( + self.db.scalars( + select(BudgetAllocation) + .where(BudgetAllocation.fiscal_year == year) + .where(BudgetAllocation.period_key == key) + .where(BudgetAllocation.subject_code == normalized_subject) + .where(BudgetAllocation.status.in_(["active", "published"])) + .order_by(BudgetAllocation.project_code.desc().nullslast()) + ).all() + ) + if not candidates: + return None + normalized_department_id = self._blank_to_none(department_id) + normalized_department_name = str(department_name or "").strip() + normalized_cost_center = self._blank_to_none(cost_center) + normalized_project_code = self._blank_to_none(project_code) + for item in candidates: + if normalized_project_code and item.project_code and item.project_code != normalized_project_code: + continue + if normalized_department_id and item.department_id == normalized_department_id: + return item + if normalized_cost_center and item.cost_center == normalized_cost_center: + return item + if normalized_department_name and item.department_name == normalized_department_name: + return item + return None + + def _find_exact_allocation( + self, + *, + fiscal_year: int, + period_key: str, + department_id: str | None, + department_name: str, + cost_center: str | None, + project_code: str | None, + subject_code: str, + ) -> BudgetAllocation | None: + rows = self.db.scalars( + select(BudgetAllocation) + .where(BudgetAllocation.fiscal_year == fiscal_year) + .where(BudgetAllocation.period_key == period_key) + .where(BudgetAllocation.subject_code == subject_code) + ).all() + normalized_department_id = self._blank_to_none(department_id) + normalized_department_name = department_name.strip() + normalized_cost_center = self._blank_to_none(cost_center) + normalized_project_code = self._blank_to_none(project_code) + for row in rows: + if row.project_code != normalized_project_code: + continue + if normalized_department_id and row.department_id == normalized_department_id: + return row + if normalized_cost_center and row.cost_center == normalized_cost_center: + return row + if row.department_name == normalized_department_name: + return row + return None + + def _find_active_reservation(self, *, source_type: str, source_id: str) -> BudgetReservation | None: + return self.db.scalar( + select(BudgetReservation) + .where(BudgetReservation.source_type == source_type) + .where(BudgetReservation.source_id == source_id) + .where(BudgetReservation.source_status == "active") + .order_by(BudgetReservation.created_at.desc()) + .limit(1) + ) + + def _find_active_reservations(self, *, source_type: str, source_id: str) -> list[BudgetReservation]: + return list( + self.db.scalars( + select(BudgetReservation) + .where(BudgetReservation.source_type == source_type) + .where(BudgetReservation.source_id == source_id) + .where(BudgetReservation.source_status == "active") + ).all() + ) + + def _seed_default_allocations(self) -> None: + units = list( + self.db.scalars( + select(OrganizationUnit).where(OrganizationUnit.unit_type == "department") + ).all() + ) + if not units: + return + year = datetime.now(UTC).year + for unit in units: + for quarter in range(1, 5): + period_key = f"{year}Q{quarter}" + for subject_code, amount in DEFAULT_SUBJECT_AMOUNTS.items(): + allocation = BudgetAllocation( + budget_no=self._make_no("BUD"), + fiscal_year=year, + period_type="quarter", + period_key=period_key, + department_id=unit.id, + department_name=unit.name, + cost_center=unit.cost_center, + project_code=None, + subject_code=subject_code, + subject_name=self._subject_label(subject_code), + original_amount=amount, + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + description="系统初始化预算池额度", + created_by="system", + updated_by="system", + ) + self.db.add(allocation) + self.db.flush() + self._record_transaction( + allocation=allocation, + transaction_type="init", + amount=amount, + before_available=Decimal("0.00"), + after_available=amount, + source_type="budget_seed", + source_id=allocation.id, + source_no=allocation.budget_no, + operator="system", + reason="系统初始化预算池额度", + ) + self.db.flush() + + def _create_fallback_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation: + fiscal_year, period_key = self._period_from_claim(claim) + subject_code = self._subject_code_from_claim(claim) + allocation = BudgetAllocation( + budget_no=self._make_no("BUD"), + fiscal_year=fiscal_year, + period_type="quarter", + period_key=period_key, + department_id=claim.department_id, + department_name=str(claim.department_name or "未归属部门").strip() or "未归属部门", + cost_center=self._resolve_claim_cost_center(claim), + project_code=claim.project_code, + subject_code=subject_code, + subject_name=self._subject_label(subject_code), + original_amount=DEFAULT_SUBJECT_AMOUNTS.get(subject_code, Decimal("100000.00")), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + description="测试或演示环境自动补齐预算池额度", + created_by="system", + updated_by="system", + ) + self.db.add(allocation) + self.db.flush() + self._record_transaction( + allocation=allocation, + transaction_type="init", + amount=allocation.original_amount, + before_available=Decimal("0.00"), + after_available=allocation.original_amount, + source_type="budget_seed", + source_id=allocation.id, + source_no=allocation.budget_no, + operator="system", + reason="自动补齐预算池额度", + ) + self.db.flush() + return allocation + + def _budget_table_empty(self) -> bool: + return self.db.scalar(select(BudgetAllocation.id).limit(1)) is None + + def _record_transaction( + self, + *, + allocation: BudgetAllocation, + transaction_type: str, + amount: Decimal, + before_available: Decimal, + after_available: Decimal, + source_type: str, + source_id: str, + source_no: str, + operator: str | None, + reason: str | None, + reservation: BudgetReservation | None = None, + context_json: dict[str, Any] | None = None, + ) -> BudgetTransaction: + transaction = BudgetTransaction( + transaction_no=self._make_no("BTX"), + allocation_id=allocation.id, + reservation_id=reservation.id if reservation is not None else None, + source_type=source_type, + source_id=source_id, + source_no=source_no, + transaction_type=transaction_type, + amount=self._money(amount), + before_available_amount=self._money(before_available), + after_available_amount=self._money(after_available), + operator=operator, + reason=reason, + context_json=context_json or {}, + ) + self.db.add(transaction) + return transaction + + @staticmethod + def _build_budget_flag( + *, + event_type: str, + severity: str, + label: str, + message: str, + amount: Decimal, + extra: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload = { + "source": "budget_control", + "event_type": event_type, + "severity": severity, + "label": label, + "message": message, + "amount": str(amount), + "created_at": datetime.now(UTC).isoformat(), + } + payload.update(extra or {}) + return payload + + def _build_operation_flag( + self, + allocation: BudgetAllocation, + *, + event_type: str, + label: str, + message: str, + amount: Decimal, + severity: str = "info", + reservation_id: str | None = None, + transaction_id: str | None = None, + extra: dict[str, Any] | None = None, + ) -> dict[str, Any]: + balance = self.get_balance(allocation) + payload = self._build_budget_flag( + event_type=event_type, + severity=severity, + label=label, + message=message, + amount=amount, + extra={ + "allocation_id": allocation.id, + "budget_no": allocation.budget_no, + "subject_code": allocation.subject_code, + "subject_name": allocation.subject_name, + "available_amount": str(balance.available_amount), + "reserved_amount": str(balance.reserved_amount), + "consumed_amount": str(balance.consumed_amount), + **(extra or {}), + }, + ) + if reservation_id: + payload["reservation_id"] = reservation_id + if transaction_id: + payload["transaction_id"] = transaction_id + return payload + + @staticmethod + def _money(value: Any) -> Decimal: + return Decimal(str(value or "0")).quantize(Decimal("0.01")) + + @staticmethod + def _percent(value: Any) -> Decimal: + return Decimal(str(value or "0")).quantize(Decimal("0.01")) + + @staticmethod + def _blank_to_none(value: str | None) -> str | None: + text = str(value or "").strip() + return text or None + + @staticmethod + def _make_no(prefix: str) -> str: + return f"{prefix}-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}" + + @staticmethod + def _normalize_period_type(value: str | None) -> str: + text = str(value or "").strip().lower() + return text if text in {"month", "quarter", "year"} else "quarter" + + @staticmethod + def _normalize_period_key(year: int, value: str | None) -> str: + text = str(value or "").strip().upper().replace("年", "").replace("第", "").replace("季度", "") + if text.startswith(str(year)) and "Q" in text: + return text + if text in {"Q1", "Q2", "Q3", "Q4"}: + return f"{year}{text}" + return text or f"{year}Q1" + + @staticmethod + def _quarter_key(year: int, month: int) -> str: + quarter = ((max(1, min(month, 12)) - 1) // 3) + 1 + return f"{year}Q{quarter}" + + def _period_from_claim(self, claim: ExpenseClaim) -> tuple[int, str]: + occurred_at = claim.occurred_at or claim.submitted_at or datetime.now(UTC) + return occurred_at.year, self._quarter_key(occurred_at.year, occurred_at.month) + + def _subject_code_from_claim(self, claim: ExpenseClaim) -> str: + expense_type = str(claim.expense_type or "").strip().lower() + if expense_type.endswith("_application"): + expense_type = expense_type.removesuffix("_application") + expense_type = SUBJECT_CODE_ALIASES.get(expense_type, expense_type) + if expense_type in DEFAULT_SUBJECT_AMOUNTS or expense_type in EXPENSE_TYPE_LABELS: + return expense_type + resolved = resolve_expense_type_code_from_text(expense_type) + if resolved: + return SUBJECT_CODE_ALIASES.get(resolved, resolved) + return resolved or expense_type or "other" + + @staticmethod + def _normalize_subject_code(value: str | None) -> str: + text = str(value or "").strip().lower() + if text.endswith("_application"): + text = text.removesuffix("_application") + text = SUBJECT_CODE_ALIASES.get(text, text) + resolved = resolve_expense_type_code_from_text(text) + if resolved: + return SUBJECT_CODE_ALIASES.get(resolved, resolved) + return text or "other" + + @staticmethod + def _is_supported_budget_subject(subject_code: str | None) -> bool: + return str(subject_code or "").strip().lower() in SUPPORTED_BUDGET_SUBJECT_CODES + + def _claim_uses_budget_control(self, claim: ExpenseClaim) -> bool: + return self._is_supported_budget_subject(self._subject_code_from_claim(claim)) + + @staticmethod + def _subject_label(code: str) -> str: + return BUDGET_SUBJECT_LABELS.get(code, EXPENSE_TYPE_LABELS.get(code, code)) + + @staticmethod + def _normalize_control_action(value: str | None) -> str: + text = str(value or "").strip().lower() + if text in {"block", "control", "管控", "强控"}: + return "block" + if text in {"warn", "warning", "提醒", "预警"}: + return "warn" + if text in {"allow", "normal", "正常", "放行"}: + return "allow" + return "block" + + def _resolve_claim_cost_center(self, claim: ExpenseClaim) -> str | None: + employee = getattr(claim, "employee", None) + if employee is not None: + cost_center = self._blank_to_none(getattr(employee, "cost_center", None)) + if cost_center: + return cost_center + organization_unit = getattr(employee, "organization_unit", None) + if organization_unit is not None: + cost_center = self._blank_to_none(getattr(organization_unit, "cost_center", None)) + if cost_center: + return cost_center + return None + + def _claim_context(self, claim: ExpenseClaim) -> dict[str, Any]: + fiscal_year, period_key = self._period_from_claim(claim) + return { + "claim_id": claim.id, + "claim_no": claim.claim_no, + "employee_id": claim.employee_id, + "employee_name": claim.employee_name, + "department_id": claim.department_id, + "department_name": claim.department_name, + "cost_center": self._resolve_claim_cost_center(claim), + "project_code": claim.project_code, + "expense_type": claim.expense_type, + "subject_code": self._subject_code_from_claim(claim), + "fiscal_year": fiscal_year, + "period_key": period_key, + } diff --git a/server/src/app/services/budget_types.py b/server/src/app/services/budget_types.py new file mode 100644 index 0000000..996b4eb --- /dev/null +++ b/server/src/app/services/budget_types.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from app.schemas.budget import BudgetBalanceRead + + +DEFAULT_SUBJECT_AMOUNTS: dict[str, Decimal] = { + "travel": Decimal("600000.00"), + "meal": Decimal("420000.00"), + "office": Decimal("180000.00"), + "communication": Decimal("120000.00"), +} + +BUDGET_SUBJECT_LABELS = { + "travel": "差旅", + "communication": "通信", + "meal": "招待费", + "office": "办公用品", +} +SUPPORTED_BUDGET_SUBJECT_CODES = frozenset(DEFAULT_SUBJECT_AMOUNTS) +SUBJECT_CODE_ALIASES = { + "entertainment": "meal", + "purchase": "office", +} + +MUTATING_TRANSACTION_TYPES = {"adjust", "reserve", "release", "consume", "rollback"} + + +class BudgetControlError(ValueError): + def __init__(self, reasons: list[str], *, flags: list[dict[str, Any]] | None = None) -> None: + self.reasons = [reason for reason in reasons if reason] + self.flags = list(flags or []) + super().__init__(";".join(self.reasons) or "预算校验未通过") + + +@dataclass(frozen=True) +class BudgetBalance: + total_amount: Decimal + reserved_amount: Decimal + consumed_amount: Decimal + available_amount: Decimal + usage_rate: Decimal + + def to_read(self) -> BudgetBalanceRead: + return BudgetBalanceRead( + total_amount=self.total_amount, + reserved_amount=self.reserved_amount, + consumed_amount=self.consumed_amount, + available_amount=self.available_amount, + usage_rate=self.usage_rate, + ) diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py index 50b74df..d1b8ff3 100644 --- a/server/src/app/services/employee.py +++ b/server/src/app/services/employee.py @@ -128,6 +128,7 @@ class EmployeeService: for status in STATUS_ORDER ] + visible_role_codes = {item["role_code"] for item in ROLE_DEFINITIONS} role_options = [ EmployeeRoleOptionRead( id=role.role_code, @@ -137,6 +138,7 @@ class EmployeeService: permissions=list(ROLE_PERMISSION_MAP.get(role.role_code, [])), ) for role in self._sorted_roles(self.repository.list_roles()) + if role.role_code in visible_role_codes ] canonical_department_codes = set(CANONICAL_DEPARTMENT_CODES) @@ -470,6 +472,11 @@ class EmployeeService: def _seed_roles(self) -> None: existing_by_code = {role.role_code: role for role in self.repository.list_roles()} + legacy_auditor = existing_by_code.get("auditor") + if legacy_auditor is not None and "budget_monitor" not in existing_by_code: + legacy_auditor.role_code = "budget_monitor" + existing_by_code["budget_monitor"] = legacy_auditor + existing_by_code.pop("auditor", None) for definition in ROLE_DEFINITIONS: role = existing_by_code.get(definition["role_code"]) @@ -481,6 +488,9 @@ class EmployeeService: ) self.db.add(role) existing_by_code[role.role_code] = role + else: + role.name = definition["name"] + role.description = definition["description"] self.db.flush() diff --git a/server/src/app/services/employee_seed_part1.py b/server/src/app/services/employee_seed_part1.py index d1fd4f7..6934128 100644 --- a/server/src/app/services/employee_seed_part1.py +++ b/server/src/app/services/employee_seed_part1.py @@ -99,7 +99,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [ "spotlight": False, "updated_at": "2026-05-07 09:35", "last_sync_at": "2026-05-07 09:10", - "role_codes": ["finance", "auditor"], + "role_codes": ["finance", "budget_monitor"], }, { "employee_no": "E10289", @@ -143,7 +143,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [ "spotlight": False, "updated_at": "2026-05-05 09:18", "last_sync_at": "2026-05-05 09:18", - "role_codes": ["manager", "auditor"], + "role_codes": ["manager", "budget_monitor"], }, { "employee_no": "E11618", @@ -363,7 +363,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [ "spotlight": False, "updated_at": "2026-05-06 13:08", "last_sync_at": "2026-05-06 13:08", - "role_codes": ["user", "approver", "auditor"], + "role_codes": ["user", "approver", "budget_monitor"], }, { "employee_no": "E11991", diff --git a/server/src/app/services/employee_seed_part2.py b/server/src/app/services/employee_seed_part2.py index 772dcc5..1489b08 100644 --- a/server/src/app/services/employee_seed_part2.py +++ b/server/src/app/services/employee_seed_part2.py @@ -87,7 +87,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [ "spotlight": True, "updated_at": "2026-05-07 09:52", "last_sync_at": "2026-05-07 09:52", - "role_codes": ["auditor", "finance"], + "role_codes": ["budget_monitor", "finance"], "history": [ { "action": "更新审计观察范围", @@ -121,7 +121,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [ "spotlight": False, "updated_at": "2026-05-07 08:58", "last_sync_at": "2026-05-07 08:40", - "role_codes": ["auditor"], + "role_codes": ["budget_monitor"], }, { "employee_no": "E12688", @@ -385,7 +385,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [ "spotlight": False, "updated_at": "2026-05-03 13:18", "last_sync_at": "2026-05-03 13:18", - "role_codes": ["auditor"], + "role_codes": ["budget_monitor"], }, { "employee_no": "E12790", @@ -407,6 +407,6 @@ EMPLOYEE_DEFINITIONS_PART_2 = [ "spotlight": False, "updated_at": "2026-05-06 08:56", "last_sync_at": "2026-05-06 08:56", - "role_codes": ["user", "auditor"], + "role_codes": ["user", "budget_monitor"], }, ] diff --git a/server/src/app/services/employee_seed_roles.py b/server/src/app/services/employee_seed_roles.py index 80765a4..5634f2c 100644 --- a/server/src/app/services/employee_seed_roles.py +++ b/server/src/app/services/employee_seed_roles.py @@ -5,7 +5,7 @@ ROLE_DISPLAY_ORDER = { "finance": 2, "approver": 3, "executive": 4, - "auditor": 5, + "budget_monitor": 5, "user": 6, } @@ -13,7 +13,7 @@ ROLE_DEFINITIONS = [ { "role_code": "user", "name": "使用者", - "description": "可以发起报销、查看个人单据和使用 AI 助手。", + "description": "可以发起费用申请、报销、查看个人单据和使用 AI 助手。", }, { "role_code": "finance", @@ -27,8 +27,8 @@ ROLE_DEFINITIONS = [ }, { "role_code": "executive", - "name": "高级管理人员", - "description": "可以查看跨部门数据看板与关键审批结果。", + "name": "高级财务人员", + "description": "可以查看跨部门预算、经营看板与关键财务审批结果。", }, { "role_code": "approver", @@ -36,17 +36,17 @@ ROLE_DEFINITIONS = [ "description": "可以处理审批中心中的待审单据。", }, { - "role_code": "auditor", - "name": "审计观察员", - "description": "可以查看变更记录和权限调整历史。", + "role_code": "budget_monitor", + "name": "预算监控员", + "description": "可以查看本部门预算执行、预警和占用情况。", }, ] ROLE_PERMISSION_MAP = { - "user": ["可发起差旅申请与报销", "可查看个人单据与票据识别结果"], + "user": ["可发起费用申请与报销", "可查看个人单据与票据识别结果"], "finance": ["可处理财务复核任务", "可查看风险校验与财务知识库"], "manager": ["可维护员工档案与组织结构", "可配置系统角色与访问边界"], - "executive": ["可查看跨部门经营看板", "可处理高金额报销最终审批"], + "executive": ["可查看全部部门预算", "可维护预算额度与处理关键财务审批"], "approver": ["可处理本部门待审单据", "可查看审批链路与 SLA 状态"], - "auditor": ["可查看权限变更与审计留痕", "可导出员工权限观察记录"], + "budget_monitor": ["可查看本部门预算执行", "可跟踪本部门预算预警与占用"], } diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index e3039aa..01f1bd4 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -13,7 +13,7 @@ from app.models.organization import OrganizationUnit PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} -ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive", "auditor"} +ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"} APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} CLAIM_DELETE_ROLE_CODES = {"executive"} ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") diff --git a/server/src/app/services/expense_claim_budget_flow.py b/server/src/app/services/expense_claim_budget_flow.py new file mode 100644 index 0000000..b098e2a --- /dev/null +++ b/server/src/app/services/expense_claim_budget_flow.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any + +from app.api.deps import CurrentUserContext +from app.models.financial_record import ExpenseClaim +from app.services.budget import BudgetService + + +class ExpenseClaimBudgetFlowMixin: + def _reserve_budget_for_submission( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + is_application_claim: bool, + ) -> list[dict[str, Any]]: + source_type = "application" if is_application_claim else "claim" + return BudgetService(self.db).reserve_for_claim( + claim, + source_type=source_type, + operator=self._resolve_budget_operator(current_user), + ) + + def _release_budget_for_return( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + *, + reason: str, + ) -> list[dict[str, Any]]: + is_application_claim = self._is_expense_application_claim(claim) + source_type = "application" if is_application_claim else "claim" + return BudgetService(self.db).release_for_claim( + claim, + source_type=source_type, + operator=self._resolve_budget_operator(current_user), + reason=reason, + ) + + def _release_budget_for_delete( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + ) -> None: + is_application_claim = self._is_expense_application_claim(claim) + source_type = "application" if is_application_claim else "claim" + BudgetService(self.db).release_for_claim( + claim, + source_type=source_type, + operator=self._resolve_budget_operator(current_user), + reason="单据删除释放预算预占", + ) + + def _consume_budget_for_finance_approval( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + ) -> dict[str, Any] | None: + return BudgetService(self.db).consume_for_claim( + claim, + operator=self._resolve_budget_operator(current_user), + reason="财务终审通过核销预算", + ) + + def _transfer_application_budget_to_reimbursement( + self, + *, + application_claim: ExpenseClaim, + draft_claim: ExpenseClaim, + current_user: CurrentUserContext, + ) -> dict[str, Any] | None: + return BudgetService(self.db).transfer_application_reservation( + application_claim=application_claim, + draft_claim=draft_claim, + operator=self._resolve_budget_operator(current_user), + ) + + @staticmethod + def _append_budget_flags( + risk_flags: list[Any] | None, + budget_flags: list[dict[str, Any]] | dict[str, Any] | None, + ) -> list[Any]: + if budget_flags is None: + return list(risk_flags or []) + if isinstance(budget_flags, dict): + next_flags = [budget_flags] + else: + next_flags = list(budget_flags or []) + if not next_flags: + return list(risk_flags or []) + return [*list(risk_flags or []), *next_flags] + + @staticmethod + def _resolve_budget_operator(current_user: CurrentUserContext) -> str: + return current_user.name or current_user.username or "system" diff --git a/server/src/app/services/expense_claim_platform_risk.py b/server/src/app/services/expense_claim_platform_risk.py index 5682bb7..72f1331 100644 --- a/server/src/app/services/expense_claim_platform_risk.py +++ b/server/src/app/services/expense_claim_platform_risk.py @@ -10,9 +10,11 @@ from app.models.agent_asset import AgentAsset from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY +from app.services.budget import BudgetService from app.services.expense_rule_runtime import ( RuntimeTravelPolicy, ) +from app.services.expense_type_keywords import resolve_expense_type_code_from_text from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor @@ -29,6 +31,16 @@ class ExpenseClaimPlatformRiskMixin: return {"flags": [], "blocking_reasons": []} contexts = self._build_claim_attachment_contexts(claim) + contexts.append( + { + "index": len(contexts) + 1, + "item": None, + "document_info": {}, + "ocr_text": "", + "ocr_summary": "", + "budget_context": BudgetService(self.db).build_claim_budget_context(claim), + } + ) flags: list[dict[str, Any]] = [] blocking_reasons: list[str] = [] @@ -163,24 +175,18 @@ class ExpenseClaimPlatformRiskMixin: if min_attachments and int(claim.invoice_count or 0) < min_attachments and not contexts: return False - expense_types = { - str(claim.expense_type or "").strip().lower(), - *{ - str(item.item_type or "").strip().lower() - for item in list(claim.items or []) - if str(item.item_type or "").strip() - }, - } + expense_types = self._normalize_expense_type_values( + str(claim.expense_type or ""), + *[str(item.item_type or "") for item in list(claim.items or [])], + ) domains = { str(value or "").strip().lower() for value in list(applies_to.get("domains") or []) if str(value or "").strip() } - configured_expense_types = { - str(value or "").strip().lower() - for value in list(applies_to.get("expense_types") or []) - if str(value or "").strip() - } + configured_expense_types = self._normalize_expense_type_values( + *[str(value or "") for value in list(applies_to.get("expense_types") or [])] + ) if configured_expense_types and not (expense_types & configured_expense_types): return False @@ -193,6 +199,19 @@ class ExpenseClaimPlatformRiskMixin: return True + @staticmethod + def _normalize_expense_type_values(*values: str) -> set[str]: + normalized: set[str] = set() + for value in values: + raw = str(value or "").strip() + if not raw: + continue + normalized.add(raw.lower()) + resolved = resolve_expense_type_code_from_text(raw) + if resolved: + normalized.add(resolved) + return normalized + def _risk_domains_match_claim( self, domains: set[str], @@ -213,6 +232,9 @@ class ExpenseClaimPlatformRiskMixin: } ) + if "expense" in domains: + return True + if "travel" in domains: if expense_types & {"travel", "hotel", "transport"}: return True diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 3d68c5a..611d7af 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -41,6 +41,7 @@ from app.services.expense_claim_application_handoff import ExpenseClaimApplicati from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin +from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin @@ -127,6 +128,7 @@ from app.services.ocr import OcrService class ExpenseClaimService( ExpenseClaimApplicationHandoffMixin, + ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, ExpenseClaimDraftFlowMixin, @@ -437,6 +439,11 @@ class ExpenseClaimService( if missing_fields: raise ExpenseClaimSubmissionBlockedError(missing_fields) + budget_flags = self._reserve_budget_for_submission( + claim, + current_user, + is_application_claim=is_application_claim, + ) before_json = self._serialize_claim(claim) if is_application_claim: submitted_at = datetime.now(UTC) @@ -453,7 +460,7 @@ class ExpenseClaimService( "event_type": "expense_application_submission", "severity": "info", "label": "申请提交", - "message": "费用申请已提交至直属领导审批,并同步纳入预算管理口径。", + "message": "费用申请已提交至直属领导审批,请等待审核结果。", "previous_status": str(claim.status or "").strip(), "previous_approval_stage": str(claim.approval_stage or "").strip(), "next_status": "submitted", @@ -462,9 +469,10 @@ class ExpenseClaimService( } claim.status = "submitted" claim.approval_stage = "直属领导审批" - claim.risk_flags_json = [*preserved_flags, submit_flag] + claim.risk_flags_json = self._append_budget_flags([*preserved_flags, submit_flag], budget_flags) claim.submitted_at = submitted_at else: + claim.risk_flags_json = self._append_budget_flags(claim.risk_flags_json, budget_flags) review_result = self._run_ai_submission_review(claim) claim.status = str(review_result.get("status") or "supplement") @@ -520,11 +528,12 @@ class ExpenseClaimService( if not self._access_policy.has_claim_delete_access(current_user): self._ensure_draft_claim(claim) if not self._access_policy.is_claim_owned_by_current_user(claim, current_user): - raise ValueError("只有高级管理人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。") + raise ValueError("只有高级财务人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。") before_json = self._serialize_claim(claim) resource_id = claim.id + self._release_budget_for_delete(claim, current_user) self._attachment_storage.delete_claim_files(claim) self.db.delete(claim) self.db.commit() @@ -554,7 +563,7 @@ class ExpenseClaimService( return None if not self._access_policy.can_return_claim(current_user, claim): - raise ValueError("只有财务人员、高级管理人员或当前审批人可以退回报销单。") + raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。") normalized_status = str(claim.status or "").strip().lower() if normalized_status == "draft": @@ -619,10 +628,18 @@ class ExpenseClaimService( if unknown_reason_codes: return_flag["unknown_reason_codes"] = unknown_reason_codes + budget_flags = self._release_budget_for_return( + claim, + current_user, + reason=message, + ) claim.status = "returned" claim.approval_stage = "待提交" claim.submitted_at = None - claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag] + claim.risk_flags_json = self._append_budget_flags( + [*list(claim.risk_flags_json or []), return_flag], + budget_flags, + ) self.db.commit() self.db.refresh(claim) @@ -691,6 +708,11 @@ class ExpenseClaimService( before_json = self._serialize_claim(claim) operator = self._access_policy.resolve_current_user_display_name(current_user) + budget_flags: list[dict[str, Any]] = [] + if approval_source == "finance_approval" and not is_application_claim: + consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user) + if consumed_budget_flag is not None: + budget_flags.append(consumed_budget_flag) approval_flag = { "source": approval_source, "event_type": event_type, @@ -723,7 +745,21 @@ class ExpenseClaimService( approval_flag=approval_flag, operator=operator, ) - claim.risk_flags_json = [*list(claim.risk_flags_json or []), approval_flag] + transferred_budget_flag = self._transfer_application_budget_to_reimbursement( + application_claim=claim, + draft_claim=generated_draft, + current_user=current_user, + ) + if transferred_budget_flag is not None: + budget_flags.append(transferred_budget_flag) + generated_draft.risk_flags_json = self._append_budget_flags( + generated_draft.risk_flags_json, + transferred_budget_flag, + ) + claim.risk_flags_json = self._append_budget_flags( + [*list(claim.risk_flags_json or []), approval_flag], + budget_flags, + ) self.db.commit() self.db.refresh(claim) diff --git a/server/src/app/services/finance_rule_catalog.py b/server/src/app/services/finance_rule_catalog.py new file mode 100644 index 0000000..9fd6c13 --- /dev/null +++ b/server/src/app/services/finance_rule_catalog.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from app.services.agent_asset_spreadsheet import COMPANY_TRAVEL_EXPENSE_RULE_CODE + +DEPRECATED_FINANCE_RULE_CODES = ( + "rule.expense.company_transport_hotel_detail_reimbursement", + "rule.expense.company_meal_expense_reimbursement", + "rule.expense.company_marketing_expense_reimbursement", + "rule.expense.company_meeting_expense_reimbursement", + "rule.expense.company_office_expense_reimbursement", + "rule.expense.company_training_expense_reimbursement", + "rule.expense.company_software_expense_reimbursement", + "rule.expense.company_welfare_expense_reimbursement", +) + +DEPRECATED_FINANCE_RULE_REPLACEMENTS = { + "rule.expense.company_transport_hotel_detail_reimbursement": ( + COMPANY_TRAVEL_EXPENSE_RULE_CODE + ), +} diff --git a/server/src/app/services/risk_rule_template_executor.py b/server/src/app/services/risk_rule_template_executor.py index bd2d345..ddbfdf5 100644 --- a/server/src/app/services/risk_rule_template_executor.py +++ b/server/src/app/services/risk_rule_template_executor.py @@ -519,6 +519,8 @@ class RiskRuleTemplateExecutor: ) if normalized.startswith("attachment."): return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts) + if normalized.startswith("budget."): + return self._resolve_budget_values(normalized.removeprefix("budget."), contexts) return [] @staticmethod @@ -566,6 +568,19 @@ class RiskRuleTemplateExecutor: values.extend(self._scan_document_values(document_info, field_key)) return self._normalize_values(values) + def _resolve_budget_values(self, field_key: str, contexts: list[dict[str, Any]]) -> list[str]: + values: list[Any] = [] + for context in contexts: + if not isinstance(context, dict): + continue + budget_context = context.get("budget_context") + if not isinstance(budget_context, dict): + continue + for key in {field_key, field_key.replace("_", ""), field_key.replace("-", "_")}: + if key in budget_context: + values.append(budget_context.get(key)) + return self._normalize_values(values) + def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]: values: list[Any] = [] for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}: diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 778fc45..326c152 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -164,7 +164,7 @@ class UserAgentApplicationMixin: f"申请单号:{application_no}", "申请信息:\n" + self._build_application_summary_table(facts), f"当前状态:{manager_name}审核中。", - "预算处理:用户预估费用已作为预算占用参考,等待领导审核确认。", + "费用预估:预计费用已随申请提交,等待领导审核确认。", ] ) diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index de12707..6a41ae7 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -32,6 +32,7 @@ from app.schemas.agent_asset import ( AgentAssetVersionCreate, ) from app.schemas.reimbursement import TravelReimbursementCalculatorRequest +from app.services import agent_foundation as agent_foundation_module from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, @@ -43,6 +44,9 @@ from app.services.agent_assets import AgentAssetService from app.services.agent_runs import AgentRunService from app.services.audit import AuditLogService from app.services.expense_rule_runtime import ExpenseRuleRuntimeService +from app.services.finance_rule_catalog import ( + DEPRECATED_FINANCE_RULE_CODES, +) from app.services.settings import OnlyOfficeRuntimeConfig from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService @@ -80,6 +84,7 @@ def isolate_rule_file_storage(tmp_path, monkeypatch) -> None: def build_session() -> Session: + agent_foundation_module._foundation_ready_keys.clear() engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, @@ -163,12 +168,59 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None: travel_config = travel_rule.config_json or {} communication_config = communication_rule.config_json or {} - assert travel_rule.scenario_json == ["差旅"] - assert travel_config["scenario_category"] == "差旅" - assert travel_config["ai_review_category"] == "差旅" - assert communication_rule.scenario_json == ["费用科目"] - assert communication_config["scenario_category"] == "费用科目" - assert communication_config["ai_review_category"] == "费用科目" + assert travel_rule.scenario_json == ["差旅费"] + assert travel_config["scenario_category"] == "差旅费" + assert travel_config["ai_review_category"] == "差旅费" + assert communication_rule.scenario_json == ["通信费"] + assert communication_config["scenario_category"] == "通信费" + assert communication_config["ai_review_category"] == "通信费" + + +def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None: + with build_session() as db: + service = AgentAssetService(db) + service.list_assets(asset_type=AgentAssetType.RULE.value) + + for code in DEPRECATED_FINANCE_RULE_CODES: + asset = db.scalar(select(AgentAsset).where(AgentAsset.code == code)) + assert asset is None or asset.config_json["tag"] == "废弃规则" + + +def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None: + with build_session() as db: + service = AgentAssetService(db) + service.list_assets(asset_type=AgentAssetType.RULE.value) + + budget_rule = db.scalar( + select(AgentAsset).where( + AgentAsset.code == "risk.budget.available_balance_insufficient" + ) + ) + marketing_rule = db.scalar( + select(AgentAsset).where( + AgentAsset.code == "risk.application.marketing_without_campaign" + ) + ) + + assert budget_rule is not None + assert "差旅费" in budget_rule.scenario_json + assert "市场推广费" in budget_rule.scenario_json + assert "软件服务费" in budget_rule.scenario_json + assert budget_rule.config_json["budget_required"] is True + assert "marketing" in budget_rule.config_json["expense_types"] + assert budget_rule.config_json["business_stage"] == [ + "expense_application", + "reimbursement", + "budget_execution", + ] + assert budget_rule.config_json["finance_rule_code"] == "budget.execution.policy" + + assert marketing_rule is not None + assert marketing_rule.scenario_json == ["市场推广费"] + assert marketing_rule.config_json["finance_rule_code"] == "expense.application.policy" + assert marketing_rule.config_json["finance_rule_sheet"] == "费用申请前置规则" + assert marketing_rule.config_json["expense_types"] == ["marketing"] + assert marketing_rule.config_json["budget_required"] is True def test_agent_asset_service_can_activate_rule_after_review() -> None: diff --git a/server/tests/test_budget_endpoints.py b/server/tests/test_budget_endpoints.py new file mode 100644 index 0000000..616668d --- /dev/null +++ b/server/tests/test_budget_endpoints.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, datetime +from decimal import Decimal + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_db +from app.db.base import Base +from app.main import create_app +from app.models.budget import BudgetAllocation + + +def build_session_factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def build_client() -> tuple[TestClient, sessionmaker[Session]]: + session_factory = build_session_factory() + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + return TestClient(app), session_factory + + +def seed_budget_allocations(db: Session) -> None: + now = datetime.now(UTC) + db.add_all( + [ + BudgetAllocation( + id="budget-market-travel", + budget_no="BUD-MARKET-TRAVEL", + fiscal_year=2026, + period_type="quarter", + period_key="2026Q2", + department_id="dept-market", + department_name="市场部", + cost_center="CC-4100", + project_code=None, + subject_code="travel", + subject_name="差旅费", + original_amount=Decimal("50000.00"), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + created_at=now, + updated_at=now, + ), + BudgetAllocation( + id="budget-finance-office", + budget_no="BUD-FINANCE-OFFICE", + fiscal_year=2026, + period_type="quarter", + period_key="2026Q2", + department_id="dept-finance", + department_name="财务部", + cost_center="CC-2100", + project_code=None, + subject_code="office", + subject_name="办公费", + original_amount=Decimal("30000.00"), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + created_at=now, + updated_at=now, + ), + BudgetAllocation( + id="budget-market-software-hidden", + budget_no="BUD-MARKET-SOFTWARE", + fiscal_year=2026, + period_type="quarter", + period_key="2026Q2", + department_id="dept-market", + department_name="市场部", + cost_center="CC-4100", + project_code=None, + subject_code="software", + subject_name="软件服务费", + original_amount=Decimal("90000.00"), + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + created_at=now, + updated_at=now, + ), + ] + ) + db.commit() + + +def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + + response = client.get( + "/api/v1/budgets/summary", + headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert {item["department_name"] for item in payload["allocations"]} == {"市场部", "财务部"} + assert {item["subject_code"] for item in payload["allocations"]} == {"travel", "office"} + + +def test_budget_monitor_is_limited_to_own_department_scope() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + + response = client.get( + "/api/v1/budgets/summary?cost_center=CC-2100", + headers={ + "x-auth-username": "monitor@example.com", + "x-auth-role-codes": "budget_monitor", + "x-auth-cost-center": "CC-4100", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert [item["cost_center"] for item in payload["allocations"]] == ["CC-4100"] + assert [item["subject_code"] for item in payload["allocations"]] == ["travel"] + + +def test_finance_user_cannot_enter_budget_center() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + + response = client.get( + "/api/v1/budgets/summary", + headers={"x-auth-username": "finance@example.com", "x-auth-role-codes": "finance"}, + ) + + assert response.status_code == 403 + + +def test_budget_monitor_cannot_edit_and_admin_can_edit() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_budget_allocations(db) + + payload = { + "fiscal_year": 2026, + "period_type": "quarter", + "period_key": "2026Q2", + "department_id": "dept-sales", + "department_name": "销售部", + "cost_center": "CC-5100", + "project_code": None, + "subject_code": "travel", + "subject_name": "差旅费", + "original_amount": "20000.00", + "warning_threshold": "80.00", + "control_action": "block", + "description": "销售部差旅预算", + } + + monitor_response = client.post( + "/api/v1/budgets/allocations", + json=payload, + headers={ + "x-auth-username": "monitor@example.com", + "x-auth-role-codes": "budget_monitor", + "x-auth-cost-center": "CC-4100", + }, + ) + assert monitor_response.status_code == 403 + + admin_response = client.post( + "/api/v1/budgets/allocations", + json=payload, + headers={"x-auth-username": "admin", "x-auth-role-codes": "manager"}, + ) + assert admin_response.status_code == 201 + assert admin_response.json()["department_name"] == "销售部" diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index e005c13..dd5b6c8 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +import uuid from datetime import UTC, date, datetime, timedelta from decimal import Decimal @@ -11,6 +12,7 @@ from sqlalchemy.pool import StaticPool from app.api.deps import CurrentUserContext from app.db.base import Base +from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.organization import OrganizationUnit @@ -75,6 +77,37 @@ def _count_claims(db: Session) -> int: return int(db.query(ExpenseClaim).count()) +def _seed_budget_allocation( + db: Session, + *, + department_id: str | None, + department_name: str, + subject_code: str = "travel", + amount: Decimal = Decimal("50000.00"), + period_key: str = "2026Q2", +) -> BudgetAllocation: + allocation = BudgetAllocation( + budget_no=f"BUD-TEST-{uuid.uuid4().hex[:8]}", + fiscal_year=2026, + period_type="quarter", + period_key=period_key, + department_id=department_id, + department_name=department_name, + cost_center=None, + project_code=None, + subject_code=subject_code, + subject_name=subject_code, + original_amount=amount, + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + ) + db.add(allocation) + db.commit() + return allocation + + def test_validate_claim_for_submission_allows_office_claim_without_location() -> None: service = ExpenseClaimService.__new__(ExpenseClaimService) claim = build_claim(expense_type="office", location="待补充") @@ -2778,7 +2811,7 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None: for flag in returned.risk_flags_json ) - with pytest.raises(ValueError, match="只有高级管理人员可以删除"): + with pytest.raises(ValueError, match="只有高级财务人员可以删除"): service.delete_claim(claim_id, current_user) assert db.get(ExpenseClaim, claim_id) is not None @@ -3079,6 +3112,157 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch ) +def test_application_submit_reserves_budget_once() -> None: + current_user = CurrentUserContext( + username="application-budget-owner@example.com", + name="张三", + role_codes=["employee"], + is_admin=True, + ) + + with build_session() as db: + _seed_budget_allocation( + db, + department_id="dept-budget", + department_name="交付部", + amount=Decimal("50000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-BUDGET", + employee_id="emp-budget", + employee_name="张三", + department_id="dept-budget", + department_name="交付部", + project_code=None, + expense_type="travel_application", + reason="客户现场交付", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) + + assert submitted is not None + reservations = db.query(BudgetReservation).all() + assert len(reservations) == 1 + assert reservations[0].source_type == "application" + assert reservations[0].source_id == claim.id + assert reservations[0].amount == Decimal("12000.00") + transactions = db.query(BudgetTransaction).all() + assert any(item.transaction_type == "reserve" for item in transactions) + assert any( + isinstance(flag, dict) + and flag.get("source") == "budget_control" + and flag.get("event_type") == "budget_reserved" + for flag in submitted.risk_flags_json + ) + + +def test_application_submit_blocks_when_budget_insufficient_without_state_change() -> None: + current_user = CurrentUserContext( + username="application-budget-block@example.com", + name="张三", + role_codes=["employee"], + is_admin=True, + ) + + with build_session() as db: + _seed_budget_allocation( + db, + department_id="dept-budget-block", + department_name="交付部", + amount=Decimal("1000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-BLOCK", + employee_id="emp-budget-block", + employee_name="张三", + department_id="dept-budget-block", + department_name="交付部", + project_code=None, + expense_type="travel_application", + reason="客户现场交付", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + with pytest.raises(ValueError): + ExpenseClaimService(db).submit_claim(claim.id, current_user) + + db.refresh(claim) + assert claim.status == "draft" + assert db.query(BudgetReservation).count() == 0 + assert db.query(BudgetTransaction).count() == 0 + + +def test_application_submit_skips_budget_for_non_demo_subject() -> None: + current_user = CurrentUserContext( + username="application-budget-skip@example.com", + name="张三", + role_codes=["employee"], + is_admin=True, + ) + + with build_session() as db: + _seed_budget_allocation( + db, + department_id="dept-budget-skip", + department_name="交付部", + amount=Decimal("1000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-SKIP", + employee_id="emp-budget-skip", + employee_name="张三", + department_id="dept-budget-skip", + department_name="交付部", + project_code=None, + expense_type="software_application", + reason="采购演示软件服务", + location="深圳", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) + + assert submitted is not None + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" + assert db.query(BudgetReservation).count() == 0 + assert db.query(BudgetTransaction).count() == 0 + assert not any( + isinstance(flag, dict) and str(flag.get("source") or "").strip() == "budget_control" + for flag in submitted.risk_flags_json + ) + + def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None: current_user = CurrentUserContext( username="manager-application-approve@example.com", @@ -3175,6 +3359,80 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() - ) +def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None: + owner = CurrentUserContext( + username="application-budget-owner-approve@example.com", + name="张三", + role_codes=["employee"], + is_admin=False, + ) + manager_user = CurrentUserContext( + username="manager-application-budget@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="M-BUDGET-APP", + name="李经理", + email="manager-application-budget@example.com", + ) + employee = Employee( + employee_no="E-BUDGET-APP", + name="张三", + email="application-budget-owner-approve@example.com", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() + _seed_budget_allocation( + db, + department_id="dept-budget-transfer", + department_name="交付部", + amount=Decimal("50000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260525-TRANSFER", + employee_id=employee.id, + employee_name="张三", + department_id="dept-budget-transfer", + department_name="交付部", + project_code=None, + expense_type="travel_application", + reason="客户现场交付", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + service = ExpenseClaimService(db) + + service.submit_claim(claim.id, owner) + approved = service.approve_claim(claim.id, manager_user, opinion="同意申请") + generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() + reservation = db.query(BudgetReservation).one() + + assert approved is not None + assert reservation.source_type == "claim" + assert reservation.source_id == generated_draft.id + assert reservation.source_no == generated_draft.claim_no + assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all()) + assert any( + isinstance(flag, dict) + and flag.get("event_type") == "budget_reservation_transferred" + for flag in generated_draft.risk_flags_json + ) + + def test_direct_manager_approval_requires_leader_opinion() -> None: current_user = CurrentUserContext( username="manager-application-required-opinion@example.com", @@ -3232,6 +3490,70 @@ def test_direct_manager_approval_requires_leader_opinion() -> None: assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 +def test_finance_approve_reimbursement_consumes_budget_reservation() -> None: + current_user = CurrentUserContext( + username="finance-budget-approve@example.com", + name="财务", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + allocation = _seed_budget_allocation( + db, + department_id="dept-finance-budget", + department_name="交付部", + amount=Decimal("50000.00"), + ) + claim = ExpenseClaim( + claim_no="RE-20260525-BUDGET", + employee_id="emp-finance-budget", + employee_name="张三", + department_id="dept-finance-budget", + department_name="交付部", + project_code=None, + expense_type="travel", + reason="客户现场交付", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="财务审批", + risk_flags_json=[], + ) + db.add(claim) + db.flush() + reservation = BudgetReservation( + reservation_no=f"BRS-TEST-{uuid.uuid4().hex[:8]}", + allocation_id=allocation.id, + source_type="claim", + source_id=claim.id, + source_no=claim.claim_no, + source_status="active", + amount=Decimal("12000.00"), + context_json={}, + ) + db.add(reservation) + db.commit() + + approved = ExpenseClaimService(db).approve_claim(claim.id, current_user, opinion="同意入账") + + assert approved is not None + db.refresh(reservation) + assert reservation.source_status == "consumed" + assert reservation.consumed_amount == Decimal("12000.00") + assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1 + assert any( + isinstance(flag, dict) + and flag.get("source") == "budget_control" + and flag.get("event_type") == "budget_consumed" + for flag in approved.risk_flags_json + ) + + def test_finance_can_approve_claim_to_archive_stage() -> None: current_user = CurrentUserContext( username="finance-approve@example.com", diff --git a/server/tests/test_risk_rule_generation.py b/server/tests/test_risk_rule_generation.py index 8cef6ec..4187b88 100644 --- a/server/tests/test_risk_rule_generation.py +++ b/server/tests/test_risk_rule_generation.py @@ -10,7 +10,12 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool -from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentReviewStatus +from app.core.agent_enums import ( + AgentAssetDomain, + AgentAssetStatus, + AgentAssetType, + AgentReviewStatus, +) from app.db.base import Base from app.models.agent_asset import AgentAsset from app.models.employee import Employee @@ -27,6 +32,7 @@ from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.agent_assets import AgentAssetService from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin +from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin from app.services.risk_rule_flow_diagram import ( RiskRuleFlowDiagramRenderer, RiskRuleFlowDiagramSpec, @@ -384,6 +390,92 @@ def test_platform_risk_sync_skips_natural_language_drafts() -> None: ) +def test_stale_demo_risk_rules_are_marked_deprecated() -> None: + class FoundationRiskSyncProbe(AgentFoundationRiskRuleMixin): + def __init__(self, db: Session) -> None: + self.db = db + + with build_session() as db: + stale_asset = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code="risk.standard.training_per_capita_over_limit", + name="培训费人均超标准", + domain=AgentAssetDomain.EXPENSE.value, + owner="风控与审计部", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + config_json={ + "enabled": True, + "tag": "风险规则", + "source_ref": "费用管控 Demo 风险规则库", + }, + ) + kept_asset = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code="risk.standard.software_contract_missing", + name="软件服务费缺少合同", + domain=AgentAssetDomain.EXPENSE.value, + owner="风控与审计部", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + config_json={ + "enabled": True, + "tag": "风险规则", + "source_ref": "费用管控 Demo 风险规则库", + }, + ) + db.add_all([stale_asset, kept_asset]) + db.flush() + + FoundationRiskSyncProbe(db)._hide_stale_demo_risk_rules( + {"risk.standard.software_contract_missing"} + ) + + assert stale_asset.status == AgentAssetStatus.DISABLED.value + assert stale_asset.config_json["tag"] == "废弃风险规则" + assert stale_asset.config_json["enabled"] is False + assert kept_asset.status == AgentAssetStatus.ACTIVE.value + assert kept_asset.config_json["tag"] == "风险规则" + + +def test_platform_risk_applies_to_chinese_expense_type_labels() -> None: + class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin): + pass + + claim = ExpenseClaim( + claim_no="TEST-MARKETING-RISK", + employee_name="测试员工", + department_name="市场部", + expense_type="市场推广费", + reason="品牌投放活动", + amount=Decimal("20000.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime.now(UTC), + status="draft", + ) + manifest = { + "applies_to": { + "domains": ["expense"], + "expense_types": ["marketing"], + } + } + + assert PlatformRiskProbe()._risk_manifest_applies_to_claim( + manifest, + claim=claim, + contexts=[], + ) + + manifest["applies_to"]["expense_types"] = ["software"] + + assert not PlatformRiskProbe()._risk_manifest_applies_to_claim( + manifest, + claim=claim, + contexts=[], + ) + + def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None: renderer = RiskRuleFlowDiagramRenderer() @@ -449,7 +541,10 @@ def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> No "attachment.route_cities", "item.item_location", ], - "natural_language": "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。", + "natural_language": ( + "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;" + "未说明绕行、跨城或改签原因时标记风险。" + ), "condition_summary": "检查住宿城市、申报地点、行程城市是否一致", "keywords": ["绕行", "跨城", "改签", "变更"], }, @@ -659,7 +754,10 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning "home_city_fields": ["employee.location"], "exception_fields": ["claim.reason"], "exception_keywords": ["绕行", "跨城办事", "临时改签"], - "condition_summary": "A=票据路线城市,B=申报城市,C=员工常驻地,A中出现B∪C之外城市则命中。", + "condition_summary": ( + "A=票据路线城市,B=申报城市,C=员工常驻地," + "A中出现B∪C之外城市则命中。" + ), }, "outcomes": {"fail": {"severity": "high"}}, } @@ -700,7 +798,11 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning "document_info": { "route_cities": ["上海", "北京", "武汉"], "fields": [ - {"key": "route_cities", "label": "行程城市", "value": ["上海", "北京", "武汉"]} + { + "key": "route_cities", + "label": "行程城市", + "value": ["上海", "北京", "武汉"], + } ], }, "ocr_text": "上海 到 北京 到 武汉", diff --git a/web/src/assets/styles/views/budget-center-dialog.css b/web/src/assets/styles/views/budget-center-dialog.css index bb11be7..bb8acf0 100644 --- a/web/src/assets/styles/views/budget-center-dialog.css +++ b/web/src/assets/styles/views/budget-center-dialog.css @@ -63,11 +63,24 @@ .budget-edit-body { min-height: 0; padding: 18px 24px 16px; - overflow: auto; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.budget-edit-section { + display: flex; + flex-direction: column; + min-height: 0; +} + +.budget-edit-section:first-child { + flex-shrink: 0; } .budget-edit-section + .budget-edit-section { margin-top: 18px; + flex: 1; } .budget-edit-section h3 { @@ -76,6 +89,7 @@ font-size: 15px; line-height: 1.35; font-weight: 800; + flex-shrink: 0; } .budget-edit-form-grid { @@ -153,6 +167,9 @@ border: 1px solid #edf1f6; border-radius: 8px; overflow-x: auto; + overflow-y: auto; + flex: 1; + min-height: 0; } .budget-edit-table { @@ -243,27 +260,35 @@ font-size: 13px; font-weight: 800; cursor: pointer; + flex-shrink: 0; } .budget-edit-total { height: 42px; margin-top: 8px; padding: 0 14px; - display: grid; - grid-template-columns: 120px 1fr; + display: flex; + justify-content: flex-end; + gap: 12px; align-items: center; border: 1px solid #edf1f6; border-radius: 8px; background: #fbfcfe; + flex-shrink: 0; } -.budget-edit-total span, -.budget-edit-total strong { +.budget-edit-total span { color: #111827; font-size: 14px; font-weight: 800; } +.budget-edit-total strong { + color: #059669; + font-size: 16px; + font-weight: 800; +} + .budget-edit-foot { padding: 18px 24px 20px; display: flex; diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index 5445707..94d1813 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -252,6 +252,10 @@ gap: 10px; } +.budget-work-grid.single-department { + grid-template-columns: minmax(0, 1fr); +} + .budget-department-panel, .budget-table-panel, .budget-chart-panel, diff --git a/web/src/components/shared/ExpenseApplicationDialog.vue b/web/src/components/shared/ExpenseApplicationDialog.vue index 0ea8062..e7f380e 100644 --- a/web/src/components/shared/ExpenseApplicationDialog.vue +++ b/web/src/components/shared/ExpenseApplicationDialog.vue @@ -18,7 +18,7 @@ id="application-intent-input" v-model="draft" rows="5" - placeholder="例如:申请下周去北京做客户现场验收,差旅预算18000元" + placeholder="例如:申请下周去北京做客户现场验收,预计费用18000元" >
只有保存测试报告的风险规则,才能提交给高级管理人员审核。
+只有保存测试报告的风险规则,才能提交给高级财务人员审核。
为员工分配管理员、财务人员、使用者、高级管理人员等业务角色。
+为员工分配管理员、财务人员、使用者、高级财务人员、预算监控员等业务角色。