Compare commits
21 Commits
8814fe7dfa
...
codex/opti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f787ff38 | ||
|
|
2908dda024 | ||
|
|
e701fa01da | ||
|
|
f28d7e6d16 | ||
|
|
b183b0bd5e | ||
|
|
8f65661809 | ||
|
|
002bf4f756 | ||
|
|
f8b25a7ccc | ||
|
|
d7e98a58b9 | ||
|
|
57957d11a0 | ||
|
|
2574bc81d1 | ||
|
|
54ffef66d3 | ||
|
|
d460ee0fe7 | ||
|
|
9472813739 | ||
|
|
dc007f948a | ||
|
|
9db663e81f | ||
|
|
813ac81950 | ||
|
|
9902a3b968 | ||
|
|
29df4eee3b | ||
|
|
5106d286a1 | ||
|
|
64ec27949f |
@@ -32,7 +32,21 @@ services:
|
|||||||
- >
|
- >
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
||||||
python3 python3-pip python3-venv &&
|
python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra &&
|
||||||
|
printf '%s\n'
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<!DOCTYPE fontconfig SYSTEM "fonts.dtd">'
|
||||||
|
'<fontconfig>'
|
||||||
|
' <alias><family>SimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||||
|
' <alias><family>NSimSun</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||||
|
' <alias><family>KaiTi</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||||
|
' <alias><family>FangSong</family><prefer><family>Noto Serif CJK SC</family></prefer></alias>'
|
||||||
|
' <alias><family>SimHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||||
|
' <alias><family>DengXian</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||||
|
' <alias><family>Microsoft YaHei</family><prefer><family>Noto Sans CJK SC</family></prefer></alias>'
|
||||||
|
'</fontconfig>'
|
||||||
|
> /etc/fonts/local.conf &&
|
||||||
|
fc-cache -f &&
|
||||||
mkdir -p /run/sshd && /usr/sbin/sshd &&
|
mkdir -p /run/sshd && /usr/sbin/sshd &&
|
||||||
printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh &&
|
printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh &&
|
||||||
chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&
|
chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh &&
|
||||||
|
|||||||
BIN
server/rules/finance-rules/公司通信费报销规则.xlsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.expense.consecutive_transport_receipts",
|
||||||
|
"name": "连号交通票据",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "consecutive_receipts",
|
||||||
|
"ontology_signal": "consecutive_transport_receipts",
|
||||||
|
"evaluator": "consecutive_transport_receipts",
|
||||||
|
"applies_to": {
|
||||||
|
"expense_types": ["transport", "travel"],
|
||||||
|
"min_attachments": 2
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"invoice_no": "attachment.invoice_no"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"min_consecutive_count": 3
|
||||||
|
},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "medium",
|
||||||
|
"action": "manual_review"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 三、车辆交通 / 连号票集中报销",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.expense.entertainment_missing_detail",
|
||||||
|
"name": "招待费事由不完整",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "entertainment_detail",
|
||||||
|
"ontology_signal": "entertainment_missing_detail",
|
||||||
|
"evaluator": "entertainment_reason_missing",
|
||||||
|
"applies_to": {
|
||||||
|
"domains": ["meal"]
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"reason": "claim.reason_corpus"
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "medium",
|
||||||
|
"action": "warn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 三、餐费招待 / 业务招待无事由对象",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.expense.meal_localized_as_travel",
|
||||||
|
"name": "同城餐饮混入差旅",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "meal_travel_mix",
|
||||||
|
"ontology_signal": "meal_as_travel",
|
||||||
|
"evaluator": "meal_as_travel_same_city",
|
||||||
|
"applies_to": {
|
||||||
|
"domains": ["travel"]
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"declared": "claim.location",
|
||||||
|
"meal_city": "attachment.cities"
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "medium",
|
||||||
|
"action": "warn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 三、餐费招待 / 同城餐饮归集异地差旅",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/rules/risk-rules/risk.expense.reason_too_brief.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.expense.reason_too_brief",
|
||||||
|
"name": "报销事由过短",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "reason_quality",
|
||||||
|
"ontology_signal": "reason_too_brief",
|
||||||
|
"evaluator": "reason_too_brief",
|
||||||
|
"applies_to": {},
|
||||||
|
"inputs": {
|
||||||
|
"reason": "claim.reason_corpus"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"min_reason_length": 6
|
||||||
|
},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "medium",
|
||||||
|
"action": "warn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 通用 / 事由不足以支撑真实性判断",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.invoice.claimant_buyer_name_match",
|
||||||
|
"name": "报销人与发票抬头一致",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "identity_consistency",
|
||||||
|
"ontology_signal": "buyer_name_mismatch",
|
||||||
|
"evaluator": "identity_consistency",
|
||||||
|
"applies_to": {
|
||||||
|
"min_attachments": 1
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"claimant": "claim.employee_name",
|
||||||
|
"buyer": "attachment.buyer_name"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"allow_keywords": ["代报", "集团", "公司", "有限公司"]
|
||||||
|
},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "high",
|
||||||
|
"action": "manual_review"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 二、发票类 / 抬头错误",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
server/rules/risk-rules/risk.invoice.cross_year_invoice.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.invoice.cross_year_invoice",
|
||||||
|
"name": "跨年发票入账",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "cross_year_invoice",
|
||||||
|
"ontology_signal": "cross_year_invoice",
|
||||||
|
"evaluator": "cross_year_invoice",
|
||||||
|
"applies_to": {
|
||||||
|
"min_attachments": 1
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"invoice_date": "attachment.invoice_date",
|
||||||
|
"claim_date": ["claim.occurred_at", "item.item_date"]
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "medium",
|
||||||
|
"action": "warn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 二、发票类 / 跨年发票",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.invoice.document_expense_mismatch",
|
||||||
|
"name": "开票内容与报销场景不符",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "document_expense_mismatch",
|
||||||
|
"ontology_signal": "document_expense_mismatch",
|
||||||
|
"evaluator": "document_expense_mismatch",
|
||||||
|
"applies_to": {
|
||||||
|
"min_attachments": 1
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"document_type": "attachment.document_type",
|
||||||
|
"expense_type": ["claim.expense_type", "item.item_type"]
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "medium",
|
||||||
|
"action": "warn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 二、发票类 / 开票内容与业务不符",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/rules/risk-rules/risk.invoice.duplicate_invoice.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.invoice.duplicate_invoice",
|
||||||
|
"name": "发票重复报销",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "duplicate_invoice",
|
||||||
|
"ontology_signal": "duplicate_invoice",
|
||||||
|
"evaluator": "duplicate_invoice",
|
||||||
|
"applies_to": {
|
||||||
|
"min_attachments": 1
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"invoice_no": "attachment.invoice_no"
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "high",
|
||||||
|
"action": "block"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 二、发票类 / 重复报销",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.invoice.vague_goods_description",
|
||||||
|
"name": "发票品名过于笼统",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "vague_goods_description",
|
||||||
|
"ontology_signal": "vague_goods_description",
|
||||||
|
"evaluator": "vague_goods_description",
|
||||||
|
"applies_to": {
|
||||||
|
"expense_types": ["office", "other"],
|
||||||
|
"min_attachments": 1
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"ocr": "attachment.ocr_text"
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "medium",
|
||||||
|
"action": "warn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 二、发票类 / 品名笼统",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.invoice.void_or_red_invoice",
|
||||||
|
"name": "作废或红冲发票",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "void_or_red_invoice",
|
||||||
|
"ontology_signal": "void_or_red_invoice",
|
||||||
|
"evaluator": "invoice_void_or_red",
|
||||||
|
"applies_to": {
|
||||||
|
"min_attachments": 1
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"status": "attachment.invoice_status",
|
||||||
|
"ocr": "attachment.ocr_text"
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "high",
|
||||||
|
"action": "block"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 二、发票类 / 作废红冲发票",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.travel.base_location_overlap",
|
||||||
|
"name": "常驻地重合出差风险",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "base_location_overlap",
|
||||||
|
"ontology_signal": "base_location_overlap",
|
||||||
|
"evaluator": "base_location_overlap",
|
||||||
|
"applies_to": {
|
||||||
|
"domains": ["travel"]
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"employee_base": "employee.location",
|
||||||
|
"declared": "claim.location"
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "high",
|
||||||
|
"action": "manual_review"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 一、出差类 / 两头在外",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.travel.destination_receipt_location",
|
||||||
|
"name": "申报地点与票据地点一致",
|
||||||
|
"risk_dimension": "location_consistency",
|
||||||
|
"ontology_signal": "location_mismatch",
|
||||||
|
"evaluator": "location_consistency",
|
||||||
|
"inputs": {
|
||||||
|
"declared": "claim.location",
|
||||||
|
"evidence": ["attachment.cities", "item.item_location"]
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"match_mode": "city_fuzzy",
|
||||||
|
"missing_evidence": "warn"
|
||||||
|
},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "high",
|
||||||
|
"action": "manual_review",
|
||||||
|
"message_template": "申报地点 {declared} 与票据识别地点 {evidence} 不一致"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"updated_at": "2026-05-18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.travel.hotel_without_itinerary",
|
||||||
|
"name": "住宿城市与行程不一致",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "hotel_itinerary",
|
||||||
|
"ontology_signal": "hotel_itinerary_mismatch",
|
||||||
|
"evaluator": "hotel_without_itinerary",
|
||||||
|
"applies_to": {
|
||||||
|
"domains": ["travel"],
|
||||||
|
"expense_types": ["hotel", "travel"]
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"declared": "claim.location",
|
||||||
|
"hotel": "attachment.hotel_city",
|
||||||
|
"itinerary": "attachment.route_cities"
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "high",
|
||||||
|
"action": "manual_review"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 三、住宿费 / 夜间异地住宿、酒店连续多天",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.travel.intracity_travel_claim",
|
||||||
|
"name": "同城虚报差旅补贴",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "intracity_travel",
|
||||||
|
"ontology_signal": "intracity_travel",
|
||||||
|
"evaluator": "intracity_travel_claim",
|
||||||
|
"applies_to": {
|
||||||
|
"domains": ["travel"]
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"declared": "claim.location",
|
||||||
|
"evidence": ["attachment.route", "attachment.cities"]
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "high",
|
||||||
|
"action": "manual_review"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 一、出差类 / 同城虚报差旅",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"rule_code": "risk.travel.multi_city_reason_required",
|
||||||
|
"name": "多城市行程需说明",
|
||||||
|
"enabled": true,
|
||||||
|
"risk_dimension": "multi_city_itinerary",
|
||||||
|
"ontology_signal": "multi_city_itinerary",
|
||||||
|
"evaluator": "multi_city_reason_required",
|
||||||
|
"applies_to": {
|
||||||
|
"domains": ["travel"]
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"reason": "claim.reason_corpus",
|
||||||
|
"cities": ["attachment.cities", "item.item_location"]
|
||||||
|
},
|
||||||
|
"params": {},
|
||||||
|
"outcomes": {
|
||||||
|
"pass": { "severity": "none", "action": "continue" },
|
||||||
|
"fail": {
|
||||||
|
"severity": "medium",
|
||||||
|
"action": "warn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"owner": "风控与审计部",
|
||||||
|
"stability": "platform_builtin",
|
||||||
|
"source_ref": "常用risk.txt / 一、出差类 / 绕道出行、行程不符",
|
||||||
|
"updated_at": "2026-05-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
server/scripts/sync_platform_risk_rules.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sync platform risk rule assets from server/rules/risk-rules/*.json."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SERVER_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
if str(SERVER_SRC) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SERVER_SRC))
|
||||||
|
|
||||||
|
from app.db.session import get_session_factory # noqa: E402
|
||||||
|
from app.services.agent_foundation import AgentFoundationService # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
db = get_session_factory()()
|
||||||
|
try:
|
||||||
|
count = AgentFoundationService(db).sync_platform_risk_rules_from_library()
|
||||||
|
db.commit()
|
||||||
|
print(f"Synced {count} risk rule manifest(s) from library.")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
13
server/scripts/test_rule_json_api.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
base = "http://127.0.0.1:8000/api/v1"
|
||||||
|
items = json.loads(urllib.request.urlopen(f"{base}/agent-assets?asset_type=rule").read())
|
||||||
|
risk = next((i for i in items if str(i.get("code", "")).startswith("risk.")), None)
|
||||||
|
print("risk asset:", risk.get("code") if risk else None)
|
||||||
|
if not risk:
|
||||||
|
raise SystemExit(1)
|
||||||
|
resp = urllib.request.urlopen(f"{base}/agent-assets/{risk['id']}/rule-json")
|
||||||
|
payload = json.loads(resp.read())
|
||||||
|
print("rule-json ok:", payload.get("file_name"), payload.get("evaluator"))
|
||||||
@@ -22,6 +22,7 @@ class CurrentUserContext:
|
|||||||
name: str
|
name: str
|
||||||
role_codes: list[str]
|
role_codes: list[str]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
department_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
@@ -41,6 +42,10 @@ def get_current_user(
|
|||||||
str | None,
|
str | None,
|
||||||
Header(description="是否管理员,支持 `true/false/1/0`。"),
|
Header(description="是否管理员,支持 `true/false/1/0`。"),
|
||||||
] = None,
|
] = None,
|
||||||
|
x_auth_department: Annotated[
|
||||||
|
str | None,
|
||||||
|
Header(description="当前登录人的所属部门。"),
|
||||||
|
] = None,
|
||||||
) -> CurrentUserContext:
|
) -> CurrentUserContext:
|
||||||
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
||||||
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
@@ -59,6 +64,7 @@ def get_current_user(
|
|||||||
name=name or username,
|
name=name or username,
|
||||||
role_codes=role_codes,
|
role_codes=role_codes,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
|
department_name=(x_auth_department or "").strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ from app.schemas.agent_asset import (
|
|||||||
AgentAssetRead,
|
AgentAssetRead,
|
||||||
AgentAssetReviewCreate,
|
AgentAssetReviewCreate,
|
||||||
AgentAssetReviewRead,
|
AgentAssetReviewRead,
|
||||||
AgentAssetVersionCompareRead,
|
AgentAssetRuleJsonRead,
|
||||||
|
AgentAssetRuleJsonWrite,
|
||||||
|
AgentAssetSpreadsheetChangeRecordRead,
|
||||||
AgentAssetUpdate,
|
AgentAssetUpdate,
|
||||||
AgentAssetVersionCreate,
|
AgentAssetVersionCreate,
|
||||||
AgentAssetVersionRead,
|
AgentAssetVersionRead,
|
||||||
@@ -49,7 +51,7 @@ RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_u
|
|||||||
|
|
||||||
|
|
||||||
def _handle_asset_error(exc: Exception) -> None:
|
def _handle_asset_error(exc: Exception) -> None:
|
||||||
if isinstance(exc, LookupError):
|
if isinstance(exc, (LookupError, FileNotFoundError)):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
if isinstance(exc, PermissionError):
|
if isinstance(exc, PermissionError):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
@@ -110,6 +112,48 @@ def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
|
|||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{asset_id}/rule-json",
|
||||||
|
response_model=AgentAssetRuleJsonRead,
|
||||||
|
summary="读取风险规则 JSON",
|
||||||
|
description="读取 JSON 风险规则资产绑定的规则文件内容。",
|
||||||
|
)
|
||||||
|
def get_agent_asset_rule_json(
|
||||||
|
asset_id: str,
|
||||||
|
_: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> AgentAssetRuleJsonRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).read_rule_json(asset_id)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{asset_id}/rule-json",
|
||||||
|
response_model=AgentAssetRuleJsonRead,
|
||||||
|
summary="保存风险规则 JSON",
|
||||||
|
description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。",
|
||||||
|
)
|
||||||
|
def save_agent_asset_rule_json(
|
||||||
|
asset_id: str,
|
||||||
|
payload: AgentAssetRuleJsonWrite,
|
||||||
|
current_user: RuleEditorUser,
|
||||||
|
db: DbSession,
|
||||||
|
x_actor: ActorHeader = None,
|
||||||
|
x_request_id: RequestIdHeader = None,
|
||||||
|
) -> AgentAssetRuleJsonRead:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).write_rule_json(
|
||||||
|
asset_id,
|
||||||
|
body=payload,
|
||||||
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
|
request_id=x_request_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{asset_id}/spreadsheet/onlyoffice-config",
|
"/{asset_id}/spreadsheet/onlyoffice-config",
|
||||||
response_model=AgentAssetOnlyOfficeConfigRead,
|
response_model=AgentAssetOnlyOfficeConfigRead,
|
||||||
@@ -122,7 +166,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
version: Annotated[
|
version: Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
Query(description="可选的规则版本号;不传时默认当前版本。"),
|
Query(description="兼容旧前端的可选参数;表格规则始终打开当前规则表。"),
|
||||||
] = None,
|
] = None,
|
||||||
) -> AgentAssetOnlyOfficeConfigRead:
|
) -> AgentAssetOnlyOfficeConfigRead:
|
||||||
try:
|
try:
|
||||||
@@ -139,7 +183,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
|
|||||||
"/{asset_id}/spreadsheet/content",
|
"/{asset_id}/spreadsheet/content",
|
||||||
response_class=FileResponse,
|
response_class=FileResponse,
|
||||||
summary="下载或预览规则 Excel 文件",
|
summary="下载或预览规则 Excel 文件",
|
||||||
description="按版本返回规则的 Excel 快照,用于浏览器预览或下载。",
|
description="返回当前规则 Excel 文件,用于浏览器预览或下载。",
|
||||||
)
|
)
|
||||||
def get_agent_asset_spreadsheet_content(
|
def get_agent_asset_spreadsheet_content(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
@@ -147,7 +191,7 @@ def get_agent_asset_spreadsheet_content(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
version: Annotated[
|
version: Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
Query(description="可选的规则版本号;不传时默认当前版本。"),
|
Query(description="兼容旧前端的可选参数;不传时返回当前规则表。"),
|
||||||
] = None,
|
] = None,
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
try:
|
try:
|
||||||
@@ -170,18 +214,18 @@ def get_agent_asset_spreadsheet_content(
|
|||||||
def get_agent_asset_spreadsheet_onlyoffice_content(
|
def get_agent_asset_spreadsheet_onlyoffice_content(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
version: Annotated[
|
|
||||||
str,
|
|
||||||
Query(min_length=1, description="规则版本号。"),
|
|
||||||
],
|
|
||||||
access_token: Annotated[
|
access_token: Annotated[
|
||||||
str,
|
str,
|
||||||
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
|
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
|
||||||
],
|
],
|
||||||
|
version: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(description="兼容旧 ONLYOFFICE URL;当前表格模式不再使用。"),
|
||||||
|
] = None,
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
try:
|
try:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
service.validate_rule_spreadsheet_access_token(asset_id, version, access_token)
|
service.validate_rule_spreadsheet_access_token(asset_id, access_token)
|
||||||
file_path, media_type, filename = service.get_rule_spreadsheet_content(
|
file_path, media_type, filename = service.get_rule_spreadsheet_content(
|
||||||
asset_id,
|
asset_id,
|
||||||
version=version,
|
version=version,
|
||||||
@@ -201,7 +245,7 @@ def get_agent_asset_spreadsheet_onlyoffice_content(
|
|||||||
response_model=AgentAssetRead,
|
response_model=AgentAssetRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="上传规则 Excel 文件",
|
summary="上传规则 Excel 文件",
|
||||||
description="为指定规则上传新的 Excel 快照,并自动生成新规则版本。",
|
description="为指定规则上传新的 Excel 文件,并记录本次表格修改。",
|
||||||
)
|
)
|
||||||
def upload_agent_asset_spreadsheet(
|
def upload_agent_asset_spreadsheet(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
@@ -266,22 +310,27 @@ def import_agent_asset_spreadsheet_content(
|
|||||||
"/{asset_id}/spreadsheet/onlyoffice/callback",
|
"/{asset_id}/spreadsheet/onlyoffice/callback",
|
||||||
response_model=AgentAssetOnlyOfficeCallbackRead,
|
response_model=AgentAssetOnlyOfficeCallbackRead,
|
||||||
summary="接收规则 Excel 的 ONLYOFFICE 回调",
|
summary="接收规则 Excel 的 ONLYOFFICE 回调",
|
||||||
description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本。",
|
description="接收 ONLYOFFICE 回写内容,并记录本次表格修改。",
|
||||||
)
|
)
|
||||||
def handle_agent_asset_spreadsheet_onlyoffice_callback(
|
def handle_agent_asset_spreadsheet_onlyoffice_callback(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
payload: AgentAssetOnlyOfficeCallbackWrite,
|
payload: AgentAssetOnlyOfficeCallbackWrite,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
version: Annotated[
|
version: Annotated[
|
||||||
str,
|
str | None,
|
||||||
Query(min_length=1, description="打开编辑器时对应的规则版本号。"),
|
Query(description="兼容旧 ONLYOFFICE 回调;当前表格模式不再使用。"),
|
||||||
],
|
] = None,
|
||||||
|
actor_name: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(description="发起编辑的用户显示名。"),
|
||||||
|
] = None,
|
||||||
) -> AgentAssetOnlyOfficeCallbackRead:
|
) -> AgentAssetOnlyOfficeCallbackRead:
|
||||||
try:
|
try:
|
||||||
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
|
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
|
||||||
asset_id,
|
asset_id,
|
||||||
version=version,
|
version=version,
|
||||||
payload=payload.model_dump(),
|
payload=payload.model_dump(),
|
||||||
|
actor_name=actor_name,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_handle_asset_error(exc)
|
_handle_asset_error(exc)
|
||||||
@@ -289,6 +338,24 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
|
|||||||
return AgentAssetOnlyOfficeCallbackRead()
|
return AgentAssetOnlyOfficeCallbackRead()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{asset_id}/spreadsheet/change-records",
|
||||||
|
response_model=list[AgentAssetSpreadsheetChangeRecordRead],
|
||||||
|
summary="读取规则表最近修改记录",
|
||||||
|
description="返回最近 30 次 ONLYOFFICE 保存级修改记录,用于展示操作者、时间和具体差异。",
|
||||||
|
)
|
||||||
|
def list_agent_asset_spreadsheet_change_records(
|
||||||
|
asset_id: str,
|
||||||
|
_: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=30, description="返回条数,最多 30 条。")] = 30,
|
||||||
|
) -> list[AgentAssetSpreadsheetChangeRecordRead]:
|
||||||
|
try:
|
||||||
|
return AgentAssetService(db).list_spreadsheet_change_records(asset_id, limit=limit)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=AgentAssetRead,
|
response_model=AgentAssetRead,
|
||||||
@@ -533,25 +600,3 @@ def get_agent_asset_version_timeline(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_handle_asset_error(exc)
|
_handle_asset_error(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{asset_id}/versions/compare",
|
|
||||||
response_model=AgentAssetVersionCompareRead,
|
|
||||||
summary="比较两个规则表版本",
|
|
||||||
description="对比两个 Excel 规则表版本的工作表变化与单元格级差异。",
|
|
||||||
)
|
|
||||||
def compare_agent_asset_spreadsheet_versions(
|
|
||||||
asset_id: str,
|
|
||||||
_: CurrentUser,
|
|
||||||
db: DbSession,
|
|
||||||
base_version: Annotated[str, Query(min_length=1, description="基准版本号")],
|
|
||||||
target_version: Annotated[str, Query(min_length=1, description="对比版本号")],
|
|
||||||
) -> AgentAssetVersionCompareRead:
|
|
||||||
try:
|
|
||||||
return AgentAssetService(db).compare_spreadsheet_versions(
|
|
||||||
asset_id,
|
|
||||||
base_version=base_version,
|
|
||||||
target_version=target_version,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
_handle_asset_error(exc)
|
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse
|
||||||
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead, EmployeeUpdate
|
from app.schemas.employee import (
|
||||||
|
EmployeeCreate,
|
||||||
|
EmployeeImportResultRead,
|
||||||
|
EmployeeMetaRead,
|
||||||
|
EmployeeRead,
|
||||||
|
EmployeeUpdate,
|
||||||
|
)
|
||||||
from app.services.employee import EmployeeService
|
from app.services.employee import EmployeeService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -44,6 +51,67 @@ def list_employees(
|
|||||||
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/import-template",
|
||||||
|
summary="下载员工导入模板",
|
||||||
|
description="下载固定格式的员工 Excel 导入模板。",
|
||||||
|
)
|
||||||
|
def download_employee_import_template(db: DbSession) -> Response:
|
||||||
|
content = EmployeeService(db).build_import_template()
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": 'attachment; filename="employee-import-template.xlsx"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/export",
|
||||||
|
summary="导出员工 Excel",
|
||||||
|
description="按筛选条件导出员工目录 Excel 文件。",
|
||||||
|
)
|
||||||
|
def export_employees(
|
||||||
|
db: DbSession,
|
||||||
|
status_filter: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(alias="status", description="员工状态筛选值。"),
|
||||||
|
] = None,
|
||||||
|
keyword: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
|
||||||
|
] = None,
|
||||||
|
) -> Response:
|
||||||
|
content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="employee-export.xlsx"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/import",
|
||||||
|
response_model=EmployeeImportResultRead,
|
||||||
|
summary="导入员工 Excel",
|
||||||
|
description="按模板批量导入员工。全部校验通过后才写入数据库,任一行有错则整批不导入。",
|
||||||
|
)
|
||||||
|
async def import_employees(
|
||||||
|
db: DbSession,
|
||||||
|
file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
|
||||||
|
) -> EmployeeImportResultRead:
|
||||||
|
filename = (file.filename or "").lower()
|
||||||
|
if not filename.endswith(".xlsx"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="当前仅支持上传 .xlsx 格式的员工表格。",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
return EmployeeService(db).import_employees(content)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=EmployeeRead,
|
response_model=EmployeeRead,
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ from app.schemas.reimbursement import (
|
|||||||
ExpenseClaimAttachmentActionResponse,
|
ExpenseClaimAttachmentActionResponse,
|
||||||
ExpenseClaimActionResponse,
|
ExpenseClaimActionResponse,
|
||||||
ExpenseClaimAttachmentRead,
|
ExpenseClaimAttachmentRead,
|
||||||
|
ExpenseClaimApprovalPayload,
|
||||||
ExpenseClaimItemCreate,
|
ExpenseClaimItemCreate,
|
||||||
ExpenseClaimItemActionResponse,
|
ExpenseClaimItemActionResponse,
|
||||||
ExpenseClaimItemUpdate,
|
ExpenseClaimItemUpdate,
|
||||||
ExpenseClaimRead,
|
ExpenseClaimRead,
|
||||||
|
ExpenseClaimReturnPayload,
|
||||||
|
ExpenseClaimUpdate,
|
||||||
ReimbursementCreate,
|
ReimbursementCreate,
|
||||||
ReimbursementRead,
|
ReimbursementRead,
|
||||||
|
TravelReimbursementCalculatorRequest,
|
||||||
|
TravelReimbursementCalculatorResponse,
|
||||||
)
|
)
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.reimbursement import ReimbursementService
|
from app.services.reimbursement import ReimbursementService
|
||||||
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
@@ -48,6 +54,29 @@ def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> Reimbur
|
|||||||
return ReimbursementService(db).create_reimbursement(payload)
|
return ReimbursementService(db).create_reimbursement(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/travel-calculator",
|
||||||
|
response_model=TravelReimbursementCalculatorResponse,
|
||||||
|
summary="差旅报销标准测算",
|
||||||
|
description="根据规则中心的差旅报销表、当前员工职级、出差天数与地点测算住宿和补贴参考金额。",
|
||||||
|
responses={
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "测算入参或规则匹配失败。",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def calculate_travel_reimbursement(
|
||||||
|
payload: TravelReimbursementCalculatorRequest,
|
||||||
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> TravelReimbursementCalculatorResponse:
|
||||||
|
try:
|
||||||
|
return TravelReimbursementCalculatorService(db).calculate(payload, current_user)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/claims",
|
"/claims",
|
||||||
response_model=list[ExpenseClaimRead],
|
response_model=list[ExpenseClaimRead],
|
||||||
@@ -58,6 +87,16 @@ def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[Expens
|
|||||||
return ExpenseClaimService(db).list_claims(current_user)
|
return ExpenseClaimService(db).list_claims(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/claims/approvals",
|
||||||
|
response_model=list[ExpenseClaimRead],
|
||||||
|
summary="查询当前用户审批待办报销单列表",
|
||||||
|
description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。",
|
||||||
|
)
|
||||||
|
def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]:
|
||||||
|
return ExpenseClaimService(db).list_approval_claims(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/claims/{claim_id}",
|
"/claims/{claim_id}",
|
||||||
response_model=ExpenseClaimRead,
|
response_model=ExpenseClaimRead,
|
||||||
@@ -77,6 +116,43 @@ def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -
|
|||||||
return claim
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/claims/{claim_id}",
|
||||||
|
response_model=ExpenseClaimRead,
|
||||||
|
summary="更新草稿报销单",
|
||||||
|
description="更新草稿待提交报销单的主说明等草稿字段。",
|
||||||
|
responses={
|
||||||
|
status.HTTP_404_NOT_FOUND: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "报销单不存在。",
|
||||||
|
},
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "报销单状态不允许更新。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def update_expense_claim(
|
||||||
|
claim_id: str,
|
||||||
|
payload: ExpenseClaimUpdate,
|
||||||
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ExpenseClaimRead:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
try:
|
||||||
|
claim = service.update_claim(
|
||||||
|
claim_id=claim_id,
|
||||||
|
payload=payload,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
return claim
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/claims/{claim_id}/items/{item_id}",
|
"/claims/{claim_id}/items/{item_id}",
|
||||||
response_model=ExpenseClaimRead,
|
response_model=ExpenseClaimRead,
|
||||||
@@ -415,11 +491,11 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
|||||||
return claim
|
return claim
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.post(
|
||||||
"/claims/{claim_id}",
|
"/claims/{claim_id}/return",
|
||||||
response_model=ExpenseClaimActionResponse,
|
response_model=ExpenseClaimRead,
|
||||||
summary="删除个人报销草稿",
|
summary="退回报销单",
|
||||||
description="删除当前登录用户可见的草稿报销单。",
|
description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_404_NOT_FOUND: {
|
status.HTTP_404_NOT_FOUND: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
@@ -427,7 +503,73 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
|||||||
},
|
},
|
||||||
status.HTTP_400_BAD_REQUEST: {
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
"description": "仅草稿状态允许删除。",
|
"description": "当前用户或单据状态不允许退回。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def return_expense_claim(
|
||||||
|
claim_id: str,
|
||||||
|
payload: ExpenseClaimReturnPayload,
|
||||||
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ExpenseClaimRead:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
try:
|
||||||
|
claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/claims/{claim_id}/approve",
|
||||||
|
response_model=ExpenseClaimRead,
|
||||||
|
summary="审批通过报销单",
|
||||||
|
description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。",
|
||||||
|
responses={
|
||||||
|
status.HTTP_404_NOT_FOUND: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "报销单不存在。",
|
||||||
|
},
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "当前用户或单据状态不允许审批通过。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def approve_expense_claim(
|
||||||
|
claim_id: str,
|
||||||
|
payload: ExpenseClaimApprovalPayload,
|
||||||
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> ExpenseClaimRead:
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
try:
|
||||||
|
claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/claims/{claim_id}",
|
||||||
|
response_model=ExpenseClaimActionResponse,
|
||||||
|
summary="删除报销单",
|
||||||
|
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限。",
|
||||||
|
responses={
|
||||||
|
status.HTTP_404_NOT_FOUND: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "报销单不存在。",
|
||||||
|
},
|
||||||
|
status.HTTP_400_BAD_REQUEST: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": "当前用户或单据状态不允许删除。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -442,7 +584,7 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||||
|
|
||||||
return ExpenseClaimActionResponse(
|
return ExpenseClaimActionResponse(
|
||||||
message=f"{claim.claim_no} 草稿已删除。",
|
message=f"{claim.claim_no} 报销单已删除。",
|
||||||
claim_id=claim.id,
|
claim_id=claim.id,
|
||||||
status="deleted",
|
status="deleted",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ class ExpenseClaimItem(Base):
|
|||||||
|
|
||||||
claim = relationship("ExpenseClaim", back_populates="items")
|
claim = relationship("ExpenseClaim", back_populates="items")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_system_generated(self) -> bool:
|
||||||
|
return str(self.item_type or "").strip().lower() in {"travel_allowance"}
|
||||||
|
|
||||||
|
|
||||||
class AccountsReceivableRecord(Base):
|
class AccountsReceivableRecord(Base):
|
||||||
__tablename__ = "accounts_receivable"
|
__tablename__ = "accounts_receivable"
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ class AgentAssetRepository:
|
|||||||
stmt = stmt.limit(limit)
|
stmt = stmt.limit(limit)
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
def list_versions_for_assets(self, asset_ids: list[str]) -> list[AgentAssetVersion]:
|
||||||
|
if not asset_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(AgentAssetVersion)
|
||||||
|
.where(AgentAssetVersion.asset_id.in_(asset_ids))
|
||||||
|
.order_by(AgentAssetVersion.asset_id, AgentAssetVersion.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
|
def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
|
||||||
stmt = select(AgentAssetVersion).where(
|
stmt = select(AgentAssetVersion).where(
|
||||||
AgentAssetVersion.asset_id == asset_id,
|
AgentAssetVersion.asset_id == asset_id,
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ class AuditLogRepository:
|
|||||||
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
|
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
|
||||||
return list(self.db.scalars(stmt).all())
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
|
def list_for_resources(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_ids: list[str],
|
||||||
|
action: str | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list[AuditLog]:
|
||||||
|
if not resource_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
stmt = select(AuditLog).where(
|
||||||
|
AuditLog.resource_type == resource_type,
|
||||||
|
AuditLog.resource_id.in_(resource_ids),
|
||||||
|
)
|
||||||
|
if action:
|
||||||
|
stmt = stmt.where(AuditLog.action == action)
|
||||||
|
stmt = stmt.order_by(AuditLog.created_at.desc())
|
||||||
|
if limit is not None:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
return list(self.db.scalars(stmt).all())
|
||||||
|
|
||||||
def create(self, log: AuditLog) -> AuditLog:
|
def create(self, log: AuditLog) -> AuditLog:
|
||||||
self.db.add(log)
|
self.db.add(log)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
@@ -93,6 +93,22 @@ class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
|
|||||||
users: list[str] = Field(default_factory=list, description="当前编辑用户列表。")
|
users: list[str] = Field(default_factory=list, description="当前编辑用户列表。")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRuleJsonWrite(BaseModel):
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRuleJsonRead(BaseModel):
|
||||||
|
file_name: str
|
||||||
|
rule_code: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
evaluator: str = ""
|
||||||
|
ontology_signal: str | None = None
|
||||||
|
inputs: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
outcomes: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class AgentAssetVersionTimelineItemRead(BaseModel):
|
class AgentAssetVersionTimelineItemRead(BaseModel):
|
||||||
event_type: str
|
event_type: str
|
||||||
version: str
|
version: str
|
||||||
@@ -117,15 +133,15 @@ class AgentAssetSpreadsheetDiffSheetRead(BaseModel):
|
|||||||
change_type: str
|
change_type: str
|
||||||
|
|
||||||
|
|
||||||
class AgentAssetVersionCompareRead(BaseModel):
|
class AgentAssetSpreadsheetChangeRecordRead(BaseModel):
|
||||||
base_version: str
|
id: str
|
||||||
target_version: str
|
actor: str
|
||||||
added_sheet_count: int = 0
|
changed_at: datetime
|
||||||
removed_sheet_count: int = 0
|
summary: str
|
||||||
changed_sheet_count: int = 0
|
|
||||||
changed_cell_count: int = 0
|
|
||||||
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
|
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
|
||||||
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
|
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
|
||||||
|
changed_sheet_count: int = 0
|
||||||
|
changed_cell_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class AgentAssetVersionRead(BaseModel):
|
class AgentAssetVersionRead(BaseModel):
|
||||||
@@ -162,6 +178,8 @@ class AgentAssetListItem(BaseModel):
|
|||||||
published_version: str | None
|
published_version: str | None
|
||||||
working_version: str | None
|
working_version: str | None
|
||||||
config_json: dict[str, Any]
|
config_json: dict[str, Any]
|
||||||
|
change_count: int = 0
|
||||||
|
modified_by: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -12,8 +14,16 @@ class AuthUserRead(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
department: str = ""
|
||||||
|
departmentName: str = ""
|
||||||
position: str = ""
|
position: str = ""
|
||||||
grade: str = ""
|
grade: str = ""
|
||||||
|
employeeNo: str = ""
|
||||||
|
managerName: str = ""
|
||||||
|
location: str = ""
|
||||||
|
costCenter: str = ""
|
||||||
|
financeOwnerName: str = ""
|
||||||
|
riskProfile: dict[str, Any] = Field(default_factory=dict)
|
||||||
roleCodes: list[str] = Field(default_factory=list)
|
roleCodes: list[str] = Field(default_factory=list)
|
||||||
email: EmailStr | str
|
email: EmailStr | str
|
||||||
avatar: str
|
avatar: str
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class EmployeeMetaRead(BaseModel):
|
|||||||
totalEmployees: int
|
totalEmployees: int
|
||||||
statusSummary: list[EmployeeStatusSummaryRead]
|
statusSummary: list[EmployeeStatusSummaryRead]
|
||||||
roleOptions: list[EmployeeRoleOptionRead]
|
roleOptions: list[EmployeeRoleOptionRead]
|
||||||
|
organizationOptions: list[EmployeeOrganizationRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class EmployeeRead(BaseModel):
|
class EmployeeRead(BaseModel):
|
||||||
@@ -63,6 +64,7 @@ class EmployeeRead(BaseModel):
|
|||||||
position: str
|
position: str
|
||||||
grade: str
|
grade: str
|
||||||
manager: str
|
manager: str
|
||||||
|
managerEmployeeNo: str | None = None
|
||||||
financeOwner: str
|
financeOwner: str
|
||||||
roles: list[str] = Field(default_factory=list)
|
roles: list[str] = Field(default_factory=list)
|
||||||
roleCodes: list[str] = Field(default_factory=list)
|
roleCodes: list[str] = Field(default_factory=list)
|
||||||
@@ -112,6 +114,28 @@ class EmployeeCreate(BaseModel):
|
|||||||
return _parse_optional_date(self.join_date, "入职日期")
|
return _parse_optional_date(self.join_date, "入职日期")
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeImportErrorRead(BaseModel):
|
||||||
|
row: int
|
||||||
|
column: str
|
||||||
|
employeeNo: str = ""
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeImportSummaryRead(BaseModel):
|
||||||
|
totalRows: int = 0
|
||||||
|
created: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
errorCount: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeImportResultRead(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
summary: EmployeeImportSummaryRead
|
||||||
|
errors: list[EmployeeImportErrorRead] = Field(default_factory=list)
|
||||||
|
importedAt: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EmployeeUpdate(BaseModel):
|
class EmployeeUpdate(BaseModel):
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
gender: str | None = Field(default=None, max_length=20)
|
gender: str | None = Field(default=None, max_length=20)
|
||||||
@@ -124,6 +148,8 @@ class EmployeeUpdate(BaseModel):
|
|||||||
grade: str | None = Field(default=None, min_length=1, max_length=20)
|
grade: str | None = Field(default=None, min_length=1, max_length=20)
|
||||||
cost_center: str | None = Field(default=None, max_length=50)
|
cost_center: str | None = Field(default=None, max_length=50)
|
||||||
finance_owner_name: str | None = Field(default=None, max_length=100)
|
finance_owner_name: str | None = Field(default=None, max_length=100)
|
||||||
|
organization_unit_code: str | None = Field(default=None, max_length=50)
|
||||||
|
manager_employee_no: str | None = Field(default=None, max_length=50)
|
||||||
role_codes: list[str] | None = None
|
role_codes: list[str] | None = None
|
||||||
password: str | None = Field(default=None, min_length=5, max_length=128)
|
password: str | None = Field(default=None, min_length=5, max_length=128)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class ExpenseClaimItemRead(BaseModel):
|
|||||||
item_location: str
|
item_location: str
|
||||||
item_amount: Decimal
|
item_amount: Decimal
|
||||||
invoice_id: str | None
|
invoice_id: str | None
|
||||||
|
is_system_generated: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ class ExpenseClaimAttachmentAnalysisRead(BaseModel):
|
|||||||
headline: str
|
headline: str
|
||||||
summary: str
|
summary: str
|
||||||
points: list[str] = Field(default_factory=list)
|
points: list[str] = Field(default_factory=list)
|
||||||
|
rule_basis: list[str] = Field(default_factory=list)
|
||||||
suggestion: str = ""
|
suggestion: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -112,6 +114,10 @@ class ExpenseClaimItemCreate(BaseModel):
|
|||||||
invoice_id: str | None = None
|
invoice_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimUpdate(BaseModel):
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimRead(BaseModel):
|
class ExpenseClaimRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -148,11 +154,54 @@ class ExpenseClaimActionResponse(BaseModel):
|
|||||||
status: str | None = None
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimReturnPayload(BaseModel):
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
reason_codes: list[str] = Field(default_factory=list, max_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimApprovalPayload(BaseModel):
|
||||||
|
opinion: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class TravelReimbursementCalculatorRequest(BaseModel):
|
||||||
|
days: int = Field(ge=1, le=365)
|
||||||
|
location: str = Field(min_length=1, max_length=120)
|
||||||
|
grade: str | None = Field(default=None, max_length=30)
|
||||||
|
|
||||||
|
|
||||||
|
class TravelReimbursementCalculatorResponse(BaseModel):
|
||||||
|
days: int
|
||||||
|
location: str
|
||||||
|
matched_city: str
|
||||||
|
city_tier: str
|
||||||
|
grade: str
|
||||||
|
grade_band: str
|
||||||
|
grade_band_label: str
|
||||||
|
hotel_rate: Decimal
|
||||||
|
hotel_amount: Decimal
|
||||||
|
allowance_region: str
|
||||||
|
meal_allowance_rate: Decimal
|
||||||
|
basic_allowance_rate: Decimal
|
||||||
|
total_allowance_rate: Decimal
|
||||||
|
allowance_amount: Decimal
|
||||||
|
total_amount: Decimal
|
||||||
|
rule_name: str
|
||||||
|
rule_version: str
|
||||||
|
formula_text: str
|
||||||
|
summary_text: str
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimAttachmentActionResponse(BaseModel):
|
class ExpenseClaimAttachmentActionResponse(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
claim_id: str
|
claim_id: str
|
||||||
item_id: str
|
item_id: str
|
||||||
invoice_id: str | None = None
|
invoice_id: str | None = None
|
||||||
|
item_date: date | None = None
|
||||||
|
item_type: str | None = None
|
||||||
|
item_reason: str | None = None
|
||||||
|
item_location: str | None = None
|
||||||
|
item_amount: Decimal | None = None
|
||||||
|
claim_amount: Decimal | None = None
|
||||||
attachment: ExpenseClaimAttachmentRead | None = None
|
attachment: ExpenseClaimAttachmentRead | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class UserAgentSuggestedAction(BaseModel):
|
|||||||
label: str = Field(description="建议动作文案。")
|
label: str = Field(description="建议动作文案。")
|
||||||
action_type: str = Field(description="动作类型,例如 open_detail / create_draft。")
|
action_type: str = Field(description="动作类型,例如 open_detail / create_draft。")
|
||||||
description: str = Field(default="", description="动作说明。")
|
description: str = Field(default="", description="动作说明。")
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict, description="动作携带的结构化参数。")
|
||||||
|
|
||||||
|
|
||||||
class UserAgentDraftPayload(BaseModel):
|
class UserAgentDraftPayload(BaseModel):
|
||||||
@@ -85,6 +86,8 @@ class UserAgentReviewRiskBrief(BaseModel):
|
|||||||
title: str = Field(description="风险或注意事项标题。")
|
title: str = Field(description="风险或注意事项标题。")
|
||||||
level: str = Field(default="info", description="级别,例如 info / warning / high。")
|
level: str = Field(default="info", description="级别,例如 info / warning / high。")
|
||||||
content: str = Field(description="面向用户展示的摘要说明。")
|
content: str = Field(description="面向用户展示的摘要说明。")
|
||||||
|
detail: str = Field(default="", description="点击风险项后展示的详细解释。")
|
||||||
|
suggestion: str = Field(default="", description="面向用户的处理建议。")
|
||||||
|
|
||||||
|
|
||||||
class UserAgentReviewSlotCard(BaseModel):
|
class UserAgentReviewSlotCard(BaseModel):
|
||||||
|
|||||||
84
server/src/app/services/agent_asset_rule_library.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import SERVER_DIR
|
||||||
|
from app.services.agent_asset_spreadsheet import RULE_LIBRARY_NAMES
|
||||||
|
|
||||||
|
JSON_RULE_MIME_TYPE = "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAssetRuleLibraryManager:
|
||||||
|
def __init__(self, rule_root: Path | None = None) -> None:
|
||||||
|
self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve()
|
||||||
|
|
||||||
|
def ensure_rule_library_dirs(self) -> None:
|
||||||
|
for library in sorted(RULE_LIBRARY_NAMES):
|
||||||
|
(self.rule_root / library).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def resolve_rule_library_path(self, *, library: str, file_name: str) -> Path:
|
||||||
|
normalized_library = str(library or "").strip()
|
||||||
|
if normalized_library not in RULE_LIBRARY_NAMES:
|
||||||
|
raise ValueError("Invalid rule library.")
|
||||||
|
|
||||||
|
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
||||||
|
if not normalized_name or not normalized_name.endswith(".json"):
|
||||||
|
raise ValueError("Rule JSON file name must end with .json.")
|
||||||
|
|
||||||
|
library_dir = (self.rule_root / normalized_library).resolve()
|
||||||
|
target_path = (library_dir / normalized_name).resolve()
|
||||||
|
try:
|
||||||
|
target_path.relative_to(library_dir)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("Invalid rule JSON path.") from None
|
||||||
|
return target_path
|
||||||
|
|
||||||
|
def read_rule_library_json(self, *, library: str, file_name: str) -> dict[str, Any]:
|
||||||
|
target_path = self.resolve_rule_library_path(library=library, file_name=file_name)
|
||||||
|
if not target_path.exists():
|
||||||
|
raise FileNotFoundError("Rule JSON file not found.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(target_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("Rule JSON file is invalid.") from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Rule JSON payload must be an object.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def write_rule_library_json(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
library: str,
|
||||||
|
file_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Rule JSON payload must be an object.")
|
||||||
|
|
||||||
|
rule_code = str(payload.get("rule_code") or "").strip()
|
||||||
|
if not rule_code:
|
||||||
|
raise ValueError("Rule JSON must include rule_code.")
|
||||||
|
|
||||||
|
evaluator = str(payload.get("evaluator") or "").strip()
|
||||||
|
if not evaluator:
|
||||||
|
raise ValueError("Rule JSON must include evaluator.")
|
||||||
|
|
||||||
|
target_path = self.resolve_rule_library_path(library=library, file_name=file_name)
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_path.write_text(
|
||||||
|
f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def list_rule_library_json_files(self, *, library: str) -> list[str]:
|
||||||
|
library_dir = self.resolve_rule_library_path(
|
||||||
|
library=library,
|
||||||
|
file_name="placeholder.json",
|
||||||
|
).parent
|
||||||
|
library_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return sorted(path.name for path in library_dir.glob("*.json") if path.is_file())
|
||||||
@@ -22,6 +22,8 @@ RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
|||||||
|
|
||||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
|
||||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
|
||||||
FINANCE_RULES_LIBRARY = "finance-rules"
|
FINANCE_RULES_LIBRARY = "finance-rules"
|
||||||
RISK_RULES_LIBRARY = "risk-rules"
|
RISK_RULES_LIBRARY = "risk-rules"
|
||||||
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
||||||
@@ -67,26 +69,13 @@ class AgentAssetSpreadsheetManager:
|
|||||||
actor_name: str,
|
actor_name: str,
|
||||||
source: str = "upload",
|
source: str = "upload",
|
||||||
) -> RuleSpreadsheetMeta:
|
) -> RuleSpreadsheetMeta:
|
||||||
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
return self.store_rule_library_spreadsheet_snapshot(
|
||||||
if not normalized_name:
|
library=FINANCE_RULES_LIBRARY,
|
||||||
raise ValueError("规则表文件名不能为空。")
|
asset_id=asset_id,
|
||||||
if not content:
|
version=version,
|
||||||
raise ValueError("规则表文件内容不能为空。")
|
file_name=file_name,
|
||||||
|
content=content,
|
||||||
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name
|
actor_name=actor_name,
|
||||||
target_path = (self.storage_root / relative_path).resolve()
|
|
||||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
target_path.write_bytes(content)
|
|
||||||
|
|
||||||
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
|
|
||||||
return RuleSpreadsheetMeta(
|
|
||||||
file_name=normalized_name,
|
|
||||||
storage_key=relative_path.as_posix(),
|
|
||||||
mime_type=mime_type,
|
|
||||||
size_bytes=len(content),
|
|
||||||
checksum=hashlib.sha256(content).hexdigest(),
|
|
||||||
updated_at=datetime.now(UTC).isoformat(),
|
|
||||||
updated_by=str(actor_name or "system").strip() or "system",
|
|
||||||
source=source,
|
source=source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,7 +104,74 @@ class AgentAssetSpreadsheetManager:
|
|||||||
try:
|
try:
|
||||||
target_path.relative_to(self.rule_root)
|
target_path.relative_to(self.rule_root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError("规则库文件路径不合法。")
|
raise ValueError("规则库文件路径不合法。") from None
|
||||||
|
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_path.write_bytes(content)
|
||||||
|
|
||||||
|
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
|
||||||
|
return RuleSpreadsheetMeta(
|
||||||
|
file_name=normalized_name,
|
||||||
|
storage_key=relative_path.as_posix(),
|
||||||
|
mime_type=mime_type,
|
||||||
|
size_bytes=len(content),
|
||||||
|
checksum=hashlib.sha256(content).hexdigest(),
|
||||||
|
updated_at=datetime.now(UTC).isoformat(),
|
||||||
|
updated_by=str(actor_name or "system").strip() or "system",
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def store_rule_library_spreadsheet_snapshot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
library: str,
|
||||||
|
asset_id: str,
|
||||||
|
version: str,
|
||||||
|
file_name: str,
|
||||||
|
content: bytes,
|
||||||
|
actor_name: str,
|
||||||
|
source: str = "rule-library-version",
|
||||||
|
) -> RuleSpreadsheetMeta:
|
||||||
|
normalized_library = str(library or "").strip()
|
||||||
|
if normalized_library not in RULE_LIBRARY_NAMES:
|
||||||
|
raise ValueError("规则库目录不合法。")
|
||||||
|
|
||||||
|
raw_asset_id = str(asset_id or "").strip()
|
||||||
|
raw_version = str(version or "").strip()
|
||||||
|
normalized_asset_id = Path(raw_asset_id).name.strip()
|
||||||
|
normalized_version = Path(raw_version).name.strip()
|
||||||
|
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
||||||
|
if (
|
||||||
|
not normalized_asset_id
|
||||||
|
or normalized_asset_id in {".", ".."}
|
||||||
|
or normalized_asset_id != raw_asset_id
|
||||||
|
):
|
||||||
|
raise ValueError("规则资产 ID 不合法。")
|
||||||
|
if (
|
||||||
|
not normalized_version
|
||||||
|
or normalized_version in {".", ".."}
|
||||||
|
or normalized_version != raw_version
|
||||||
|
):
|
||||||
|
raise ValueError("规则表版本号不合法。")
|
||||||
|
if not normalized_name:
|
||||||
|
raise ValueError("规则表文件名不能为空。")
|
||||||
|
if not content:
|
||||||
|
raise ValueError("规则表文件内容不能为空。")
|
||||||
|
|
||||||
|
self.ensure_rule_library_dirs()
|
||||||
|
relative_path = (
|
||||||
|
Path("rules")
|
||||||
|
/ normalized_library
|
||||||
|
/ ".versions"
|
||||||
|
/ normalized_asset_id
|
||||||
|
/ normalized_version
|
||||||
|
/ normalized_name
|
||||||
|
)
|
||||||
|
target_path = (SERVER_DIR / relative_path).resolve()
|
||||||
|
try:
|
||||||
|
target_path.relative_to(self.rule_root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("规则库版本文件路径不合法。") from None
|
||||||
|
|
||||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target_path.write_bytes(content)
|
target_path.write_bytes(content)
|
||||||
@@ -147,7 +203,7 @@ class AgentAssetSpreadsheetManager:
|
|||||||
try:
|
try:
|
||||||
resolved.relative_to(allowed_root)
|
resolved.relative_to(allowed_root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise FileNotFoundError("规则表文件不存在。")
|
raise FileNotFoundError("规则表文件不存在。") from None
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -228,11 +284,46 @@ class AgentAssetSpreadsheetManager:
|
|||||||
def build_company_travel_rule_template() -> bytes:
|
def build_company_travel_rule_template() -> bytes:
|
||||||
standard_rows = [
|
standard_rows = [
|
||||||
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
||||||
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"],
|
[
|
||||||
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"],
|
"长途交通",
|
||||||
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"],
|
"飞机、高铁、火车等跨城出行",
|
||||||
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"],
|
"行程单、车票、发票",
|
||||||
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"],
|
"据实报销",
|
||||||
|
"超预算需直属领导审批",
|
||||||
|
"优先选择公共交通",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"住宿费",
|
||||||
|
"出差住宿",
|
||||||
|
"酒店发票、入住清单",
|
||||||
|
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
|
||||||
|
"超标需总监审批",
|
||||||
|
"协议酒店优先",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"市内交通",
|
||||||
|
"出租车、网约车、地铁、公交",
|
||||||
|
"发票或电子行程单",
|
||||||
|
"150/天",
|
||||||
|
"超限需补充说明",
|
||||||
|
"夜间或无公共交通场景可豁免",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"餐补",
|
||||||
|
"出差期间日常补助",
|
||||||
|
"无需票据",
|
||||||
|
"120/天",
|
||||||
|
"系统自动核定",
|
||||||
|
"当天往返默认不享受",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"招待餐费",
|
||||||
|
"客户接待或项目宴请",
|
||||||
|
"餐饮发票、参与人清单",
|
||||||
|
"300/人",
|
||||||
|
"需业务负责人审批",
|
||||||
|
"需关联客户或项目",
|
||||||
|
],
|
||||||
]
|
]
|
||||||
instruction_rows = [
|
instruction_rows = [
|
||||||
["字段", "填写说明"],
|
["字段", "填写说明"],
|
||||||
@@ -306,21 +397,41 @@ def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
|||||||
|
|
||||||
def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||||
overrides = [
|
overrides = [
|
||||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
|
(
|
||||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
|
'<Override PartName="/xl/workbook.xml" '
|
||||||
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
|
'ContentType="application/vnd.openxmlformats-officedocument.'
|
||||||
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
|
'spreadsheetml.sheet.main+xml"/>'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'<Override PartName="/xl/styles.xml" '
|
||||||
|
'ContentType="application/vnd.openxmlformats-officedocument.'
|
||||||
|
'spreadsheetml.styles+xml"/>'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'<Override PartName="/docProps/core.xml" '
|
||||||
|
'ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'<Override PartName="/docProps/app.xml" '
|
||||||
|
'ContentType="application/vnd.openxmlformats-officedocument.'
|
||||||
|
'extended-properties+xml"/>'
|
||||||
|
),
|
||||||
]
|
]
|
||||||
overrides.extend(
|
overrides.extend(
|
||||||
[
|
[
|
||||||
f'<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
(
|
||||||
|
f'<Override PartName="/xl/worksheets/sheet{index}.xml" '
|
||||||
|
'ContentType="application/vnd.openxmlformats-officedocument.'
|
||||||
|
'spreadsheetml.worksheet+xml"/>'
|
||||||
|
)
|
||||||
for index, _ in enumerate(sheets, start=1)
|
for index, _ in enumerate(sheets, start=1)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
'<Default Extension="rels" '
|
||||||
|
'ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
f'{"".join(overrides)}'
|
f'{"".join(overrides)}'
|
||||||
"</Types>"
|
"</Types>"
|
||||||
@@ -331,9 +442,15 @@ def _build_root_rels_xml() -> str:
|
|||||||
return (
|
return (
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
'<Relationship Id="rId1" '
|
||||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
|
||||||
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
|
'relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||||
|
'<Relationship Id="rId2" '
|
||||||
|
'Type="http://schemas.openxmlformats.org/package/2006/relationships/'
|
||||||
|
'metadata/core-properties" Target="docProps/core.xml"/>'
|
||||||
|
'<Relationship Id="rId3" '
|
||||||
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
|
||||||
|
'relationships/extended-properties" Target="docProps/app.xml"/>'
|
||||||
"</Relationships>"
|
"</Relationships>"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -345,11 +462,16 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
|||||||
sheet_count = len(sheets)
|
sheet_count = len(sheets)
|
||||||
return (
|
return (
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
|
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/'
|
||||||
|
'extended-properties" '
|
||||||
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
||||||
'<Application>Microsoft Excel</Application>'
|
'<Application>Microsoft Excel</Application>'
|
||||||
f"<HeadingPairs><vt:vector size=\"2\" baseType=\"variant\"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant></vt:vector></HeadingPairs>"
|
'<HeadingPairs><vt:vector size="2" baseType="variant">'
|
||||||
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>"
|
"<vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant>"
|
||||||
|
f"<vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant>"
|
||||||
|
"</vt:vector></HeadingPairs>"
|
||||||
|
f'<TitlesOfParts><vt:vector size="{sheet_count}" baseType="lpstr">'
|
||||||
|
f"{titles}</vt:vector></TitlesOfParts>"
|
||||||
"</Properties>"
|
"</Properties>"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -357,7 +479,8 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
|||||||
def _build_core_xml(created_at: str) -> str:
|
def _build_core_xml(created_at: str) -> str:
|
||||||
return (
|
return (
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/'
|
||||||
|
'2006/metadata/core-properties" '
|
||||||
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
||||||
'xmlns:dcterms="http://purl.org/dc/terms/" '
|
'xmlns:dcterms="http://purl.org/dc/terms/" '
|
||||||
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
|
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
|
||||||
@@ -390,7 +513,11 @@ def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
|||||||
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||||
relationships = "".join(
|
relationships = "".join(
|
||||||
[
|
[
|
||||||
f'<Relationship Id="rId{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
|
(
|
||||||
|
f'<Relationship Id="rId{index}" '
|
||||||
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
|
||||||
|
f'relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
|
||||||
|
)
|
||||||
for index, _ in enumerate(sheets, start=1)
|
for index, _ in enumerate(sheets, start=1)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -412,10 +539,15 @@ def _build_styles_xml() -> str:
|
|||||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||||
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||||||
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
|
'<fills count="2"><fill><patternFill patternType="none"/></fill>'
|
||||||
|
'<fill><patternFill patternType="gray125"/></fill></fills>'
|
||||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
||||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
'<cellStyleXfs count="1">'
|
||||||
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
|
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>'
|
||||||
|
"</cellStyleXfs>"
|
||||||
|
'<cellXfs count="1">'
|
||||||
|
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
|
||||||
|
"</cellXfs>"
|
||||||
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
||||||
'</styleSheet>'
|
'</styleSheet>'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,50 @@ STATEFUL_CONTEXT_KEYS = (
|
|||||||
"attachment_count",
|
"attachment_count",
|
||||||
"ocr_summary",
|
"ocr_summary",
|
||||||
"ocr_documents",
|
"ocr_documents",
|
||||||
|
"review_form_values",
|
||||||
|
"business_time_context",
|
||||||
|
)
|
||||||
|
REVIEW_FLOW_CONTEXT_KEYS = {
|
||||||
|
"draft_claim_id",
|
||||||
|
"draft_claim_no",
|
||||||
|
"draft_status",
|
||||||
|
"request_context",
|
||||||
|
"attachment_names",
|
||||||
|
"attachment_count",
|
||||||
|
"ocr_summary",
|
||||||
|
"ocr_documents",
|
||||||
|
"review_form_values",
|
||||||
|
"business_time_context",
|
||||||
|
}
|
||||||
|
REVIEW_FLOW_CONTINUATION_KEYWORDS = (
|
||||||
|
"补充",
|
||||||
|
"继续",
|
||||||
|
"继续上传",
|
||||||
|
"当前",
|
||||||
|
"这张",
|
||||||
|
"这个",
|
||||||
|
"该单据",
|
||||||
|
"现有",
|
||||||
|
"已有",
|
||||||
|
"关联",
|
||||||
|
"合并",
|
||||||
|
"修改",
|
||||||
|
"更正",
|
||||||
|
"改成",
|
||||||
|
"调整",
|
||||||
|
"下一步",
|
||||||
|
"保存草稿",
|
||||||
|
)
|
||||||
|
NEW_EXPENSE_PROMPT_KEYWORDS = (
|
||||||
|
"申请报销",
|
||||||
|
"我要报销",
|
||||||
|
"我想报销",
|
||||||
|
"帮我报销",
|
||||||
|
"发起报销",
|
||||||
|
"提交报销",
|
||||||
|
"生成报销",
|
||||||
|
"创建报销",
|
||||||
|
"新建报销",
|
||||||
)
|
)
|
||||||
DEFAULT_CONVERSATION_RETENTION_DAYS = 3
|
DEFAULT_CONVERSATION_RETENTION_DAYS = 3
|
||||||
|
|
||||||
@@ -39,6 +83,7 @@ class AgentConversationService:
|
|||||||
normalized_id = str(conversation_id or "").strip()
|
normalized_id = str(conversation_id or "").strip()
|
||||||
normalized_user_id = str(user_id or "").strip() or None
|
normalized_user_id = str(user_id or "").strip() or None
|
||||||
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense"
|
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense"
|
||||||
|
incoming_draft_claim_id = self._resolve_draft_claim_id(context_json)
|
||||||
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
||||||
if conversation is not None and conversation.user_id != normalized_user_id:
|
if conversation is not None and conversation.user_id != normalized_user_id:
|
||||||
normalized_id = ""
|
normalized_id = ""
|
||||||
@@ -56,6 +101,7 @@ class AgentConversationService:
|
|||||||
source=source,
|
source=source,
|
||||||
entry_source=str(context_json.get("entry_source") or "").strip() or None,
|
entry_source=str(context_json.get("entry_source") or "").strip() or None,
|
||||||
title=self._resolve_title(context_json),
|
title=self._resolve_title(context_json),
|
||||||
|
draft_claim_id=incoming_draft_claim_id or None,
|
||||||
state_json=self._extract_state_json(context_json),
|
state_json=self._extract_state_json(context_json),
|
||||||
)
|
)
|
||||||
self.db.add(conversation)
|
self.db.add(conversation)
|
||||||
@@ -69,6 +115,8 @@ class AgentConversationService:
|
|||||||
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
|
conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None
|
||||||
if not conversation.title:
|
if not conversation.title:
|
||||||
conversation.title = self._resolve_title(context_json)
|
conversation.title = self._resolve_title(context_json)
|
||||||
|
if incoming_draft_claim_id:
|
||||||
|
conversation.draft_claim_id = incoming_draft_claim_id
|
||||||
conversation.state_json = self._merge_state_json(
|
conversation.state_json = self._merge_state_json(
|
||||||
conversation.state_json,
|
conversation.state_json,
|
||||||
self._extract_state_json(context_json),
|
self._extract_state_json(context_json),
|
||||||
@@ -86,7 +134,11 @@ class AgentConversationService:
|
|||||||
resolved_retention_days = retention_days or self._resolve_retention_days()
|
resolved_retention_days = retention_days or self._resolve_retention_days()
|
||||||
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
|
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
|
||||||
stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff)
|
stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff)
|
||||||
expired_conversations = list(self.db.scalars(stmt).all())
|
expired_conversations = [
|
||||||
|
conversation
|
||||||
|
for conversation in self.db.scalars(stmt).all()
|
||||||
|
if not self._is_saved_conversation(conversation)
|
||||||
|
]
|
||||||
if not expired_conversations:
|
if not expired_conversations:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -96,6 +148,13 @@ class AgentConversationService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return len(expired_conversations)
|
return len(expired_conversations)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_saved_conversation(conversation: AgentConversation) -> bool:
|
||||||
|
if str(conversation.draft_claim_id or "").strip():
|
||||||
|
return True
|
||||||
|
state_json = dict(conversation.state_json or {})
|
||||||
|
return bool(str(state_json.get("draft_claim_id") or "").strip())
|
||||||
|
|
||||||
def _resolve_retention_days(self) -> int:
|
def _resolve_retention_days(self) -> int:
|
||||||
try:
|
try:
|
||||||
settings_row, _ = SettingsService(self.db).ensure_settings_ready()
|
settings_row, _ = SettingsService(self.db).ensure_settings_ready()
|
||||||
@@ -178,10 +237,18 @@ class AgentConversationService:
|
|||||||
*,
|
*,
|
||||||
conversation: AgentConversation,
|
conversation: AgentConversation,
|
||||||
context_json: dict[str, Any],
|
context_json: dict[str, Any],
|
||||||
|
message: str | None = None,
|
||||||
history_limit: int = 8,
|
history_limit: int = 8,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
merged = dict(context_json or {})
|
merged = dict(context_json or {})
|
||||||
state_json = dict(conversation.state_json or {})
|
state_json = dict(conversation.state_json or {})
|
||||||
|
should_hydrate_review_flow = self._should_hydrate_review_flow_context(
|
||||||
|
context_json=merged,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
if not should_hydrate_review_flow:
|
||||||
|
for key in REVIEW_FLOW_CONTEXT_KEYS:
|
||||||
|
merged.pop(key, None)
|
||||||
|
|
||||||
merged["conversation_id"] = conversation.conversation_id
|
merged["conversation_id"] = conversation.conversation_id
|
||||||
merged["conversation_history"] = self.list_message_history(
|
merged["conversation_history"] = self.list_message_history(
|
||||||
@@ -192,16 +259,58 @@ class AgentConversationService:
|
|||||||
merged.setdefault("conversation_scenario", conversation.last_scenario)
|
merged.setdefault("conversation_scenario", conversation.last_scenario)
|
||||||
if conversation.last_intent:
|
if conversation.last_intent:
|
||||||
merged.setdefault("conversation_intent", conversation.last_intent)
|
merged.setdefault("conversation_intent", conversation.last_intent)
|
||||||
if conversation.draft_claim_id and not str(merged.get("draft_claim_id") or "").strip():
|
if (
|
||||||
|
should_hydrate_review_flow
|
||||||
|
and conversation.draft_claim_id
|
||||||
|
and not str(merged.get("draft_claim_id") or "").strip()
|
||||||
|
):
|
||||||
merged["draft_claim_id"] = conversation.draft_claim_id
|
merged["draft_claim_id"] = conversation.draft_claim_id
|
||||||
merged["conversation_state"] = state_json
|
merged["conversation_state"] = state_json
|
||||||
|
|
||||||
for key in STATEFUL_CONTEXT_KEYS:
|
for key in STATEFUL_CONTEXT_KEYS:
|
||||||
|
if key in REVIEW_FLOW_CONTEXT_KEYS and not should_hydrate_review_flow:
|
||||||
|
continue
|
||||||
if self._is_empty_value(merged.get(key)) and not self._is_empty_value(state_json.get(key)):
|
if self._is_empty_value(merged.get(key)) and not self._is_empty_value(state_json.get(key)):
|
||||||
merged[key] = state_json.get(key)
|
merged[key] = state_json.get(key)
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_hydrate_review_flow_context(
|
||||||
|
*,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
message: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if isinstance(context_json.get("expense_scene_selection"), dict):
|
||||||
|
return True
|
||||||
|
if AgentConversationService._resolve_draft_claim_id(context_json):
|
||||||
|
compact_message = str(message or "").replace(" ", "")
|
||||||
|
if compact_message and any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
if str(context_json.get("review_action") or "").strip():
|
||||||
|
return True
|
||||||
|
if str(context_json.get("entry_source") or "").strip() == "detail":
|
||||||
|
return True
|
||||||
|
if not AgentConversationService._is_empty_value(context_json.get("attachment_names")):
|
||||||
|
return True
|
||||||
|
if not AgentConversationService._is_empty_value(context_json.get("ocr_documents")):
|
||||||
|
return True
|
||||||
|
if str(context_json.get("ocr_summary") or "").strip():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
if int(context_json.get("attachment_count") or 0) > 0:
|
||||||
|
return True
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
compact_message = str(message or "").replace(" ", "")
|
||||||
|
if not compact_message:
|
||||||
|
return False
|
||||||
|
if any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
|
||||||
|
return False
|
||||||
|
return any(keyword in compact_message for keyword in REVIEW_FLOW_CONTINUATION_KEYWORDS)
|
||||||
|
|
||||||
def append_message(
|
def append_message(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -354,6 +463,38 @@ class AgentConversationService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return len(conversations)
|
return len(conversations)
|
||||||
|
|
||||||
|
def delete_conversations_for_draft_claim(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
claim_id: str | None,
|
||||||
|
source: str | None = "user_message",
|
||||||
|
session_type: str | None = "expense",
|
||||||
|
) -> int:
|
||||||
|
normalized_claim_id = str(claim_id or "").strip()
|
||||||
|
if not normalized_claim_id:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
stmt = select(AgentConversation).where(AgentConversation.draft_claim_id == normalized_claim_id)
|
||||||
|
if source:
|
||||||
|
stmt = stmt.where(AgentConversation.source == source)
|
||||||
|
conversations = list(self.db.scalars(stmt).all())
|
||||||
|
normalized_session_type = str(session_type or "").strip()
|
||||||
|
if normalized_session_type:
|
||||||
|
conversations = [
|
||||||
|
conversation
|
||||||
|
for conversation in conversations
|
||||||
|
if (str((conversation.state_json or {}).get("session_type") or "").strip() or "expense")
|
||||||
|
== normalized_session_type
|
||||||
|
]
|
||||||
|
if not conversations:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for conversation in conversations:
|
||||||
|
self.db.delete(conversation)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
return len(conversations)
|
||||||
|
|
||||||
def delete_conversation(
|
def delete_conversation(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -478,11 +619,28 @@ class AgentConversationService:
|
|||||||
continue
|
continue
|
||||||
state_json[key] = value
|
state_json[key] = value
|
||||||
|
|
||||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
draft_claim_id = AgentConversationService._resolve_draft_claim_id(context_json)
|
||||||
if draft_claim_id:
|
if draft_claim_id:
|
||||||
state_json["draft_claim_id"] = draft_claim_id
|
state_json["draft_claim_id"] = draft_claim_id
|
||||||
return state_json
|
return state_json
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_draft_claim_id(context_json: dict[str, Any]) -> str:
|
||||||
|
draft_claim_id = str((context_json or {}).get("draft_claim_id") or "").strip()
|
||||||
|
if draft_claim_id:
|
||||||
|
return draft_claim_id
|
||||||
|
|
||||||
|
request_context = (context_json or {}).get("request_context")
|
||||||
|
if isinstance(request_context, dict):
|
||||||
|
return str(
|
||||||
|
request_context.get("claim_id")
|
||||||
|
or request_context.get("claimId")
|
||||||
|
or request_context.get("draft_claim_id")
|
||||||
|
or request_context.get("draftClaimId")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_state_json(
|
def _merge_state_json(
|
||||||
current_state: dict[str, Any] | None,
|
current_state: dict[str, Any] | None,
|
||||||
|
|||||||
@@ -34,13 +34,20 @@ from app.models.financial_record import (
|
|||||||
ExpenseClaim,
|
ExpenseClaim,
|
||||||
ExpenseClaimItem,
|
ExpenseClaimItem,
|
||||||
)
|
)
|
||||||
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
from app.services.agent_asset_spreadsheet import (
|
from app.services.agent_asset_spreadsheet import (
|
||||||
AgentAssetSpreadsheetManager,
|
AgentAssetSpreadsheetManager,
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
FINANCE_RULES_LIBRARY,
|
FINANCE_RULES_LIBRARY,
|
||||||
|
RISK_RULES_LIBRARY,
|
||||||
RuleSpreadsheetMeta,
|
RuleSpreadsheetMeta,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PLATFORM_DESTINATION_LOCATION_RULE_CODE = "risk.travel.destination_receipt_location"
|
||||||
|
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME = "risk.travel.destination_receipt_location.json"
|
||||||
from app.services.expense_rule_runtime import (
|
from app.services.expense_rule_runtime import (
|
||||||
build_scene_submission_standard_markdown,
|
build_scene_submission_standard_markdown,
|
||||||
build_travel_risk_control_standard_markdown,
|
build_travel_risk_control_standard_markdown,
|
||||||
@@ -88,6 +95,9 @@ LEGACY_RULE_CODES = (
|
|||||||
|
|
||||||
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
|
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
|
||||||
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
||||||
|
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
|
||||||
|
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",)
|
||||||
|
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("费用科目",)
|
||||||
|
|
||||||
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
||||||
"kind": "policy_rule_draft",
|
"kind": "policy_rule_draft",
|
||||||
@@ -263,7 +273,7 @@ class AgentFoundationService:
|
|||||||
name="公司差旅费报销规则",
|
name="公司差旅费报销规则",
|
||||||
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
||||||
domain=AgentAssetDomain.EXPENSE.value,
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
scenario_json=["expense", "travel_policy", "travel_standard"],
|
scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
|
||||||
owner="财务制度管理组",
|
owner="财务制度管理组",
|
||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
@@ -276,9 +286,36 @@ class AgentFoundationService:
|
|||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
"rule_library": FINANCE_RULES_LIBRARY,
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "差旅报销 Excel 模板",
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
platform_risk_assets = self._build_platform_risk_seed_assets()
|
||||||
|
company_communication_rule = AgentAsset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
|
name="公司通信费报销规则",
|
||||||
|
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
|
||||||
|
owner="财务制度管理组",
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
published_version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
working_version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
config_json={
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
|
},
|
||||||
|
)
|
||||||
skill_expense_asset = AgentAsset(
|
skill_expense_asset = AgentAsset(
|
||||||
asset_type=AgentAssetType.SKILL.value,
|
asset_type=AgentAssetType.SKILL.value,
|
||||||
code="skill.expense.summary_lookup",
|
code="skill.expense.summary_lookup",
|
||||||
@@ -405,7 +442,9 @@ class AgentFoundationService:
|
|||||||
attachment_rule,
|
attachment_rule,
|
||||||
scene_submission_rule,
|
scene_submission_rule,
|
||||||
travel_policy_rule,
|
travel_policy_rule,
|
||||||
|
*platform_risk_assets,
|
||||||
company_travel_rule,
|
company_travel_rule,
|
||||||
|
company_communication_rule,
|
||||||
skill_expense_asset,
|
skill_expense_asset,
|
||||||
skill_ar_asset,
|
skill_ar_asset,
|
||||||
invoice_mcp_asset,
|
invoice_mcp_asset,
|
||||||
@@ -423,6 +462,11 @@ class AgentFoundationService:
|
|||||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
actor_name="系统初始化",
|
actor_name="系统初始化",
|
||||||
)
|
)
|
||||||
|
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
|
||||||
|
company_communication_rule,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
actor_name="系统初始化",
|
||||||
|
)
|
||||||
|
|
||||||
self.db.add_all(
|
self.db.add_all(
|
||||||
[
|
[
|
||||||
@@ -472,6 +516,17 @@ class AgentFoundationService:
|
|||||||
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
|
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
|
||||||
created_by="系统初始化",
|
created_by="系统初始化",
|
||||||
),
|
),
|
||||||
|
*[
|
||||||
|
AgentAssetVersion(
|
||||||
|
asset=asset,
|
||||||
|
version="v1.0.0",
|
||||||
|
content=self._platform_risk_rule_markdown(asset),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note=f"平台通用风险规则:{asset.name}",
|
||||||
|
created_by="系统初始化",
|
||||||
|
)
|
||||||
|
for asset in platform_risk_assets
|
||||||
|
],
|
||||||
AgentAssetVersion(
|
AgentAssetVersion(
|
||||||
asset=company_travel_rule,
|
asset=company_travel_rule,
|
||||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||||
@@ -484,6 +539,18 @@ class AgentFoundationService:
|
|||||||
change_note="初始化差旅费报销 Excel 规则表。",
|
change_note="初始化差旅费报销 Excel 规则表。",
|
||||||
created_by="系统初始化",
|
created_by="系统初始化",
|
||||||
),
|
),
|
||||||
|
AgentAssetVersion(
|
||||||
|
asset=company_communication_rule,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||||
|
rule_name=company_communication_rule.name,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
metadata=company_communication_rule_meta,
|
||||||
|
),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note="初始化通信费报销 Excel 规则表。",
|
||||||
|
created_by="系统初始化",
|
||||||
|
),
|
||||||
AgentAssetVersion(
|
AgentAssetVersion(
|
||||||
asset=skill_expense_asset,
|
asset=skill_expense_asset,
|
||||||
version="v1.0.0",
|
version="v1.0.0",
|
||||||
@@ -635,6 +702,14 @@ class AgentFoundationService:
|
|||||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||||
reviewed_at=datetime.now(UTC),
|
reviewed_at=datetime.now(UTC),
|
||||||
),
|
),
|
||||||
|
AgentAssetReview(
|
||||||
|
asset=company_communication_rule,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
reviewer="顾承宇",
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1001,6 +1076,9 @@ class AgentFoundationService:
|
|||||||
company_travel_rule = self.db.scalar(
|
company_travel_rule = self.db.scalar(
|
||||||
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
)
|
)
|
||||||
|
company_communication_rule = self.db.scalar(
|
||||||
|
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
|
||||||
|
)
|
||||||
|
|
||||||
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
|
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
|
||||||
attachment_rule = self._create_seed_asset(
|
attachment_rule = self._create_seed_asset(
|
||||||
@@ -1189,6 +1267,8 @@ class AgentFoundationService:
|
|||||||
reviewed_at=datetime.now(UTC),
|
reviewed_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.sync_platform_risk_rules_from_library()
|
||||||
|
|
||||||
if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes:
|
if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes:
|
||||||
company_travel_rule = self._create_seed_asset(
|
company_travel_rule = self._create_seed_asset(
|
||||||
asset_type=AgentAssetType.RULE.value,
|
asset_type=AgentAssetType.RULE.value,
|
||||||
@@ -1196,7 +1276,7 @@ class AgentFoundationService:
|
|||||||
name="公司差旅费报销规则",
|
name="公司差旅费报销规则",
|
||||||
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
||||||
domain=AgentAssetDomain.EXPENSE.value,
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
scenario_json=["expense", "travel_policy", "travel_standard"],
|
scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
|
||||||
owner="财务制度管理组",
|
owner="财务制度管理组",
|
||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
@@ -1206,11 +1286,36 @@ class AgentFoundationService:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "差旅报销 Excel 模板",
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if COMPANY_COMMUNICATION_EXPENSE_RULE_CODE not in existing_codes:
|
||||||
|
company_communication_rule = self._create_seed_asset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
|
name="公司通信费报销规则",
|
||||||
|
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
|
||||||
|
owner="财务制度管理组",
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
config_json={
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if company_travel_rule is not None:
|
if company_travel_rule is not None:
|
||||||
|
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
|
||||||
if not str(company_travel_rule.current_version or "").strip():
|
if not str(company_travel_rule.current_version or "").strip():
|
||||||
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
|
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
|
||||||
if not str(company_travel_rule.working_version or "").strip():
|
if not str(company_travel_rule.working_version or "").strip():
|
||||||
@@ -1227,6 +1332,8 @@ class AgentFoundationService:
|
|||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
"rule_library": FINANCE_RULES_LIBRARY,
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "差旅报销 Excel 模板",
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
}
|
}
|
||||||
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
||||||
@@ -1256,6 +1363,55 @@ class AgentFoundationService:
|
|||||||
reviewed_at=datetime.now(UTC),
|
reviewed_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if company_communication_rule is not None:
|
||||||
|
company_communication_rule.scenario_json = list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON)
|
||||||
|
if not str(company_communication_rule.current_version or "").strip():
|
||||||
|
company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION
|
||||||
|
if not str(company_communication_rule.working_version or "").strip():
|
||||||
|
company_communication_rule.working_version = company_communication_rule.current_version
|
||||||
|
if not str(company_communication_rule.published_version or "").strip():
|
||||||
|
company_communication_rule.published_version = company_communication_rule.current_version
|
||||||
|
if not str(company_communication_rule.status or "").strip():
|
||||||
|
company_communication_rule.status = AgentAssetStatus.ACTIVE.value
|
||||||
|
company_communication_rule.description = "通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。"
|
||||||
|
company_communication_rule.config_json = {
|
||||||
|
**(company_communication_rule.config_json or {}),
|
||||||
|
"severity": "medium",
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "财务规则",
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
|
}
|
||||||
|
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
|
||||||
|
company_communication_rule,
|
||||||
|
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
|
||||||
|
actor_name="系统初始化",
|
||||||
|
)
|
||||||
|
self._ensure_asset_version(
|
||||||
|
company_communication_rule,
|
||||||
|
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
|
||||||
|
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||||
|
rule_name=company_communication_rule.name,
|
||||||
|
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),
|
||||||
|
metadata=company_communication_rule_meta,
|
||||||
|
),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note="初始化通信费报销 Excel 规则表。",
|
||||||
|
created_by="系统初始化",
|
||||||
|
)
|
||||||
|
if str(company_communication_rule.current_version or "").strip() == COMPANY_COMMUNICATION_RULE_VERSION:
|
||||||
|
self._ensure_asset_review(
|
||||||
|
company_communication_rule,
|
||||||
|
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||||
|
reviewer="顾承宇",
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
if "skill.ar.aging_summary" not in existing_codes:
|
if "skill.ar.aging_summary" not in existing_codes:
|
||||||
asset = self._create_seed_asset(
|
asset = self._create_seed_asset(
|
||||||
asset_type=AgentAssetType.SKILL.value,
|
asset_type=AgentAssetType.SKILL.value,
|
||||||
@@ -1435,19 +1591,6 @@ class AgentFoundationService:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
existing_path = None
|
existing_path = None
|
||||||
if existing_path is not None and existing_path.exists():
|
if existing_path is not None and existing_path.exists():
|
||||||
metadata = RuleSpreadsheetMeta(
|
|
||||||
file_name=str(existing_document.get("file_name") or COMPANY_TRAVEL_EXPENSE_RULE_FILENAME),
|
|
||||||
storage_key=storage_key,
|
|
||||||
mime_type=str(existing_document.get("mime_type") or "").strip()
|
|
||||||
or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
size_bytes=int(existing_document.get("size_bytes") or existing_path.stat().st_size),
|
|
||||||
checksum=hashlib.sha256(existing_path.read_bytes()).hexdigest(),
|
|
||||||
updated_at=str(existing_document.get("updated_at") or "").strip()
|
|
||||||
or datetime.now(UTC).isoformat(),
|
|
||||||
updated_by=str(existing_document.get("updated_by") or actor_name).strip()
|
|
||||||
or actor_name,
|
|
||||||
source=str(existing_document.get("source") or "seed").strip() or "seed",
|
|
||||||
)
|
|
||||||
asset.config_json = {
|
asset.config_json = {
|
||||||
**(asset.config_json or {}),
|
**(asset.config_json or {}),
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
@@ -1461,17 +1604,8 @@ class AgentFoundationService:
|
|||||||
"storage_key": live_document.storage_key,
|
"storage_key": live_document.storage_key,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return metadata
|
return live_document
|
||||||
|
|
||||||
live_content = manager.resolve_storage_path(live_document.storage_key).read_bytes()
|
|
||||||
metadata = manager.store_spreadsheet(
|
|
||||||
asset_id=asset.id,
|
|
||||||
version=version,
|
|
||||||
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
|
||||||
content=live_content,
|
|
||||||
actor_name=actor_name,
|
|
||||||
source="seed",
|
|
||||||
)
|
|
||||||
asset.config_json = {
|
asset.config_json = {
|
||||||
**(asset.config_json or {}),
|
**(asset.config_json or {}),
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
@@ -1485,7 +1619,22 @@ class AgentFoundationService:
|
|||||||
"storage_key": live_document.storage_key,
|
"storage_key": live_document.storage_key,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return metadata
|
return live_document
|
||||||
|
|
||||||
|
def _ensure_company_communication_rule_spreadsheet_seed(
|
||||||
|
self,
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
actor_name: str,
|
||||||
|
):
|
||||||
|
return self._ensure_finance_rule_spreadsheet_seed(
|
||||||
|
asset,
|
||||||
|
version=version,
|
||||||
|
actor_name=actor_name,
|
||||||
|
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
|
fallback_sheet_name="通信费报销规则",
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_or_build_company_travel_rule_file(
|
def _read_or_build_company_travel_rule_file(
|
||||||
@@ -1501,6 +1650,91 @@ class AgentFoundationService:
|
|||||||
return live_path.read_bytes()
|
return live_path.read_bytes()
|
||||||
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
|
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
|
||||||
|
|
||||||
|
def _ensure_finance_rule_spreadsheet_seed(
|
||||||
|
self,
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
version: str,
|
||||||
|
actor_name: str,
|
||||||
|
file_name: str,
|
||||||
|
fallback_sheet_name: str,
|
||||||
|
):
|
||||||
|
manager = AgentAssetSpreadsheetManager()
|
||||||
|
manager.ensure_rule_library_dirs()
|
||||||
|
live_document = manager.store_rule_library_spreadsheet(
|
||||||
|
library=FINANCE_RULES_LIBRARY,
|
||||||
|
file_name=file_name,
|
||||||
|
content=self._read_or_build_finance_rule_file(
|
||||||
|
manager,
|
||||||
|
file_name=file_name,
|
||||||
|
fallback_sheet_name=fallback_sheet_name,
|
||||||
|
),
|
||||||
|
actor_name=actor_name,
|
||||||
|
source="rule-library",
|
||||||
|
)
|
||||||
|
existing_document = (
|
||||||
|
asset.config_json.get("rule_document")
|
||||||
|
if isinstance(asset.config_json, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
storage_key = (
|
||||||
|
str(existing_document.get("storage_key") or "").strip()
|
||||||
|
if isinstance(existing_document, dict)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
if storage_key:
|
||||||
|
try:
|
||||||
|
existing_path = manager.resolve_storage_path(storage_key)
|
||||||
|
except FileNotFoundError:
|
||||||
|
existing_path = None
|
||||||
|
if existing_path is not None and existing_path.exists():
|
||||||
|
asset.config_json = {
|
||||||
|
**(asset.config_json or {}),
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"tag": "财务规则",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"rule_document": {
|
||||||
|
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||||
|
live_document,
|
||||||
|
asset_version=version,
|
||||||
|
),
|
||||||
|
"storage_key": live_document.storage_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return live_document
|
||||||
|
|
||||||
|
asset.config_json = {
|
||||||
|
**(asset.config_json or {}),
|
||||||
|
"detail_mode": "spreadsheet",
|
||||||
|
"tag": "财务规则",
|
||||||
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"rule_document": {
|
||||||
|
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||||
|
live_document,
|
||||||
|
asset_version=version,
|
||||||
|
),
|
||||||
|
"storage_key": live_document.storage_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return live_document
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_or_build_finance_rule_file(
|
||||||
|
manager: AgentAssetSpreadsheetManager,
|
||||||
|
*,
|
||||||
|
file_name: str,
|
||||||
|
fallback_sheet_name: str,
|
||||||
|
) -> bytes:
|
||||||
|
live_key = (
|
||||||
|
Path("rules")
|
||||||
|
/ FINANCE_RULES_LIBRARY
|
||||||
|
/ file_name
|
||||||
|
).as_posix()
|
||||||
|
live_path = manager.resolve_storage_path(live_key)
|
||||||
|
if live_path.exists():
|
||||||
|
return live_path.read_bytes()
|
||||||
|
return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name)
|
||||||
|
|
||||||
def _create_seed_asset(
|
def _create_seed_asset(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1710,6 +1944,229 @@ class AgentFoundationService:
|
|||||||
def _travel_risk_control_standard_markdown(self, *, version: str = "v1.1.0") -> str:
|
def _travel_risk_control_standard_markdown(self, *, version: str = "v1.1.0") -> str:
|
||||||
return self._markdown_content(build_travel_risk_control_standard_markdown())
|
return self._markdown_content(build_travel_risk_control_standard_markdown())
|
||||||
|
|
||||||
|
def _iter_platform_risk_manifests(self) -> list[tuple[str, dict[str, object]]]:
|
||||||
|
manager = AgentAssetRuleLibraryManager()
|
||||||
|
manifests: list[tuple[str, dict[str, object]]] = []
|
||||||
|
for file_name in sorted(manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY)):
|
||||||
|
payload = manager.read_rule_library_json(library=RISK_RULES_LIBRARY, file_name=file_name)
|
||||||
|
if payload.get("enabled") is False:
|
||||||
|
continue
|
||||||
|
manifests.append((file_name, payload))
|
||||||
|
return manifests
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_platform_risk_category(manifest: dict[str, object]) -> str:
|
||||||
|
explicit = str(manifest.get("risk_category") or "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
rule_code = str(manifest.get("rule_code") or "").strip().lower()
|
||||||
|
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||||
|
domains = {str(item or "").strip().lower() for item in applies_to.get("domains") or []}
|
||||||
|
expense_types = {
|
||||||
|
str(item or "").strip().lower() for item in applies_to.get("expense_types") or []
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule_code.startswith("risk.invoice."):
|
||||||
|
return "发票"
|
||||||
|
if "meal" in domains or "entertainment" in expense_types:
|
||||||
|
return "餐饮招待"
|
||||||
|
if "transport" in expense_types or "consecutive_transport" in rule_code:
|
||||||
|
return "交通出行"
|
||||||
|
if "office" in expense_types:
|
||||||
|
return "办公物料"
|
||||||
|
if "travel" in domains or rule_code.startswith("risk.travel."):
|
||||||
|
return "差旅"
|
||||||
|
if rule_code.startswith("risk.expense."):
|
||||||
|
return "费用科目"
|
||||||
|
return "通用"
|
||||||
|
|
||||||
|
def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]:
|
||||||
|
category = self._resolve_platform_risk_category(manifest)
|
||||||
|
return [category] if category else ["通用"]
|
||||||
|
|
||||||
|
def _platform_risk_config_json(self, file_name: str, manifest: dict[str, object]) -> dict[str, object]:
|
||||||
|
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
|
||||||
|
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||||
|
risk_category = self._resolve_platform_risk_category(manifest)
|
||||||
|
return {
|
||||||
|
"severity": str(fail_outcome.get("severity") or "medium"),
|
||||||
|
"enabled": True,
|
||||||
|
"tag": "风险规则",
|
||||||
|
"detail_mode": "json_risk",
|
||||||
|
"risk_category": risk_category,
|
||||||
|
"rule_library": RISK_RULES_LIBRARY,
|
||||||
|
"rule_document": {
|
||||||
|
"file_name": file_name,
|
||||||
|
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||||
|
},
|
||||||
|
"ontology_signal": str(manifest.get("ontology_signal") or "").strip(),
|
||||||
|
"evaluator": str(manifest.get("evaluator") or "").strip(),
|
||||||
|
"source_ref": (
|
||||||
|
(manifest.get("metadata") or {}).get("source_ref")
|
||||||
|
if isinstance(manifest.get("metadata"), dict)
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_platform_risk_seed_assets(self) -> list[AgentAsset]:
|
||||||
|
assets: list[AgentAsset] = []
|
||||||
|
for file_name, manifest in self._iter_platform_risk_manifests():
|
||||||
|
rule_code = str(manifest.get("rule_code") or "").strip()
|
||||||
|
if not rule_code:
|
||||||
|
continue
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||||
|
rule_description = str(manifest.get("description") or "").strip()
|
||||||
|
assets.append(
|
||||||
|
AgentAsset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=rule_code,
|
||||||
|
name=str(manifest.get("name") or rule_code),
|
||||||
|
description=rule_description
|
||||||
|
or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=self._platform_risk_scenario_json(manifest),
|
||||||
|
owner=str(metadata.get("owner") or "风控与审计部"),
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version="v1.0.0",
|
||||||
|
published_version="v1.0.0",
|
||||||
|
working_version="v1.0.0",
|
||||||
|
config_json=self._platform_risk_config_json(file_name, manifest),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return assets
|
||||||
|
|
||||||
|
def sync_platform_risk_rules_from_library(self) -> int:
|
||||||
|
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
|
||||||
|
before_count = len(existing_codes)
|
||||||
|
self._ensure_platform_risk_rules_from_library(existing_codes)
|
||||||
|
self.db.flush()
|
||||||
|
after_codes = set(self.db.scalars(select(AgentAsset.code)).all())
|
||||||
|
synced = max(len(after_codes) - before_count, 0)
|
||||||
|
manifest_count = len(self._iter_platform_risk_manifests())
|
||||||
|
logger.info(
|
||||||
|
"Platform risk rules synced from library",
|
||||||
|
extra={"manifest_count": manifest_count, "created_count": synced, "total": len(after_codes)},
|
||||||
|
)
|
||||||
|
return manifest_count
|
||||||
|
|
||||||
|
def _ensure_platform_risk_rules_from_library(self, existing_codes: set[str]) -> None:
|
||||||
|
for file_name, manifest in self._iter_platform_risk_manifests():
|
||||||
|
rule_code = str(manifest.get("rule_code") or "").strip()
|
||||||
|
if not rule_code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||||
|
rule_description = str(manifest.get("description") or "").strip()
|
||||||
|
config_json = self._platform_risk_config_json(file_name, manifest)
|
||||||
|
scenario_json = self._platform_risk_scenario_json(manifest)
|
||||||
|
|
||||||
|
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == rule_code))
|
||||||
|
if asset is None and rule_code not in existing_codes:
|
||||||
|
asset = self._create_seed_asset(
|
||||||
|
asset_type=AgentAssetType.RULE.value,
|
||||||
|
code=rule_code,
|
||||||
|
name=str(manifest.get("name") or rule_code),
|
||||||
|
description=rule_description
|
||||||
|
or f"平台通用风险规则:{source_ref or manifest.get('name') or rule_code}",
|
||||||
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
|
scenario_json=scenario_json,
|
||||||
|
owner=str(metadata.get("owner") or "风控与审计部"),
|
||||||
|
reviewer="顾承宇",
|
||||||
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
|
current_version="v1.0.0",
|
||||||
|
config_json=config_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
if asset is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not str(asset.current_version or "").strip():
|
||||||
|
asset.current_version = "v1.0.0"
|
||||||
|
if not str(asset.working_version or "").strip():
|
||||||
|
asset.working_version = asset.current_version
|
||||||
|
if not str(asset.published_version or "").strip():
|
||||||
|
asset.published_version = asset.current_version
|
||||||
|
asset.status = asset.status or AgentAssetStatus.ACTIVE.value
|
||||||
|
asset.name = str(manifest.get("name") or asset.name or rule_code)
|
||||||
|
if rule_description:
|
||||||
|
asset.description = rule_description
|
||||||
|
asset.config_json = config_json
|
||||||
|
asset.scenario_json = scenario_json
|
||||||
|
|
||||||
|
self._ensure_asset_version(
|
||||||
|
asset,
|
||||||
|
version="v1.0.0",
|
||||||
|
content=self._platform_risk_rule_markdown(asset, manifest=manifest, file_name=file_name),
|
||||||
|
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||||
|
change_note=f"平台通用风险规则:{asset.name}",
|
||||||
|
created_by="系统初始化",
|
||||||
|
)
|
||||||
|
self._ensure_asset_review(
|
||||||
|
asset,
|
||||||
|
version="v1.0.0",
|
||||||
|
reviewer="顾承宇",
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="平台内置风险规则,供提交验审与风险问答共用。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _platform_risk_rule_markdown(
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
manifest: dict[str, object] | None = None,
|
||||||
|
file_name: str = "",
|
||||||
|
) -> str:
|
||||||
|
config = asset.config_json if isinstance(asset.config_json, dict) else {}
|
||||||
|
rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
|
||||||
|
resolved_file_name = file_name or str(rule_document.get("file_name") or "").strip()
|
||||||
|
evaluator = str(config.get("evaluator") or (manifest or {}).get("evaluator") or "").strip()
|
||||||
|
ontology_signal = str(config.get("ontology_signal") or (manifest or {}).get("ontology_signal") or "").strip()
|
||||||
|
source_ref = str(config.get("source_ref") or "").strip()
|
||||||
|
if not source_ref and isinstance(manifest, dict):
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
source_ref = str(metadata.get("source_ref") or "").strip()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# {asset.name}",
|
||||||
|
"",
|
||||||
|
"## 规则类型",
|
||||||
|
"",
|
||||||
|
"- 平台内置通用风险规则(`json_risk`)",
|
||||||
|
]
|
||||||
|
if evaluator:
|
||||||
|
lines.append(f"- 检查器:`{evaluator}`")
|
||||||
|
if ontology_signal:
|
||||||
|
lines.append(f"- 本体信号:`{ontology_signal}`")
|
||||||
|
if source_ref:
|
||||||
|
lines.extend(["", "## 来源", "", f"- {source_ref}"])
|
||||||
|
if resolved_file_name:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## 配置文件",
|
||||||
|
"",
|
||||||
|
f"- `rules/{RISK_RULES_LIBRARY}/{resolved_file_name}`",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _platform_destination_location_risk_markdown() -> str:
|
||||||
|
return AgentFoundationService._platform_risk_rule_markdown(
|
||||||
|
AgentAsset(name="申报地点与票据地点一致", config_json={"evaluator": "location_consistency"}),
|
||||||
|
manifest={
|
||||||
|
"evaluator": "location_consistency",
|
||||||
|
"ontology_signal": "location_mismatch",
|
||||||
|
"metadata": {"source_ref": "常用risk.txt / 一、出差类 / 行程不符"},
|
||||||
|
},
|
||||||
|
file_name=PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _markdown_content(content: str) -> str:
|
def _markdown_content(content: str) -> str:
|
||||||
return content
|
return content
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.core.security import verify_password
|
from app.core.security import verify_password
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
||||||
from app.services.employee import EmployeeService
|
from app.services.employee import EmployeeService
|
||||||
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
||||||
@@ -31,8 +34,15 @@ class AuthenticatedUser:
|
|||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
department: str
|
||||||
position: str
|
position: str
|
||||||
grade: str
|
grade: str
|
||||||
|
employee_no: str
|
||||||
|
manager_name: str
|
||||||
|
location: str
|
||||||
|
cost_center: str
|
||||||
|
finance_owner_name: str
|
||||||
|
risk_profile: dict[str, Any]
|
||||||
role_codes: list[str]
|
role_codes: list[str]
|
||||||
email: str
|
email: str
|
||||||
avatar: str
|
avatar: str
|
||||||
@@ -78,8 +88,15 @@ class AuthService:
|
|||||||
username=admin_username or admin_email,
|
username=admin_username or admin_email,
|
||||||
name=display_name,
|
name=display_name,
|
||||||
role="管理员",
|
role="管理员",
|
||||||
|
department="",
|
||||||
position="系统管理员",
|
position="系统管理员",
|
||||||
grade="",
|
grade="",
|
||||||
|
employee_no="",
|
||||||
|
manager_name="",
|
||||||
|
location="",
|
||||||
|
cost_center="",
|
||||||
|
finance_owner_name="",
|
||||||
|
risk_profile={},
|
||||||
role_codes=["manager"],
|
role_codes=["manager"],
|
||||||
email=admin_email or f"{admin_username}@local",
|
email=admin_email or f"{admin_username}@local",
|
||||||
avatar=display_name[:1].upper(),
|
avatar=display_name[:1].upper(),
|
||||||
@@ -94,7 +111,11 @@ class AuthService:
|
|||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Employee)
|
select(Employee)
|
||||||
.options(selectinload(Employee.roles))
|
.options(
|
||||||
|
selectinload(Employee.organization_unit),
|
||||||
|
selectinload(Employee.manager),
|
||||||
|
selectinload(Employee.roles),
|
||||||
|
)
|
||||||
.where(func.lower(Employee.email) == identifier.lower())
|
.where(func.lower(Employee.email) == identifier.lower())
|
||||||
)
|
)
|
||||||
employee = self.db.execute(stmt).scalars().first()
|
employee = self.db.execute(stmt).scalars().first()
|
||||||
@@ -115,27 +136,91 @@ class AuthService:
|
|||||||
)
|
)
|
||||||
role_codes = [role.role_code for role in sorted_roles]
|
role_codes = [role.role_code for role in sorted_roles]
|
||||||
primary_role_code = role_codes[0] if role_codes else "user"
|
primary_role_code = role_codes[0] if role_codes else "user"
|
||||||
|
department = employee.organization_unit.name if employee.organization_unit is not None else ""
|
||||||
|
manager_name = self._resolve_manager_name(employee)
|
||||||
|
|
||||||
return AuthenticatedUser(
|
return AuthenticatedUser(
|
||||||
username=employee.email,
|
username=employee.email,
|
||||||
name=employee.name,
|
name=employee.name,
|
||||||
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
||||||
|
department=department,
|
||||||
position=employee.position,
|
position=employee.position,
|
||||||
grade=employee.grade,
|
grade=employee.grade,
|
||||||
|
employee_no=employee.employee_no,
|
||||||
|
manager_name=manager_name,
|
||||||
|
location=employee.location or "",
|
||||||
|
cost_center=employee.cost_center or "",
|
||||||
|
finance_owner_name=employee.finance_owner_name or "",
|
||||||
|
risk_profile=self._build_risk_profile(employee),
|
||||||
role_codes=role_codes or ["user"],
|
role_codes=role_codes or ["user"],
|
||||||
email=employee.email,
|
email=employee.email,
|
||||||
avatar=(employee.name or "?")[:1].upper(),
|
avatar=(employee.name or "?")[:1].upper(),
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_manager_name(employee: Employee) -> str:
|
||||||
|
if employee.manager is not None and employee.manager.name:
|
||||||
|
return str(employee.manager.name).strip()
|
||||||
|
if employee.organization_unit is not None and employee.organization_unit.manager_name:
|
||||||
|
return str(employee.organization_unit.manager_name).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _build_risk_profile(self, employee: Employee) -> dict[str, Any]:
|
||||||
|
since = datetime.now(UTC) - timedelta(days=90)
|
||||||
|
identity_values = [
|
||||||
|
str(employee.name or "").strip(),
|
||||||
|
str(employee.email or "").strip(),
|
||||||
|
str(employee.employee_no or "").strip(),
|
||||||
|
]
|
||||||
|
name_candidates = [item for item in dict.fromkeys(identity_values) if item]
|
||||||
|
conditions = [ExpenseClaim.employee_id == employee.id]
|
||||||
|
if name_candidates:
|
||||||
|
conditions.append(ExpenseClaim.employee_name.in_(name_candidates))
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(ExpenseClaim)
|
||||||
|
.where(or_(*conditions), ExpenseClaim.occurred_at >= since)
|
||||||
|
.order_by(ExpenseClaim.occurred_at.desc())
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
claims = list(self.db.scalars(stmt).all())
|
||||||
|
recent_risk_flags: list[str] = []
|
||||||
|
for claim in claims:
|
||||||
|
for flag in claim.risk_flags_json or []:
|
||||||
|
normalized = str(flag or "").strip()
|
||||||
|
if normalized and normalized not in recent_risk_flags:
|
||||||
|
recent_risk_flags.append(normalized)
|
||||||
|
if len(recent_risk_flags) >= 6:
|
||||||
|
break
|
||||||
|
if len(recent_risk_flags) >= 6:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"windowDays": 90,
|
||||||
|
"totalClaimCount": len(claims),
|
||||||
|
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
|
||||||
|
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
|
||||||
|
"recentRiskFlags": recent_risk_flags,
|
||||||
|
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
|
def _serialize_user(user: AuthenticatedUser) -> AuthUserRead:
|
||||||
return AuthUserRead(
|
return AuthUserRead(
|
||||||
username=user.username,
|
username=user.username,
|
||||||
name=user.name,
|
name=user.name,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
|
department=user.department,
|
||||||
|
departmentName=user.department,
|
||||||
position=user.position,
|
position=user.position,
|
||||||
grade=user.grade,
|
grade=user.grade,
|
||||||
|
employeeNo=user.employee_no,
|
||||||
|
managerName=user.manager_name,
|
||||||
|
location=user.location,
|
||||||
|
costCenter=user.cost_center,
|
||||||
|
financeOwnerName=user.finance_owner_name,
|
||||||
|
riskProfile=user.risk_profile,
|
||||||
roleCodes=user.role_codes,
|
roleCodes=user.role_codes,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
avatar=user.avatar,
|
avatar=user.avatar,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
|||||||
scene_code="travel",
|
scene_code="travel",
|
||||||
scene_label="差旅票据",
|
scene_label="差旅票据",
|
||||||
expense_type="travel",
|
expense_type="travel",
|
||||||
keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"),
|
keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"),
|
||||||
score_bias=0.32,
|
score_bias=0.32,
|
||||||
),
|
),
|
||||||
DocumentRule(
|
DocumentRule(
|
||||||
@@ -104,7 +104,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = (
|
|||||||
scene_code="transport",
|
scene_code="transport",
|
||||||
scene_label="交通票据",
|
scene_label="交通票据",
|
||||||
expense_type="transport",
|
expense_type="transport",
|
||||||
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
|
keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "乘车", "用车", "叫车", "车费", "车资", "的士", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"),
|
||||||
score_bias=0.38,
|
score_bias=0.38,
|
||||||
),
|
),
|
||||||
DocumentRule(
|
DocumentRule(
|
||||||
@@ -177,13 +177,14 @@ SUPPORTED_DOCUMENT_TYPES = tuple(DOCUMENT_TYPE_RULE_MAP.keys()) + ("other",)
|
|||||||
|
|
||||||
AMOUNT_PATTERNS = (
|
AMOUNT_PATTERNS = (
|
||||||
re.compile(
|
re.compile(
|
||||||
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)"
|
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
|
||||||
r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
r"[::\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
||||||
),
|
),
|
||||||
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
|
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
|
||||||
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
|
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
|
||||||
)
|
)
|
||||||
DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
|
DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
|
||||||
|
TIME_PATTERN = re.compile(r"(?<!\d)([01]?\d|2[0-3])[::]([0-5]\d)(?!\d)")
|
||||||
INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[::\s]*([A-Za-z0-9-]{6,24})")
|
INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[::\s]*([A-Za-z0-9-]{6,24})")
|
||||||
INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[::\s]*([A-Za-z0-9-]{6,24})")
|
INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[::\s]*([A-Za-z0-9-]{6,24})")
|
||||||
TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[::\s]*([A-Za-z0-9]{2,12})")
|
TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[::\s]*([A-Za-z0-9]{2,12})")
|
||||||
@@ -192,6 +193,58 @@ MERCHANT_PATTERNS = (
|
|||||||
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[::\s]*([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40})"),
|
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[::\s]*([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40})"),
|
||||||
re.compile(r"([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40}(?:酒店|宾馆|饭店|酒楼|餐厅|航空|铁路|滴滴出行|停车场|服务区))"),
|
re.compile(r"([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40}(?:酒店|宾馆|饭店|酒楼|餐厅|航空|铁路|滴滴出行|停车场|服务区))"),
|
||||||
)
|
)
|
||||||
|
DATE_FIELD_KEYS = {
|
||||||
|
"date",
|
||||||
|
"time",
|
||||||
|
"issued_at",
|
||||||
|
"invoice_date",
|
||||||
|
"issue_date",
|
||||||
|
"travel_date",
|
||||||
|
"trip_date",
|
||||||
|
"journey_date",
|
||||||
|
"departure_date",
|
||||||
|
"departure_time",
|
||||||
|
"depart_date",
|
||||||
|
"depart_time",
|
||||||
|
"boarding_date",
|
||||||
|
"boarding_time",
|
||||||
|
"train_date",
|
||||||
|
"train_time",
|
||||||
|
"train_departure_time",
|
||||||
|
"scheduled_departure_time",
|
||||||
|
"flight_date",
|
||||||
|
"flight_time",
|
||||||
|
"ride_date",
|
||||||
|
"ride_time",
|
||||||
|
"pickup_time",
|
||||||
|
"start_time",
|
||||||
|
}
|
||||||
|
TRIP_DATE_LABEL_BY_DOCUMENT_TYPE = {
|
||||||
|
"train_ticket": "列车出发时间",
|
||||||
|
"flight_itinerary": "起飞日期",
|
||||||
|
"taxi_receipt": "乘车时间",
|
||||||
|
"transport_receipt": "乘车时间",
|
||||||
|
"parking_toll_receipt": "通行日期",
|
||||||
|
}
|
||||||
|
TRIP_DATE_FIELD_LABEL_TOKENS = (
|
||||||
|
"日期",
|
||||||
|
"时间",
|
||||||
|
"开票日期",
|
||||||
|
"发生时间",
|
||||||
|
"行程日期",
|
||||||
|
"出发日期",
|
||||||
|
"出发时间",
|
||||||
|
"列车出发时间",
|
||||||
|
"发车日期",
|
||||||
|
"发车时间",
|
||||||
|
"开车时间",
|
||||||
|
"乘车日期",
|
||||||
|
"乘车时间",
|
||||||
|
"起飞日期",
|
||||||
|
"航班日期",
|
||||||
|
"上车时间",
|
||||||
|
"用车时间",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DocumentIntelligenceService:
|
class DocumentIntelligenceService:
|
||||||
@@ -212,7 +265,10 @@ class DocumentIntelligenceService:
|
|||||||
compact = re.sub(r"\s+", "", raw_text).lower()
|
compact = re.sub(r"\s+", "", raw_text).lower()
|
||||||
rule_match = _match_document_rule(compact)
|
rule_match = _match_document_rule(compact)
|
||||||
base_rule = rule_match.rule or DEFAULT_RULE
|
base_rule = rule_match.rule or DEFAULT_RULE
|
||||||
fields = tuple(_extract_document_fields(raw_text))
|
fields = _apply_document_type_field_labels(
|
||||||
|
tuple(_extract_document_fields(raw_text, base_rule.document_type)),
|
||||||
|
base_rule.document_type,
|
||||||
|
)
|
||||||
rule_insight = DocumentInsight(
|
rule_insight = DocumentInsight(
|
||||||
document_type=base_rule.document_type,
|
document_type=base_rule.document_type,
|
||||||
document_type_label=base_rule.document_type_label,
|
document_type_label=base_rule.document_type_label,
|
||||||
@@ -275,7 +331,10 @@ class DocumentIntelligenceService:
|
|||||||
for item in parsed.evidence
|
for item in parsed.evidence
|
||||||
if str(item or "").strip()
|
if str(item or "").strip()
|
||||||
][:4]
|
][:4]
|
||||||
normalized_fields = _normalize_llm_document_fields(parsed.fields)
|
normalized_fields = _apply_document_type_field_labels(
|
||||||
|
tuple(_normalize_llm_document_fields(parsed.fields)),
|
||||||
|
normalized_type,
|
||||||
|
)
|
||||||
|
|
||||||
return LlmDocumentClassification(
|
return LlmDocumentClassification(
|
||||||
document_type=normalized_type,
|
document_type=normalized_type,
|
||||||
@@ -312,7 +371,10 @@ class DocumentIntelligenceService:
|
|||||||
scene_code=rule_insight.scene_code,
|
scene_code=rule_insight.scene_code,
|
||||||
scene_label=rule_insight.scene_label,
|
scene_label=rule_insight.scene_label,
|
||||||
expense_type=rule_insight.expense_type,
|
expense_type=rule_insight.expense_type,
|
||||||
fields=merged_fields,
|
fields=_apply_document_type_field_labels(
|
||||||
|
merged_fields,
|
||||||
|
rule_insight.document_type,
|
||||||
|
),
|
||||||
classification_source=rule_insight.classification_source,
|
classification_source=rule_insight.classification_source,
|
||||||
classification_confidence=rule_insight.classification_confidence,
|
classification_confidence=rule_insight.classification_confidence,
|
||||||
evidence=rule_insight.evidence,
|
evidence=rule_insight.evidence,
|
||||||
@@ -337,7 +399,10 @@ class DocumentIntelligenceService:
|
|||||||
scene_code=rule_insight.scene_code,
|
scene_code=rule_insight.scene_code,
|
||||||
scene_label=rule_insight.scene_label,
|
scene_label=rule_insight.scene_label,
|
||||||
expense_type=rule_insight.expense_type,
|
expense_type=rule_insight.expense_type,
|
||||||
fields=merged_fields,
|
fields=_apply_document_type_field_labels(
|
||||||
|
merged_fields,
|
||||||
|
rule_insight.document_type,
|
||||||
|
),
|
||||||
classification_source=rule_insight.classification_source,
|
classification_source=rule_insight.classification_source,
|
||||||
classification_confidence=rule_insight.classification_confidence,
|
classification_confidence=rule_insight.classification_confidence,
|
||||||
evidence=rule_insight.evidence,
|
evidence=rule_insight.evidence,
|
||||||
@@ -354,7 +419,7 @@ class DocumentIntelligenceService:
|
|||||||
scene_code=rule.scene_code if parsed.scene_code == "other" else parsed.scene_code,
|
scene_code=rule.scene_code if parsed.scene_code == "other" else parsed.scene_code,
|
||||||
scene_label=rule.scene_label if parsed.scene_label == "其他票据" else parsed.scene_label,
|
scene_label=rule.scene_label if parsed.scene_label == "其他票据" else parsed.scene_label,
|
||||||
expense_type=rule.expense_type if parsed.expense_type == "other" else parsed.expense_type,
|
expense_type=rule.expense_type if parsed.expense_type == "other" else parsed.expense_type,
|
||||||
fields=merged_fields,
|
fields=_apply_document_type_field_labels(merged_fields, rule.document_type),
|
||||||
classification_source=source,
|
classification_source=source,
|
||||||
classification_confidence=max(parsed.confidence, rule_insight.classification_confidence),
|
classification_confidence=max(parsed.confidence, rule_insight.classification_confidence),
|
||||||
evidence=tuple(parsed.evidence or rule_insight.evidence),
|
evidence=tuple(parsed.evidence or rule_insight.evidence),
|
||||||
@@ -464,8 +529,49 @@ def _normalize_llm_document_field_key(key: str, label: str) -> str:
|
|||||||
token in compact_label for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
|
token in compact_label for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
|
||||||
):
|
):
|
||||||
return "amount"
|
return "amount"
|
||||||
if compact_key in {"date", "time", "issued_at", "invoice_date"} or any(
|
if compact_key in {
|
||||||
token in compact_label for token in ("日期", "时间", "开票日期", "发生时间")
|
"travel_date",
|
||||||
|
"trip_date",
|
||||||
|
"journey_date",
|
||||||
|
"departure_date",
|
||||||
|
"departure_time",
|
||||||
|
"depart_date",
|
||||||
|
"depart_time",
|
||||||
|
"boarding_date",
|
||||||
|
"boarding_time",
|
||||||
|
"train_date",
|
||||||
|
"train_time",
|
||||||
|
"train_departure_time",
|
||||||
|
"scheduled_departure_time",
|
||||||
|
"flight_date",
|
||||||
|
"flight_time",
|
||||||
|
"ride_date",
|
||||||
|
"ride_time",
|
||||||
|
"pickup_time",
|
||||||
|
"start_time",
|
||||||
|
} or any(
|
||||||
|
token in compact_label
|
||||||
|
for token in (
|
||||||
|
"行程日期",
|
||||||
|
"出发日期",
|
||||||
|
"出发时间",
|
||||||
|
"列车出发时间",
|
||||||
|
"发车日期",
|
||||||
|
"发车时间",
|
||||||
|
"开车时间",
|
||||||
|
"乘车日期",
|
||||||
|
"乘车时间",
|
||||||
|
"起飞日期",
|
||||||
|
"航班日期",
|
||||||
|
"上车时间",
|
||||||
|
"用车时间",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return "trip_date"
|
||||||
|
if compact_key in {"issued_at", "issue_date", "invoice_date"} or "开票日期" in compact_label:
|
||||||
|
return "invoice_date"
|
||||||
|
if compact_key in {"date", "time"} or any(
|
||||||
|
token in compact_label for token in ("日期", "时间", "发生时间")
|
||||||
):
|
):
|
||||||
return "date"
|
return "date"
|
||||||
if compact_key in {"merchant_name", "merchant", "seller_name", "vendor_name"} or any(
|
if compact_key in {"merchant_name", "merchant", "seller_name", "vendor_name"} or any(
|
||||||
@@ -504,7 +610,7 @@ def _normalize_llm_document_field_value(key: str, value: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
text_value = format(candidate.quantize(Decimal("0.01")), "f").rstrip("0").rstrip(".")
|
text_value = format(candidate.quantize(Decimal("0.01")), "f").rstrip("0").rstrip(".")
|
||||||
return f"{text_value}元"
|
return f"{text_value}元"
|
||||||
if key == "date":
|
if key in {"date", "time", "invoice_date", "trip_date"}:
|
||||||
return _extract_date(raw_value) or _clean_field_value(raw_value)
|
return _extract_date(raw_value) or _clean_field_value(raw_value)
|
||||||
if key == "route":
|
if key == "route":
|
||||||
return _extract_route(raw_value) or _clean_field_value(
|
return _extract_route(raw_value) or _clean_field_value(
|
||||||
@@ -517,6 +623,8 @@ def _llm_document_field_label(key: str) -> str:
|
|||||||
return {
|
return {
|
||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
"date": "日期",
|
"date": "日期",
|
||||||
|
"invoice_date": "开票日期",
|
||||||
|
"trip_date": "行程日期",
|
||||||
"merchant_name": "商户",
|
"merchant_name": "商户",
|
||||||
"invoice_number": "票据号码",
|
"invoice_number": "票据号码",
|
||||||
"invoice_code": "发票代码",
|
"invoice_code": "发票代码",
|
||||||
@@ -525,6 +633,35 @@ def _llm_document_field_label(key: str) -> str:
|
|||||||
}.get(key, key)
|
}.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_document_type_field_labels(
|
||||||
|
fields: tuple[DocumentField, ...],
|
||||||
|
document_type: str,
|
||||||
|
) -> tuple[DocumentField, ...]:
|
||||||
|
date_label = TRIP_DATE_LABEL_BY_DOCUMENT_TYPE.get(
|
||||||
|
str(document_type or "").strip().lower()
|
||||||
|
)
|
||||||
|
if not date_label:
|
||||||
|
return fields
|
||||||
|
|
||||||
|
adjusted: list[DocumentField] = []
|
||||||
|
for field in fields:
|
||||||
|
compact_key = str(field.key or "").strip().lower()
|
||||||
|
compact_label = str(field.label or "").replace(" ", "")
|
||||||
|
if compact_key in {"issued_at", "issue_date", "invoice_date"} or any(
|
||||||
|
token in compact_label for token in ("开票日期", "发票日期")
|
||||||
|
):
|
||||||
|
adjusted.append(field)
|
||||||
|
continue
|
||||||
|
is_date_field = compact_key in DATE_FIELD_KEYS or any(
|
||||||
|
token in compact_label for token in TRIP_DATE_FIELD_LABEL_TOKENS
|
||||||
|
)
|
||||||
|
if is_date_field:
|
||||||
|
adjusted.append(DocumentField(key=field.key, label=date_label, value=field.value))
|
||||||
|
continue
|
||||||
|
adjusted.append(field)
|
||||||
|
return tuple(adjusted)
|
||||||
|
|
||||||
|
|
||||||
def _merge_document_fields(
|
def _merge_document_fields(
|
||||||
base_fields: tuple[DocumentField, ...],
|
base_fields: tuple[DocumentField, ...],
|
||||||
override_fields: tuple[DocumentField, ...],
|
override_fields: tuple[DocumentField, ...],
|
||||||
@@ -540,13 +677,13 @@ def _merge_document_fields(
|
|||||||
return tuple(merged[key] for key in order if key in merged)
|
return tuple(merged[key] for key in order if key in merged)
|
||||||
|
|
||||||
|
|
||||||
def _extract_document_fields(text: str) -> list[DocumentField]:
|
def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
|
||||||
fields: list[DocumentField] = []
|
fields: list[DocumentField] = []
|
||||||
amount = _extract_amount(text)
|
amount = _extract_amount(text)
|
||||||
if amount:
|
if amount:
|
||||||
fields.append(DocumentField(key="amount", label="金额", value=amount))
|
fields.append(DocumentField(key="amount", label="金额", value=amount))
|
||||||
|
|
||||||
date_value = _extract_date(text)
|
date_value = _extract_date(text, document_type=document_type)
|
||||||
if date_value:
|
if date_value:
|
||||||
fields.append(DocumentField(key="date", label="日期", value=date_value))
|
fields.append(DocumentField(key="date", label="日期", value=date_value))
|
||||||
|
|
||||||
@@ -584,6 +721,8 @@ def _extract_amount(text: str) -> str:
|
|||||||
continue
|
continue
|
||||||
if candidate <= Decimal("0.00"):
|
if candidate <= Decimal("0.00"):
|
||||||
continue
|
continue
|
||||||
|
if _is_amount_match_date_fragment(candidate, text, match.start(1), match.end(1)):
|
||||||
|
continue
|
||||||
if best_value is None or candidate > best_value:
|
if best_value is None or candidate > best_value:
|
||||||
best_value = candidate
|
best_value = candidate
|
||||||
|
|
||||||
@@ -594,10 +733,49 @@ def _extract_amount(text: str) -> str:
|
|||||||
return f"{text_value}元"
|
return f"{text_value}元"
|
||||||
|
|
||||||
|
|
||||||
def _extract_date(text: str) -> str:
|
def _is_amount_match_date_fragment(amount: Decimal, text: str, start: int, end: int) -> bool:
|
||||||
match = DATE_PATTERN.search(text)
|
if start < 0 or end < 0:
|
||||||
if not match:
|
return False
|
||||||
|
normalized = amount.quantize(Decimal("0.01"))
|
||||||
|
if normalized != normalized.to_integral_value() or normalized < Decimal("1900") or normalized > Decimal("2099"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
before = str(text or "")[max(0, start - 8):start]
|
||||||
|
after = str(text or "")[end:end + 10]
|
||||||
|
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
|
||||||
|
return True
|
||||||
|
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_date(text: str, *, document_type: str = "") -> str:
|
||||||
|
matches = list(DATE_PATTERN.finditer(text))
|
||||||
|
if not matches:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
normalized_type = str(document_type or "").strip().lower()
|
||||||
|
if normalized_type in TRIP_DATE_LABEL_BY_DOCUMENT_TYPE:
|
||||||
|
candidates: list[tuple[int, int, bool, str]] = []
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
value = _format_date_match_with_time(text, match)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
invoice_context = _is_invoice_date_context(text, match)
|
||||||
|
score = _score_trip_date_context(text, match, value, invoice_context)
|
||||||
|
candidates.append((score, index, invoice_context, value))
|
||||||
|
|
||||||
|
non_invoice_candidates = [candidate for candidate in candidates if not candidate[2]]
|
||||||
|
if non_invoice_candidates:
|
||||||
|
return max(non_invoice_candidates, key=lambda candidate: (candidate[0], -candidate[1]))[3]
|
||||||
|
if candidates:
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return _format_date_match_with_time(text, matches[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date_match_with_time(text: str, match: re.Match[str]) -> str:
|
||||||
raw_value = str(match.group(1) or "").strip()
|
raw_value = str(match.group(1) or "").strip()
|
||||||
normalized = raw_value.replace("年", "-").replace("月", "-").replace("日", "")
|
normalized = raw_value.replace("年", "-").replace("月", "-").replace("日", "")
|
||||||
normalized = normalized.replace("/", "-").replace(".", "-")
|
normalized = normalized.replace("/", "-").replace(".", "-")
|
||||||
@@ -605,7 +783,60 @@ def _extract_date(text: str) -> str:
|
|||||||
if len(parts) != 3:
|
if len(parts) != 3:
|
||||||
return raw_value
|
return raw_value
|
||||||
year, month, day = parts
|
year, month, day = parts
|
||||||
return f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
|
date_value = f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
|
||||||
|
surrounding = str(text or "")[max(0, match.start() - 18): match.end() + 24]
|
||||||
|
time_match = TIME_PATTERN.search(surrounding)
|
||||||
|
if time_match:
|
||||||
|
hour = str(time_match.group(1) or "").zfill(2)
|
||||||
|
minute = str(time_match.group(2) or "").zfill(2)
|
||||||
|
return f"{date_value} {hour}:{minute}"
|
||||||
|
return date_value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_invoice_date_context(text: str, match: re.Match[str]) -> bool:
|
||||||
|
window = str(text or "")[max(0, match.start() - 12): match.end() + 8]
|
||||||
|
compact = window.replace(" ", "")
|
||||||
|
return any(token in compact for token in ("开票日期", "发票日期", "开票时间", "开票"))
|
||||||
|
|
||||||
|
|
||||||
|
def _score_trip_date_context(
|
||||||
|
text: str,
|
||||||
|
match: re.Match[str],
|
||||||
|
value: str,
|
||||||
|
invoice_context: bool,
|
||||||
|
) -> int:
|
||||||
|
window = str(text or "")[max(0, match.start() - 32): match.end() + 32]
|
||||||
|
compact = window.replace(" ", "")
|
||||||
|
score = -20 if invoice_context else 0
|
||||||
|
if ":" in value or ":" in value:
|
||||||
|
score += 8
|
||||||
|
if any(
|
||||||
|
token in compact
|
||||||
|
for token in (
|
||||||
|
"行程日期",
|
||||||
|
"出发日期",
|
||||||
|
"出发时间",
|
||||||
|
"列车出发时间",
|
||||||
|
"发车日期",
|
||||||
|
"发车时间",
|
||||||
|
"开车时间",
|
||||||
|
"乘车日期",
|
||||||
|
"乘车时间",
|
||||||
|
"起飞日期",
|
||||||
|
"起飞时间",
|
||||||
|
"航班日期",
|
||||||
|
"上车时间",
|
||||||
|
"用车时间",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
score += 6
|
||||||
|
if any(token in compact for token in ("车次", "检票", "二等座", "一等座", "商务座", "软卧", "硬卧")):
|
||||||
|
score += 3
|
||||||
|
if re.search(r"[A-Z]\d{1,4}", compact):
|
||||||
|
score += 2
|
||||||
|
if re.search(r"[\u4e00-\u9fa5A-Za-z0-9()()·]{2,20}(?:至|到|→|->|—|–|-)[\u4e00-\u9fa5A-Za-z0-9()()·]{2,20}", compact):
|
||||||
|
score += 2
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
def _extract_merchant(text: str) -> str:
|
def _extract_merchant(text: str) -> str:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -20,6 +21,9 @@ from app.repositories.employee import EmployeeRepository
|
|||||||
from app.schemas.employee import (
|
from app.schemas.employee import (
|
||||||
EmployeeCreate,
|
EmployeeCreate,
|
||||||
EmployeeHistoryRead,
|
EmployeeHistoryRead,
|
||||||
|
EmployeeImportErrorRead,
|
||||||
|
EmployeeImportResultRead,
|
||||||
|
EmployeeImportSummaryRead,
|
||||||
EmployeeMetaRead,
|
EmployeeMetaRead,
|
||||||
EmployeeOrganizationRead,
|
EmployeeOrganizationRead,
|
||||||
EmployeeRead,
|
EmployeeRead,
|
||||||
@@ -27,8 +31,16 @@ from app.schemas.employee import (
|
|||||||
EmployeeStatusSummaryRead,
|
EmployeeStatusSummaryRead,
|
||||||
EmployeeUpdate,
|
EmployeeUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services.employee_spreadsheet import (
|
||||||
|
EmployeeImportRow,
|
||||||
|
EmployeeSpreadsheetError,
|
||||||
|
build_export_workbook_bytes,
|
||||||
|
build_import_template_bytes,
|
||||||
|
parse_employee_workbook,
|
||||||
|
)
|
||||||
from app.services.employee_seed import (
|
from app.services.employee_seed import (
|
||||||
EMPLOYEE_DEFINITIONS,
|
EMPLOYEE_DEFINITIONS,
|
||||||
|
EMPLOYEE_PROFILE_REPAIRS,
|
||||||
ORGANIZATION_DEFINITIONS,
|
ORGANIZATION_DEFINITIONS,
|
||||||
ROLE_DEFINITIONS,
|
ROLE_DEFINITIONS,
|
||||||
ROLE_DISPLAY_ORDER,
|
ROLE_DISPLAY_ORDER,
|
||||||
@@ -37,6 +49,8 @@ from app.services.employee_seed import (
|
|||||||
|
|
||||||
logger = get_logger("app.services.employee")
|
logger = get_logger("app.services.employee")
|
||||||
DEFAULT_EMPLOYEE_PASSWORD = "123456"
|
DEFAULT_EMPLOYEE_PASSWORD = "123456"
|
||||||
|
MAX_EMPLOYEE_CHANGE_LOGS = 5
|
||||||
|
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
STATUS_TONE_MAP = {
|
STATUS_TONE_MAP = {
|
||||||
"在职": "success",
|
"在职": "success",
|
||||||
@@ -57,7 +71,9 @@ def prepare_employee_directory() -> None:
|
|||||||
|
|
||||||
session_factory = get_session_factory()
|
session_factory = get_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
EmployeeService(db).ensure_directory_ready()
|
service = EmployeeService(db)
|
||||||
|
service.ensure_directory_ready()
|
||||||
|
service.apply_profile_repairs()
|
||||||
|
|
||||||
|
|
||||||
class EmployeeService:
|
class EmployeeService:
|
||||||
@@ -120,10 +136,27 @@ class EmployeeService:
|
|||||||
for role in self._sorted_roles(self.repository.list_roles())
|
for role in self._sorted_roles(self.repository.list_roles())
|
||||||
]
|
]
|
||||||
|
|
||||||
|
organization_options = [
|
||||||
|
EmployeeOrganizationRead(
|
||||||
|
id=unit.id,
|
||||||
|
code=unit.unit_code,
|
||||||
|
name=unit.name,
|
||||||
|
unitType=unit.unit_type,
|
||||||
|
costCenter=unit.cost_center,
|
||||||
|
location=unit.location,
|
||||||
|
managerName=unit.manager_name,
|
||||||
|
)
|
||||||
|
for unit in sorted(
|
||||||
|
self.repository.list_organization_units(),
|
||||||
|
key=lambda item: item.name,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
return EmployeeMetaRead(
|
return EmployeeMetaRead(
|
||||||
totalEmployees=len(employees),
|
totalEmployees=len(employees),
|
||||||
statusSummary=status_summary,
|
statusSummary=status_summary,
|
||||||
roleOptions=role_options,
|
roleOptions=role_options,
|
||||||
|
organizationOptions=organization_options,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
|
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
|
||||||
@@ -152,7 +185,7 @@ class EmployeeService:
|
|||||||
sync_state=payload.sync_state,
|
sync_state=payload.sync_state,
|
||||||
spotlight=payload.spotlight,
|
spotlight=payload.spotlight,
|
||||||
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||||
last_sync_at=datetime.now(),
|
last_sync_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload.organization_unit_code:
|
if payload.organization_unit_code:
|
||||||
@@ -261,6 +294,43 @@ class EmployeeService:
|
|||||||
employee.finance_owner_name = finance_owner_name
|
employee.finance_owner_name = finance_owner_name
|
||||||
changed_fields.append("财务归口")
|
changed_fields.append("财务归口")
|
||||||
|
|
||||||
|
if "organization_unit_code" in payload.model_fields_set:
|
||||||
|
organization_code = self._normalize_optional_text(payload.organization_unit_code)
|
||||||
|
current_code = (
|
||||||
|
employee.organization_unit.unit_code if employee.organization_unit else None
|
||||||
|
)
|
||||||
|
if organization_code != current_code:
|
||||||
|
if organization_code:
|
||||||
|
organization = self.repository.get_organization_by_code(organization_code)
|
||||||
|
if organization is None:
|
||||||
|
raise ValueError(f"部门编码 {organization_code} 不存在")
|
||||||
|
employee.organization_unit = organization
|
||||||
|
else:
|
||||||
|
employee.organization_unit = None
|
||||||
|
changed_fields.append("所属部门")
|
||||||
|
|
||||||
|
if "manager_employee_no" in payload.model_fields_set:
|
||||||
|
manager_employee_no = self._normalize_optional_text(payload.manager_employee_no)
|
||||||
|
current_manager_no = employee.manager.employee_no if employee.manager else None
|
||||||
|
|
||||||
|
if manager_employee_no:
|
||||||
|
if manager_employee_no == employee.employee_no:
|
||||||
|
raise ValueError("直属上级不能是员工本人")
|
||||||
|
|
||||||
|
manager = self.repository.get_by_employee_no(manager_employee_no)
|
||||||
|
if manager is None:
|
||||||
|
raise ValueError(f"直属上级工号 {manager_employee_no} 不存在")
|
||||||
|
|
||||||
|
if manager_employee_no != current_manager_no:
|
||||||
|
employee.manager = manager
|
||||||
|
changed_fields.append("直属上级")
|
||||||
|
elif current_manager_no is not None:
|
||||||
|
employee.manager = None
|
||||||
|
changed_fields.append("直属上级")
|
||||||
|
|
||||||
|
role_changed = False
|
||||||
|
sorted_roles: list[Role] = []
|
||||||
|
|
||||||
if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
|
if "role_codes" in payload.model_fields_set and payload.role_codes is not None:
|
||||||
requested_codes = list(dict.fromkeys(payload.role_codes))
|
requested_codes = list(dict.fromkeys(payload.role_codes))
|
||||||
roles: list[Role] = []
|
roles: list[Role] = []
|
||||||
@@ -280,7 +350,7 @@ class EmployeeService:
|
|||||||
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
|
current_role_codes = [role.role_code for role in self._sorted_roles(list(employee.roles))]
|
||||||
if next_role_codes != current_role_codes:
|
if next_role_codes != current_role_codes:
|
||||||
employee.roles = sorted_roles
|
employee.roles = sorted_roles
|
||||||
changed_fields.append("系统角色")
|
role_changed = True
|
||||||
|
|
||||||
if "password" in payload.model_fields_set and payload.password:
|
if "password" in payload.model_fields_set and payload.password:
|
||||||
password = payload.password.strip()
|
password = payload.password.strip()
|
||||||
@@ -289,10 +359,10 @@ class EmployeeService:
|
|||||||
employee.password_hash = hash_password(password)
|
employee.password_hash = hash_password(password)
|
||||||
password_changed = True
|
password_changed = True
|
||||||
|
|
||||||
if not changed_fields and not password_changed:
|
if not changed_fields and not password_changed and not role_changed:
|
||||||
return self._serialize_employee(employee)
|
return self._serialize_employee(employee)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now(UTC)
|
||||||
employee.last_sync_at = now
|
employee.last_sync_at = now
|
||||||
employee.sync_state = "已同步"
|
employee.sync_state = "已同步"
|
||||||
|
|
||||||
@@ -303,13 +373,25 @@ class EmployeeService:
|
|||||||
occurred_at=now,
|
occurred_at=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if role_changed:
|
||||||
|
role_labels = "、".join(role.name for role in sorted_roles)
|
||||||
|
self._append_change_log(
|
||||||
|
employee,
|
||||||
|
action=f"更新系统角色({role_labels})",
|
||||||
|
occurred_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
if password_changed:
|
if password_changed:
|
||||||
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
|
self._append_change_log(employee, action="重置员工登录密码", occurred_at=now)
|
||||||
|
|
||||||
saved = self.repository.save(employee)
|
hydrated = self._save_employee_and_reload(employee)
|
||||||
hydrated = self.repository.get(saved.id)
|
logger.info(
|
||||||
logger.info("Updated employee id=%s fields=%s", employee.id, ",".join(changed_fields))
|
"Updated employee id=%s fields=%s role_changed=%s",
|
||||||
return self._serialize_employee(hydrated or saved)
|
employee.id,
|
||||||
|
",".join(changed_fields),
|
||||||
|
role_changed,
|
||||||
|
)
|
||||||
|
return self._serialize_employee(hydrated)
|
||||||
|
|
||||||
def disable_employee(self, employee_id: str) -> EmployeeRead:
|
def disable_employee(self, employee_id: str) -> EmployeeRead:
|
||||||
self.ensure_directory_ready()
|
self.ensure_directory_ready()
|
||||||
@@ -321,17 +403,16 @@ class EmployeeService:
|
|||||||
if employee.employment_status == "停用":
|
if employee.employment_status == "停用":
|
||||||
return self._serialize_employee(employee)
|
return self._serialize_employee(employee)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now(UTC)
|
||||||
employee.employment_status = "停用"
|
employee.employment_status = "停用"
|
||||||
employee.sync_state = "已同步"
|
employee.sync_state = "已同步"
|
||||||
employee.last_sync_at = now
|
employee.last_sync_at = now
|
||||||
employee.spotlight = False
|
employee.spotlight = False
|
||||||
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
|
self._append_change_log(employee, action="停用员工账号", occurred_at=now)
|
||||||
|
|
||||||
saved = self.repository.save(employee)
|
hydrated = self._save_employee_and_reload(employee)
|
||||||
hydrated = self.repository.get(saved.id)
|
|
||||||
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
|
logger.info("Disabled employee id=%s no=%s", employee.id, employee.employee_no)
|
||||||
return self._serialize_employee(hydrated or saved)
|
return self._serialize_employee(hydrated)
|
||||||
|
|
||||||
def enable_employee(self, employee_id: str) -> EmployeeRead:
|
def enable_employee(self, employee_id: str) -> EmployeeRead:
|
||||||
self.ensure_directory_ready()
|
self.ensure_directory_ready()
|
||||||
@@ -343,16 +424,305 @@ class EmployeeService:
|
|||||||
if employee.employment_status != "停用":
|
if employee.employment_status != "停用":
|
||||||
return self._serialize_employee(employee)
|
return self._serialize_employee(employee)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now(UTC)
|
||||||
employee.employment_status = "在职"
|
employee.employment_status = "在职"
|
||||||
employee.sync_state = "已同步"
|
employee.sync_state = "已同步"
|
||||||
employee.last_sync_at = now
|
employee.last_sync_at = now
|
||||||
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
|
self._append_change_log(employee, action="启用员工账号", occurred_at=now)
|
||||||
|
|
||||||
saved = self.repository.save(employee)
|
hydrated = self._save_employee_and_reload(employee)
|
||||||
hydrated = self.repository.get(saved.id)
|
|
||||||
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
|
logger.info("Enabled employee id=%s no=%s", employee.id, employee.employee_no)
|
||||||
return self._serialize_employee(hydrated or saved)
|
return self._serialize_employee(hydrated)
|
||||||
|
|
||||||
|
def build_import_template(self) -> bytes:
|
||||||
|
self.ensure_directory_ready()
|
||||||
|
return build_import_template_bytes()
|
||||||
|
|
||||||
|
def export_employees(self, status: str | None = None, keyword: str | None = None) -> bytes:
|
||||||
|
self.ensure_directory_ready()
|
||||||
|
employees = self.repository.list(status=status, keyword=keyword)
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
|
||||||
|
for employee in employees:
|
||||||
|
organization = employee.organization_unit
|
||||||
|
role_codes = ",".join(role.role_code for role in self._sorted_roles(list(employee.roles)))
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
employee.employee_no,
|
||||||
|
employee.name,
|
||||||
|
employee.email,
|
||||||
|
employee.gender or "",
|
||||||
|
self._format_date(employee.birth_date) or "",
|
||||||
|
employee.phone or "",
|
||||||
|
self._format_date(employee.join_date) or "",
|
||||||
|
employee.location or "",
|
||||||
|
employee.position,
|
||||||
|
employee.grade,
|
||||||
|
organization.unit_code if organization else "",
|
||||||
|
employee.manager.employee_no if employee.manager else "",
|
||||||
|
employee.finance_owner_name or "",
|
||||||
|
employee.cost_center or "",
|
||||||
|
employee.employment_status,
|
||||||
|
role_codes,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_export_workbook_bytes(rows)
|
||||||
|
|
||||||
|
def import_employees(self, content: bytes, actor: str = "系统管理员") -> EmployeeImportResultRead:
|
||||||
|
self.ensure_directory_ready()
|
||||||
|
parsed_rows, parse_errors = parse_employee_workbook(content)
|
||||||
|
if parse_errors:
|
||||||
|
return self._build_import_failure(parse_errors, total_rows=len(parsed_rows))
|
||||||
|
|
||||||
|
validation_errors = self._validate_import_rows(parsed_rows)
|
||||||
|
if validation_errors:
|
||||||
|
return self._build_import_failure(validation_errors, total_rows=len(parsed_rows))
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = self._apply_import_rows(parsed_rows, actor=actor)
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.exception("Employee import failed during database write")
|
||||||
|
raise
|
||||||
|
|
||||||
|
imported_at = self._format_datetime(datetime.now(UTC)) or ""
|
||||||
|
message = f"导入成功:新增 {summary['created']} 人,更新 {summary['updated']} 人。"
|
||||||
|
logger.info(
|
||||||
|
"Imported employees created=%d updated=%d total=%d",
|
||||||
|
summary["created"],
|
||||||
|
summary["updated"],
|
||||||
|
len(parsed_rows),
|
||||||
|
)
|
||||||
|
return EmployeeImportResultRead(
|
||||||
|
success=True,
|
||||||
|
message=message,
|
||||||
|
summary=EmployeeImportSummaryRead(
|
||||||
|
totalRows=len(parsed_rows),
|
||||||
|
created=summary["created"],
|
||||||
|
updated=summary["updated"],
|
||||||
|
errorCount=0,
|
||||||
|
),
|
||||||
|
errors=[],
|
||||||
|
importedAt=imported_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_import_rows(
|
||||||
|
self, rows: list[EmployeeImportRow]
|
||||||
|
) -> list[EmployeeSpreadsheetError]:
|
||||||
|
errors: list[EmployeeSpreadsheetError] = []
|
||||||
|
employee_nos_in_file: dict[str, int] = {}
|
||||||
|
emails_in_file: dict[str, int] = {}
|
||||||
|
|
||||||
|
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||||
|
organizations_by_code = {
|
||||||
|
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||||
|
}
|
||||||
|
employees_by_no = {
|
||||||
|
employee.employee_no: employee for employee in self.repository.list()
|
||||||
|
}
|
||||||
|
import_employee_nos = {row.employee_no for row in rows}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.employee_no in employee_nos_in_file:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="员工编号*",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"员工编号 {row.employee_no} 在文件中重复。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
employee_nos_in_file[row.employee_no] = row.row_number
|
||||||
|
|
||||||
|
if row.email in emails_in_file:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="邮箱*",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"邮箱 {row.email} 在文件中重复。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
emails_in_file[row.email] = row.row_number
|
||||||
|
|
||||||
|
existing_by_email = self.repository.get_by_email(row.email)
|
||||||
|
if existing_by_email is not None and existing_by_email.employee_no != row.employee_no:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="邮箱*",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=(
|
||||||
|
f"邮箱 {row.email} 已被员工 "
|
||||||
|
f"{existing_by_email.employee_no} 使用。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if row.organization_unit_code and row.organization_unit_code not in organizations_by_code:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="部门编码",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"部门编码 {row.organization_unit_code} 不存在。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if row.manager_employee_no:
|
||||||
|
manager_exists = (
|
||||||
|
row.manager_employee_no in employees_by_no
|
||||||
|
or row.manager_employee_no in import_employee_nos
|
||||||
|
)
|
||||||
|
if not manager_exists:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="直属上级工号",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"直属上级工号 {row.manager_employee_no} 不存在。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row.manager_employee_no == row.employee_no:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="直属上级工号",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message="直属上级不能是员工本人。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid_role_codes = [
|
||||||
|
code for code in row.role_codes if code not in roles_by_code
|
||||||
|
]
|
||||||
|
if invalid_role_codes:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row.row_number,
|
||||||
|
column="角色编码",
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
message=f"角色不存在:{'、'.join(invalid_role_codes)}。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def _apply_import_rows(
|
||||||
|
self,
|
||||||
|
rows: list[EmployeeImportRow],
|
||||||
|
*,
|
||||||
|
actor: str,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||||
|
organizations_by_code = {
|
||||||
|
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||||
|
}
|
||||||
|
employees_by_no = {
|
||||||
|
employee.employee_no: employee for employee in self.repository.list()
|
||||||
|
}
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for row in rows:
|
||||||
|
employee = employees_by_no.get(row.employee_no)
|
||||||
|
is_new = employee is None
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no=row.employee_no,
|
||||||
|
name=row.name,
|
||||||
|
email=row.email,
|
||||||
|
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||||
|
)
|
||||||
|
self.db.add(employee)
|
||||||
|
employees_by_no[row.employee_no] = employee
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
employee.name = row.name
|
||||||
|
employee.email = row.email
|
||||||
|
employee.gender = row.gender
|
||||||
|
employee.birth_date = row.birth_date
|
||||||
|
employee.phone = row.phone
|
||||||
|
employee.join_date = row.join_date
|
||||||
|
employee.location = row.location
|
||||||
|
employee.position = row.position
|
||||||
|
employee.grade = row.grade
|
||||||
|
employee.finance_owner_name = row.finance_owner_name
|
||||||
|
employee.cost_center = row.cost_center
|
||||||
|
employee.employment_status = row.employment_status
|
||||||
|
employee.sync_state = "已同步"
|
||||||
|
employee.last_sync_at = now
|
||||||
|
|
||||||
|
if row.organization_unit_code:
|
||||||
|
employee.organization_unit = organizations_by_code[row.organization_unit_code]
|
||||||
|
else:
|
||||||
|
employee.organization_unit = None
|
||||||
|
|
||||||
|
employee.roles = self._sorted_roles(
|
||||||
|
[roles_by_code[code] for code in row.role_codes if code in roles_by_code]
|
||||||
|
)
|
||||||
|
|
||||||
|
action = (
|
||||||
|
"通过 Excel 导入新建员工档案"
|
||||||
|
if is_new
|
||||||
|
else "通过 Excel 导入更新员工档案"
|
||||||
|
)
|
||||||
|
self._append_change_log(employee, action=action, owner=actor, occurred_at=now)
|
||||||
|
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
employee = employees_by_no[row.employee_no]
|
||||||
|
if row.manager_employee_no:
|
||||||
|
employee.manager = employees_by_no.get(row.manager_employee_no)
|
||||||
|
else:
|
||||||
|
employee.manager = None
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return {"created": created, "updated": updated}
|
||||||
|
|
||||||
|
def _build_import_failure(
|
||||||
|
self,
|
||||||
|
errors: list[EmployeeSpreadsheetError],
|
||||||
|
*,
|
||||||
|
total_rows: int,
|
||||||
|
) -> EmployeeImportResultRead:
|
||||||
|
error_reads = [
|
||||||
|
EmployeeImportErrorRead(
|
||||||
|
row=item.row,
|
||||||
|
column=item.column,
|
||||||
|
employeeNo=item.employee_no,
|
||||||
|
message=item.message,
|
||||||
|
)
|
||||||
|
for item in errors
|
||||||
|
]
|
||||||
|
return EmployeeImportResultRead(
|
||||||
|
success=False,
|
||||||
|
message=(
|
||||||
|
f"导入未执行:共发现 {len(error_reads)} 处错误,请修正后重新导入。"
|
||||||
|
"原有员工数据未变更。"
|
||||||
|
),
|
||||||
|
summary=EmployeeImportSummaryRead(
|
||||||
|
totalRows=total_rows,
|
||||||
|
created=0,
|
||||||
|
updated=0,
|
||||||
|
errorCount=len(error_reads),
|
||||||
|
),
|
||||||
|
errors=error_reads,
|
||||||
|
importedAt=None,
|
||||||
|
)
|
||||||
|
|
||||||
def _seed_roles(self) -> None:
|
def _seed_roles(self) -> None:
|
||||||
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||||
@@ -471,6 +841,69 @@ class EmployeeService:
|
|||||||
|
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
|
def apply_profile_repairs(self) -> None:
|
||||||
|
"""Apply one-off demo profile repairs. Intended for startup/bootstrap only."""
|
||||||
|
try:
|
||||||
|
self._repair_employee_profiles()
|
||||||
|
self._trim_all_employee_change_logs()
|
||||||
|
self.db.commit()
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.exception("Failed to apply employee profile repairs")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _repair_employee_profiles(self) -> None:
|
||||||
|
if not EMPLOYEE_PROFILE_REPAIRS:
|
||||||
|
return
|
||||||
|
|
||||||
|
employees = self.repository.list()
|
||||||
|
employees_by_email = {employee.email.lower(): employee for employee in employees if employee.email}
|
||||||
|
employees_by_no = {employee.employee_no: employee for employee in employees if employee.employee_no}
|
||||||
|
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
|
||||||
|
organizations_by_code = {
|
||||||
|
unit.unit_code: unit for unit in self.repository.list_organization_units()
|
||||||
|
}
|
||||||
|
|
||||||
|
for definition in EMPLOYEE_PROFILE_REPAIRS:
|
||||||
|
email = str(definition.get("email") or "").strip().lower()
|
||||||
|
employee_no = str(definition.get("employee_no") or "").strip()
|
||||||
|
employee = employees_by_email.get(email) or employees_by_no.get(employee_no)
|
||||||
|
if employee is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for field_name in (
|
||||||
|
"position",
|
||||||
|
"grade",
|
||||||
|
"location",
|
||||||
|
"cost_center",
|
||||||
|
"finance_owner_name",
|
||||||
|
"employment_status",
|
||||||
|
"sync_state",
|
||||||
|
):
|
||||||
|
value = definition.get(field_name)
|
||||||
|
if value:
|
||||||
|
setattr(employee, field_name, value)
|
||||||
|
|
||||||
|
organization_code = definition.get("organization_unit_code")
|
||||||
|
if organization_code:
|
||||||
|
employee.organization_unit = organizations_by_code.get(organization_code)
|
||||||
|
|
||||||
|
manager_employee_no = definition.get("manager_employee_no")
|
||||||
|
if manager_employee_no:
|
||||||
|
employee.manager = employees_by_no.get(manager_employee_no)
|
||||||
|
|
||||||
|
if not employee.password_hash:
|
||||||
|
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
|
||||||
|
|
||||||
|
role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code]
|
||||||
|
if role_codes:
|
||||||
|
merged_roles = {role.role_code: role for role in employee.roles}
|
||||||
|
for role_code in role_codes:
|
||||||
|
merged_roles[role_code] = roles_by_code[role_code]
|
||||||
|
employee.roles = self._sorted_roles(list(merged_roles.values()))
|
||||||
|
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
def _prune_extra_seed_employees(self) -> None:
|
def _prune_extra_seed_employees(self) -> None:
|
||||||
if not EXTRA_SEED_EMPLOYEE_NOS:
|
if not EXTRA_SEED_EMPLOYEE_NOS:
|
||||||
return
|
return
|
||||||
@@ -530,6 +963,12 @@ class EmployeeService:
|
|||||||
)
|
)
|
||||||
existing_keys.add(identity)
|
existing_keys.add(identity)
|
||||||
|
|
||||||
|
def _save_employee_and_reload(self, employee: Employee) -> Employee:
|
||||||
|
saved = self.repository.save(employee)
|
||||||
|
self._trim_employee_change_logs(saved.id)
|
||||||
|
self.db.commit()
|
||||||
|
return self.repository.get(saved.id) or saved
|
||||||
|
|
||||||
def _append_change_log(
|
def _append_change_log(
|
||||||
self,
|
self,
|
||||||
employee: Employee,
|
employee: Employee,
|
||||||
@@ -542,10 +981,30 @@ class EmployeeService:
|
|||||||
employee=employee,
|
employee=employee,
|
||||||
action=action,
|
action=action,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
occurred_at=occurred_at or datetime.now(),
|
occurred_at=occurred_at or datetime.now(UTC),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _trim_all_employee_change_logs(self) -> None:
|
||||||
|
for employee in self.repository.list():
|
||||||
|
self._trim_employee_change_logs(employee.id)
|
||||||
|
|
||||||
|
def _sorted_change_logs(self, employee: Employee) -> list[EmployeeChangeLog]:
|
||||||
|
return sorted(employee.change_logs, key=lambda item: item.occurred_at, reverse=True)
|
||||||
|
|
||||||
|
def _trim_employee_change_logs(self, employee_id: str) -> None:
|
||||||
|
stmt = (
|
||||||
|
select(EmployeeChangeLog)
|
||||||
|
.where(EmployeeChangeLog.employee_id == employee_id)
|
||||||
|
.order_by(EmployeeChangeLog.occurred_at.desc())
|
||||||
|
)
|
||||||
|
logs = list(self.db.execute(stmt).scalars().all())
|
||||||
|
if len(logs) <= MAX_EMPLOYEE_CHANGE_LOGS:
|
||||||
|
return
|
||||||
|
|
||||||
|
for stale in logs[MAX_EMPLOYEE_CHANGE_LOGS:]:
|
||||||
|
self.db.delete(stale)
|
||||||
|
|
||||||
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
|
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
|
||||||
organization = employee.organization_unit
|
organization = employee.organization_unit
|
||||||
roles = self._sorted_roles(list(employee.roles))
|
roles = self._sorted_roles(list(employee.roles))
|
||||||
@@ -556,10 +1015,10 @@ class EmployeeService:
|
|||||||
EmployeeHistoryRead(
|
EmployeeHistoryRead(
|
||||||
action=item.action,
|
action=item.action,
|
||||||
owner=item.owner,
|
owner=item.owner,
|
||||||
time=self._format_datetime(item.occurred_at) or "",
|
time=self._format_history_datetime(item.occurred_at),
|
||||||
occurredAt=self._format_datetime(item.occurred_at) or "",
|
occurredAt=self._format_history_datetime(item.occurred_at),
|
||||||
)
|
)
|
||||||
for item in employee.change_logs
|
for item in self._sorted_change_logs(employee)[:MAX_EMPLOYEE_CHANGE_LOGS]
|
||||||
]
|
]
|
||||||
|
|
||||||
return EmployeeRead(
|
return EmployeeRead(
|
||||||
@@ -571,6 +1030,7 @@ class EmployeeService:
|
|||||||
position=employee.position,
|
position=employee.position,
|
||||||
grade=employee.grade,
|
grade=employee.grade,
|
||||||
manager=employee.manager.name if employee.manager else "CEO",
|
manager=employee.manager.name if employee.manager else "CEO",
|
||||||
|
managerEmployeeNo=employee.manager.employee_no if employee.manager else None,
|
||||||
financeOwner=employee.finance_owner_name or "",
|
financeOwner=employee.finance_owner_name or "",
|
||||||
roles=role_labels,
|
roles=role_labels,
|
||||||
roleCodes=role_codes,
|
roleCodes=role_codes,
|
||||||
@@ -648,11 +1108,30 @@ class EmployeeService:
|
|||||||
return None
|
return None
|
||||||
return value.strftime("%Y-%m-%d")
|
return value.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_display_datetime(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
normalized = value.replace(tzinfo=UTC)
|
||||||
|
else:
|
||||||
|
normalized = value.astimezone(UTC)
|
||||||
|
return normalized.astimezone(DISPLAY_TIMEZONE)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_datetime(value: datetime | None) -> str | None:
|
def _format_datetime(value: datetime | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return value.strftime("%Y-%m-%d %H:%M")
|
local = EmployeeService._to_display_datetime(value)
|
||||||
|
return local.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_history_datetime(value: datetime | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
local = EmployeeService._to_display_datetime(value)
|
||||||
|
return (
|
||||||
|
f"{local.year}年{local.month}月{local.day}日"
|
||||||
|
f"{local.hour}时{local.minute}分"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _calculate_age(birth_date: date | None) -> int | None:
|
def _calculate_age(birth_date: date | None) -> int | None:
|
||||||
|
|||||||
@@ -144,6 +144,24 @@ ORGANIZATION_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
EMPLOYEE_PROFILE_REPAIRS = [
|
||||||
|
{
|
||||||
|
"employee_no": "E90919",
|
||||||
|
"name": "曹笑竹",
|
||||||
|
"email": "caoxiaozhu@xf.com",
|
||||||
|
"location": "武汉",
|
||||||
|
"position": "财务智能化产品经理",
|
||||||
|
"grade": "P5",
|
||||||
|
"organization_unit_code": "RND-CENTER",
|
||||||
|
"manager_employee_no": "E11745",
|
||||||
|
"finance_owner_name": "研发财务BP",
|
||||||
|
"cost_center": "CC-6112",
|
||||||
|
"employment_status": "在职",
|
||||||
|
"sync_state": "已同步",
|
||||||
|
"role_codes": ["user"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
EMPLOYEE_DEFINITIONS = [
|
EMPLOYEE_DEFINITIONS = [
|
||||||
{
|
{
|
||||||
"employee_no": "E10018",
|
"employee_no": "E10018",
|
||||||
|
|||||||
368
server/src/app/services/employee_spreadsheet.py
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
from email.utils import parseaddr
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openpyxl import Workbook, load_workbook
|
||||||
|
|
||||||
|
EMPLOYEE_SHEET_NAME = "员工目录"
|
||||||
|
INSTRUCTION_SHEET_NAME = "填表说明"
|
||||||
|
|
||||||
|
EMPLOYEE_HEADERS: tuple[str, ...] = (
|
||||||
|
"员工编号*",
|
||||||
|
"姓名*",
|
||||||
|
"邮箱*",
|
||||||
|
"性别",
|
||||||
|
"出生日期",
|
||||||
|
"手机号",
|
||||||
|
"入职日期",
|
||||||
|
"办公地点",
|
||||||
|
"岗位*",
|
||||||
|
"职级*",
|
||||||
|
"部门编码",
|
||||||
|
"直属上级工号",
|
||||||
|
"财务归口",
|
||||||
|
"成本中心",
|
||||||
|
"在职状态*",
|
||||||
|
"角色编码",
|
||||||
|
)
|
||||||
|
|
||||||
|
HEADER_TO_FIELD: dict[str, str] = {
|
||||||
|
"员工编号*": "employee_no",
|
||||||
|
"姓名*": "name",
|
||||||
|
"邮箱*": "email",
|
||||||
|
"性别": "gender",
|
||||||
|
"出生日期": "birth_date",
|
||||||
|
"手机号": "phone",
|
||||||
|
"入职日期": "join_date",
|
||||||
|
"办公地点": "location",
|
||||||
|
"岗位*": "position",
|
||||||
|
"职级*": "grade",
|
||||||
|
"部门编码": "organization_unit_code",
|
||||||
|
"直属上级工号": "manager_employee_no",
|
||||||
|
"财务归口": "finance_owner_name",
|
||||||
|
"成本中心": "cost_center",
|
||||||
|
"在职状态*": "employment_status",
|
||||||
|
"角色编码": "role_codes",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_EMPLOYMENT_STATUSES = {"在职", "试用中", "停用"}
|
||||||
|
DEFAULT_ROLE_CODES = ("user",)
|
||||||
|
MAX_IMPORT_ROWS = 2000
|
||||||
|
MAX_IMPORT_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmployeeImportRow:
|
||||||
|
row_number: int
|
||||||
|
employee_no: str
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
gender: str | None
|
||||||
|
birth_date: date | None
|
||||||
|
phone: str | None
|
||||||
|
join_date: date | None
|
||||||
|
location: str | None
|
||||||
|
position: str
|
||||||
|
grade: str
|
||||||
|
organization_unit_code: str | None
|
||||||
|
manager_employee_no: str | None
|
||||||
|
finance_owner_name: str | None
|
||||||
|
cost_center: str | None
|
||||||
|
employment_status: str
|
||||||
|
role_codes: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmployeeSpreadsheetError:
|
||||||
|
row: int
|
||||||
|
column: str
|
||||||
|
employee_no: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def build_import_template_bytes() -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = EMPLOYEE_SHEET_NAME
|
||||||
|
sheet.append(list(EMPLOYEE_HEADERS))
|
||||||
|
|
||||||
|
instructions = workbook.create_sheet(INSTRUCTION_SHEET_NAME)
|
||||||
|
instructions.append(["字段", "说明"])
|
||||||
|
instruction_rows = [
|
||||||
|
("员工编号*", "必填,全局唯一,导入时用于判断新建或覆盖。"),
|
||||||
|
("姓名*", "必填。"),
|
||||||
|
("邮箱*", "必填,全局唯一。"),
|
||||||
|
("性别", "可选:男、女,留空表示不填写。"),
|
||||||
|
("出生日期", "可选,格式 YYYY-MM-DD。"),
|
||||||
|
("手机号", "可选。"),
|
||||||
|
("入职日期", "可选,格式 YYYY-MM-DD。"),
|
||||||
|
("办公地点", "可选。"),
|
||||||
|
("岗位*", "必填。"),
|
||||||
|
("职级*", "必填,例如 P3、P5。"),
|
||||||
|
("部门编码", "可选,须与系统组织编码一致,例如 FIN-SSC。"),
|
||||||
|
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
|
||||||
|
("财务归口", "可选。"),
|
||||||
|
("成本中心", "可选。"),
|
||||||
|
("在职状态*", "必填:在职、试用中、停用。"),
|
||||||
|
("角色编码", "可选,多个角色用英文逗号分隔,例如 user,finance;留空默认为 user。"),
|
||||||
|
("导入规则", "全部校验通过后才写入数据库;任一行有错则整批不导入,原有数据保持不变。"),
|
||||||
|
]
|
||||||
|
for row in instruction_rows:
|
||||||
|
instructions.append(list(row))
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_export_workbook_bytes(rows: list[list[Any]]) -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = EMPLOYEE_SHEET_NAME
|
||||||
|
sheet.append(list(EMPLOYEE_HEADERS))
|
||||||
|
for row in rows:
|
||||||
|
sheet.append(row)
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_employee_workbook(content: bytes) -> tuple[list[EmployeeImportRow], list[EmployeeSpreadsheetError]]:
|
||||||
|
errors: list[EmployeeSpreadsheetError] = []
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return [], [
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=0,
|
||||||
|
column="文件",
|
||||||
|
employee_no="",
|
||||||
|
message="上传文件不能为空。",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(content) > MAX_IMPORT_BYTES:
|
||||||
|
return [], [
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=0,
|
||||||
|
column="文件",
|
||||||
|
employee_no="",
|
||||||
|
message=f"文件大小不能超过 {MAX_IMPORT_BYTES // (1024 * 1024)}MB。",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
|
||||||
|
except Exception:
|
||||||
|
return [], [
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=0,
|
||||||
|
column="文件",
|
||||||
|
employee_no="",
|
||||||
|
message="无法解析 Excel 文件,请使用系统提供的 .xlsx 模板。",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if EMPLOYEE_SHEET_NAME not in workbook.sheetnames:
|
||||||
|
return [], [
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=0,
|
||||||
|
column="工作表",
|
||||||
|
employee_no="",
|
||||||
|
message=f"缺少工作表“{EMPLOYEE_SHEET_NAME}”。",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
worksheet = workbook[EMPLOYEE_SHEET_NAME]
|
||||||
|
raw_rows = list(worksheet.iter_rows(values_only=True))
|
||||||
|
if not raw_rows:
|
||||||
|
return [], [
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=0,
|
||||||
|
column="文件",
|
||||||
|
employee_no="",
|
||||||
|
message="Excel 中没有可导入的数据行。",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
header_row = [_normalize_cell(value) for value in raw_rows[0]]
|
||||||
|
if list(header_row) != list(EMPLOYEE_HEADERS):
|
||||||
|
return [], [
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=1,
|
||||||
|
column="表头",
|
||||||
|
employee_no="",
|
||||||
|
message="表头与员工导入模板不一致,请下载最新模板后重试。",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
parsed_rows: list[EmployeeImportRow] = []
|
||||||
|
for index, raw_row in enumerate(raw_rows[1:], start=2):
|
||||||
|
if index - 1 > MAX_IMPORT_ROWS:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=index,
|
||||||
|
column="文件",
|
||||||
|
employee_no="",
|
||||||
|
message=f"单次最多导入 {MAX_IMPORT_ROWS} 行数据。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if _is_empty_data_row(raw_row):
|
||||||
|
continue
|
||||||
|
|
||||||
|
row_errors, parsed = _parse_data_row(index, raw_row)
|
||||||
|
errors.extend(row_errors)
|
||||||
|
if parsed is not None:
|
||||||
|
parsed_rows.append(parsed)
|
||||||
|
|
||||||
|
if not parsed_rows and not errors:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=0,
|
||||||
|
column="文件",
|
||||||
|
employee_no="",
|
||||||
|
message="Excel 中没有可导入的数据行。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return parsed_rows, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_data_row(
|
||||||
|
row_number: int,
|
||||||
|
raw_row: tuple[Any, ...],
|
||||||
|
) -> tuple[list[EmployeeSpreadsheetError], EmployeeImportRow | None]:
|
||||||
|
errors: list[EmployeeSpreadsheetError] = []
|
||||||
|
values = {
|
||||||
|
HEADER_TO_FIELD[header]: _normalize_cell(raw_row[index] if index < len(raw_row) else "")
|
||||||
|
for index, header in enumerate(EMPLOYEE_HEADERS)
|
||||||
|
}
|
||||||
|
employee_no = values["employee_no"]
|
||||||
|
|
||||||
|
def add_error(column: str, message: str) -> None:
|
||||||
|
errors.append(
|
||||||
|
EmployeeSpreadsheetError(
|
||||||
|
row=row_number,
|
||||||
|
column=column,
|
||||||
|
employee_no=employee_no,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not employee_no:
|
||||||
|
add_error("员工编号*", "员工编号不能为空。")
|
||||||
|
|
||||||
|
name = values["name"]
|
||||||
|
if not name:
|
||||||
|
add_error("姓名*", "姓名不能为空。")
|
||||||
|
|
||||||
|
email = values["email"].lower() if values["email"] else ""
|
||||||
|
if not email:
|
||||||
|
add_error("邮箱*", "邮箱不能为空。")
|
||||||
|
elif not _is_valid_email(email):
|
||||||
|
add_error("邮箱*", "邮箱格式不正确。")
|
||||||
|
|
||||||
|
position = values["position"]
|
||||||
|
if not position:
|
||||||
|
add_error("岗位*", "岗位不能为空。")
|
||||||
|
|
||||||
|
grade = values["grade"]
|
||||||
|
if not grade:
|
||||||
|
add_error("职级*", "职级不能为空。")
|
||||||
|
|
||||||
|
employment_status = values["employment_status"]
|
||||||
|
if not employment_status:
|
||||||
|
add_error("在职状态*", "在职状态不能为空。")
|
||||||
|
elif employment_status not in VALID_EMPLOYMENT_STATUSES:
|
||||||
|
add_error("在职状态*", "在职状态必须为:在职、试用中、停用。")
|
||||||
|
|
||||||
|
gender = values["gender"] or None
|
||||||
|
if gender and gender not in {"男", "女"}:
|
||||||
|
add_error("性别", "性别只能填写:男、女,或留空。")
|
||||||
|
|
||||||
|
birth_date, birth_error = _parse_optional_date(values["birth_date"], "出生日期")
|
||||||
|
if birth_error:
|
||||||
|
add_error("出生日期", birth_error)
|
||||||
|
|
||||||
|
join_date, join_error = _parse_optional_date(values["join_date"], "入职日期")
|
||||||
|
if join_error:
|
||||||
|
add_error("入职日期", join_error)
|
||||||
|
|
||||||
|
role_codes = _parse_role_codes(values["role_codes"])
|
||||||
|
if values["role_codes"] and not role_codes:
|
||||||
|
add_error("角色编码", "角色编码不能为空片段,多个角色请用英文逗号分隔。")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return errors, None
|
||||||
|
|
||||||
|
return (
|
||||||
|
[],
|
||||||
|
EmployeeImportRow(
|
||||||
|
row_number=row_number,
|
||||||
|
employee_no=employee_no,
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
gender=gender,
|
||||||
|
birth_date=birth_date,
|
||||||
|
phone=values["phone"] or None,
|
||||||
|
join_date=join_date,
|
||||||
|
location=values["location"] or None,
|
||||||
|
position=position,
|
||||||
|
grade=grade,
|
||||||
|
organization_unit_code=values["organization_unit_code"] or None,
|
||||||
|
manager_employee_no=values["manager_employee_no"] or None,
|
||||||
|
finance_owner_name=values["finance_owner_name"] or None,
|
||||||
|
cost_center=values["cost_center"] or None,
|
||||||
|
employment_status=employment_status,
|
||||||
|
role_codes=role_codes or list(DEFAULT_ROLE_CODES),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_role_codes(value: str) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
codes = [item.strip() for item in value.replace(",", ",").split(",")]
|
||||||
|
return list(dict.fromkeys(code for code in codes if code))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_date(value: str, label: str) -> tuple[date | None, str | None]:
|
||||||
|
if not value:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date(), None
|
||||||
|
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value, None
|
||||||
|
|
||||||
|
text = str(value).strip()
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, "%Y-%m-%d").date(), None
|
||||||
|
except ValueError:
|
||||||
|
return None, f"{label}格式必须为 YYYY-MM-DD。"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_email(value: str) -> bool:
|
||||||
|
_, address = parseaddr(value)
|
||||||
|
return bool(address) and "@" in address
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_cell(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.strftime("%Y-%m-%d")
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.strftime("%Y-%m-%d")
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_empty_data_row(raw_row: tuple[Any, ...]) -> bool:
|
||||||
|
return not any(_normalize_cell(value) for value in raw_row)
|
||||||
206
server/src/app/services/expense_amounts.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DOCUMENT_AMOUNT_PATTERNS = (
|
||||||
|
re.compile(
|
||||||
|
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
|
||||||
|
r"[::\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
|
||||||
|
),
|
||||||
|
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
|
||||||
|
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DOCUMENT_AMOUNT_FIELD_KEYS = {
|
||||||
|
"amount",
|
||||||
|
"totalamount",
|
||||||
|
"paymentamount",
|
||||||
|
"paidamount",
|
||||||
|
"actualamount",
|
||||||
|
}
|
||||||
|
DOCUMENT_AMOUNT_LABEL_TOKENS = (
|
||||||
|
"金额",
|
||||||
|
"价税合计",
|
||||||
|
"合计",
|
||||||
|
"总额",
|
||||||
|
"总计",
|
||||||
|
"票价",
|
||||||
|
"支付金额",
|
||||||
|
"实付金额",
|
||||||
|
"实收金额",
|
||||||
|
)
|
||||||
|
DOCUMENT_TEXT_AMOUNT_PATTERNS = (
|
||||||
|
r"(?:金额|价税合计|合计|小写|实收金额|支付金额|订单金额|总额|总计|总费用|费用总计|票价|房费|住宿费|餐费)[::\s¥¥人民币为是]*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
|
||||||
|
r"[¥¥]\s*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
|
||||||
|
r"([0-9]{1,6}(?:[.,][0-9]{1,2})?)\s*元",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_document_item_amount(document: dict[str, Any]) -> Decimal | None:
|
||||||
|
text = " ".join(
|
||||||
|
[
|
||||||
|
str(document.get("summary") or "").strip(),
|
||||||
|
str(document.get("text") or "").strip(),
|
||||||
|
]
|
||||||
|
).strip()
|
||||||
|
field_amount = resolve_document_field_amount(document)
|
||||||
|
text_amount = resolve_document_text_amount(text)
|
||||||
|
|
||||||
|
if field_amount is not None:
|
||||||
|
if is_date_like_amount_candidate(field_amount, text):
|
||||||
|
return text_amount
|
||||||
|
return field_amount
|
||||||
|
|
||||||
|
return text_amount
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_document_field_amount(document: dict[str, Any]) -> Decimal | None:
|
||||||
|
for field in list(document.get("document_fields") or []):
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
continue
|
||||||
|
key = str(field.get("key") or "").strip().lower().replace("_", "")
|
||||||
|
label = str(field.get("label") or "").replace(" ", "")
|
||||||
|
is_amount_field = key in DOCUMENT_AMOUNT_FIELD_KEYS or any(
|
||||||
|
token in label for token in DOCUMENT_AMOUNT_LABEL_TOKENS
|
||||||
|
)
|
||||||
|
if not is_amount_field:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_value = str(field.get("value") or "")
|
||||||
|
value = parse_document_amount_value(raw_value) or parse_plain_document_amount_value(
|
||||||
|
raw_value
|
||||||
|
)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_document_text_amount(text: str) -> Decimal | None:
|
||||||
|
candidates = [
|
||||||
|
candidate
|
||||||
|
for candidate in extract_amount_candidates(text)
|
||||||
|
if not is_date_like_amount_candidate(candidate, text)
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return max(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_document_amount_value(value: str) -> Decimal | None:
|
||||||
|
raw_value = str(value or "").strip()
|
||||||
|
if not raw_value:
|
||||||
|
return None
|
||||||
|
for pattern in DOCUMENT_AMOUNT_PATTERNS:
|
||||||
|
match = pattern.search(raw_value)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
numeric = str(match.group(1) or "").replace(",", ".").strip()
|
||||||
|
try:
|
||||||
|
amount = Decimal(numeric).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
continue
|
||||||
|
if amount > Decimal("0.00"):
|
||||||
|
return amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_plain_document_amount_value(value: str) -> Decimal | None:
|
||||||
|
raw_value = str(value or "").strip()
|
||||||
|
if not re.fullmatch(r"[0-9]{1,6}(?:[.,][0-9]{1,2})?", raw_value):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
amount = Decimal(raw_value.replace(",", ".")).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
return amount if amount > Decimal("0.00") else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_probable_year_amount(amount: Decimal | None) -> bool:
|
||||||
|
if amount is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
normalized = Decimal(amount).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
normalized == normalized.to_integral_value()
|
||||||
|
and Decimal("1900") <= normalized <= Decimal("2099")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_date_like_amount_candidate(amount: Decimal | None, text: str) -> bool:
|
||||||
|
if not is_probable_year_amount(amount):
|
||||||
|
return False
|
||||||
|
year = str(int(Decimal(amount or 0)))
|
||||||
|
pattern = re.compile(rf"(?<!\d){re.escape(year)}\s*(?:年|[-/.])\s*\d{{1,2}}")
|
||||||
|
return bool(pattern.search(str(text or "")))
|
||||||
|
|
||||||
|
|
||||||
|
def format_decimal_amount(amount: Decimal | None) -> str:
|
||||||
|
if amount is None:
|
||||||
|
return ""
|
||||||
|
normalized = Decimal(amount).quantize(Decimal("0.01"))
|
||||||
|
return format(normalized, "f")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_amount_candidates(text: str) -> list[Decimal]:
|
||||||
|
values: list[Decimal] = []
|
||||||
|
seen: set[Decimal] = set()
|
||||||
|
|
||||||
|
def append_candidate(
|
||||||
|
raw: str,
|
||||||
|
*,
|
||||||
|
source_text: str = "",
|
||||||
|
start: int = -1,
|
||||||
|
end: int = -1,
|
||||||
|
) -> None:
|
||||||
|
compact = str(raw or "").replace(",", ".").strip()
|
||||||
|
if not compact:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
candidate = Decimal(compact).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return
|
||||||
|
if is_amount_match_date_fragment(candidate, source_text, start, end):
|
||||||
|
return
|
||||||
|
if candidate in seen:
|
||||||
|
return
|
||||||
|
seen.add(candidate)
|
||||||
|
values.append(candidate)
|
||||||
|
|
||||||
|
for pattern in DOCUMENT_TEXT_AMOUNT_PATTERNS:
|
||||||
|
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
|
||||||
|
append_candidate(
|
||||||
|
match.group(1),
|
||||||
|
source_text=text,
|
||||||
|
start=match.start(1),
|
||||||
|
end=match.end(1),
|
||||||
|
)
|
||||||
|
|
||||||
|
if values:
|
||||||
|
return values
|
||||||
|
|
||||||
|
for match in re.finditer(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text):
|
||||||
|
append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def is_amount_match_date_fragment(
|
||||||
|
amount: Decimal,
|
||||||
|
text: str,
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
) -> bool:
|
||||||
|
if start < 0 or end < 0 or not is_probable_year_amount(amount):
|
||||||
|
return False
|
||||||
|
|
||||||
|
before = str(text or "")[max(0, start - 8):start]
|
||||||
|
after = str(text or "")[end:end + 10]
|
||||||
|
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
|
||||||
|
return True
|
||||||
|
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -6,12 +6,17 @@ from dataclasses import dataclass, field
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||||
|
from app.services.agent_asset_spreadsheet import (
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
AgentAssetSpreadsheetManager,
|
||||||
|
)
|
||||||
|
|
||||||
EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL)
|
EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL)
|
||||||
|
|
||||||
@@ -351,6 +356,11 @@ class TravelPolicyConfig(BaseModel):
|
|||||||
band_labels: dict[str, str] = Field(default_factory=dict)
|
band_labels: dict[str, str] = Field(default_factory=dict)
|
||||||
city_tiers: dict[str, str] = Field(default_factory=dict)
|
city_tiers: dict[str, str] = Field(default_factory=dict)
|
||||||
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||||
|
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||||
|
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||||
|
standard_rule_code: str = ""
|
||||||
|
standard_rule_name: str = ""
|
||||||
|
standard_rule_version: str = ""
|
||||||
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
|
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||||
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
|
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||||
train_classes: list[TravelClassConfig] = Field(default_factory=list)
|
train_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||||
@@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService:
|
|||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
if not assets:
|
if not assets:
|
||||||
return catalog
|
assets = []
|
||||||
|
|
||||||
|
asset_ids = {asset.id for asset in assets}
|
||||||
|
travel_spreadsheet_asset = self.db.scalar(
|
||||||
|
select(AgentAsset)
|
||||||
|
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
|
||||||
|
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
|
||||||
|
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
|
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids:
|
||||||
|
assets.append(travel_spreadsheet_asset)
|
||||||
|
|
||||||
|
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
version = self._get_current_version(asset)
|
version = self._get_current_version(asset)
|
||||||
if version is None:
|
if version is None:
|
||||||
continue
|
continue
|
||||||
|
is_travel_spreadsheet_asset = (
|
||||||
|
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||||
|
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
|
||||||
|
)
|
||||||
runtime_payload = self._extract_runtime_payload(
|
runtime_payload = self._extract_runtime_payload(
|
||||||
markdown_content=str(version.content or ""),
|
markdown_content=str(version.content or ""),
|
||||||
config_json=asset.config_json,
|
config_json=asset.config_json,
|
||||||
)
|
)
|
||||||
if not isinstance(runtime_payload, dict):
|
if not isinstance(runtime_payload, dict):
|
||||||
|
spreadsheet_assets.append((asset, version))
|
||||||
continue
|
continue
|
||||||
self._apply_runtime_payload(
|
self._apply_runtime_payload(
|
||||||
catalog,
|
catalog,
|
||||||
@@ -594,6 +622,15 @@ class ExpenseRuleRuntimeService:
|
|||||||
asset=asset,
|
asset=asset,
|
||||||
version=version,
|
version=version,
|
||||||
)
|
)
|
||||||
|
if is_travel_spreadsheet_asset:
|
||||||
|
spreadsheet_assets.append((asset, version))
|
||||||
|
|
||||||
|
for asset, version in spreadsheet_assets:
|
||||||
|
self._apply_spreadsheet_runtime_payload(
|
||||||
|
catalog,
|
||||||
|
asset=asset,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
|
||||||
return catalog
|
return catalog
|
||||||
|
|
||||||
@@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService:
|
|||||||
)
|
)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _apply_spreadsheet_runtime_payload(
|
||||||
|
self,
|
||||||
|
catalog: ExpenseRuleCatalog,
|
||||||
|
*,
|
||||||
|
asset: AgentAsset,
|
||||||
|
version: AgentAssetVersion,
|
||||||
|
) -> None:
|
||||||
|
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||||
|
return
|
||||||
|
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
|
||||||
|
return
|
||||||
|
|
||||||
|
manager = AgentAssetSpreadsheetManager()
|
||||||
|
metadata = manager.parse_version_markdown(str(version.content or ""))
|
||||||
|
rule_document = (asset.config_json or {}).get("rule_document")
|
||||||
|
if not isinstance(rule_document, dict):
|
||||||
|
rule_document = {}
|
||||||
|
storage_key = str(metadata.storage_key if metadata is not None else "").strip()
|
||||||
|
if storage_key:
|
||||||
|
try:
|
||||||
|
workbook_path = manager.resolve_storage_path(storage_key)
|
||||||
|
except FileNotFoundError:
|
||||||
|
workbook_path = None
|
||||||
|
if workbook_path is not None and not workbook_path.exists():
|
||||||
|
workbook_path = None
|
||||||
|
else:
|
||||||
|
workbook_path = None
|
||||||
|
|
||||||
|
if workbook_path is None:
|
||||||
|
fallback_storage_key = str(rule_document.get("storage_key") or "").strip()
|
||||||
|
if not fallback_storage_key:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
workbook_path = manager.resolve_storage_path(fallback_storage_key)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
if not workbook_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(
|
||||||
|
workbook_path,
|
||||||
|
read_only=True,
|
||||||
|
data_only=True,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
standards = self._extract_travel_amount_standards_from_workbook(workbook)
|
||||||
|
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
|
||||||
|
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
|
||||||
|
transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
|
||||||
|
finally:
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
standard_rule_version = str(
|
||||||
|
rule_document.get("asset_version") or asset.current_version or version.version
|
||||||
|
).strip()
|
||||||
|
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None:
|
||||||
|
payload = catalog.travel_policy.model_dump()
|
||||||
|
payload["standard_rule_code"] = asset.code
|
||||||
|
payload["standard_rule_name"] = asset.name
|
||||||
|
payload["standard_rule_version"] = standard_rule_version
|
||||||
|
if hotel_city_limits:
|
||||||
|
payload["hotel_city_limits"] = {
|
||||||
|
**payload.get("hotel_city_limits", {}),
|
||||||
|
**hotel_city_limits,
|
||||||
|
}
|
||||||
|
if allowance_limits:
|
||||||
|
payload["allowance_limits"] = {
|
||||||
|
**payload.get("allowance_limits", {}),
|
||||||
|
**allowance_limits,
|
||||||
|
}
|
||||||
|
if transport_limits:
|
||||||
|
payload["transport_limits"] = {
|
||||||
|
**payload.get("transport_limits", {}),
|
||||||
|
**transport_limits,
|
||||||
|
}
|
||||||
|
catalog.travel_policy = RuntimeTravelPolicy(**payload)
|
||||||
|
|
||||||
|
for expense_type, amount in standards.items():
|
||||||
|
current = catalog.scene_policies.get(expense_type)
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
limit_attr = "item_amount_limit" if expense_type == "transport" else "claim_amount_limit"
|
||||||
|
base_limit = getattr(current, limit_attr, None)
|
||||||
|
next_limit = self._replace_amount_limit_warn_amount(
|
||||||
|
base_limit,
|
||||||
|
amount=amount,
|
||||||
|
metric_label=self._spreadsheet_metric_label(expense_type),
|
||||||
|
)
|
||||||
|
payload = current.model_dump()
|
||||||
|
payload["rule_code"] = asset.code
|
||||||
|
payload["rule_name"] = asset.name
|
||||||
|
payload["rule_version"] = standard_rule_version
|
||||||
|
payload[limit_attr] = next_limit.model_dump()
|
||||||
|
catalog.scene_policies[expense_type] = ExpenseScenePolicy(**payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_travel_amount_standards_from_workbook(workbook: Any) -> dict[str, Decimal]:
|
||||||
|
standards: dict[str, Decimal] = {}
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
header_index = -1
|
||||||
|
category_index = -1
|
||||||
|
standard_index = -1
|
||||||
|
for index, row in enumerate(rows[:8]):
|
||||||
|
values = [str(value or "").strip() for value in row]
|
||||||
|
if "费用分类" in values and "报销标准" in values:
|
||||||
|
header_index = index
|
||||||
|
category_index = values.index("费用分类")
|
||||||
|
standard_index = values.index("报销标准")
|
||||||
|
break
|
||||||
|
if header_index < 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
category = str(row[category_index] or "").strip() if len(row) > category_index else ""
|
||||||
|
standard_text = str(row[standard_index] or "").strip() if len(row) > standard_index else ""
|
||||||
|
amount = ExpenseRuleRuntimeService._extract_first_standard_amount(standard_text)
|
||||||
|
if not category or amount is None:
|
||||||
|
continue
|
||||||
|
normalized_type = ExpenseRuleRuntimeService._map_spreadsheet_category_to_expense_type(category)
|
||||||
|
if normalized_type:
|
||||||
|
standards[normalized_type] = amount
|
||||||
|
return standards
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_hotel_city_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||||
|
city_limits: dict[str, dict[str, Decimal]] = {}
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
header_index = -1
|
||||||
|
city_index = -1
|
||||||
|
band_indexes: dict[str, int] = {}
|
||||||
|
for index, row in enumerate(rows[:10]):
|
||||||
|
values = [str(value or "").strip() for value in row]
|
||||||
|
for candidate in ("地区(城市)", "城市", "地区"):
|
||||||
|
if candidate in values:
|
||||||
|
city_index = values.index(candidate)
|
||||||
|
break
|
||||||
|
if city_index < 0:
|
||||||
|
continue
|
||||||
|
for column_index, header in enumerate(values):
|
||||||
|
compact = re.sub(r"\s+", "", header)
|
||||||
|
if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
|
||||||
|
band_indexes["junior"] = column_index
|
||||||
|
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
|
||||||
|
band_indexes["mid"] = column_index
|
||||||
|
band_indexes["senior"] = column_index
|
||||||
|
if any(keyword in compact for keyword in ("P7", "高层经理", "公司级管理")):
|
||||||
|
band_indexes["manager"] = column_index
|
||||||
|
band_indexes["executive"] = column_index
|
||||||
|
if band_indexes:
|
||||||
|
header_index = index
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_index < 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
raw_city = str(row[city_index] or "").strip() if len(row) > city_index else ""
|
||||||
|
cities = ExpenseRuleRuntimeService._extract_city_names_from_cell(raw_city)
|
||||||
|
if not cities:
|
||||||
|
continue
|
||||||
|
for city in cities:
|
||||||
|
city_entry = city_limits.setdefault(city, {})
|
||||||
|
for band, column_index in band_indexes.items():
|
||||||
|
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||||
|
row[column_index] if len(row) > column_index else None
|
||||||
|
)
|
||||||
|
if amount is not None:
|
||||||
|
city_entry[band] = amount
|
||||||
|
return city_limits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||||
|
allowance_limits: dict[str, dict[str, Decimal]] = {}
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
header_index = -1
|
||||||
|
type_index = -1
|
||||||
|
region_indexes: dict[str, int] = {}
|
||||||
|
for index, row in enumerate(rows[:10]):
|
||||||
|
values = [str(value or "").strip() for value in row]
|
||||||
|
if "补助类型" not in values:
|
||||||
|
continue
|
||||||
|
header_index = index
|
||||||
|
type_index = values.index("补助类型")
|
||||||
|
for column_index, header in enumerate(values):
|
||||||
|
if column_index <= type_index:
|
||||||
|
continue
|
||||||
|
normalized = str(header or "").strip()
|
||||||
|
if not normalized or normalized == "项目":
|
||||||
|
continue
|
||||||
|
region_indexes[normalized] = column_index
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_index < 0 or type_index < 0 or not region_indexes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
raw_type = str(row[type_index] or "").strip() if len(row) > type_index else ""
|
||||||
|
allowance_key = ExpenseRuleRuntimeService._map_allowance_type_to_key(raw_type)
|
||||||
|
if not allowance_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry: dict[str, Decimal] = {}
|
||||||
|
for region_label, column_index in region_indexes.items():
|
||||||
|
amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||||
|
row[column_index] if len(row) > column_index else None
|
||||||
|
)
|
||||||
|
if amount is not None:
|
||||||
|
entry[region_label] = amount
|
||||||
|
if entry:
|
||||||
|
allowance_limits[allowance_key] = entry
|
||||||
|
return allowance_limits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_allowance_type_to_key(value: str) -> str:
|
||||||
|
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||||
|
if "伙食" in normalized or "餐" in normalized:
|
||||||
|
return "meal"
|
||||||
|
if "基本" in normalized:
|
||||||
|
return "basic"
|
||||||
|
if "合计" in normalized or "总计" in normalized:
|
||||||
|
return "total"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_transport_class_limits_from_workbook(workbook: Any) -> dict[str, dict[str, int]]:
|
||||||
|
limits: dict[str, dict[str, int]] = {}
|
||||||
|
for sheet in workbook.worksheets:
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
employee_index = -1
|
||||||
|
flight_index = -1
|
||||||
|
train_index = -1
|
||||||
|
for row_index, row in enumerate(rows[:10]):
|
||||||
|
values = [str(value or "").strip() for value in row]
|
||||||
|
if "员工职级" in values:
|
||||||
|
employee_index = values.index("员工职级")
|
||||||
|
for next_row in rows[row_index + 1 : row_index + 4]:
|
||||||
|
next_values = [str(value or "").strip() for value in next_row]
|
||||||
|
if "飞机" in next_values:
|
||||||
|
flight_index = next_values.index("飞机")
|
||||||
|
if "火车" in next_values:
|
||||||
|
train_index = next_values.index("火车")
|
||||||
|
if flight_index >= 0 and train_index >= 0:
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
|
if employee_index < 0 or (flight_index < 0 and train_index < 0):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
|
||||||
|
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text)
|
||||||
|
if not bands:
|
||||||
|
continue
|
||||||
|
flight_level = (
|
||||||
|
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||||
|
row[flight_index] if len(row) > flight_index else None,
|
||||||
|
kind="flight",
|
||||||
|
)
|
||||||
|
if flight_index >= 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
train_level = (
|
||||||
|
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||||
|
row[train_index] if len(row) > train_index else None,
|
||||||
|
kind="train",
|
||||||
|
)
|
||||||
|
if train_index >= 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
for band in bands:
|
||||||
|
entry = limits.setdefault(band, {})
|
||||||
|
if flight_level is not None:
|
||||||
|
entry["flight"] = flight_level
|
||||||
|
if train_level is not None:
|
||||||
|
entry["train"] = train_level
|
||||||
|
return limits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_transport_grade_row_to_bands(value: str) -> list[str]:
|
||||||
|
normalized = re.sub(r"\s+", "", str(value or "").upper())
|
||||||
|
if not normalized or normalized.startswith("注"):
|
||||||
|
return []
|
||||||
|
bands: list[str] = []
|
||||||
|
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")):
|
||||||
|
bands.extend(["junior", "mid"])
|
||||||
|
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")):
|
||||||
|
bands.extend(["mid", "senior", "manager", "executive"])
|
||||||
|
return list(dict.fromkeys(bands))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
|
||||||
|
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if kind == "flight":
|
||||||
|
if any(keyword in normalized for keyword in ("头等舱",)):
|
||||||
|
return 4
|
||||||
|
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
|
||||||
|
return 3
|
||||||
|
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
|
||||||
|
return 2
|
||||||
|
if "经济舱" in normalized:
|
||||||
|
return 1
|
||||||
|
if kind == "train":
|
||||||
|
if "商务座" in normalized:
|
||||||
|
return 3
|
||||||
|
if any(keyword in normalized for keyword in ("一等座", "软卧")):
|
||||||
|
return 2
|
||||||
|
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
|
||||||
|
return 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_city_names_from_cell(value: str) -> list[str]:
|
||||||
|
normalized = re.sub(r"[;;,,、/]+", "、", str(value or "").strip())
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
names: list[str] = []
|
||||||
|
for part in normalized.split("、"):
|
||||||
|
cleaned = re.sub(r"\s+", "", part)
|
||||||
|
cleaned = re.sub(r"[((].*?[))]", "", cleaned)
|
||||||
|
if not cleaned or any(keyword in cleaned for keyword in ("不含", "中心城区", "新区")):
|
||||||
|
continue
|
||||||
|
if len(cleaned) <= 12:
|
||||||
|
names.append(cleaned)
|
||||||
|
return list(dict.fromkeys(names))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_decimal_cell(value: Any) -> Decimal | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
|
||||||
|
except (ArithmeticError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_first_standard_amount(text: str) -> Decimal | None:
|
||||||
|
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)\s*/\s*(?:天|人|晚|次|笔)", str(text or ""))
|
||||||
|
if match is None:
|
||||||
|
match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", str(text or ""))
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(match.group(1)).quantize(Decimal("0.01"))
|
||||||
|
except (ArithmeticError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_spreadsheet_category_to_expense_type(category: str) -> str:
|
||||||
|
normalized = re.sub(r"\s+", "", str(category or ""))
|
||||||
|
if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")):
|
||||||
|
return "transport"
|
||||||
|
if "招待" in normalized and "餐" in normalized:
|
||||||
|
return "entertainment"
|
||||||
|
if "餐补" in normalized or normalized == "餐费":
|
||||||
|
return "meal"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _spreadsheet_metric_label(expense_type: str) -> str:
|
||||||
|
return {
|
||||||
|
"transport": "单笔交通金额",
|
||||||
|
"meal": "差旅餐补金额",
|
||||||
|
"entertainment": "人均招待餐费",
|
||||||
|
}.get(expense_type, "金额")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _replace_amount_limit_warn_amount(
|
||||||
|
base_limit: AmountLimitConfig | None,
|
||||||
|
*,
|
||||||
|
amount: Decimal,
|
||||||
|
metric_label: str,
|
||||||
|
) -> AmountLimitConfig:
|
||||||
|
if base_limit is None:
|
||||||
|
return AmountLimitConfig(
|
||||||
|
warn_amount=amount,
|
||||||
|
block_amount=None,
|
||||||
|
metric_label=metric_label,
|
||||||
|
)
|
||||||
|
payload = base_limit.model_dump()
|
||||||
|
payload["warn_amount"] = amount
|
||||||
|
payload["metric_label"] = metric_label
|
||||||
|
return AmountLimitConfig(**payload)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -27,6 +28,7 @@ class PreparedOcrInput:
|
|||||||
page_index: int | None = None
|
page_index: int | None = None
|
||||||
preview_kind: str = ""
|
preview_kind: str = ""
|
||||||
preview_data_url: str = ""
|
preview_data_url: str = ""
|
||||||
|
text_layer: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -38,6 +40,7 @@ class AggregatedOcrDocument:
|
|||||||
model: str = "PP-OCRv5_mobile"
|
model: str = "PP-OCRv5_mobile"
|
||||||
summary_fragments: list[str] = field(default_factory=list)
|
summary_fragments: list[str] = field(default_factory=list)
|
||||||
text_fragments: list[str] = field(default_factory=list)
|
text_fragments: list[str] = field(default_factory=list)
|
||||||
|
text_layer_fragments: list[str] = field(default_factory=list)
|
||||||
score_values: list[float] = field(default_factory=list)
|
score_values: list[float] = field(default_factory=list)
|
||||||
warnings: list[str] = field(default_factory=list)
|
warnings: list[str] = field(default_factory=list)
|
||||||
lines: list[OcrRecognizeLineRead] = field(default_factory=list)
|
lines: list[OcrRecognizeLineRead] = field(default_factory=list)
|
||||||
@@ -112,12 +115,14 @@ class OcrService:
|
|||||||
|
|
||||||
if suffix == ".pdf":
|
if suffix == ".pdf":
|
||||||
try:
|
try:
|
||||||
|
text_layer = self._extract_pdf_text_layer(temp_path)
|
||||||
prepared_inputs.extend(
|
prepared_inputs.extend(
|
||||||
self._prepare_pdf_inputs(
|
self._prepare_pdf_inputs(
|
||||||
pdf_path=temp_path,
|
pdf_path=temp_path,
|
||||||
filename=normalized_name,
|
filename=normalized_name,
|
||||||
media_type=resolved_media_type,
|
media_type=resolved_media_type,
|
||||||
cleanup_paths=cleanup_paths,
|
cleanup_paths=cleanup_paths,
|
||||||
|
text_layer=text_layer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
@@ -261,6 +266,7 @@ class OcrService:
|
|||||||
filename: str,
|
filename: str,
|
||||||
media_type: str,
|
media_type: str,
|
||||||
cleanup_paths: list[Path],
|
cleanup_paths: list[Path],
|
||||||
|
text_layer: str = "",
|
||||||
) -> list[PreparedOcrInput]:
|
) -> list[PreparedOcrInput]:
|
||||||
output_dir = pdf_path.with_suffix("")
|
output_dir = pdf_path.with_suffix("")
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -283,10 +289,33 @@ class OcrService:
|
|||||||
page_index=page_index,
|
page_index=page_index,
|
||||||
preview_kind="image" if page_index == 0 else "",
|
preview_kind="image" if page_index == 0 else "",
|
||||||
preview_data_url=preview_data_url if page_index == 0 else "",
|
preview_data_url=preview_data_url if page_index == 0 else "",
|
||||||
|
text_layer=text_layer if page_index == 0 else "",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return descriptors
|
return descriptors
|
||||||
|
|
||||||
|
def _extract_pdf_text_layer(self, pdf_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
"pdftotext",
|
||||||
|
"-layout",
|
||||||
|
str(pdf_path),
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=self.settings.ocr_timeout_seconds,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError, UnicodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return self._normalize_extracted_text(completed.stdout)
|
||||||
|
|
||||||
def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||||
prefix = output_dir / "page"
|
prefix = output_dir / "page"
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
@@ -367,6 +396,8 @@ class OcrService:
|
|||||||
aggregated.preview_kind = descriptor.preview_kind
|
aggregated.preview_kind = descriptor.preview_kind
|
||||||
if descriptor.preview_data_url and not aggregated.preview_data_url:
|
if descriptor.preview_data_url and not aggregated.preview_data_url:
|
||||||
aggregated.preview_data_url = descriptor.preview_data_url
|
aggregated.preview_data_url = descriptor.preview_data_url
|
||||||
|
if descriptor.text_layer and descriptor.text_layer not in aggregated.text_layer_fragments:
|
||||||
|
aggregated.text_layer_fragments.append(descriptor.text_layer)
|
||||||
|
|
||||||
page_summary = str(payload.get("summary", "") or "").strip()
|
page_summary = str(payload.get("summary", "") or "").strip()
|
||||||
if page_summary:
|
if page_summary:
|
||||||
@@ -401,6 +432,20 @@ class OcrService:
|
|||||||
aggregated = aggregated_by_source.get(source_key)
|
aggregated = aggregated_by_source.get(source_key)
|
||||||
if aggregated is None:
|
if aggregated is None:
|
||||||
first_descriptor = descriptors[0]
|
first_descriptor = descriptors[0]
|
||||||
|
text_layer = self._collect_descriptor_text_layer(descriptors)
|
||||||
|
if text_layer:
|
||||||
|
fallback = AggregatedOcrDocument(
|
||||||
|
filename=first_descriptor.filename,
|
||||||
|
media_type=first_descriptor.media_type,
|
||||||
|
source_key=first_descriptor.source_key,
|
||||||
|
page_count=max(1, len(descriptors)),
|
||||||
|
preview_kind=first_descriptor.preview_kind,
|
||||||
|
preview_data_url=first_descriptor.preview_data_url,
|
||||||
|
warnings=["OCR worker 未返回该文件的识别结果,已使用 PDF 文本层。"],
|
||||||
|
)
|
||||||
|
fallback.text_layer_fragments.append(text_layer)
|
||||||
|
documents.append(self._finalize_document(fallback))
|
||||||
|
continue
|
||||||
documents.append(
|
documents.append(
|
||||||
OcrRecognizeDocumentRead(
|
OcrRecognizeDocumentRead(
|
||||||
filename=first_descriptor.filename,
|
filename=first_descriptor.filename,
|
||||||
@@ -416,6 +461,13 @@ class OcrService:
|
|||||||
|
|
||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_descriptor_text_layer(descriptors: list[PreparedOcrInput]) -> str:
|
||||||
|
for descriptor in descriptors:
|
||||||
|
if descriptor.text_layer:
|
||||||
|
return descriptor.text_layer
|
||||||
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_lines(
|
def _build_lines(
|
||||||
items: list[dict],
|
items: list[dict],
|
||||||
@@ -451,13 +503,26 @@ class OcrService:
|
|||||||
return summary
|
return summary
|
||||||
|
|
||||||
def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead:
|
def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead:
|
||||||
full_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
|
ocr_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip()
|
||||||
|
text_layer = "\n".join(fragment for fragment in aggregated.text_layer_fragments if fragment).strip()
|
||||||
|
full_text, used_text_layer = self._choose_document_text(ocr_text=ocr_text, text_layer=text_layer)
|
||||||
summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments)
|
summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments)
|
||||||
|
if used_text_layer or self._placeholder_ratio(summary) >= 0.12:
|
||||||
|
summary = self._summarize_text(full_text)
|
||||||
|
preview_kind = aggregated.preview_kind
|
||||||
|
preview_data_url = aggregated.preview_data_url
|
||||||
|
if (
|
||||||
|
used_text_layer
|
||||||
|
and aggregated.media_type == "application/pdf"
|
||||||
|
and self._placeholder_ratio(ocr_text) >= 0.12
|
||||||
|
):
|
||||||
|
preview_kind = ""
|
||||||
|
preview_data_url = ""
|
||||||
insight = self.document_intelligence_service.build_document_insight(
|
insight = self.document_intelligence_service.build_document_insight(
|
||||||
filename=aggregated.filename,
|
filename=aggregated.filename,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
text=full_text,
|
text=full_text,
|
||||||
preview_data_url=aggregated.preview_data_url,
|
preview_data_url=preview_data_url,
|
||||||
)
|
)
|
||||||
warnings = list(aggregated.warnings)
|
warnings = list(aggregated.warnings)
|
||||||
for warning in insight.warnings:
|
for warning in insight.warnings:
|
||||||
@@ -493,8 +558,8 @@ class OcrService:
|
|||||||
)
|
)
|
||||||
for field in insight.fields
|
for field in insight.fields
|
||||||
],
|
],
|
||||||
preview_kind=aggregated.preview_kind,
|
preview_kind=preview_kind,
|
||||||
preview_data_url=aggregated.preview_data_url,
|
preview_data_url=preview_data_url,
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
lines=sorted(
|
lines=sorted(
|
||||||
aggregated.lines,
|
aggregated.lines,
|
||||||
@@ -502,6 +567,45 @@ class OcrService:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _choose_document_text(cls, *, ocr_text: str, text_layer: str) -> tuple[str, bool]:
|
||||||
|
normalized_ocr_text = cls._normalize_extracted_text(ocr_text)
|
||||||
|
normalized_text_layer = cls._normalize_extracted_text(text_layer)
|
||||||
|
if not normalized_text_layer:
|
||||||
|
return normalized_ocr_text, False
|
||||||
|
if not normalized_ocr_text:
|
||||||
|
return normalized_text_layer, True
|
||||||
|
if cls._placeholder_ratio(normalized_ocr_text) >= 0.12 and cls._meaningful_char_count(normalized_text_layer) >= 8:
|
||||||
|
return normalized_text_layer, True
|
||||||
|
if cls._meaningful_char_count(normalized_text_layer) > cls._meaningful_char_count(normalized_ocr_text) * 1.3:
|
||||||
|
return normalized_text_layer, True
|
||||||
|
return normalized_ocr_text, False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_extracted_text(value: str) -> str:
|
||||||
|
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in str(value or "").replace("\r", "\n").split("\n")]
|
||||||
|
return "\n".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _summarize_text(value: str) -> str:
|
||||||
|
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
|
||||||
|
summary = ";".join(lines[:3])
|
||||||
|
if len(summary) > 180:
|
||||||
|
return f"{summary[:177]}..."
|
||||||
|
return summary
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _meaningful_char_count(value: str) -> int:
|
||||||
|
return len(re.findall(r"[0-9A-Za-z\u4e00-\u9fff]", str(value or "")))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _placeholder_ratio(value: str) -> float:
|
||||||
|
chars = [char for char in str(value or "") if not char.isspace()]
|
||||||
|
if not chars:
|
||||||
|
return 0.0
|
||||||
|
placeholder_count = sum(1 for char in chars if char in {"□", "<EFBFBD>"})
|
||||||
|
return placeholder_count / len(chars)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cleanup_temp_paths(paths: list[Path]) -> None:
|
def _cleanup_temp_paths(paths: list[Path]) -> None:
|
||||||
for path in reversed(paths):
|
for path in reversed(paths):
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ TOP_N_PATTERN = re.compile(r"(?:top|TOP|前|最高的?|最低的?)\s*(?P<top>\d+
|
|||||||
SCENARIO_KEYWORDS = {
|
SCENARIO_KEYWORDS = {
|
||||||
"expense": (
|
"expense": (
|
||||||
("报销", 0.20),
|
("报销", 0.20),
|
||||||
|
("报销单", 0.20),
|
||||||
|
("单据报销", 0.18),
|
||||||
("报账", 0.20),
|
("报账", 0.20),
|
||||||
("差旅", 0.20),
|
("差旅", 0.20),
|
||||||
("费用", 0.14),
|
("费用", 0.14),
|
||||||
@@ -122,6 +124,8 @@ RISK_KEYWORDS = ("风险", "异常", "重复", "超标", "超预算", "逾期",
|
|||||||
DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备")
|
DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备")
|
||||||
DRAFT_FOLLOW_UP_KEYWORDS = (
|
DRAFT_FOLLOW_UP_KEYWORDS = (
|
||||||
"继续",
|
"继续",
|
||||||
|
"下一步",
|
||||||
|
"核对",
|
||||||
"补充",
|
"补充",
|
||||||
"补一下",
|
"补一下",
|
||||||
"修改",
|
"修改",
|
||||||
@@ -138,6 +142,13 @@ DRAFT_FOLLOW_UP_KEYWORDS = (
|
|||||||
"日期是",
|
"日期是",
|
||||||
"时间是",
|
"时间是",
|
||||||
)
|
)
|
||||||
|
EXPENSE_REVIEW_ACTIONS = {
|
||||||
|
"save_draft",
|
||||||
|
"next_step",
|
||||||
|
"edit_review",
|
||||||
|
"link_to_existing_draft",
|
||||||
|
"create_new_claim_from_documents",
|
||||||
|
}
|
||||||
OPERATE_KEYWORDS = (
|
OPERATE_KEYWORDS = (
|
||||||
"直接付款",
|
"直接付款",
|
||||||
"帮我付款",
|
"帮我付款",
|
||||||
@@ -162,6 +173,11 @@ EXPENSE_TYPE_KEYWORDS = {
|
|||||||
"打车": "transport",
|
"打车": "transport",
|
||||||
"网约车": "transport",
|
"网约车": "transport",
|
||||||
"出租车": "transport",
|
"出租车": "transport",
|
||||||
|
"乘车": "transport",
|
||||||
|
"乘车费": "transport",
|
||||||
|
"用车": "transport",
|
||||||
|
"叫车": "transport",
|
||||||
|
"车资": "transport",
|
||||||
"停车费": "transport",
|
"停车费": "transport",
|
||||||
"餐费": "meal",
|
"餐费": "meal",
|
||||||
"用餐": "meal",
|
"用餐": "meal",
|
||||||
@@ -195,6 +211,11 @@ EXPENSE_NARRATIVE_KEYWORDS = (
|
|||||||
"垫付",
|
"垫付",
|
||||||
"打车",
|
"打车",
|
||||||
"车费",
|
"车费",
|
||||||
|
"乘车",
|
||||||
|
"乘车费",
|
||||||
|
"用车",
|
||||||
|
"叫车",
|
||||||
|
"车资",
|
||||||
"餐费",
|
"餐费",
|
||||||
"吃饭",
|
"吃饭",
|
||||||
"用餐",
|
"用餐",
|
||||||
@@ -231,16 +252,51 @@ MISSING_SLOT_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
STATUS_KEYWORDS = {
|
STATUS_KEYWORDS = {
|
||||||
|
"草稿": "draft",
|
||||||
|
"待提交": "draft",
|
||||||
|
"待补充": "supplement",
|
||||||
|
"退回": "returned",
|
||||||
|
"已退回": "returned",
|
||||||
|
"进行中": "review",
|
||||||
|
"审批中": "review",
|
||||||
|
"审核中": "review",
|
||||||
|
"流转中": "review",
|
||||||
|
"已提交": "submitted",
|
||||||
"逾期": "overdue",
|
"逾期": "overdue",
|
||||||
"待审批": "pending",
|
"待审批": "pending",
|
||||||
"待审": "pending",
|
"待审": "pending",
|
||||||
"已审批": "approved",
|
"已审批": "approved",
|
||||||
"已通过": "approved",
|
"已通过": "approved",
|
||||||
|
"已审核": "approved",
|
||||||
|
"已入账": "paid",
|
||||||
"已付款": "paid",
|
"已付款": "paid",
|
||||||
"未付款": "unpaid",
|
"未付款": "unpaid",
|
||||||
"未回款": "unreceived",
|
"未回款": "unreceived",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LOCATION_KEYWORDS = (
|
||||||
|
"北京",
|
||||||
|
"上海",
|
||||||
|
"广州",
|
||||||
|
"深圳",
|
||||||
|
"杭州",
|
||||||
|
"南京",
|
||||||
|
"苏州",
|
||||||
|
"成都",
|
||||||
|
"重庆",
|
||||||
|
"天津",
|
||||||
|
"武汉",
|
||||||
|
"西安",
|
||||||
|
"郑州",
|
||||||
|
"长沙",
|
||||||
|
"青岛",
|
||||||
|
"厦门",
|
||||||
|
"宁波",
|
||||||
|
"合肥",
|
||||||
|
"济南",
|
||||||
|
"福州",
|
||||||
|
)
|
||||||
|
|
||||||
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
|
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
|
||||||
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
|
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
|
||||||
KNOWLEDGE_INTENTS = {"query", "explain", "compare"}
|
KNOWLEDGE_INTENTS = {"query", "explain", "compare"}
|
||||||
@@ -641,6 +697,11 @@ class SemanticOntologyService:
|
|||||||
value = str(context_json.get("conversation_scenario") or "").strip()
|
value = str(context_json.get("conversation_scenario") or "").strip()
|
||||||
if value in CONTEXTUAL_SCENARIOS:
|
if value in CONTEXTUAL_SCENARIOS:
|
||||||
return value
|
return value
|
||||||
|
review_action = str(context_json.get("review_action") or "").strip()
|
||||||
|
if review_action in EXPENSE_REVIEW_ACTIONS:
|
||||||
|
return "expense"
|
||||||
|
if str(context_json.get("draft_claim_id") or "").strip():
|
||||||
|
return "expense"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -661,6 +722,10 @@ class SemanticOntologyService:
|
|||||||
best_scenario = max(scores, key=scores.get)
|
best_scenario = max(scores, key=scores.get)
|
||||||
best_score = scores[best_scenario]
|
best_score = scores[best_scenario]
|
||||||
if best_score <= 0:
|
if best_score <= 0:
|
||||||
|
if "单据" in compact_query and any(
|
||||||
|
keyword in compact_query for keyword in STATUS_KEYWORDS
|
||||||
|
):
|
||||||
|
return "expense", 0.14
|
||||||
return "unknown", 0.0
|
return "unknown", 0.0
|
||||||
|
|
||||||
if best_scenario == "knowledge":
|
if best_scenario == "knowledge":
|
||||||
@@ -687,6 +752,40 @@ class SemanticOntologyService:
|
|||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
|
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
|
||||||
return "operate", 0.30
|
return "operate", 0.30
|
||||||
|
status_document_query = (
|
||||||
|
"单据" in compact_query
|
||||||
|
and any(keyword in compact_query for keyword in STATUS_KEYWORDS)
|
||||||
|
and not any(keyword in compact_query for keyword in DRAFT_KEYWORDS if keyword != "草稿")
|
||||||
|
)
|
||||||
|
historical_document_query = any(
|
||||||
|
keyword in compact_query
|
||||||
|
for keyword in ("报销的单据", "报销单据", "报销过的单据", "报销记录")
|
||||||
|
)
|
||||||
|
if scenario == "expense" and any(
|
||||||
|
keyword in compact_query
|
||||||
|
for keyword in (
|
||||||
|
"报销了吗",
|
||||||
|
"报销了么",
|
||||||
|
"报销了没",
|
||||||
|
"报销了没有",
|
||||||
|
"报销没",
|
||||||
|
"单据状态",
|
||||||
|
"审批状态",
|
||||||
|
"报销进度",
|
||||||
|
"到哪了",
|
||||||
|
"到了哪",
|
||||||
|
"有没有报销",
|
||||||
|
"是否报销",
|
||||||
|
"进行中的单据",
|
||||||
|
"草稿单据",
|
||||||
|
"草稿的单据",
|
||||||
|
"待补充单据",
|
||||||
|
"审批中的单据",
|
||||||
|
"已提交单据",
|
||||||
|
"已入账单据",
|
||||||
|
)
|
||||||
|
) or (scenario == "expense" and (status_document_query or historical_document_query)):
|
||||||
|
return "query", 0.24
|
||||||
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
|
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
|
||||||
return "draft", 0.26
|
return "draft", 0.26
|
||||||
if scenario == "expense" and self._is_generic_expense_prompt(compact_query):
|
if scenario == "expense" and self._is_generic_expense_prompt(compact_query):
|
||||||
@@ -739,6 +838,9 @@ class SemanticOntologyService:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
context_scenario = self._resolve_context_scenario(context_json)
|
context_scenario = self._resolve_context_scenario(context_json)
|
||||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||||
|
review_action = str(context_json.get("review_action") or "").strip()
|
||||||
|
if review_action in EXPENSE_REVIEW_ACTIONS:
|
||||||
|
return True
|
||||||
if context_scenario != "expense" and not draft_claim_id:
|
if context_scenario != "expense" and not draft_claim_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1154,6 +1256,9 @@ class SemanticOntologyService:
|
|||||||
upsert(self._make_entity("invoice", code, code.upper()))
|
upsert(self._make_entity("invoice", code, code.upper()))
|
||||||
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
|
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
|
||||||
upsert(self._make_entity("contract", code, code.upper()))
|
upsert(self._make_entity("contract", code, code.upper()))
|
||||||
|
for location in LOCATION_KEYWORDS:
|
||||||
|
if location in query:
|
||||||
|
upsert(self._make_entity("location", location, location, role="filter", confidence=0.86))
|
||||||
|
|
||||||
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
|
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
|
||||||
if label in query:
|
if label in query:
|
||||||
@@ -1173,7 +1278,10 @@ class SemanticOntologyService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("打车", "网约车", "出租车", "车费", "停车费", "过路费")):
|
if any(
|
||||||
|
keyword in query
|
||||||
|
for keyword in ("打车", "网约车", "出租车", "车费", "乘车", "用车", "叫车", "车资", "停车费", "过路费")
|
||||||
|
):
|
||||||
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
|
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")):
|
if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")):
|
||||||
@@ -1314,6 +1422,12 @@ class SemanticOntologyService:
|
|||||||
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
|
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
|
||||||
0.10,
|
0.10,
|
||||||
)
|
)
|
||||||
|
if "去年" in query or "上一年" in query:
|
||||||
|
year = today.year - 1
|
||||||
|
return (
|
||||||
|
self._range(date(year, 1, 1), date(year, 12, 31), "去年", "year"),
|
||||||
|
0.10,
|
||||||
|
)
|
||||||
|
|
||||||
match = DATE_RANGE_PATTERN.search(query)
|
match = DATE_RANGE_PATTERN.search(query)
|
||||||
if match:
|
if match:
|
||||||
@@ -1463,6 +1577,7 @@ class SemanticOntologyService:
|
|||||||
"customer",
|
"customer",
|
||||||
"vendor",
|
"vendor",
|
||||||
"project",
|
"project",
|
||||||
|
"location",
|
||||||
"expense_type",
|
"expense_type",
|
||||||
}:
|
}:
|
||||||
upsert(
|
upsert(
|
||||||
@@ -1682,7 +1797,8 @@ class SemanticOntologyService:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
if scenario != "expense" or intent != "draft":
|
if scenario != "expense" or intent != "draft":
|
||||||
return False
|
return False
|
||||||
return str(context_json.get("review_action") or "").strip() == "save_draft"
|
review_action = str(context_json.get("review_action") or "").strip()
|
||||||
|
return review_action in EXPENSE_REVIEW_ACTIONS
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _display_slot_label(slot: str) -> str:
|
def _display_slot_label(slot: str) -> str:
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ class OrchestratorService:
|
|||||||
context_json = self.conversation_service.hydrate_context_json(
|
context_json = self.conversation_service.hydrate_context_json(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
context_json=context_json,
|
context_json=context_json,
|
||||||
|
message=payload.message,
|
||||||
)
|
)
|
||||||
|
|
||||||
route_json: dict[str, Any] = {
|
route_json: dict[str, Any] = {
|
||||||
@@ -173,8 +174,10 @@ class OrchestratorService:
|
|||||||
task_asset=task_asset,
|
task_asset=task_asset,
|
||||||
)
|
)
|
||||||
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
||||||
|
is_expense_review_action = self._is_expense_review_action(context_json)
|
||||||
requires_confirmation = (
|
requires_confirmation = (
|
||||||
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||||
|
and not is_expense_review_action
|
||||||
)
|
)
|
||||||
|
|
||||||
route_json = {
|
route_json = {
|
||||||
@@ -526,7 +529,11 @@ class OrchestratorService:
|
|||||||
failed_tool_count=1 if degraded else 0,
|
failed_tool_count=1 if degraded else 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
next_step = self._resolve_next_step(ontology, payload.source)
|
next_step = self._resolve_next_step(
|
||||||
|
ontology,
|
||||||
|
payload.source,
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
if next_step == "query_database":
|
if next_step == "query_database":
|
||||||
tool_payload, degraded = self._invoke_tool(
|
tool_payload, degraded = self._invoke_tool(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@@ -658,13 +665,22 @@ class OrchestratorService:
|
|||||||
"draft_only": True,
|
"draft_only": True,
|
||||||
}
|
}
|
||||||
fallback_factory = lambda exc: {
|
fallback_factory = lambda exc: {
|
||||||
"message": f"草稿生成暂时不可用,请稍后再试:{exc}",
|
"message": f"内容整理暂时不可用,请稍后再试:{exc}",
|
||||||
"degraded": True,
|
"degraded": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ontology.scenario == "expense":
|
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||||
tool_type = AgentToolType.DATABASE.value
|
is_persistence_action = self._is_expense_persistence_action(context_json)
|
||||||
tool_name = "database.expense_claims.save_or_submit"
|
tool_type = (
|
||||||
|
AgentToolType.DATABASE.value
|
||||||
|
if is_persistence_action
|
||||||
|
else AgentToolType.LLM.value
|
||||||
|
)
|
||||||
|
tool_name = (
|
||||||
|
"database.expense_claims.save_or_submit"
|
||||||
|
if is_persistence_action
|
||||||
|
else "user_agent.expense_review_preview"
|
||||||
|
)
|
||||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
user_id=payload.user_id,
|
user_id=payload.user_id,
|
||||||
@@ -673,7 +689,11 @@ class OrchestratorService:
|
|||||||
context_json=context_json,
|
context_json=context_json,
|
||||||
)
|
)
|
||||||
fallback_factory = lambda exc: {
|
fallback_factory = lambda exc: {
|
||||||
"message": f"报销草稿落库失败,请稍后再试:{exc}",
|
"message": (
|
||||||
|
f"报销草稿落库失败,请稍后再试:{exc}"
|
||||||
|
if is_persistence_action
|
||||||
|
else f"报销内容预览生成失败,请稍后再试:{exc}"
|
||||||
|
),
|
||||||
"degraded": True,
|
"degraded": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,7 +802,14 @@ class OrchestratorService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_next_step(ontology: OntologyParseResult, source: str) -> str:
|
def _resolve_next_step(
|
||||||
|
ontology: OntologyParseResult,
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
context_json: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
if OrchestratorService._is_expense_review_action(context_json or {}):
|
||||||
|
return "create_draft"
|
||||||
if ontology.clarification_required:
|
if ontology.clarification_required:
|
||||||
return "ask_clarification"
|
return "ask_clarification"
|
||||||
if ontology.intent == "draft":
|
if ontology.intent == "draft":
|
||||||
@@ -795,6 +822,27 @@ class OrchestratorService:
|
|||||||
return "query_database"
|
return "query_database"
|
||||||
return "create_draft"
|
return "create_draft"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_review_action(context_json: dict[str, Any]) -> bool:
|
||||||
|
review_action = str((context_json or {}).get("review_action") or "").strip()
|
||||||
|
return review_action in {
|
||||||
|
"save_draft",
|
||||||
|
"next_step",
|
||||||
|
"edit_review",
|
||||||
|
"link_to_existing_draft",
|
||||||
|
"create_new_claim_from_documents",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_persistence_action(context_json: dict[str, Any]) -> bool:
|
||||||
|
review_action = str((context_json or {}).get("review_action") or "").strip()
|
||||||
|
return review_action in {
|
||||||
|
"save_draft",
|
||||||
|
"next_step",
|
||||||
|
"link_to_existing_draft",
|
||||||
|
"create_new_claim_from_documents",
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _flatten_capability_codes(
|
def _flatten_capability_codes(
|
||||||
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
||||||
@@ -1147,6 +1195,8 @@ class OrchestratorService:
|
|||||||
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
|
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
project_values = self._collect_expense_query_filter_values(ontology, "project")
|
||||||
|
location_values = self._collect_expense_query_filter_values(ontology, "location")
|
||||||
status_values = list(
|
status_values = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
str(item.value).strip()
|
str(item.value).strip()
|
||||||
@@ -1168,6 +1218,20 @@ class OrchestratorService:
|
|||||||
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
|
||||||
if status_values:
|
if status_values:
|
||||||
conditions.append(ExpenseClaim.status.in_(status_values))
|
conditions.append(ExpenseClaim.status.in_(status_values))
|
||||||
|
if project_values:
|
||||||
|
project_conditions = []
|
||||||
|
for value in project_values:
|
||||||
|
pattern = f"%{value}%"
|
||||||
|
project_conditions.append(ExpenseClaim.project_code.ilike(pattern))
|
||||||
|
project_conditions.append(ExpenseClaim.reason.ilike(pattern))
|
||||||
|
conditions.append(or_(*project_conditions))
|
||||||
|
if location_values:
|
||||||
|
location_conditions = []
|
||||||
|
for value in location_values:
|
||||||
|
pattern = f"%{value}%"
|
||||||
|
location_conditions.append(ExpenseClaim.location.ilike(pattern))
|
||||||
|
location_conditions.append(ExpenseClaim.reason.ilike(pattern))
|
||||||
|
conditions.append(or_(*location_conditions))
|
||||||
|
|
||||||
for item in amount_constraints:
|
for item in amount_constraints:
|
||||||
amount_value = float(item.value)
|
amount_value = float(item.value)
|
||||||
@@ -1229,6 +1293,26 @@ class OrchestratorService:
|
|||||||
|
|
||||||
return conditions, scope_label, scoped_to_current_user
|
return conditions, scope_label, scoped_to_current_user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_expense_query_filter_values(
|
||||||
|
ontology: OntologyParseResult,
|
||||||
|
field_name: str,
|
||||||
|
) -> list[str]:
|
||||||
|
values: list[str] = []
|
||||||
|
for entity in ontology.entities:
|
||||||
|
if entity.type != field_name:
|
||||||
|
continue
|
||||||
|
value = str(entity.normalized_value or entity.value or "").strip()
|
||||||
|
if value:
|
||||||
|
values.append(value)
|
||||||
|
for constraint in ontology.constraints:
|
||||||
|
if constraint.field != field_name or constraint.operator != "=":
|
||||||
|
continue
|
||||||
|
value = str(constraint.value or "").strip()
|
||||||
|
if value:
|
||||||
|
values.append(value)
|
||||||
|
return list(dict.fromkeys(values))
|
||||||
|
|
||||||
def _build_current_user_claim_conditions(
|
def _build_current_user_claim_conditions(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
77
server/src/app/services/risk_ontology_bridge.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.schemas.ontology import OntologyParseResult
|
||||||
|
|
||||||
|
RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
|
||||||
|
"location_mismatch": ["risk.travel.destination_receipt_location"],
|
||||||
|
"base_location_overlap": ["risk.travel.base_location_overlap"],
|
||||||
|
"intracity_travel": ["risk.travel.intracity_travel_claim"],
|
||||||
|
"multi_city_itinerary": ["risk.travel.multi_city_reason_required"],
|
||||||
|
"hotel_itinerary_mismatch": ["risk.travel.hotel_without_itinerary"],
|
||||||
|
"duplicate_invoice": ["risk.invoice.duplicate_invoice"],
|
||||||
|
"buyer_name_mismatch": ["risk.invoice.claimant_buyer_name_match"],
|
||||||
|
"document_expense_mismatch": ["risk.invoice.document_expense_mismatch"],
|
||||||
|
"cross_year_invoice": ["risk.invoice.cross_year_invoice"],
|
||||||
|
"void_or_red_invoice": ["risk.invoice.void_or_red_invoice"],
|
||||||
|
"vague_goods_description": ["risk.invoice.vague_goods_description"],
|
||||||
|
"entertainment_missing_detail": ["risk.expense.entertainment_missing_detail"],
|
||||||
|
"meal_as_travel": ["risk.expense.meal_localized_as_travel"],
|
||||||
|
"consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"],
|
||||||
|
"reason_too_brief": ["risk.expense.reason_too_brief"],
|
||||||
|
}
|
||||||
|
|
||||||
|
TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
|
||||||
|
"location_mismatch": ("地点", "行程", "出差地", "票据地", "城市不一致"),
|
||||||
|
"duplicate_invoice": ("重复", "同一张票", "重复报销", "发票重复"),
|
||||||
|
"buyer_name_mismatch": ("购买方", "抬头", "开票单位"),
|
||||||
|
"document_expense_mismatch": ("附件", "票据", "单据", "材料不一致"),
|
||||||
|
"cross_year_invoice": ("跨年", "以前年度", "去年发票"),
|
||||||
|
"void_or_red_invoice": ("作废", "红冲", "红字"),
|
||||||
|
"vague_goods_description": ("商品名称", "品名", "笼统"),
|
||||||
|
"entertainment_missing_detail": ("招待", "宴请", "陪同", "客户餐"),
|
||||||
|
"meal_as_travel": ("餐费", "差旅餐", "本地餐"),
|
||||||
|
"consecutive_transport_receipts": ("连续交通", "多张车票", "打车"),
|
||||||
|
"reason_too_brief": ("事由", "说明太短", "理由不足"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_platform_risk_rule_codes() -> list[str]:
|
||||||
|
return sorted({code for codes in RISK_SIGNAL_TO_RULE_CODES.values() for code in codes})
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_rule_codes_from_ontology(ontology: OntologyParseResult) -> list[str]:
|
||||||
|
resolved: list[str] = []
|
||||||
|
for signal in ontology.risk_flags:
|
||||||
|
for rule_code in RISK_SIGNAL_TO_RULE_CODES.get(str(signal or "").strip(), []):
|
||||||
|
if rule_code not in resolved:
|
||||||
|
resolved.append(rule_code)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def infer_risk_signals_from_text(text: str) -> list[str]:
|
||||||
|
normalized = str(text or "").strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
|
||||||
|
signals: list[str] = []
|
||||||
|
for signal, keywords in TEXT_SIGNAL_KEYWORDS.items():
|
||||||
|
if any(keyword.lower() in normalized for keyword in keywords):
|
||||||
|
signals.append(signal)
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_rule_codes_for_risk_check(
|
||||||
|
ontology: OntologyParseResult,
|
||||||
|
*,
|
||||||
|
query_text: str = "",
|
||||||
|
) -> list[str]:
|
||||||
|
if ontology.intent != "risk_check":
|
||||||
|
return []
|
||||||
|
|
||||||
|
resolved = resolve_rule_codes_from_ontology(ontology)
|
||||||
|
for signal in infer_risk_signals_from_text(query_text):
|
||||||
|
for rule_code in RISK_SIGNAL_TO_RULE_CODES.get(signal, []):
|
||||||
|
if rule_code not in resolved:
|
||||||
|
resolved.append(rule_code)
|
||||||
|
|
||||||
|
return resolved or list_all_platform_risk_rule_codes()
|
||||||
593
server/src/app/services/travel_reimbursement_calculator.py
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.core.agent_enums import AgentAssetType
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.schemas.reimbursement import (
|
||||||
|
TravelReimbursementCalculatorRequest,
|
||||||
|
TravelReimbursementCalculatorResponse,
|
||||||
|
)
|
||||||
|
from app.services.agent_assets import AgentAssetService
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService
|
||||||
|
|
||||||
|
OTHER_REGION_LOCATION_KEYWORDS = {
|
||||||
|
"河北",
|
||||||
|
"石家庄",
|
||||||
|
"唐山",
|
||||||
|
"秦皇岛",
|
||||||
|
"邯郸",
|
||||||
|
"邢台",
|
||||||
|
"保定",
|
||||||
|
"张家口",
|
||||||
|
"承德",
|
||||||
|
"沧州",
|
||||||
|
"廊坊",
|
||||||
|
"衡水",
|
||||||
|
"山西",
|
||||||
|
"太原",
|
||||||
|
"大同",
|
||||||
|
"长治",
|
||||||
|
"晋城",
|
||||||
|
"晋中",
|
||||||
|
"运城",
|
||||||
|
"临汾",
|
||||||
|
"吕梁",
|
||||||
|
"内蒙古",
|
||||||
|
"呼和浩特",
|
||||||
|
"包头",
|
||||||
|
"赤峰",
|
||||||
|
"通辽",
|
||||||
|
"鄂尔多斯",
|
||||||
|
"辽宁",
|
||||||
|
"鞍山",
|
||||||
|
"抚顺",
|
||||||
|
"本溪",
|
||||||
|
"丹东",
|
||||||
|
"锦州",
|
||||||
|
"营口",
|
||||||
|
"盘锦",
|
||||||
|
"吉林",
|
||||||
|
"长春",
|
||||||
|
"吉林市",
|
||||||
|
"四平",
|
||||||
|
"通化",
|
||||||
|
"白山",
|
||||||
|
"松原",
|
||||||
|
"延边",
|
||||||
|
"黑龙江",
|
||||||
|
"哈尔滨",
|
||||||
|
"齐齐哈尔",
|
||||||
|
"牡丹江",
|
||||||
|
"佳木斯",
|
||||||
|
"大庆",
|
||||||
|
"江苏",
|
||||||
|
"常州",
|
||||||
|
"南通",
|
||||||
|
"连云港",
|
||||||
|
"淮安",
|
||||||
|
"盐城",
|
||||||
|
"扬州",
|
||||||
|
"镇江",
|
||||||
|
"泰州",
|
||||||
|
"宿迁",
|
||||||
|
"浙江",
|
||||||
|
"温州",
|
||||||
|
"嘉兴",
|
||||||
|
"湖州",
|
||||||
|
"绍兴",
|
||||||
|
"金华",
|
||||||
|
"衢州",
|
||||||
|
"舟山",
|
||||||
|
"台州",
|
||||||
|
"丽水",
|
||||||
|
"安徽",
|
||||||
|
"芜湖",
|
||||||
|
"蚌埠",
|
||||||
|
"淮南",
|
||||||
|
"马鞍山",
|
||||||
|
"淮北",
|
||||||
|
"铜陵",
|
||||||
|
"安庆",
|
||||||
|
"黄山",
|
||||||
|
"滁州",
|
||||||
|
"阜阳",
|
||||||
|
"宿州",
|
||||||
|
"六安",
|
||||||
|
"亳州",
|
||||||
|
"池州",
|
||||||
|
"宣城",
|
||||||
|
"福建",
|
||||||
|
"泉州",
|
||||||
|
"漳州",
|
||||||
|
"莆田",
|
||||||
|
"三明",
|
||||||
|
"南平",
|
||||||
|
"龙岩",
|
||||||
|
"宁德",
|
||||||
|
"江西",
|
||||||
|
"南昌",
|
||||||
|
"景德镇",
|
||||||
|
"萍乡",
|
||||||
|
"九江",
|
||||||
|
"新余",
|
||||||
|
"鹰潭",
|
||||||
|
"赣州",
|
||||||
|
"吉安",
|
||||||
|
"宜春",
|
||||||
|
"抚州",
|
||||||
|
"上饶",
|
||||||
|
"山东",
|
||||||
|
"淄博",
|
||||||
|
"枣庄",
|
||||||
|
"东营",
|
||||||
|
"烟台",
|
||||||
|
"潍坊",
|
||||||
|
"济宁",
|
||||||
|
"泰安",
|
||||||
|
"威海",
|
||||||
|
"日照",
|
||||||
|
"临沂",
|
||||||
|
"德州",
|
||||||
|
"聊城",
|
||||||
|
"滨州",
|
||||||
|
"菏泽",
|
||||||
|
"河南",
|
||||||
|
"洛阳",
|
||||||
|
"开封",
|
||||||
|
"平顶山",
|
||||||
|
"安阳",
|
||||||
|
"鹤壁",
|
||||||
|
"新乡",
|
||||||
|
"焦作",
|
||||||
|
"濮阳",
|
||||||
|
"许昌",
|
||||||
|
"漯河",
|
||||||
|
"三门峡",
|
||||||
|
"南阳",
|
||||||
|
"商丘",
|
||||||
|
"信阳",
|
||||||
|
"周口",
|
||||||
|
"驻马店",
|
||||||
|
"湖北",
|
||||||
|
"黄石",
|
||||||
|
"十堰",
|
||||||
|
"宜昌",
|
||||||
|
"襄阳",
|
||||||
|
"鄂州",
|
||||||
|
"荆门",
|
||||||
|
"孝感",
|
||||||
|
"荆州",
|
||||||
|
"黄冈",
|
||||||
|
"咸宁",
|
||||||
|
"随州",
|
||||||
|
"恩施",
|
||||||
|
"湖南",
|
||||||
|
"株洲",
|
||||||
|
"湘潭",
|
||||||
|
"衡阳",
|
||||||
|
"邵阳",
|
||||||
|
"岳阳",
|
||||||
|
"常德",
|
||||||
|
"张家界",
|
||||||
|
"益阳",
|
||||||
|
"郴州",
|
||||||
|
"永州",
|
||||||
|
"怀化",
|
||||||
|
"娄底",
|
||||||
|
"湘西",
|
||||||
|
"广东",
|
||||||
|
"惠州",
|
||||||
|
"江门",
|
||||||
|
"湛江",
|
||||||
|
"茂名",
|
||||||
|
"肇庆",
|
||||||
|
"梅州",
|
||||||
|
"汕尾",
|
||||||
|
"河源",
|
||||||
|
"阳江",
|
||||||
|
"清远",
|
||||||
|
"潮州",
|
||||||
|
"揭阳",
|
||||||
|
"云浮",
|
||||||
|
"广西",
|
||||||
|
"南宁",
|
||||||
|
"柳州",
|
||||||
|
"桂林",
|
||||||
|
"梧州",
|
||||||
|
"北海",
|
||||||
|
"防城港",
|
||||||
|
"钦州",
|
||||||
|
"贵港",
|
||||||
|
"玉林",
|
||||||
|
"百色",
|
||||||
|
"贺州",
|
||||||
|
"河池",
|
||||||
|
"来宾",
|
||||||
|
"崇左",
|
||||||
|
"海南",
|
||||||
|
"儋州",
|
||||||
|
"四川",
|
||||||
|
"自贡",
|
||||||
|
"攀枝花",
|
||||||
|
"泸州",
|
||||||
|
"德阳",
|
||||||
|
"绵阳",
|
||||||
|
"广元",
|
||||||
|
"遂宁",
|
||||||
|
"内江",
|
||||||
|
"乐山",
|
||||||
|
"南充",
|
||||||
|
"眉山",
|
||||||
|
"宜宾",
|
||||||
|
"广安",
|
||||||
|
"达州",
|
||||||
|
"雅安",
|
||||||
|
"巴中",
|
||||||
|
"资阳",
|
||||||
|
"阿坝",
|
||||||
|
"甘孜",
|
||||||
|
"凉山",
|
||||||
|
"贵州",
|
||||||
|
"贵阳",
|
||||||
|
"遵义",
|
||||||
|
"六盘水",
|
||||||
|
"安顺",
|
||||||
|
"毕节",
|
||||||
|
"铜仁",
|
||||||
|
"黔东南",
|
||||||
|
"黔南",
|
||||||
|
"黔西南",
|
||||||
|
"云南",
|
||||||
|
"曲靖",
|
||||||
|
"玉溪",
|
||||||
|
"保山",
|
||||||
|
"昭通",
|
||||||
|
"丽江",
|
||||||
|
"普洱",
|
||||||
|
"临沧",
|
||||||
|
"楚雄",
|
||||||
|
"红河",
|
||||||
|
"文山",
|
||||||
|
"西双版纳",
|
||||||
|
"大理",
|
||||||
|
"德宏",
|
||||||
|
"怒江",
|
||||||
|
"迪庆",
|
||||||
|
"陕西",
|
||||||
|
"宝鸡",
|
||||||
|
"咸阳",
|
||||||
|
"铜川",
|
||||||
|
"渭南",
|
||||||
|
"延安",
|
||||||
|
"汉中",
|
||||||
|
"榆林",
|
||||||
|
"安康",
|
||||||
|
"商洛",
|
||||||
|
"甘肃",
|
||||||
|
"兰州",
|
||||||
|
"嘉峪关",
|
||||||
|
"金昌",
|
||||||
|
"白银",
|
||||||
|
"天水",
|
||||||
|
"武威",
|
||||||
|
"张掖",
|
||||||
|
"平凉",
|
||||||
|
"酒泉",
|
||||||
|
"庆阳",
|
||||||
|
"定西",
|
||||||
|
"陇南",
|
||||||
|
"临夏",
|
||||||
|
"甘南",
|
||||||
|
"青海",
|
||||||
|
"西宁",
|
||||||
|
"海东",
|
||||||
|
"海北",
|
||||||
|
"黄南",
|
||||||
|
"海南州",
|
||||||
|
"果洛",
|
||||||
|
"玉树",
|
||||||
|
"海西",
|
||||||
|
"宁夏",
|
||||||
|
"银川",
|
||||||
|
"石嘴山",
|
||||||
|
"吴忠",
|
||||||
|
"固原",
|
||||||
|
"中卫",
|
||||||
|
}
|
||||||
|
|
||||||
|
OTHER_REGION_PROVINCE_KEYWORDS = {
|
||||||
|
"河北",
|
||||||
|
"山西",
|
||||||
|
"内蒙古",
|
||||||
|
"辽宁",
|
||||||
|
"吉林",
|
||||||
|
"黑龙江",
|
||||||
|
"江苏",
|
||||||
|
"浙江",
|
||||||
|
"安徽",
|
||||||
|
"福建",
|
||||||
|
"江西",
|
||||||
|
"山东",
|
||||||
|
"河南",
|
||||||
|
"湖北",
|
||||||
|
"湖南",
|
||||||
|
"广东",
|
||||||
|
"广西",
|
||||||
|
"海南",
|
||||||
|
"四川",
|
||||||
|
"贵州",
|
||||||
|
"云南",
|
||||||
|
"陕西",
|
||||||
|
"甘肃",
|
||||||
|
"青海",
|
||||||
|
"宁夏",
|
||||||
|
"新疆",
|
||||||
|
"西藏",
|
||||||
|
"台湾",
|
||||||
|
"香港",
|
||||||
|
"澳门",
|
||||||
|
}
|
||||||
|
|
||||||
|
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}
|
||||||
|
|
||||||
|
|
||||||
|
class TravelReimbursementCalculatorService:
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def calculate(
|
||||||
|
self,
|
||||||
|
payload: TravelReimbursementCalculatorRequest,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> TravelReimbursementCalculatorResponse:
|
||||||
|
days = max(1, int(payload.days))
|
||||||
|
location = str(payload.location or "").strip()
|
||||||
|
if not location:
|
||||||
|
raise ValueError("请先填写出差地点。")
|
||||||
|
|
||||||
|
policy = self._load_travel_policy()
|
||||||
|
grade = self._resolve_grade(payload.grade, current_user)
|
||||||
|
if not grade:
|
||||||
|
raise ValueError("未识别到当前员工职级,请在个人信息中维护职级后再计算。")
|
||||||
|
|
||||||
|
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
|
||||||
|
if not grade_band:
|
||||||
|
raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位。")
|
||||||
|
|
||||||
|
matched_city = self._resolve_city(location, policy)
|
||||||
|
matched_other_region = "" if matched_city else self._resolve_other_region(location)
|
||||||
|
if not matched_city and not matched_other_region:
|
||||||
|
raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。")
|
||||||
|
city_tier = policy.city_tiers.get(matched_city, "tier_3") if matched_city else "tier_3"
|
||||||
|
hotel_rate = self._resolve_hotel_rate(policy, grade_band, matched_city, city_tier)
|
||||||
|
allowance_region = self._resolve_allowance_region(location, matched_city or matched_other_region)
|
||||||
|
meal_rate = self._resolve_allowance_rate(policy, "meal", allowance_region)
|
||||||
|
basic_rate = self._resolve_allowance_rate(policy, "basic", allowance_region)
|
||||||
|
total_allowance_rate = self._resolve_total_allowance_rate(policy, allowance_region, meal_rate, basic_rate)
|
||||||
|
|
||||||
|
hotel_amount = hotel_rate * Decimal(days)
|
||||||
|
allowance_amount = total_allowance_rate * Decimal(days)
|
||||||
|
total_amount = hotel_amount + allowance_amount
|
||||||
|
band_label = policy.band_labels.get(grade_band, grade_band)
|
||||||
|
rule_name = policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则"
|
||||||
|
rule_version = policy.standard_rule_version or policy.rule_version or ""
|
||||||
|
display_city = matched_city or self._format_other_region_display(matched_other_region)
|
||||||
|
formula_text = (
|
||||||
|
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
|
||||||
|
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
|
||||||
|
f"{self._format_money(total_amount)}"
|
||||||
|
)
|
||||||
|
summary_text = (
|
||||||
|
f"按《{rule_name}》{f'({rule_version})' if rule_version else ''}测算:"
|
||||||
|
f"当前职级 {grade} 对应 {band_label} 档,出差地点“{location}”匹配为“{display_city}”,"
|
||||||
|
f"住宿标准 {self._format_money(hotel_rate)} 元/天,补贴区域为“{allowance_region}”,"
|
||||||
|
f"补贴标准 {self._format_money(total_allowance_rate)} 元/天"
|
||||||
|
f"(伙食 {self._format_money(meal_rate)} + 基本 {self._format_money(basic_rate)})。"
|
||||||
|
f"按 {days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
|
||||||
|
f"补贴合计 {self._format_money(allowance_amount)} 元,"
|
||||||
|
f"参考可报销总金额为 {self._format_money(total_amount)} 元。"
|
||||||
|
)
|
||||||
|
|
||||||
|
return TravelReimbursementCalculatorResponse(
|
||||||
|
days=days,
|
||||||
|
location=location,
|
||||||
|
matched_city=display_city,
|
||||||
|
city_tier=city_tier,
|
||||||
|
grade=grade,
|
||||||
|
grade_band=grade_band,
|
||||||
|
grade_band_label=band_label,
|
||||||
|
hotel_rate=hotel_rate,
|
||||||
|
hotel_amount=hotel_amount,
|
||||||
|
allowance_region=allowance_region,
|
||||||
|
meal_allowance_rate=meal_rate,
|
||||||
|
basic_allowance_rate=basic_rate,
|
||||||
|
total_allowance_rate=total_allowance_rate,
|
||||||
|
allowance_amount=allowance_amount,
|
||||||
|
total_amount=total_amount,
|
||||||
|
rule_name=rule_name,
|
||||||
|
rule_version=rule_version,
|
||||||
|
formula_text=formula_text,
|
||||||
|
summary_text=summary_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_travel_policy(self) -> RuntimeTravelPolicy:
|
||||||
|
AgentAssetService(self.db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
policy = ExpenseRuleRuntimeService(self.db).load_catalog().travel_policy
|
||||||
|
if policy is None:
|
||||||
|
raise ValueError("规则中心暂未配置差旅报销规则。")
|
||||||
|
return policy
|
||||||
|
|
||||||
|
def _resolve_grade(
|
||||||
|
self,
|
||||||
|
grade: str | None,
|
||||||
|
current_user: CurrentUserContext,
|
||||||
|
) -> str:
|
||||||
|
normalized_grade = str(grade or "").strip()
|
||||||
|
if normalized_grade:
|
||||||
|
return normalized_grade
|
||||||
|
|
||||||
|
employee = self._resolve_current_employee(current_user)
|
||||||
|
if employee is not None and str(employee.grade or "").strip():
|
||||||
|
return str(employee.grade).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_other_region(location: str) -> str:
|
||||||
|
normalized = re.sub(r"\s+", "", str(location or "").strip())
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
if any(keyword in normalized for keyword in ("国外", "境外", "海外")):
|
||||||
|
return "国外"
|
||||||
|
for keyword in ("香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"):
|
||||||
|
if keyword in normalized:
|
||||||
|
return keyword
|
||||||
|
city_matches = []
|
||||||
|
province_matches = []
|
||||||
|
for keyword in OTHER_REGION_LOCATION_KEYWORDS:
|
||||||
|
if not keyword or keyword not in normalized:
|
||||||
|
continue
|
||||||
|
if keyword in OTHER_REGION_PROVINCE_KEYWORDS:
|
||||||
|
province_matches.append(keyword)
|
||||||
|
else:
|
||||||
|
city_matches.append(keyword)
|
||||||
|
candidates = city_matches or province_matches
|
||||||
|
if candidates:
|
||||||
|
return sorted(candidates, key=len, reverse=True)[0]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_other_region_display(region: str) -> str:
|
||||||
|
normalized = str(region or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
if normalized in {"国外", "香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"}:
|
||||||
|
return normalized
|
||||||
|
return f"{normalized}(其他地区)"
|
||||||
|
|
||||||
|
def _resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None:
|
||||||
|
candidates = [
|
||||||
|
str(current_user.username or "").strip(),
|
||||||
|
str(current_user.name or "").strip(),
|
||||||
|
]
|
||||||
|
normalized_candidates = [
|
||||||
|
item
|
||||||
|
for item in dict.fromkeys(candidate for candidate in candidates if candidate)
|
||||||
|
if item
|
||||||
|
]
|
||||||
|
if not normalized_candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for candidate in normalized_candidates:
|
||||||
|
employee = self.db.scalar(
|
||||||
|
select(Employee)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
func.lower(Employee.email) == candidate.lower(),
|
||||||
|
func.lower(Employee.employee_no) == candidate.lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if employee is not None:
|
||||||
|
return employee
|
||||||
|
|
||||||
|
for candidate in normalized_candidates:
|
||||||
|
matches = list(
|
||||||
|
self.db.scalars(
|
||||||
|
select(Employee)
|
||||||
|
.where(Employee.name == candidate)
|
||||||
|
.limit(2)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str:
|
||||||
|
normalized = str(location or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
city_names = set(policy.city_tiers.keys())
|
||||||
|
city_names.update(policy.hotel_city_limits.keys())
|
||||||
|
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
|
||||||
|
if city in AMBIGUOUS_PROVINCE_CITY_NAMES and normalized != city and f"{city}市" not in normalized:
|
||||||
|
continue
|
||||||
|
if city and city in normalized:
|
||||||
|
return city
|
||||||
|
compact = re.sub(r"(省|市|区|县|自治州|特别行政区)$", "", normalized)
|
||||||
|
for city in sorted(city_names, key=lambda item: len(item), reverse=True):
|
||||||
|
if city in AMBIGUOUS_PROVINCE_CITY_NAMES and compact != city and f"{city}市" not in normalized:
|
||||||
|
continue
|
||||||
|
if city and city in compact:
|
||||||
|
return city
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_hotel_rate(
|
||||||
|
policy: RuntimeTravelPolicy,
|
||||||
|
grade_band: str,
|
||||||
|
matched_city: str,
|
||||||
|
city_tier: str,
|
||||||
|
) -> Decimal:
|
||||||
|
city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {}
|
||||||
|
if city_limits.get(grade_band) is not None:
|
||||||
|
return Decimal(city_limits[grade_band])
|
||||||
|
|
||||||
|
band_limits = policy.hotel_limits.get(grade_band, {})
|
||||||
|
if band_limits.get(city_tier) is not None:
|
||||||
|
return Decimal(band_limits[city_tier])
|
||||||
|
if band_limits.get("tier_3") is not None:
|
||||||
|
return Decimal(band_limits["tier_3"])
|
||||||
|
return Decimal("0")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_allowance_region(location: str, matched_city: str) -> str:
|
||||||
|
text = f"{location} {matched_city}".strip()
|
||||||
|
if any(keyword in text for keyword in ("国外", "境外", "海外")):
|
||||||
|
return "国外"
|
||||||
|
if any(keyword in text for keyword in ("香港", "澳门", "台湾", "港澳台")):
|
||||||
|
return "港澳台"
|
||||||
|
if "乌鲁木齐" in text:
|
||||||
|
return "新疆-乌鲁木齐"
|
||||||
|
if "新疆" in text:
|
||||||
|
return "新疆-其他"
|
||||||
|
if "西藏" in text or "拉萨" in text:
|
||||||
|
return "西藏"
|
||||||
|
if any(keyword in text for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")):
|
||||||
|
return "直辖市/特区"
|
||||||
|
return "其他地区"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_allowance_rate(policy: RuntimeTravelPolicy, allowance_key: str, region: str) -> Decimal:
|
||||||
|
limits = policy.allowance_limits.get(allowance_key, {})
|
||||||
|
if limits.get(region) is not None:
|
||||||
|
return Decimal(limits[region])
|
||||||
|
if limits.get("其他地区") is not None:
|
||||||
|
return Decimal(limits["其他地区"])
|
||||||
|
return Decimal("0")
|
||||||
|
|
||||||
|
def _resolve_total_allowance_rate(
|
||||||
|
self,
|
||||||
|
policy: RuntimeTravelPolicy,
|
||||||
|
region: str,
|
||||||
|
meal_rate: Decimal,
|
||||||
|
basic_rate: Decimal,
|
||||||
|
) -> Decimal:
|
||||||
|
total_limits = policy.allowance_limits.get("total", {})
|
||||||
|
if total_limits.get(region) is not None:
|
||||||
|
return Decimal(total_limits[region])
|
||||||
|
if total_limits.get("其他地区") is not None:
|
||||||
|
return Decimal(total_limits["其他地区"])
|
||||||
|
return meal_rate + basic_rate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_money(value: Decimal | int | float | str) -> str:
|
||||||
|
return f"{Decimal(str(value)).quantize(Decimal('0.01'))}"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"file_name": "2月23_上海-武汉.pdf",
|
||||||
|
"storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.pdf",
|
||||||
|
"media_type": "application/pdf",
|
||||||
|
"size_bytes": 24940,
|
||||||
|
"uploaded_at": "2026-05-21T07:15:50.184565+00:00",
|
||||||
|
"previewable": true,
|
||||||
|
"preview_kind": "image",
|
||||||
|
"preview_storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.preview.png",
|
||||||
|
"preview_media_type": "image/png",
|
||||||
|
"preview_file_name": "2月23_上海-武汉.preview.png",
|
||||||
|
"analysis": {
|
||||||
|
"severity": "pass",
|
||||||
|
"label": "AI提示符合条件",
|
||||||
|
"headline": "AI提示:附件符合基础校验条件",
|
||||||
|
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||||
|
"points": [
|
||||||
|
"票据类型:已识别为火车/高铁票。",
|
||||||
|
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
|
||||||
|
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
|
||||||
|
],
|
||||||
|
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||||
|
},
|
||||||
|
"document_info": {
|
||||||
|
"document_type": "train_ticket",
|
||||||
|
"document_type_label": "火车/高铁票",
|
||||||
|
"scene_code": "travel",
|
||||||
|
"scene_label": "差旅票据",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "amount",
|
||||||
|
"label": "金额",
|
||||||
|
"value": "354元"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"label": "列车出发时间",
|
||||||
|
"value": "2026-02-23 13:54"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "merchant_name",
|
||||||
|
"label": "商户",
|
||||||
|
"value": "中国铁路"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "invoice_number",
|
||||||
|
"label": "票据号码",
|
||||||
|
"value": "26319166100006175398"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "route",
|
||||||
|
"label": "行程",
|
||||||
|
"value": "上海-武汉"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requirement_check": {
|
||||||
|
"matches": true,
|
||||||
|
"current_expense_type": "train_ticket",
|
||||||
|
"current_expense_type_label": "火车票",
|
||||||
|
"allowed_scene_labels": [],
|
||||||
|
"allowed_document_type_labels": [],
|
||||||
|
"recognized_scene_code": "travel",
|
||||||
|
"recognized_scene_label": "差旅票据",
|
||||||
|
"recognized_document_type": "train_ticket",
|
||||||
|
"recognized_document_type_label": "火车/高铁票",
|
||||||
|
"mismatch_severity": "high",
|
||||||
|
"rule_code": "rule.expense.scene_submission_standard",
|
||||||
|
"rule_name": "报销场景提交与附件标准",
|
||||||
|
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
|
||||||
|
},
|
||||||
|
"ocr_status": "recognized",
|
||||||
|
"ocr_error": "",
|
||||||
|
"ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
|
||||||
|
"ocr_summary": "电子发票;(铁路电子客票);州",
|
||||||
|
"ocr_avg_score": 0.9620026834309101,
|
||||||
|
"ocr_line_count": 24,
|
||||||
|
"ocr_classification_source": "rule",
|
||||||
|
"ocr_classification_confidence": 0.88,
|
||||||
|
"ocr_classification_evidence": [
|
||||||
|
"铁路电子客票",
|
||||||
|
"电子客票",
|
||||||
|
"铁路",
|
||||||
|
"二等座"
|
||||||
|
],
|
||||||
|
"ocr_warnings": []
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 134 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"file_name": "2月20_武汉-上海.pdf",
|
||||||
|
"storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.pdf",
|
||||||
|
"media_type": "application/pdf",
|
||||||
|
"size_bytes": 24995,
|
||||||
|
"uploaded_at": "2026-05-21T07:12:29.488414+00:00",
|
||||||
|
"previewable": true,
|
||||||
|
"preview_kind": "image",
|
||||||
|
"preview_storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.preview.png",
|
||||||
|
"preview_media_type": "image/png",
|
||||||
|
"preview_file_name": "2月20_武汉-上海.preview.png",
|
||||||
|
"analysis": {
|
||||||
|
"severity": "pass",
|
||||||
|
"label": "AI提示符合条件",
|
||||||
|
"headline": "AI提示:附件符合基础校验条件",
|
||||||
|
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||||
|
"points": [
|
||||||
|
"票据类型:已识别为火车/高铁票。",
|
||||||
|
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
|
||||||
|
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
|
||||||
|
],
|
||||||
|
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||||
|
},
|
||||||
|
"document_info": {
|
||||||
|
"document_type": "train_ticket",
|
||||||
|
"document_type_label": "火车/高铁票",
|
||||||
|
"scene_code": "travel",
|
||||||
|
"scene_label": "差旅票据",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "amount",
|
||||||
|
"label": "金额",
|
||||||
|
"value": "354元"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"label": "列车出发时间",
|
||||||
|
"value": "2026-02-20 07:55"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "merchant_name",
|
||||||
|
"label": "商户",
|
||||||
|
"value": "中国铁路"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "invoice_number",
|
||||||
|
"label": "票据号码",
|
||||||
|
"value": "26429165800002785705"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "route",
|
||||||
|
"label": "行程",
|
||||||
|
"value": "武汉-上海"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requirement_check": {
|
||||||
|
"matches": true,
|
||||||
|
"current_expense_type": "train_ticket",
|
||||||
|
"current_expense_type_label": "火车票",
|
||||||
|
"allowed_scene_labels": [],
|
||||||
|
"allowed_document_type_labels": [],
|
||||||
|
"recognized_scene_code": "travel",
|
||||||
|
"recognized_scene_label": "差旅票据",
|
||||||
|
"recognized_document_type": "train_ticket",
|
||||||
|
"recognized_document_type_label": "火车/高铁票",
|
||||||
|
"mismatch_severity": "high",
|
||||||
|
"rule_code": "rule.expense.scene_submission_standard",
|
||||||
|
"rule_name": "报销场景提交与附件标准",
|
||||||
|
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
|
||||||
|
},
|
||||||
|
"ocr_status": "recognized",
|
||||||
|
"ocr_error": "",
|
||||||
|
"ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
|
||||||
|
"ocr_summary": "电子发票;(铁路电子客票);州",
|
||||||
|
"ocr_avg_score": 0.9580968717734019,
|
||||||
|
"ocr_line_count": 24,
|
||||||
|
"ocr_classification_source": "rule",
|
||||||
|
"ocr_classification_confidence": 0.88,
|
||||||
|
"ocr_classification_evidence": [
|
||||||
|
"铁路电子客票",
|
||||||
|
"电子客票",
|
||||||
|
"铁路",
|
||||||
|
"二等座"
|
||||||
|
],
|
||||||
|
"ocr_warnings": []
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"file_name": "酒店1.jpg",
|
||||||
|
"storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg",
|
||||||
|
"media_type": "image/jpeg",
|
||||||
|
"size_bytes": 135977,
|
||||||
|
"uploaded_at": "2026-05-21T07:21:03.814491+00:00",
|
||||||
|
"previewable": true,
|
||||||
|
"preview_kind": "image",
|
||||||
|
"preview_storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.preview.jpg",
|
||||||
|
"preview_media_type": "image/jpeg",
|
||||||
|
"preview_file_name": "酒店1.preview.jpg",
|
||||||
|
"analysis": {
|
||||||
|
"severity": "pass",
|
||||||
|
"label": "AI提示符合条件",
|
||||||
|
"headline": "AI提示:附件符合基础校验条件",
|
||||||
|
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||||
|
"points": [
|
||||||
|
"票据类型:已识别为酒店住宿票据。",
|
||||||
|
"附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。",
|
||||||
|
"金额字段:已识别到与当前明细接近的金额 2026.00 元。"
|
||||||
|
],
|
||||||
|
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
|
||||||
|
},
|
||||||
|
"document_info": {
|
||||||
|
"document_type": "hotel_invoice",
|
||||||
|
"document_type_label": "酒店住宿票据",
|
||||||
|
"scene_code": "hotel",
|
||||||
|
"scene_label": "住宿票据",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "amount",
|
||||||
|
"label": "金额",
|
||||||
|
"value": "2026元"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"label": "日期",
|
||||||
|
"value": "2026-02-23"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "merchant_name",
|
||||||
|
"label": "商户",
|
||||||
|
"value": "上海喜来登酒店"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"requirement_check": {
|
||||||
|
"matches": true,
|
||||||
|
"current_expense_type": "hotel_ticket",
|
||||||
|
"current_expense_type_label": "住宿票",
|
||||||
|
"allowed_scene_labels": [],
|
||||||
|
"allowed_document_type_labels": [],
|
||||||
|
"recognized_scene_code": "hotel",
|
||||||
|
"recognized_scene_label": "住宿票据",
|
||||||
|
"recognized_document_type": "hotel_invoice",
|
||||||
|
"recognized_document_type_label": "酒店住宿票据",
|
||||||
|
"mismatch_severity": "high",
|
||||||
|
"rule_code": "rule.expense.scene_submission_standard",
|
||||||
|
"rule_name": "报销场景提交与附件标准",
|
||||||
|
"message": "当前费用项目为住宿票,已识别为酒店住宿票据。"
|
||||||
|
},
|
||||||
|
"ocr_status": "recognized",
|
||||||
|
"ocr_error": "",
|
||||||
|
"ocr_text": "上海喜来登酒店(样例)\n住宿发票\n发票编号:SH-SAMPLE-20260223-002\n开票日期:2026年2月23日\n客姓名:曹笑\n住晚数:3晚\n住期:2026年220\n房型:豪华床房\n离店期:2026年223\n预订渠道:酒店官\n日期\n项目\n房费单价\n数量\n金额\n2026-02-20至2026-02-22\n住宿费\n¥276/晚\n3晚\n¥828\n合计:¥828\n额写:捌佰贰拾捌元整\n备注:\n以上费用已由酒店收取并开具发票。\n本发票仅含住宿费,不含其他增值服务费。\n如有疑问,请联系酒店前台或致电酒店财务部。\n样例票据|仅供系统测试|无效凭证",
|
||||||
|
"ocr_summary": "上海喜来登酒店(样例);住宿发票;发票编号:SH-SAMPLE-20260223-002",
|
||||||
|
"ocr_avg_score": 0.9884135921796163,
|
||||||
|
"ocr_line_count": 27,
|
||||||
|
"ocr_classification_source": "rule",
|
||||||
|
"ocr_classification_confidence": 0.84,
|
||||||
|
"ocr_classification_evidence": [
|
||||||
|
"住宿",
|
||||||
|
"房费",
|
||||||
|
"离店",
|
||||||
|
"酒店"
|
||||||
|
],
|
||||||
|
"ocr_warnings": []
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 133 KiB |
BIN
server/storage/font-test-after-install.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
@@ -20,7 +20,7 @@
|
|||||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||||
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
||||||
"ingest_agent_run_id": "run_8b0ead1e3c734a53"
|
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "a8f8465df08e455ebe133351721d49f8",
|
"id": "a8f8465df08e455ebe133351721d49f8",
|
||||||
@@ -35,13 +35,13 @@
|
|||||||
"updated_at": "2026-05-17T13:00:09.485818+00:00",
|
"updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||||
"uploaded_by": "admin",
|
"uploaded_by": "admin",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 1,
|
"ingest_status": 4,
|
||||||
"ingest_status_updated_at": "2026-05-17T13:00:09.485818+00:00",
|
"ingest_status_updated_at": "2026-05-20T16:00:02.515903+00:00",
|
||||||
"ingest_completed_at": "",
|
"ingest_completed_at": "",
|
||||||
"ingest_document_name": "",
|
"ingest_document_name": "",
|
||||||
"ingest_document_updated_at": "",
|
"ingest_document_updated_at": "",
|
||||||
"ingest_document_sha256": "",
|
"ingest_document_sha256": "",
|
||||||
"ingest_agent_run_id": ""
|
"ingest_agent_run_id": "run_3a0b0ecb941b4c8e"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -24,5 +24,28 @@
|
|||||||
"processing_start_time": 1779011842,
|
"processing_start_time": 1779011842,
|
||||||
"processing_end_time": 1779012093
|
"processing_end_time": 1779012093
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"a8f8465df08e455ebe133351721d49f8": {
|
||||||
|
"status": "failed",
|
||||||
|
"error_msg": "Embedding func: Worker execution timeout after 60s",
|
||||||
|
"chunks_count": 6,
|
||||||
|
"chunks_list": [
|
||||||
|
"chunk-07de6ea74f60535b689f977295770273",
|
||||||
|
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8",
|
||||||
|
"chunk-1746bd83138e85e66a78e0cb9ad79272",
|
||||||
|
"chunk-ce44e4483e4119265b43eacb72e0326a",
|
||||||
|
"chunk-2187fa0609874bdda339c9850da45a26",
|
||||||
|
"chunk-2224d777c0b72d0b2dab622c79096c2c"
|
||||||
|
],
|
||||||
|
"content_summary": "# 产品需求文档\n## 文档信息\n| 项目 | 内容 |\n|------|------|\n| 项目名称 |\n无单报销\n|\n| 版本 | V1.0 |\n| 日期 | 2026-05-06 |\n| 状态 | 正式版 |\n---\n## 1. 项目概述\n### 1.1 项目背景\n面向\n大型企业,\n从业务人员视角出发,解决现有ERP使用体验不佳的问题。\n在ERP的发展历程中,“单据化”曾是财务合规的一大进步,它确保了每笔支出都有据可查。但不可否认,传统的人工填单确实\n也制造了很多\n“枷锁”。在AI时代,解...",
|
||||||
|
"content_length": 9088,
|
||||||
|
"created_at": "2026-05-19T15:59:57.283110+00:00",
|
||||||
|
"updated_at": "2026-05-19T16:00:57.323299+00:00",
|
||||||
|
"file_path": "/app/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
|
||||||
|
"track_id": "insert_20260519_155957_88c49850",
|
||||||
|
"metadata": {
|
||||||
|
"processing_start_time": 1779206397,
|
||||||
|
"processing_end_time": 1779206457
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,9 +15,8 @@ def test_rule_spreadsheet_onlyoffice_key_uses_safe_characters() -> None:
|
|||||||
|
|
||||||
key = AgentAssetService._build_onlyoffice_document_key(
|
key = AgentAssetService._build_onlyoffice_document_key(
|
||||||
"asset:id",
|
"asset:id",
|
||||||
"v1.0.0",
|
|
||||||
metadata,
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert key == "asset_id-v1.0.0-abc123"
|
assert key == "asset_id-abc123"
|
||||||
assert ":" not in key
|
assert ":" not in key
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
from app.core.agent_enums import (
|
from app.core.agent_enums import (
|
||||||
AgentAssetContentType,
|
AgentAssetContentType,
|
||||||
AgentAssetDomain,
|
AgentAssetDomain,
|
||||||
@@ -19,16 +22,61 @@ from app.core.agent_enums import (
|
|||||||
AgentRunSource,
|
AgentRunSource,
|
||||||
AgentRunStatus,
|
AgentRunStatus,
|
||||||
)
|
)
|
||||||
|
from app.core.config import SERVER_DIR
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
from app.models.agent_asset import AgentAsset
|
||||||
|
from app.models.employee import Employee
|
||||||
from app.schemas.agent_asset import (
|
from app.schemas.agent_asset import (
|
||||||
AgentAssetCreate,
|
AgentAssetCreate,
|
||||||
AgentAssetReviewCreate,
|
AgentAssetReviewCreate,
|
||||||
AgentAssetVersionCreate,
|
AgentAssetVersionCreate,
|
||||||
)
|
)
|
||||||
|
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||||
|
from app.services.agent_asset_spreadsheet import (
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
|
FINANCE_RULES_LIBRARY,
|
||||||
|
)
|
||||||
from app.services.agent_assets import AgentAssetService
|
from app.services.agent_assets import AgentAssetService
|
||||||
from app.services.agent_runs import AgentRunService
|
from app.services.agent_runs import AgentRunService
|
||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||||
|
from app.services.settings import OnlyOfficeRuntimeConfig
|
||||||
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
|
||||||
|
temp_server_dir = tmp_path / "server"
|
||||||
|
temp_rules_root = temp_server_dir / "rules"
|
||||||
|
temp_finance_rules = temp_rules_root / FINANCE_RULES_LIBRARY
|
||||||
|
temp_finance_rules.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY
|
||||||
|
for file_name in (
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
|
):
|
||||||
|
source_path = real_finance_rules / file_name
|
||||||
|
if source_path.exists():
|
||||||
|
shutil.copy2(source_path, temp_finance_rules / file_name)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.agent_asset_spreadsheet.SERVER_DIR",
|
||||||
|
temp_server_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
def init_manager(self, storage_root=None, rule_root=None) -> None:
|
||||||
|
self.storage_root = Path(storage_root or tmp_path / "storage").resolve()
|
||||||
|
self.asset_root = (self.storage_root / "agent_assets").resolve()
|
||||||
|
self.rule_root = Path(rule_root or temp_rules_root).resolve()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.agent_asset_spreadsheet.AgentAssetSpreadsheetManager.__init__",
|
||||||
|
init_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_session() -> Session:
|
def build_session() -> Session:
|
||||||
@@ -53,6 +101,19 @@ def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则
|
|||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_multi_sheet_workbook_bytes(sheets: dict[str, list[list[object]]]) -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
default_sheet = workbook.active
|
||||||
|
workbook.remove(default_sheet)
|
||||||
|
for sheet_name, rows in sheets.items():
|
||||||
|
sheet = workbook.create_sheet(sheet_name)
|
||||||
|
for row in rows:
|
||||||
|
sheet.append(row)
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
|
def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
@@ -60,7 +121,8 @@ def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation(
|
|||||||
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
assert len(rules) >= 3
|
assert len(rules) >= 3
|
||||||
assert any(
|
assert any(
|
||||||
item.code == "rule.expense.travel_risk_control_standard" and item.status == AgentAssetStatus.ACTIVE.value
|
item.code == "rule.expense.travel_risk_control_standard"
|
||||||
|
and item.status == AgentAssetStatus.ACTIVE.value
|
||||||
for item in rules
|
for item in rules
|
||||||
)
|
)
|
||||||
assert all(
|
assert all(
|
||||||
@@ -89,6 +151,26 @@ def test_agent_asset_service_seeds_all_foundation_asset_types() -> None:
|
|||||||
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
|
||||||
|
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
travel_rule = next(item for item in rules if item.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
|
communication_rule = next(
|
||||||
|
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
|
||||||
|
)
|
||||||
|
travel_config = travel_rule.config_json or {}
|
||||||
|
communication_config = communication_rule.config_json or {}
|
||||||
|
|
||||||
|
assert travel_rule.scenario_json == ["差旅"]
|
||||||
|
assert travel_config["scenario_category"] == "差旅"
|
||||||
|
assert travel_config["ai_review_category"] == "差旅"
|
||||||
|
assert communication_rule.scenario_json == ["费用科目"]
|
||||||
|
assert communication_config["scenario_category"] == "费用科目"
|
||||||
|
assert communication_config["ai_review_category"] == "费用科目"
|
||||||
|
|
||||||
|
|
||||||
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
@@ -173,6 +255,36 @@ def test_rule_working_version_does_not_replace_published_version_until_activatio
|
|||||||
assert detail.latest_review is None
|
assert detail.latest_review is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_review_can_name_new_working_version_before_submission() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
rule = next(
|
||||||
|
item
|
||||||
|
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
if item.code == "rule.expense.travel_risk_control_standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
review = service.create_review(
|
||||||
|
rule.id,
|
||||||
|
AgentAssetReviewCreate(
|
||||||
|
version="v1.2.0",
|
||||||
|
reviewer="manager_user",
|
||||||
|
review_status=AgentReviewStatus.PENDING,
|
||||||
|
review_note="请审核",
|
||||||
|
),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
detail = service.get_asset(rule.id)
|
||||||
|
|
||||||
|
assert review.version == "v1.2.0"
|
||||||
|
assert detail is not None
|
||||||
|
assert detail.current_version == "v1.2.0"
|
||||||
|
assert detail.working_version == "v1.2.0"
|
||||||
|
assert detail.published_version == "v1.1.0"
|
||||||
|
assert detail.latest_review is not None
|
||||||
|
assert detail.latest_review.reviewer == "manager_user"
|
||||||
|
|
||||||
|
|
||||||
def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None:
|
def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
@@ -186,7 +298,10 @@ def test_expense_rule_runtime_uses_published_version_instead_of_working_version(
|
|||||||
rule.id,
|
rule.id,
|
||||||
AgentAssetVersionCreate(
|
AgentAssetVersionCreate(
|
||||||
version="v1.1.1",
|
version="v1.1.1",
|
||||||
content="# 工作稿\n\n```expense-rule\n{\"kind\":\"travel_policy\",\"version\":1}\n```",
|
content=(
|
||||||
|
"# 工作稿\n\n"
|
||||||
|
'```expense-rule\n{"kind":"travel_policy","version":1}\n```'
|
||||||
|
),
|
||||||
content_type=AgentAssetContentType.MARKDOWN,
|
content_type=AgentAssetContentType.MARKDOWN,
|
||||||
change_note="未上线草稿",
|
change_note="未上线草稿",
|
||||||
created_by="finance_user",
|
created_by="finance_user",
|
||||||
@@ -221,7 +336,7 @@ def test_restore_version_creates_new_working_copy_without_rewriting_published_ve
|
|||||||
assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿"
|
assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿"
|
||||||
|
|
||||||
|
|
||||||
def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
|
def test_spreadsheet_upload_records_sheet_and_cell_changes_without_versions() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
rule = next(
|
rule = next(
|
||||||
@@ -233,31 +348,33 @@ def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
|
|||||||
service.upload_rule_spreadsheet(
|
service.upload_rule_spreadsheet(
|
||||||
rule.id,
|
rule.id,
|
||||||
filename="公司差旅费报销规则.xlsx",
|
filename="公司差旅费报销规则.xlsx",
|
||||||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||||||
actor="finance_user",
|
actor="finance_user",
|
||||||
)
|
)
|
||||||
base_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
|
|
||||||
service.upload_rule_spreadsheet(
|
service.upload_rule_spreadsheet(
|
||||||
rule.id,
|
rule.id,
|
||||||
filename="公司差旅费报销规则.xlsx",
|
filename="公司差旅费报销规则.xlsx",
|
||||||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
|
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
|
||||||
actor="finance_user",
|
actor="finance_user",
|
||||||
)
|
)
|
||||||
target_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
|
|
||||||
|
|
||||||
diff = service.compare_spreadsheet_versions(
|
records = service.list_spreadsheet_change_records(rule.id)
|
||||||
rule.id,
|
latest = records[0]
|
||||||
base_version=base_version or "",
|
|
||||||
target_version=target_version or "",
|
assert latest.changed_sheet_count == 1
|
||||||
|
assert latest.changed_cell_count == 3
|
||||||
|
assert any(
|
||||||
|
item.cell == "B2" and item.change_type == "modified"
|
||||||
|
for item in latest.cell_changes
|
||||||
)
|
)
|
||||||
|
assert any(
|
||||||
assert diff.changed_sheet_count == 1
|
item.cell == "A3" and item.change_type == "added"
|
||||||
assert diff.changed_cell_count == 3
|
for item in latest.cell_changes
|
||||||
assert any(item.cell == "B2" and item.change_type == "modified" for item in diff.cell_changes)
|
)
|
||||||
assert any(item.cell == "A3" and item.change_type == "added" for item in diff.cell_changes)
|
assert not hasattr(latest, "version")
|
||||||
|
|
||||||
|
|
||||||
def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_copy() -> None:
|
def test_spreadsheet_content_reads_current_rule_file_without_version_snapshot() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
rule = next(
|
rule = next(
|
||||||
@@ -274,26 +391,143 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
|
|||||||
)
|
)
|
||||||
detail = service.get_asset(rule.id)
|
detail = service.get_asset(rule.id)
|
||||||
assert detail is not None
|
assert detail is not None
|
||||||
working_version = detail.working_version or ""
|
|
||||||
|
|
||||||
current_asset = service.repository.get(rule.id)
|
current_asset = service.repository.get(rule.id)
|
||||||
assert current_asset is not None
|
assert current_asset is not None
|
||||||
live_storage_key = str((current_asset.config_json or {})["rule_document"]["storage_key"])
|
live_storage_key = str((current_asset.config_json or {})["rule_document"]["storage_key"])
|
||||||
|
assert live_storage_key.startswith(f"rules/{FINANCE_RULES_LIBRARY}/")
|
||||||
|
assert "agent_assets" not in live_storage_key
|
||||||
live_path = service.spreadsheet_manager.resolve_storage_path(live_storage_key)
|
live_path = service.spreadsheet_manager.resolve_storage_path(live_storage_key)
|
||||||
original_live_bytes = live_path.read_bytes()
|
assert not service.spreadsheet_manager.asset_root.exists()
|
||||||
try:
|
|
||||||
live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]]))
|
|
||||||
|
|
||||||
snapshot_path, _, _ = service.get_rule_spreadsheet_content(
|
current_path, _, _ = service.get_rule_spreadsheet_content(rule.id)
|
||||||
rule.id,
|
|
||||||
version=working_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert snapshot_path != live_path
|
assert current_path == live_path
|
||||||
workbook = load_workbook(snapshot_path, data_only=False)
|
assert ".versions" not in current_path.parts
|
||||||
assert workbook.active["B2"].value == 500
|
workbook = load_workbook(current_path, data_only=False)
|
||||||
finally:
|
assert workbook.active["B2"].value == 500
|
||||||
live_path.write_bytes(original_live_bytes)
|
|
||||||
|
|
||||||
|
def test_spreadsheet_change_records_return_recent_edit_details() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
rule = next(
|
||||||
|
item
|
||||||
|
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||||||
|
)
|
||||||
|
service.audit_service.log_action(
|
||||||
|
actor="manager_user",
|
||||||
|
action="edit_rule_spreadsheet",
|
||||||
|
resource_type=rule.asset_type,
|
||||||
|
resource_id=rule.id,
|
||||||
|
after_json={
|
||||||
|
"summary": "在线编辑:共 1 处改动。",
|
||||||
|
"changed_sheet_count": 1,
|
||||||
|
"changed_cell_count": 1,
|
||||||
|
"sheet_changes": [],
|
||||||
|
"cell_changes": [
|
||||||
|
{
|
||||||
|
"sheet_name": "规则表",
|
||||||
|
"cell": "B2",
|
||||||
|
"change_type": "modified",
|
||||||
|
"before_value": 500,
|
||||||
|
"after_value": 550,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
records = service.list_spreadsheet_change_records(rule.id)
|
||||||
|
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0].actor == "manager_user"
|
||||||
|
assert records[0].changed_cell_count == 1
|
||||||
|
assert records[0].cell_changes[0].cell == "B2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
rule = next(
|
||||||
|
item
|
||||||
|
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||||||
|
)
|
||||||
|
|
||||||
|
service.upload_rule_spreadsheet(
|
||||||
|
rule.id,
|
||||||
|
filename="公司差旅费报销规则.xlsx",
|
||||||
|
content=build_multi_sheet_workbook_bytes(
|
||||||
|
{
|
||||||
|
"差旅标准": [["城市", "住宿"], ["北京", 500]],
|
||||||
|
"填表说明": [["字段", "说明"], ["住宿", "按城市标准"]],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
detail = service.get_asset(rule.id)
|
||||||
|
assert detail is not None
|
||||||
|
|
||||||
|
service.upload_rule_spreadsheet(
|
||||||
|
rule.id,
|
||||||
|
filename="公司差旅费报销规则.xlsx",
|
||||||
|
content=build_multi_sheet_workbook_bytes(
|
||||||
|
{
|
||||||
|
"差旅标准": [["城市", "住宿"], ["北京", 550]],
|
||||||
|
"填表说明": [["字段", "说明"], ["住宿", "按城市等级标准"]],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
actor="finance_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
records = service.list_spreadsheet_change_records(rule.id)
|
||||||
|
latest = records[0]
|
||||||
|
changed_sheets = {item.sheet_name for item in latest.sheet_changes}
|
||||||
|
changed_cell_sheets = {item.sheet_name for item in latest.cell_changes}
|
||||||
|
|
||||||
|
assert not hasattr(latest, "version")
|
||||||
|
assert latest.changed_sheet_count == 2
|
||||||
|
assert {"差旅标准", "填表说明"}.issubset(changed_sheets)
|
||||||
|
assert {"差旅标准", "填表说明"}.issubset(changed_cell_sheets)
|
||||||
|
assert "差旅标准" in latest.summary
|
||||||
|
assert "填表说明" in latest.summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.agent_assets.resolve_onlyoffice_settings",
|
||||||
|
lambda: OnlyOfficeRuntimeConfig(
|
||||||
|
enabled=True,
|
||||||
|
public_url="http://onlyoffice.example.com",
|
||||||
|
backend_url="http://backend.example.com",
|
||||||
|
jwt_secret="secret",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
rule = next(
|
||||||
|
item
|
||||||
|
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||||||
|
)
|
||||||
|
|
||||||
|
config = service.build_rule_spreadsheet_onlyoffice_config(
|
||||||
|
rule.id,
|
||||||
|
CurrentUserContext(
|
||||||
|
username="finance_user",
|
||||||
|
name="财务人员",
|
||||||
|
role_codes=["finance"],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
customization = config.config["editorConfig"]["customization"]
|
||||||
|
assert config.config["editorConfig"]["mode"] == "edit"
|
||||||
|
assert customization["forcesave"] is True
|
||||||
|
assert "version=" not in config.config["document"]["url"]
|
||||||
|
assert "version=" not in config.config["editorConfig"]["callbackUrl"]
|
||||||
|
|
||||||
|
|
||||||
def test_version_timeline_contains_created_review_and_publish_events() -> None:
|
def test_version_timeline_contains_created_review_and_publish_events() -> None:
|
||||||
@@ -388,6 +622,126 @@ def test_agent_asset_service_returns_travel_policy_rule_detail() -> None:
|
|||||||
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
|
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
travel_spreadsheet_rule = db.scalar(
|
||||||
|
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
|
)
|
||||||
|
assert travel_spreadsheet_rule is not None
|
||||||
|
travel_spreadsheet_rule.status = AgentAssetStatus.REVIEW.value
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||||||
|
|
||||||
|
assert catalog.travel_policy is not None
|
||||||
|
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||||
|
assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则"
|
||||||
|
assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450
|
||||||
|
assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450
|
||||||
|
assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500
|
||||||
|
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
|
||||||
|
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
|
||||||
|
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
|
||||||
|
assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1
|
||||||
|
assert catalog.travel_policy.transport_limits["executive"]["train"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
db.add(
|
||||||
|
Employee(
|
||||||
|
employee_no="E9001",
|
||||||
|
name="测试员工",
|
||||||
|
email="traveler@example.com",
|
||||||
|
position="产品经理",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = TravelReimbursementCalculatorService(db).calculate(
|
||||||
|
TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"),
|
||||||
|
CurrentUserContext(
|
||||||
|
username="traveler@example.com",
|
||||||
|
name="测试员工",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.rule_name == "公司差旅费报销规则"
|
||||||
|
assert result.grade == "P4"
|
||||||
|
assert result.grade_band == "mid"
|
||||||
|
assert result.matched_city == "北京"
|
||||||
|
assert result.hotel_rate == 450
|
||||||
|
assert result.hotel_amount == 1350
|
||||||
|
assert result.allowance_region == "直辖市/特区"
|
||||||
|
assert result.total_allowance_rate == 100
|
||||||
|
assert result.allowance_amount == 300
|
||||||
|
assert result.total_amount == 1650
|
||||||
|
assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text
|
||||||
|
assert "参考可报销总金额为 1650.00 元" in result.summary_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
db.add(
|
||||||
|
Employee(
|
||||||
|
employee_no="E9002",
|
||||||
|
name="其他地区员工",
|
||||||
|
email="other-region@example.com",
|
||||||
|
position="产品经理",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = TravelReimbursementCalculatorService(db).calculate(
|
||||||
|
TravelReimbursementCalculatorRequest(days=2, location="吉林延边"),
|
||||||
|
CurrentUserContext(
|
||||||
|
username="other-region@example.com",
|
||||||
|
name="其他地区员工",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.matched_city == "延边(其他地区)"
|
||||||
|
assert result.city_tier == "tier_3"
|
||||||
|
assert result.hotel_rate == 380
|
||||||
|
assert result.hotel_amount == 760
|
||||||
|
assert result.allowance_region == "其他地区"
|
||||||
|
assert result.total_allowance_rate == 90
|
||||||
|
assert result.allowance_amount == 180
|
||||||
|
assert result.total_amount == 940
|
||||||
|
|
||||||
|
|
||||||
|
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
db.add(
|
||||||
|
Employee(
|
||||||
|
employee_no="E9003",
|
||||||
|
name="无效地点员工",
|
||||||
|
email="invalid-location@example.com",
|
||||||
|
position="产品经理",
|
||||||
|
grade="P4",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="未识别为有效出差地区"):
|
||||||
|
TravelReimbursementCalculatorService(db).calculate(
|
||||||
|
TravelReimbursementCalculatorRequest(days=2, location="背景"),
|
||||||
|
CurrentUserContext(
|
||||||
|
username="invalid-location@example.com",
|
||||||
|
name="无效地点员工",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentRunService(db)
|
service = AgentRunService(db)
|
||||||
|
|||||||
@@ -51,6 +51,57 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand
|
|||||||
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
|
assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_intelligence_extracts_hotel_total_fee_instead_of_date_year() -> None:
|
||||||
|
insight = build_document_insight(
|
||||||
|
filename="hotel-invoice.png",
|
||||||
|
summary="酒店住宿票据",
|
||||||
|
text="北京中心酒店 金额 2026-02-20 入住 总费用是828元 离店日期 2026-02-21",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert insight.document_type == "hotel_invoice"
|
||||||
|
assert any(field.label == "金额" and field.value == "828元" for field in insight.fields)
|
||||||
|
assert not any(field.label == "金额" and field.value == "2026元" for field in insight.fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None:
|
||||||
|
insight = build_document_insight(
|
||||||
|
filename="铁路电子客票.pdf",
|
||||||
|
summary="电子发票(铁路电子客票)",
|
||||||
|
text=(
|
||||||
|
"电子发票(铁路电子客票)\n"
|
||||||
|
"发票号码:26319166100006175398\n"
|
||||||
|
"上海虹桥站\n"
|
||||||
|
"武汉站\n"
|
||||||
|
"G456\n"
|
||||||
|
"二等座\n"
|
||||||
|
"票价:¥354.00"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert insight.document_type == "train_ticket"
|
||||||
|
assert insight.document_type_label == "火车/高铁票"
|
||||||
|
assert insight.scene_code == "travel"
|
||||||
|
assert any(field.label == "金额" and field.value == "354元" for field in insight.fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_intelligence_labels_train_ticket_date_as_train_departure_time() -> None:
|
||||||
|
insight = build_document_insight(
|
||||||
|
filename="铁路电子客票.pdf",
|
||||||
|
summary="铁路电子客票",
|
||||||
|
text=(
|
||||||
|
"中国铁路电子客票 开票日期 2026-02-18 "
|
||||||
|
"G456 上海虹桥-武汉 2026-02-20 08:30开 票价:¥354.00"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert insight.document_type == "train_ticket"
|
||||||
|
assert any(
|
||||||
|
field.key == "date" and field.label == "列车出发时间" and field.value == "2026-02-20 08:30"
|
||||||
|
for field in insight.fields
|
||||||
|
)
|
||||||
|
assert not any(field.label == "开票日期" for field in insight.fields)
|
||||||
|
|
||||||
|
|
||||||
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
|
def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None:
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite+pysqlite:///:memory:",
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine, func, select
|
from sqlalchemy import create_engine, func, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -135,6 +137,120 @@ def test_enable_employee_restores_status_and_logs_change() -> None:
|
|||||||
assert any(item.action == "启用员工账号" for item in updated.history)
|
assert any(item.action == "启用员工账号" for item in updated.history)
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_repairs_do_not_run_on_every_list() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
|
||||||
|
updated = service.update_employee(
|
||||||
|
employee.id,
|
||||||
|
EmployeeUpdate(position="测试岗位-不会被回滚"),
|
||||||
|
)
|
||||||
|
|
||||||
|
listed = next(item for item in service.list_employees() if item.id == employee.id)
|
||||||
|
assert updated.position == "测试岗位-不会被回滚"
|
||||||
|
assert listed.position == "测试岗位-不会被回滚"
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_update_appends_recent_history() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
current_codes = list(employee.roleCodes)
|
||||||
|
next_codes = ["finance", "user"] if "finance" not in current_codes else ["user"]
|
||||||
|
|
||||||
|
updated = service.update_employee(employee.id, EmployeeUpdate(role_codes=next_codes))
|
||||||
|
|
||||||
|
assert any("更新系统角色" in item.action for item in updated.history)
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_change_logs_keep_only_latest_five() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
persisted = db.get(Employee, employee.id)
|
||||||
|
assert persisted is not None
|
||||||
|
|
||||||
|
for index in range(7):
|
||||||
|
service._append_change_log(
|
||||||
|
persisted,
|
||||||
|
action=f"测试变更-{index}",
|
||||||
|
owner="单元测试",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
service._trim_employee_change_logs(persisted.id)
|
||||||
|
db.commit()
|
||||||
|
hydrated = db.get(Employee, employee.id)
|
||||||
|
assert hydrated is not None
|
||||||
|
assert len(hydrated.change_logs) == 5
|
||||||
|
assert hydrated.change_logs[0].action == "测试变更-6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_meta_includes_organization_options() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
meta = service.get_employee_meta()
|
||||||
|
|
||||||
|
assert meta.organizationOptions
|
||||||
|
assert all(item.code and item.name for item in meta.organizationOptions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_employee_changes_organization() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
organizations = service.repository.list_organization_units()
|
||||||
|
current_code = employee.organization.code if employee.organization else None
|
||||||
|
target = next(unit for unit in organizations if unit.unit_code != current_code)
|
||||||
|
|
||||||
|
updated = service.update_employee(
|
||||||
|
employee.id,
|
||||||
|
EmployeeUpdate(organization_unit_code=target.unit_code),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.organization is not None
|
||||||
|
assert updated.organization.code == target.unit_code
|
||||||
|
assert updated.department == target.name
|
||||||
|
assert any("更新员工信息" in item.action for item in updated.history)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_employee_rejects_unknown_organization() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="部门编码"):
|
||||||
|
service.update_employee(
|
||||||
|
employee.id,
|
||||||
|
EmployeeUpdate(organization_unit_code="ORG-NOT-EXISTS"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_employee_changes_manager() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employees = service.list_employees()
|
||||||
|
employee = employees[0]
|
||||||
|
manager = next(item for item in employees if item.id != employee.id)
|
||||||
|
|
||||||
|
updated = service.update_employee(
|
||||||
|
employee.id,
|
||||||
|
EmployeeUpdate(manager_employee_no=manager.employeeNo),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.managerEmployeeNo == manager.employeeNo
|
||||||
|
assert updated.manager == manager.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_history_datetime_uses_local_timezone_without_seconds() -> None:
|
||||||
|
value = datetime(2026, 5, 20, 6, 30, 45, tzinfo=UTC)
|
||||||
|
formatted = EmployeeService._format_history_datetime(value)
|
||||||
|
|
||||||
|
assert formatted == "2026年5月20日14时30分"
|
||||||
|
assert "秒" not in formatted
|
||||||
|
|
||||||
|
|
||||||
def test_update_employee_rejects_invalid_date_format() -> None:
|
def test_update_employee_rejects_invalid_date_format() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = EmployeeService(db)
|
service = EmployeeService(db)
|
||||||
|
|||||||
153
server/tests/test_employee_spreadsheet_import.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.services.employee import EmployeeService
|
||||||
|
from app.services.employee_spreadsheet import EMPLOYEE_HEADERS, EMPLOYEE_SHEET_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def build_session() -> Session:
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
return session_factory()
|
||||||
|
|
||||||
|
|
||||||
|
def build_workbook_bytes(rows: list[list[object]]) -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = EMPLOYEE_SHEET_NAME
|
||||||
|
sheet.append(list(EMPLOYEE_HEADERS))
|
||||||
|
for row in rows:
|
||||||
|
sheet.append(row)
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_employees_rejects_invalid_row_without_writing() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
first = service.list_employees()[0]
|
||||||
|
|
||||||
|
content = build_workbook_bytes(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
first.employeeNo,
|
||||||
|
"",
|
||||||
|
first.email,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
first.position,
|
||||||
|
first.grade,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"在职",
|
||||||
|
"user",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.import_employees(content)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.summary.errorCount >= 1
|
||||||
|
assert any("姓名" in item.message for item in result.errors)
|
||||||
|
refreshed = service.get_employee(first.id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.name == first.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_employees_updates_existing_employee() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
employee = service.list_employees()[0]
|
||||||
|
new_name = f"{employee.name}-导入"
|
||||||
|
|
||||||
|
content = build_workbook_bytes(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
employee.employeeNo,
|
||||||
|
new_name,
|
||||||
|
employee.email,
|
||||||
|
"男",
|
||||||
|
"",
|
||||||
|
"13900000001",
|
||||||
|
"",
|
||||||
|
"上海",
|
||||||
|
employee.position,
|
||||||
|
employee.grade,
|
||||||
|
"FIN-SSC",
|
||||||
|
"",
|
||||||
|
"华东财务组",
|
||||||
|
"CC-TEST",
|
||||||
|
"在职",
|
||||||
|
"user",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.import_employees(content, actor="测试管理员")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.summary.updated == 1
|
||||||
|
updated = service.get_employee(employee.id)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.name == new_name
|
||||||
|
assert updated.phone == "13900000001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_employees_creates_new_employee() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = EmployeeService(db)
|
||||||
|
service.list_employees()
|
||||||
|
|
||||||
|
content = build_workbook_bytes(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"E90001",
|
||||||
|
"导入新员工",
|
||||||
|
"import.new.user@xfinance.com",
|
||||||
|
"女",
|
||||||
|
"",
|
||||||
|
"13811112222",
|
||||||
|
"2025-01-01",
|
||||||
|
"上海",
|
||||||
|
"业务专员",
|
||||||
|
"P3",
|
||||||
|
"FIN-SSC",
|
||||||
|
"E10234",
|
||||||
|
"华东财务组",
|
||||||
|
"CC-9001",
|
||||||
|
"在职",
|
||||||
|
"user",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.import_employees(content)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.summary.created == 1
|
||||||
|
imported = db.execute(
|
||||||
|
select(Employee).where(Employee.employee_no == "E90001")
|
||||||
|
).scalar_one()
|
||||||
|
assert imported.name == "导入新员工"
|
||||||
|
assert imported.email == "import.new.user@xfinance.com"
|
||||||
@@ -177,3 +177,80 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
|||||||
assert any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields)
|
assert any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields)
|
||||||
assert recognized.lines[0].page_index == 0
|
assert recognized.lines[0].page_index == 0
|
||||||
assert recognized.lines[1].page_index == 1
|
assert recognized.lines[1].page_index == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||||
|
page = output_dir / "page-1.png"
|
||||||
|
page.write_bytes(b"fake-page")
|
||||||
|
return [page]
|
||||||
|
|
||||||
|
def fake_invoke_worker(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
python_bin: str,
|
||||||
|
worker_path: str,
|
||||||
|
input_paths: list[Path],
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"engine": "paddleocr_mobile",
|
||||||
|
"model": "PP-OCRv5_mobile",
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"input_path": str(input_paths[0]),
|
||||||
|
"engine": "paddleocr_mobile",
|
||||||
|
"model": "PP-OCRv5_mobile",
|
||||||
|
"text": "□□□□□□\n□□□□:26319166100006175398\nG456\n□□:□354.00",
|
||||||
|
"summary": "□□□□□□;□□□□:26319166100006175398",
|
||||||
|
"avg_score": 0.88,
|
||||||
|
"line_count": 4,
|
||||||
|
"page_count": 1,
|
||||||
|
"warnings": [],
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"text": "□□□□□□",
|
||||||
|
"score": 0.88,
|
||||||
|
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||||
|
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
|
||||||
|
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
|
||||||
|
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
|
||||||
|
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
OcrService,
|
||||||
|
"_extract_pdf_text_layer",
|
||||||
|
lambda self, pdf_path: (
|
||||||
|
"电子发票(铁路电子客票)\n"
|
||||||
|
"发票号码:26319166100006175398\n"
|
||||||
|
"上海虹桥站\n"
|
||||||
|
"武汉站\n"
|
||||||
|
"G456\n"
|
||||||
|
"票价:¥354.00"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
try:
|
||||||
|
result = OcrService().recognize_files(
|
||||||
|
[
|
||||||
|
("train-ticket.pdf", b"%PDF-1.4 fake", "application/pdf"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
recognized = result.documents[0]
|
||||||
|
assert "电子发票(铁路电子客票)" in recognized.text
|
||||||
|
assert "上海虹桥站" in recognized.text
|
||||||
|
assert "□□□□" not in recognized.summary
|
||||||
|
assert recognized.document_type == "train_ticket"
|
||||||
|
assert recognized.preview_kind == ""
|
||||||
|
assert recognized.preview_data_url == ""
|
||||||
|
|||||||
78
server/tests/test_onlyoffice_callback_summary.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from io import BytesIO
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from app.services.agent_assets import AgentAssetService
|
||||||
|
from app.schemas.agent_asset import AgentAssetOnlyOfficeCallbackWrite
|
||||||
|
|
||||||
|
def test_onlyoffice_callback_generates_summary_note():
|
||||||
|
# Setup mock DB and repository
|
||||||
|
db = MagicMock()
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
service.repository = MagicMock()
|
||||||
|
service.spreadsheet_manager = MagicMock()
|
||||||
|
service._ensure_ready = MagicMock()
|
||||||
|
|
||||||
|
# Mock asset and metadata
|
||||||
|
asset = MagicMock()
|
||||||
|
asset.id = "test-asset"
|
||||||
|
asset.name = "测试规则"
|
||||||
|
service._require_spreadsheet_rule = MagicMock(return_value=asset)
|
||||||
|
service._resolve_working_version = MagicMock(return_value="v1")
|
||||||
|
|
||||||
|
base_meta = MagicMock()
|
||||||
|
base_meta.file_name = "test.xlsx"
|
||||||
|
base_meta.storage_key = "old-key"
|
||||||
|
base_meta.checksum = "old-checksum"
|
||||||
|
service._resolve_spreadsheet_version_meta = MagicMock(return_value=("v1", base_meta))
|
||||||
|
|
||||||
|
# Create base workbook
|
||||||
|
base_wb = Workbook()
|
||||||
|
base_ws = base_wb.active
|
||||||
|
base_ws["A1"] = "old value"
|
||||||
|
|
||||||
|
# Mock loading base workbook
|
||||||
|
service._load_spreadsheet_for_compare = MagicMock(return_value=base_wb)
|
||||||
|
service.spreadsheet_manager.resolve_storage_path = MagicMock()
|
||||||
|
|
||||||
|
# Create new content (modified)
|
||||||
|
new_wb = Workbook()
|
||||||
|
new_ws = new_wb.active
|
||||||
|
new_ws["A1"] = "new value" # 1 cell changed
|
||||||
|
new_ws["B2"] = "added" # 1 more cell changed
|
||||||
|
|
||||||
|
# Mock URL open to return new content
|
||||||
|
new_content_bio = BytesIO()
|
||||||
|
new_wb.save(new_content_bio)
|
||||||
|
new_content = new_content_bio.getvalue()
|
||||||
|
|
||||||
|
with patch("app.services.agent_assets.urlopen") as mock_urlopen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = new_content
|
||||||
|
mock_response.__enter__.return_value = mock_response
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
# Mock upload_rule_spreadsheet
|
||||||
|
service.upload_rule_spreadsheet = MagicMock()
|
||||||
|
|
||||||
|
# Execute callback handler
|
||||||
|
payload = {
|
||||||
|
"status": 2,
|
||||||
|
"url": "http://onlyoffice/download",
|
||||||
|
"users": ["test_user"]
|
||||||
|
}
|
||||||
|
|
||||||
|
service.handle_rule_spreadsheet_onlyoffice_callback(
|
||||||
|
"test-asset",
|
||||||
|
version="v1",
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify upload_rule_spreadsheet was called with correct change_note
|
||||||
|
service.upload_rule_spreadsheet.assert_called_once()
|
||||||
|
call_args = service.upload_rule_spreadsheet.call_args[1]
|
||||||
|
assert "涉及 1 个 Sheet,共 2 处改动" in call_args["change_note"]
|
||||||
|
assert call_args["actor"] == "test_user"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__])
|
||||||
@@ -310,7 +310,9 @@ def test_semantic_ontology_service_keeps_travel_amount_follow_up_in_knowledge_qu
|
|||||||
assert result.clarification_required is False
|
assert result.clarification_required is False
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(monkeypatch) -> None:
|
def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
service = SemanticOntologyService(db)
|
service = SemanticOntologyService(db)
|
||||||
@@ -348,6 +350,28 @@ def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session
|
|||||||
assert result.clarification_question is None
|
assert result.clarification_question is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_next_step_context_inherits_expense_draft_flow() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="我已核对右侧识别结果,请进入下一步。",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={
|
||||||
|
"review_action": "next_step",
|
||||||
|
"draft_claim_id": "claim-1",
|
||||||
|
"attachment_count": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "draft"
|
||||||
|
assert result.permission.level == "draft_write"
|
||||||
|
assert result.clarification_required is False
|
||||||
|
assert result.clarification_question is None
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None:
|
def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
@@ -409,6 +433,71 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc
|
|||||||
assert result.time_range.end_date == "2026-05-11"
|
assert result.time_range.end_date == "2026-05-11"
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_treats_status_document_text_as_query() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="查询草稿的单据",
|
||||||
|
user_id="pytest",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "query"
|
||||||
|
assert result.permission.level == "read"
|
||||||
|
assert any(
|
||||||
|
item.field == "status" and item.value == "draft"
|
||||||
|
for item in result.constraints
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_extracts_history_query_time_and_location() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="我去年去北京报销的单据",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={
|
||||||
|
"client_now_iso": "2026-05-21T04:00:00.000Z",
|
||||||
|
"client_timezone_offset_minutes": -480,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "query"
|
||||||
|
assert result.time_range.raw == "去年"
|
||||||
|
assert result.time_range.start_date == "2025-01-01"
|
||||||
|
assert result.time_range.end_date == "2025-12-31"
|
||||||
|
assert any(
|
||||||
|
item.type == "location" and item.normalized_value == "北京"
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_understands_last_week_claim_progress_query() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="我上周提交的单据报销了么?",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={
|
||||||
|
"client_now_iso": "2026-05-21T04:00:00.000Z",
|
||||||
|
"client_timezone_offset_minutes": -480,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "query"
|
||||||
|
assert result.time_range.raw == "上周"
|
||||||
|
assert result.time_range.start_date == "2026-05-11"
|
||||||
|
assert result.time_range.end_date == "2026-05-17"
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
|
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
@@ -427,6 +516,24 @@ def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type()
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用",
|
||||||
|
user_id="pytest",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "draft"
|
||||||
|
assert any(
|
||||||
|
item.type == "expense_type" and item.normalized_value == "transport"
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
399
server/tests/test_orchestrator_review_flow.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.schemas.orchestrator import OrchestratorRequest
|
||||||
|
from app.services.agent_conversations import AgentConversationService
|
||||||
|
from app.services.orchestrator import OrchestratorService
|
||||||
|
|
||||||
|
|
||||||
|
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 test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="E9000",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-next@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E9001",
|
||||||
|
name="张三",
|
||||||
|
email="emp-next@example.com",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-next-step",
|
||||||
|
claim_no="EXP-202605-001",
|
||||||
|
employee=employee,
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="销售部",
|
||||||
|
expense_type="office",
|
||||||
|
reason="采购办公用品",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("128.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
items=[
|
||||||
|
ExpenseClaimItem(
|
||||||
|
item_date=date(2026, 5, 20),
|
||||||
|
item_type="office",
|
||||||
|
item_reason="采购办公用品",
|
||||||
|
item_location="上海",
|
||||||
|
item_amount=Decimal("128.00"),
|
||||||
|
invoice_id="office-invoice.png",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrchestratorService(db).run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="emp-next@example.com",
|
||||||
|
message="我已核对右侧识别结果,请进入下一步。",
|
||||||
|
context_json={
|
||||||
|
"review_action": "next_step",
|
||||||
|
"draft_claim_id": claim.id,
|
||||||
|
"attachment_count": 1,
|
||||||
|
"name": "张三",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.refresh(claim)
|
||||||
|
assert response.status == "succeeded"
|
||||||
|
assert response.requires_confirmation is False
|
||||||
|
assert response.result["draft_payload"]["status"] == "submitted"
|
||||||
|
assert response.result["draft_payload"]["approval_stage"] == "直属领导审批"
|
||||||
|
assert claim.status == "submitted"
|
||||||
|
assert claim.approval_stage == "直属领导审批"
|
||||||
|
assert claim.submitted_at is not None
|
||||||
|
assert response.conversation_id
|
||||||
|
assert AgentConversationService(db).get_conversation(response.conversation_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E9011",
|
||||||
|
name="张三",
|
||||||
|
email="emp-blocked@example.com",
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-next-step-blocked",
|
||||||
|
claim_no="EXP-202605-002",
|
||||||
|
employee=employee,
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="待补充",
|
||||||
|
expense_type="office",
|
||||||
|
reason="采购办公用品",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("128.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
items=[
|
||||||
|
ExpenseClaimItem(
|
||||||
|
item_date=date(2026, 5, 20),
|
||||||
|
item_type="office",
|
||||||
|
item_reason="采购办公用品",
|
||||||
|
item_location="上海",
|
||||||
|
item_amount=Decimal("128.00"),
|
||||||
|
invoice_id="office-invoice.png",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add_all([employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrchestratorService(db).run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="emp-blocked@example.com",
|
||||||
|
message="我已核对右侧识别结果,请进入下一步。",
|
||||||
|
context_json={
|
||||||
|
"review_action": "next_step",
|
||||||
|
"draft_claim_id": claim.id,
|
||||||
|
"attachment_count": 1,
|
||||||
|
"name": "张三",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.result
|
||||||
|
review_payload = result["review_payload"]
|
||||||
|
actions = {
|
||||||
|
str(item.get("action_type") or "").strip()
|
||||||
|
for item in review_payload["confirmation_actions"]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert response.status == "succeeded"
|
||||||
|
assert result["draft_payload"]["status"] == "draft"
|
||||||
|
assert response.conversation_id
|
||||||
|
assert AgentConversationService(db).get_conversation(response.conversation_id) is not None
|
||||||
|
assert "AI预审暂未通过" in result["answer"]
|
||||||
|
assert "所属部门未完善" in result["answer"]
|
||||||
|
assert "next_step" not in actions
|
||||||
|
assert "save_draft" in actions
|
||||||
|
assert any(
|
||||||
|
"所属部门未完善" in str(item.get("content") or "")
|
||||||
|
for item in review_payload["risk_briefs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_prompt() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
service = AgentConversationService(db)
|
||||||
|
conversation = service.get_or_create_conversation(
|
||||||
|
conversation_id="conv-review-type-lock",
|
||||||
|
user_id="emp-review-type@example.com",
|
||||||
|
source="user_message",
|
||||||
|
context_json={
|
||||||
|
"session_type": "expense",
|
||||||
|
"draft_claim_id": "claim-old",
|
||||||
|
"attachment_names": ["old-train-ticket.pdf"],
|
||||||
|
"attachment_count": 1,
|
||||||
|
"review_form_values": {
|
||||||
|
"expense_type": "差旅费",
|
||||||
|
"business_location": "北京",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fresh_context = service.hydrate_context_json(
|
||||||
|
conversation=conversation,
|
||||||
|
context_json={"draft_claim_id": "claim-old"},
|
||||||
|
message="业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销",
|
||||||
|
)
|
||||||
|
continued_context = service.hydrate_context_json(
|
||||||
|
conversation=conversation,
|
||||||
|
context_json={},
|
||||||
|
message="继续补充酒店发票",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "draft_claim_id" not in fresh_context
|
||||||
|
assert "attachment_names" not in fresh_context
|
||||||
|
assert "review_form_values" not in fresh_context
|
||||||
|
assert fresh_context["conversation_state"]["review_form_values"]["expense_type"] == "差旅费"
|
||||||
|
assert continued_context["draft_claim_id"] == "claim-old"
|
||||||
|
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_history_query_filters_location_time_and_returns_real_amount(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
employee = Employee(
|
||||||
|
id="emp-history-query",
|
||||||
|
employee_no="E9020",
|
||||||
|
name="张三",
|
||||||
|
email="history-query@example.com",
|
||||||
|
)
|
||||||
|
beijing_claim = ExpenseClaim(
|
||||||
|
id="claim-history-beijing",
|
||||||
|
claim_no="EXP-202506-001",
|
||||||
|
employee=employee,
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="交付部",
|
||||||
|
expense_type="travel",
|
||||||
|
reason="去北京支持客户项目",
|
||||||
|
location="北京",
|
||||||
|
amount=Decimal("321.45"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=2,
|
||||||
|
occurred_at=datetime(2025, 6, 18, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2025, 6, 19, 10, 0, tzinfo=UTC),
|
||||||
|
status="paid",
|
||||||
|
approval_stage="已入账",
|
||||||
|
)
|
||||||
|
shanghai_claim = ExpenseClaim(
|
||||||
|
id="claim-history-shanghai",
|
||||||
|
claim_no="EXP-202507-001",
|
||||||
|
employee=employee,
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="交付部",
|
||||||
|
expense_type="travel",
|
||||||
|
reason="去上海支持项目",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("888.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2025, 7, 8, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2025, 7, 9, 10, 0, tzinfo=UTC),
|
||||||
|
status="paid",
|
||||||
|
approval_stage="已入账",
|
||||||
|
)
|
||||||
|
current_year_claim = ExpenseClaim(
|
||||||
|
id="claim-history-beijing-current",
|
||||||
|
claim_no="EXP-202601-001",
|
||||||
|
employee=employee,
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="交付部",
|
||||||
|
expense_type="travel",
|
||||||
|
reason="去北京支持年度项目",
|
||||||
|
location="北京",
|
||||||
|
amount=Decimal("666.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 1, 8, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 1, 9, 10, 0, tzinfo=UTC),
|
||||||
|
status="paid",
|
||||||
|
approval_stage="已入账",
|
||||||
|
)
|
||||||
|
db.add_all([employee, beijing_claim, shanghai_claim, current_year_claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrchestratorService(db).run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="history-query@example.com",
|
||||||
|
message="我去年去北京报销的单据",
|
||||||
|
context_json={
|
||||||
|
"client_now_iso": "2026-05-21T04:00:00.000Z",
|
||||||
|
"client_timezone_offset_minutes": -480,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
query_payload = response.result["query_payload"]
|
||||||
|
assert response.status == "succeeded"
|
||||||
|
assert response.trace_summary.scenario == "expense"
|
||||||
|
assert response.trace_summary.intent == "query"
|
||||||
|
assert query_payload["record_count"] == 1
|
||||||
|
assert query_payload["total_amount"] == 321.45
|
||||||
|
assert [item["claim_no"] for item in query_payload["records"]] == ["EXP-202506-001"]
|
||||||
|
assert "321.45" in response.result["answer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_expense_preview_does_not_persist_claim_before_user_action(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E9030",
|
||||||
|
name="预览员工",
|
||||||
|
email="preview-orchestrator@example.com",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrchestratorService(db).run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="preview-orchestrator@example.com",
|
||||||
|
message="业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报",
|
||||||
|
context_json={
|
||||||
|
"name": "预览员工",
|
||||||
|
"user_input_text": "业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_claims = [
|
||||||
|
claim
|
||||||
|
for claim in db.query(ExpenseClaim).all()
|
||||||
|
if claim.employee_name == "预览员工"
|
||||||
|
]
|
||||||
|
assert response.status == "succeeded"
|
||||||
|
assert response.result.get("review_payload") is not None
|
||||||
|
assert response.result.get("draft_payload") is None
|
||||||
|
assert "交通费通常以实际票据金额为基础" in response.result["answer"]
|
||||||
|
assert user_claims == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_expense(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
service = AgentConversationService(db)
|
||||||
|
conversation = service.get_or_create_conversation(
|
||||||
|
conversation_id="conv-scene-choice",
|
||||||
|
user_id="emp-scene-choice@example.com",
|
||||||
|
source="user_message",
|
||||||
|
context_json={
|
||||||
|
"session_type": "expense",
|
||||||
|
"draft_claim_id": "claim-old",
|
||||||
|
"review_form_values": {
|
||||||
|
"expense_type": "差旅费",
|
||||||
|
"business_location": "北京",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = OrchestratorService(db).run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="emp-scene-choice@example.com",
|
||||||
|
conversation_id=conversation.conversation_id,
|
||||||
|
message="业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销",
|
||||||
|
context_json={
|
||||||
|
"session_type": "expense",
|
||||||
|
"draft_claim_id": "claim-old",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.result
|
||||||
|
assert response.status == "succeeded"
|
||||||
|
assert result.get("review_payload") is None
|
||||||
|
assert result.get("draft_payload") is None
|
||||||
|
assert "请先在下面选择报销场景" in result["answer"]
|
||||||
|
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]
|
||||||
@@ -158,6 +158,11 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
|
|||||||
assert upload_payload["attachment"]["document_info"]["document_type"] == "office_invoice"
|
assert upload_payload["attachment"]["document_info"]["document_type"] == "office_invoice"
|
||||||
assert upload_payload["attachment"]["requirement_check"]["matches"] is True
|
assert upload_payload["attachment"]["requirement_check"]["matches"] is True
|
||||||
assert upload_payload["invoice_id"]
|
assert upload_payload["invoice_id"]
|
||||||
|
assert upload_payload["item_type"] == "office"
|
||||||
|
assert upload_payload["item_reason"] == "识别到办公用品发票,金额 88 元。"
|
||||||
|
assert upload_payload["item_location"] == "深圳南山"
|
||||||
|
assert upload_payload["item_date"] == "2026-05-13"
|
||||||
|
assert upload_payload["item_amount"] == "88.00"
|
||||||
|
|
||||||
meta_response = client.get(
|
meta_response = client.get(
|
||||||
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",
|
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",
|
||||||
@@ -289,6 +294,75 @@ def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monke
|
|||||||
assert any("附件内容" in point for point in analysis["points"])
|
assert any("附件内容" in point for point in analysis["points"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() -> None:
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
manager = Employee(
|
||||||
|
id="mgr-approve-1",
|
||||||
|
employee_no="E21001",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-approve-api@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
id="emp-approve-1",
|
||||||
|
employee_no="E11001",
|
||||||
|
name="张三",
|
||||||
|
email="zhangsan-approve-api@example.com",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-approve-1",
|
||||||
|
claim_no="EXP-APP-API-001",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_id="dept-1",
|
||||||
|
department_name="市场部",
|
||||||
|
project_code=None,
|
||||||
|
expense_type="transport",
|
||||||
|
reason="交通报销",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("88.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 13, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="直属领导审批",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||||
|
json={"opinion": "情况属实,同意报销。"},
|
||||||
|
headers={
|
||||||
|
"X-Auth-Username": "manager-approve-api@example.com",
|
||||||
|
"X-Auth-Name": "manager-approve-api@example.com",
|
||||||
|
"X-Auth-Role-Codes": "manager",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["status"] == "submitted"
|
||||||
|
assert payload["approval_stage"] == "财务审批"
|
||||||
|
assert any(
|
||||||
|
item["source"] == "manual_approval"
|
||||||
|
and item["opinion"] == "情况属实,同意报销。"
|
||||||
|
and item["operator"] == "李经理"
|
||||||
|
and item["next_approval_stage"] == "财务审批"
|
||||||
|
for item in payload["risk_flags_json"]
|
||||||
|
)
|
||||||
|
approval_events = [
|
||||||
|
item
|
||||||
|
for item in payload["risk_flags_json"]
|
||||||
|
if item["source"] == "manual_approval"
|
||||||
|
]
|
||||||
|
assert approval_events[0]["operator"] == "李经理"
|
||||||
|
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||||
|
|
||||||
|
|
||||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||||
preview_bytes = b"fake-preview-png"
|
preview_bytes = b"fake-preview-png"
|
||||||
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
||||||
|
|||||||
BIN
web/UI/流程输入.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai/1.501/lxgw-wenkai.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai/1.501/lxgw-wenkai.min.css" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
|
||||||
<title>ReimburseOps - 企业报销智能运营台</title>
|
<title>ReimburseOps - 企业报销智能运营台</title>
|
||||||
|
|||||||
5
web/public/assets/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#0f766e"/>
|
||||||
|
<path fill="#ffffff" d="M36 10c10 2 17 10 18 20-5-2-10-1-14 1-5 3-8 8-9 15-8-5-12-12-11-20 0-7 6-14 16-16Z"/>
|
||||||
|
<path fill="#d1fae5" d="M18 15c-6 6-8 13-6 20 2 8 9 13 19 15-4 3-9 5-15 4-7-5-10-12-9-19 0-8 4-15 11-20Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -103,3 +103,39 @@ h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; fon
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
|
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-loading__spinner {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 3px solid #e2e8f0;
|
||||||
|
border-top-color: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: table-spinner-rotate .8s linear infinite !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-loading.sky .table-loading__spinner {
|
||||||
|
border-top-color: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-loading.detail .table-loading__spinner {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-loading.banner .table-loading__spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-loading__spinner i {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes table-spinner-rotate {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
336
web/src/assets/styles/views/approval-center-view-part2.css
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
.opinion-wrap textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
resize: none;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opinion-wrap textarea::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opinion-wrap textarea:focus {
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Side Cards ── */
|
||||||
|
.side-card {
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .06); }
|
||||||
|
|
||||||
|
.side-card .card-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card.compact {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Risk Card ── */
|
||||||
|
.risk-total {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-total span { font-weight: 750; }
|
||||||
|
|
||||||
|
.risk-total.high {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-total.high strong { font-size: 16px; font-weight: 900; }
|
||||||
|
|
||||||
|
.risk-items {
|
||||||
|
padding: 4px 14px 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
transition: all 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-row:hover { border-color: #e2e8f0; background: #fafbfd; }
|
||||||
|
|
||||||
|
.risk-icon {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-row.high .risk-icon { background: #fef2f2; color: #ef4444; }
|
||||||
|
.risk-row.medium .risk-icon { background: #fff7ed; color: #f97316; }
|
||||||
|
|
||||||
|
.risk-text {
|
||||||
|
flex: 1;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-level {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-level.high { background: #fef2f2; color: #ef4444; }
|
||||||
|
.risk-level.medium { background: #fff7ed; color: #f97316; }
|
||||||
|
|
||||||
|
/* ── Side Dual ── */
|
||||||
|
.side-dual {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 16px 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-list li i {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 6px 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 16px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-list dt { color: #94a3b8; font-weight: 700; }
|
||||||
|
.info-list dd { margin: 0; color: #0f172a; font-weight: 850; }
|
||||||
|
|
||||||
|
/* ── Modal Footer ── */
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 28px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e8eef6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
height: 40px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: all 180ms ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.back {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.back:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.supplement {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #fed7aa;
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.supplement:hover {
|
||||||
|
background: #fff7ed;
|
||||||
|
box-shadow: 0 4px 12px rgba(234, 88, 12, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.reject {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.reject:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.approve {
|
||||||
|
background: #059669;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 16px rgba(5, 150, 105, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.approve:hover {
|
||||||
|
background: #047857;
|
||||||
|
box-shadow: 0 8px 24px rgba(5, 150, 105, .30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:active { transform: scale(.97); }
|
||||||
|
|
||||||
|
/* ── Modal Transitions ── */
|
||||||
|
.detail-modal-enter-active { transition: opacity 260ms ease; }
|
||||||
|
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
|
||||||
|
|
||||||
|
.detail-modal-leave-active { transition: opacity 200ms ease; }
|
||||||
|
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
|
||||||
|
|
||||||
|
.detail-modal-enter-from { opacity: 0; }
|
||||||
|
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
|
||||||
|
|
||||||
|
.detail-modal-leave-to { opacity: 0; }
|
||||||
|
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 1320px) {
|
||||||
|
.list-toolbar, .list-foot { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.detail-hero {
|
||||||
|
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-summary-panel {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-expense-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-expense-table table {
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-grid { grid-template-columns: 1fr; }
|
||||||
|
.metrics-strip { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.side-dual { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.approval-list { padding: 16px; }
|
||||||
|
.status-tabs { gap: 18px; overflow-x: auto; }
|
||||||
|
.filter-set { width: 100%; }
|
||||||
|
.filter-btn, .page-size { width: 100%; }
|
||||||
|
.list-foot { justify-items: stretch; }
|
||||||
|
.pager, .page-size { justify-self: stretch; }
|
||||||
|
|
||||||
|
.detail-hero {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applicant-card,
|
||||||
|
.hero-summary-panel,
|
||||||
|
.progress-line {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-summary-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-summary-item {
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-total {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-expense-table table {
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
|
||||||
|
.header-right { width: 100%; justify-content: flex-end; }
|
||||||
|
.metrics-strip { grid-template-columns: 1fr 1fr; }
|
||||||
|
.summary-grid { grid-template-columns: 1fr; }
|
||||||
|
.progress-track { overflow-x: auto; padding-bottom: 8px; }
|
||||||
|
.node-label strong { font-size: 11px; }
|
||||||
|
.modal-footer { flex-direction: column; padding: 14px 18px; }
|
||||||
|
.footer-right { width: 100%; }
|
||||||
|
.action-btn { flex: 1; }
|
||||||
|
}
|
||||||
@@ -1477,339 +1477,3 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opinion-wrap textarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100px;
|
|
||||||
resize: none;
|
|
||||||
border: 1px solid #d7e0ea;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opinion-wrap textarea::placeholder {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opinion-wrap textarea:focus {
|
|
||||||
border-color: #10b981;
|
|
||||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Side Cards ── */
|
|
||||||
.side-card {
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #edf2f7;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: box-shadow 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-card:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .06); }
|
|
||||||
|
|
||||||
.side-card .card-header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-card.compact {
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Risk Card ── */
|
|
||||||
.risk-total {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-total span { font-weight: 750; }
|
|
||||||
|
|
||||||
.risk-total.high {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-total.high strong { font-size: 16px; font-weight: 900; }
|
|
||||||
|
|
||||||
.risk-items {
|
|
||||||
padding: 4px 14px 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #f1f5f9;
|
|
||||||
transition: all 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-row:hover { border-color: #e2e8f0; background: #fafbfd; }
|
|
||||||
|
|
||||||
.risk-icon {
|
|
||||||
width: 30px; height: 30px;
|
|
||||||
display: grid; place-items: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-row.high .risk-icon { background: #fef2f2; color: #ef4444; }
|
|
||||||
.risk-row.medium .risk-icon { background: #fff7ed; color: #f97316; }
|
|
||||||
|
|
||||||
.risk-text {
|
|
||||||
flex: 1;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-level {
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-level.high { background: #fef2f2; color: #ef4444; }
|
|
||||||
.risk-level.medium { background: #fff7ed; color: #f97316; }
|
|
||||||
|
|
||||||
/* ── Side Dual ── */
|
|
||||||
.side-dual {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reminder-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 16px 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reminder-list li {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reminder-list li i {
|
|
||||||
margin-top: 2px;
|
|
||||||
color: #f59e0b;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 6px 14px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 16px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-list dt { color: #94a3b8; font-weight: 700; }
|
|
||||||
.info-list dd { margin: 0; color: #0f172a; font-weight: 850; }
|
|
||||||
|
|
||||||
/* ── Modal Footer ── */
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 28px;
|
|
||||||
background: #fff;
|
|
||||||
border-top: 1px solid #e8eef6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
height: 40px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 0 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
|
||||||
transition: all 180ms ease;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.back {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-color: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.back:hover {
|
|
||||||
background: #f1f5f9;
|
|
||||||
border-color: #cbd5e1;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.supplement {
|
|
||||||
background: #fff;
|
|
||||||
border-color: #fed7aa;
|
|
||||||
color: #ea580c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.supplement:hover {
|
|
||||||
background: #fff7ed;
|
|
||||||
box-shadow: 0 4px 12px rgba(234, 88, 12, .12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.reject {
|
|
||||||
background: #fff;
|
|
||||||
border-color: #fecaca;
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.reject:hover {
|
|
||||||
background: #fef2f2;
|
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, .12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.approve {
|
|
||||||
background: #059669;
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 4px 16px rgba(5, 150, 105, .25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn.approve:hover {
|
|
||||||
background: #047857;
|
|
||||||
box-shadow: 0 8px 24px rgba(5, 150, 105, .30);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:active { transform: scale(.97); }
|
|
||||||
|
|
||||||
/* ── Modal Transitions ── */
|
|
||||||
.detail-modal-enter-active { transition: opacity 260ms ease; }
|
|
||||||
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
|
|
||||||
|
|
||||||
.detail-modal-leave-active { transition: opacity 200ms ease; }
|
|
||||||
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
|
|
||||||
|
|
||||||
.detail-modal-enter-from { opacity: 0; }
|
|
||||||
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
|
|
||||||
|
|
||||||
.detail-modal-leave-to { opacity: 0; }
|
|
||||||
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 1320px) {
|
|
||||||
.list-toolbar, .list-foot { grid-template-columns: 1fr; }
|
|
||||||
|
|
||||||
.detail-hero {
|
|
||||||
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-summary-panel {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-expense-table {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-expense-table table {
|
|
||||||
min-width: 980px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-modal {
|
|
||||||
width: calc(100vw - 40px);
|
|
||||||
height: calc(100vh - 40px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-grid { grid-template-columns: 1fr; }
|
|
||||||
.metrics-strip { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.side-dual { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.approval-list { padding: 16px; }
|
|
||||||
.status-tabs { gap: 18px; overflow-x: auto; }
|
|
||||||
.filter-set { width: 100%; }
|
|
||||||
.filter-btn, .page-size { width: 100%; }
|
|
||||||
.list-foot { justify-items: stretch; }
|
|
||||||
.pager, .page-size { justify-self: stretch; }
|
|
||||||
|
|
||||||
.detail-hero {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.applicant-card,
|
|
||||||
.hero-summary-panel,
|
|
||||||
.progress-line {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-summary-panel {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-summary-item {
|
|
||||||
padding: 14px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-card {
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-card-head {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-total {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-expense-table table {
|
|
||||||
min-width: 980px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-modal {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
max-width: 100vw;
|
|
||||||
max-height: 100vh;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
|
|
||||||
.header-right { width: 100%; justify-content: flex-end; }
|
|
||||||
.metrics-strip { grid-template-columns: 1fr 1fr; }
|
|
||||||
.summary-grid { grid-template-columns: 1fr; }
|
|
||||||
.progress-track { overflow-x: auto; padding-bottom: 8px; }
|
|
||||||
.node-label strong { font-size: 11px; }
|
|
||||||
.modal-footer { flex-direction: column; padding: 14px 18px; }
|
|
||||||
.footer-right { width: 100%; }
|
|
||||||
.action-btn { flex: 1; }
|
|
||||||
}
|
|
||||||
|
|||||||
1255
web/src/assets/styles/views/audit-view-part2.css
Normal file
@@ -142,6 +142,8 @@
|
|||||||
|
|
||||||
.picker-trigger,
|
.picker-trigger,
|
||||||
.ghost-filter-btn,
|
.ghost-filter-btn,
|
||||||
|
.template-btn,
|
||||||
|
.export-btn,
|
||||||
.create-btn,
|
.create-btn,
|
||||||
.row-action {
|
.row-action {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
@@ -282,6 +284,24 @@
|
|||||||
color: #047857;
|
color: #047857;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-btn,
|
||||||
|
.export-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-btn:hover,
|
||||||
|
.export-btn:hover {
|
||||||
|
border-color: rgba(16, 185, 129, 0.34);
|
||||||
|
background: #f6fffb;
|
||||||
|
color: #0f9f78;
|
||||||
|
}
|
||||||
|
|
||||||
.create-btn {
|
.create-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -293,6 +313,50 @@
|
|||||||
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
|
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-btn:disabled,
|
||||||
|
.template-btn:disabled,
|
||||||
|
.export-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-error-table-wrap {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-error-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-error-table th,
|
||||||
|
.import-error-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-error-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-error-table td:last-child {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -333,13 +397,14 @@
|
|||||||
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
|
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap table {
|
.table-wrap table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-self: flex-start;
|
flex: 0 0 auto;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-foot {
|
.list-foot {
|
||||||
@@ -503,7 +568,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
height: 100%;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1180px;
|
min-width: 1180px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -659,9 +724,12 @@ tbody tr:last-child td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-stack {
|
.role-stack {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-pill {
|
.role-pill {
|
||||||
@@ -770,6 +838,7 @@ tbody tr:last-child td {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
|
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-main,
|
.detail-main,
|
||||||
@@ -777,6 +846,7 @@ tbody tr:last-child td {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card,
|
.detail-card,
|
||||||
@@ -821,6 +891,7 @@ tbody tr:last-child td {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@@ -850,6 +921,118 @@ tbody tr:last-child td {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manager-picker,
|
||||||
|
.department-picker {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker.open,
|
||||||
|
.department-picker.open {
|
||||||
|
z-index: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker.open .manager-picker-trigger,
|
||||||
|
.manager-picker-trigger:hover {
|
||||||
|
border-color: rgba(16, 185, 129, 0.34);
|
||||||
|
background: #f6fffb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-panel input[type='search'] {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-panel input[type='search']:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(16, 185, 129, 0.6);
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-options {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-option {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfdff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-option strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-option span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-option:hover,
|
||||||
|
.manager-picker-option.active {
|
||||||
|
border-color: rgba(16, 185, 129, 0.32);
|
||||||
|
background: linear-gradient(180deg, rgba(240, 253, 244, 0.85), #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manager-picker-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 4px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.role-grid {
|
.role-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -911,10 +1094,10 @@ tbody tr:last-child td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-row {
|
.history-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: flex-start;
|
grid-template-columns: minmax(0, 1fr) 128px 112px;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
gap: 10px;
|
column-gap: 16px;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border-top: 1px solid #edf2f7;
|
border-top: 1px solid #edf2f7;
|
||||||
}
|
}
|
||||||
@@ -925,19 +1108,45 @@ tbody tr:last-child td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-row strong {
|
.history-row strong {
|
||||||
display: block;
|
min-width: 0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row span,
|
.history-row-owner,
|
||||||
.history-row small {
|
.history-row-time {
|
||||||
display: block;
|
display: inline-block;
|
||||||
margin-top: 4px;
|
min-width: 0;
|
||||||
|
margin-top: 0;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.45;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row-owner {
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row-time {
|
||||||
|
color: #64748b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.cell-updated {
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-card {
|
.publish-card {
|
||||||
@@ -1087,4 +1296,18 @@ tbody tr:last-child td {
|
|||||||
.role-grid {
|
.role-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
row-gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row-owner {
|
||||||
|
padding-left: 0;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row-time {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -707,6 +707,15 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-empty.is-loading {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-empty.is-loading > .table-loading {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.inspector-empty {
|
.inspector-empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
|
|||||||
@@ -423,6 +423,14 @@ th {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-loading-row {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-loading-row > .table-loading {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.list-foot {
|
.list-foot {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
.review-preview-modal {
|
||||||
|
width: min(980px, calc(100vw - 40px));
|
||||||
|
max-height: min(92vh, calc(100vh - 32px));
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%),
|
||||||
|
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 80px rgba(15, 23, 42, 0.22),
|
||||||
|
0 2px 12px rgba(15, 23, 42, 0.08);
|
||||||
|
border: 1px solid #e7eef6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-preview-head h3 {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-preview-body {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(248, 250, 252, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-preview-body.image img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(92vh - 170px);
|
||||||
|
display: block;
|
||||||
|
border-radius: 20px;
|
||||||
|
object-fit: contain;
|
||||||
|
box-shadow: 0 16px 34px rgba(148, 163, 184, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-preview-body.pdf iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: min(78vh, 820px);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-quick-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed rgba(203, 213, 225, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-quick-actions-title {
|
||||||
|
margin: 0 0 22px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-quick-action-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-quick-action-btn {
|
||||||
|
min-height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid rgba(191, 219, 254, 0.92);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.94) 100%);
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: var(--wb-fs-chip);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.07);
|
||||||
|
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-quick-action-btn i {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-quick-action-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(59, 130, 246, 0.34);
|
||||||
|
box-shadow: 0 7px 14px rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-quick-action-btn:disabled {
|
||||||
|
opacity: 0.48;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card i {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: var(--wb-fs-welcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-modal-enter-active,
|
||||||
|
.assistant-modal-leave-active {
|
||||||
|
transition: opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-modal-enter-active .assistant-modal,
|
||||||
|
.assistant-modal-leave-active .assistant-modal {
|
||||||
|
transition: transform 260ms ease, opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-modal-enter-from,
|
||||||
|
.assistant-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-modal-enter-from .assistant-modal,
|
||||||
|
.assistant-modal-leave-to .assistant-modal {
|
||||||
|
transform: translateY(10px) scale(0.985);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-switch-enter-active,
|
||||||
|
.insight-switch-leave-active {
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-switch-enter-from,
|
||||||
|
.insight-switch-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 笔记本 / 中等屏:工作台正文字号整体下调一档 */
|
||||||
|
@media (max-width: 1680px) {
|
||||||
|
.assistant-modal-stage {
|
||||||
|
--wb-fs-title: 19px;
|
||||||
|
--wb-fs-desc: 12px;
|
||||||
|
--wb-fs-badge: 11px;
|
||||||
|
--wb-fs-bubble: 12px;
|
||||||
|
--wb-fs-bubble-meta: 11px;
|
||||||
|
--wb-fs-bubble-time: 11px;
|
||||||
|
--wb-fs-chip: 11px;
|
||||||
|
--wb-fs-composer: 13px;
|
||||||
|
--wb-fs-tool-icon: 16px;
|
||||||
|
--wb-fs-md-h1: 12px;
|
||||||
|
--wb-fs-md-h2: 12px;
|
||||||
|
--wb-fs-md-h3: 12px;
|
||||||
|
--wb-fs-insight-title: 17px;
|
||||||
|
--wb-fs-insight-num: 17px;
|
||||||
|
--wb-fs-insight-body: 11px;
|
||||||
|
--wb-fs-insight-h4: 14px;
|
||||||
|
--wb-fs-metric: 12px;
|
||||||
|
--wb-fs-metric-strong: 12px;
|
||||||
|
--wb-fs-welcome: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-modal-stage .message-answer-markdown table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-modal-stage .intent-pill {
|
||||||
|
font-size: var(--wb-fs-chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1440px) {
|
||||||
|
.assistant-modal-stage {
|
||||||
|
--wb-fs-title: 18px;
|
||||||
|
--wb-fs-bubble: 12px;
|
||||||
|
--wb-fs-bubble-meta: 11px;
|
||||||
|
--wb-fs-composer: 12px;
|
||||||
|
--wb-fs-insight-title: 16px;
|
||||||
|
--wb-fs-insight-num: 16px;
|
||||||
|
--wb-fs-md-h1: 12px;
|
||||||
|
--wb-fs-md-h2: 12px;
|
||||||
|
--wb-fs-md-h3: 12px;
|
||||||
|
--wb-fs-insight-h4: 13px;
|
||||||
|
--wb-fs-welcome: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 大屏:左右分栏;右侧详情区宽度随视口收缩 */
|
||||||
|
@media (min-width: 1441px) and (max-width: 1680px) {
|
||||||
|
.insight-panel-shell {
|
||||||
|
width: clamp(280px, 26vw, 360px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 笔记本常见宽度:改为上下布局,对话区占满宽度,避免侧栏挤占 */
|
||||||
|
@media (max-width: 1440px) {
|
||||||
|
.assistant-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-panel-shell {
|
||||||
|
width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: min(38dvh, 400px);
|
||||||
|
transition:
|
||||||
|
max-height 320ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-panel-shell.collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-panel {
|
||||||
|
width: 100%;
|
||||||
|
min-height: min(280px, 32dvh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-panel-shell.collapsed .insight-panel {
|
||||||
|
transform: translateY(-12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-side-grid.compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 矮屏笔记本(如 1366×768):压缩顶栏与间距,把高度留给对话列表 */
|
||||||
|
@media (max-height: 820px) {
|
||||||
|
.assistant-modal-stage {
|
||||||
|
--wb-fs-title: 17px;
|
||||||
|
--wb-fs-bubble: 12px;
|
||||||
|
--wb-fs-composer: 12px;
|
||||||
|
--wb-fs-insight-title: 15px;
|
||||||
|
--wb-fs-insight-num: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header-actions {
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-layout {
|
||||||
|
padding: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-toolbar {
|
||||||
|
padding: 12px 14px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
padding: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-shell-body {
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.insight-panel-shell:not(.collapsed) {
|
||||||
|
max-height: min(34dvh, 360px);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.assistant-overlay {
|
||||||
|
--assistant-viewport-inset: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-modal,
|
||||||
|
.assistant-modal-stage {
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header {
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header-actions {
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
gap: 10px;
|
||||||
|
width: auto;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-toggle-btn,
|
||||||
|
.session-trash-btn,
|
||||||
|
.assistant-close-btn,
|
||||||
|
.close-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step-card header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-layout {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-row {
|
||||||
|
gap: 8px;
|
||||||
|
--composer-control-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-shell textarea {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-calculator-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-toolbar {
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-chip {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row,
|
||||||
|
.message-row.user {
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.user .message-avatar {
|
||||||
|
order: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.user .message-bubble {
|
||||||
|
order: 0;
|
||||||
|
justify-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-suggested-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-files-head,
|
||||||
|
.review-insight-title-row,
|
||||||
|
.review-document-stage-head,
|
||||||
|
.review-document-switch-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-files-actions,
|
||||||
|
.review-document-nav {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-side-grid,
|
||||||
|
.review-side-category-grid,
|
||||||
|
.review-document-edit-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-pending-item {
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-pending-status {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-footer-btn-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-footer-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-slot-grid,
|
||||||
|
.review-doc-field-grid,
|
||||||
|
.review-mini-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-document-plain,
|
||||||
|
.review-document-bubble {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-preview-modal {
|
||||||
|
width: calc(100vw - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-confirm-actions {
|
||||||
|
padding: 0 18px 18px;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-upload-decision-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-dialog-btn,
|
||||||
|
.secondary-dialog-btn,
|
||||||
|
.danger-dialog-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
945
web/src/assets/styles/views/travel-request-detail-view-part2.css
Normal file
@@ -0,0 +1,945 @@
|
|||||||
|
.validation-pill.pending {
|
||||||
|
background: #fff7ed;
|
||||||
|
border-color: #fed7aa;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-pill.warning {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-summary {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-sections {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .validation-section-title {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .validation-section-title::before {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 18px;
|
||||||
|
color: #0f766e;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-list li::marker {
|
||||||
|
color: #14b8a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-card-head span {
|
||||||
|
min-height: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-card.medium .risk-advice-card-head span {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-card.low .risk-advice-card-head span {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-card-head strong {
|
||||||
|
min-width: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-point {
|
||||||
|
margin: 0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-meta > div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section--risk .risk-advice-meta span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-card.medium {
|
||||||
|
border-color: #fed7aa;
|
||||||
|
background: #fffaf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-card.low {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-card-head span {
|
||||||
|
min-height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-card.medium .risk-advice-card-head span {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-card.low .risk-advice-card-head span {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-card-head strong {
|
||||||
|
min-width: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-point {
|
||||||
|
margin: 0;
|
||||||
|
color: #7f1d1d;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-card.medium .risk-advice-point {
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-meta > div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, .72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-meta span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-meta ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-meta p {
|
||||||
|
margin: 0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(15, 23, 42, .45);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal {
|
||||||
|
position: relative;
|
||||||
|
width: calc(100vw - 80px);
|
||||||
|
max-width: 1440px;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
max-height: 960px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(15, 23, 42, .08),
|
||||||
|
0 20px 60px rgba(15, 23, 42, .18),
|
||||||
|
0 4px 16px rgba(15, 23, 42, .06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-entry-modal {
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
background: linear-gradient(135deg, #fff 0%, #f9fbff 100%);
|
||||||
|
border-bottom: 1px solid #e8eef6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req-badge {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid rgba(29, 78, 216, .16);
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-group h2 {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-group p {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 18px;
|
||||||
|
transition: all 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20px 28px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #cbd5e1 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-entry-grid {
|
||||||
|
min-height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) 360px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-card,
|
||||||
|
.ai-preview-card {
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow: auto;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(240, 253, 244, .5) 0%, rgba(255, 255, 255, 0) 140px),
|
||||||
|
#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-bubble {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-bubble.user {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-bubble.user .ai-chat-avatar {
|
||||||
|
order: 2;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-bubble.user .ai-chat-content {
|
||||||
|
order: 1;
|
||||||
|
justify-self: end;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-content {
|
||||||
|
max-width: min(100%, 640px);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-content header {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-content strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-content p {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-composer {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-composer-surface {
|
||||||
|
min-height: 78px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 8px 8px 14px;
|
||||||
|
border: 1px solid #cbd8e5;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-composer-surface:focus-within {
|
||||||
|
border-color: rgba(16, 185, 129, .58);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, .11), 0 10px 24px rgba(15, 23, 42, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-composer textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60px;
|
||||||
|
height: 60px;
|
||||||
|
resize: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-composer textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-composer-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-upload-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-upload-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef6ff;
|
||||||
|
border: 1px solid #d7e8fb;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-upload-chip i {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-upload-btn,
|
||||||
|
.ai-send-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-upload-btn {
|
||||||
|
border: 0;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-upload-btn:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-send-btn {
|
||||||
|
border: 0;
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 18px rgba(16, 185, 129, .20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-send-btn:hover {
|
||||||
|
background: #0ea672;
|
||||||
|
box-shadow: 0 10px 22px rgba(16, 185, 129, .24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-upload-btn:active,
|
||||||
|
.ai-send-btn:active {
|
||||||
|
transform: scale(.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-card {
|
||||||
|
padding: 18px;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-head p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-field {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-field.full {
|
||||||
|
min-height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-field span {
|
||||||
|
display: block;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-field strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-field p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-empty {
|
||||||
|
min-height: 280px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-empty i {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-secondary,
|
||||||
|
.ai-preview-primary {
|
||||||
|
height: 40px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: all 180ms ease;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-secondary {
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-primary {
|
||||||
|
border: 1px solid #059669;
|
||||||
|
background: #059669;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 20px rgba(5, 150, 105, .18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-secondary:hover {
|
||||||
|
background: #ffedd5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-primary:hover {
|
||||||
|
background: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-preview-secondary:disabled,
|
||||||
|
.ai-preview-primary:disabled,
|
||||||
|
.approve-action:disabled,
|
||||||
|
.return-action:disabled,
|
||||||
|
.ai-send-btn:disabled {
|
||||||
|
opacity: .45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal-enter-active { transition: opacity 260ms ease; }
|
||||||
|
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
|
||||||
|
|
||||||
|
.detail-modal-leave-active { transition: opacity 200ms ease; }
|
||||||
|
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
|
||||||
|
|
||||||
|
.detail-modal-enter-from { opacity: 0; }
|
||||||
|
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
|
||||||
|
|
||||||
|
.detail-modal-leave-to { opacity: 0; }
|
||||||
|
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 1320px) {
|
||||||
|
.hero-banner-main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fact-grid {
|
||||||
|
grid-template-columns: repeat(5, minmax(132px, 1fr));
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fact {
|
||||||
|
min-width: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-expense-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-expense-table table {
|
||||||
|
min-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-entry-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.detail-hero { gap: 10px; padding: 16px; }
|
||||||
|
.progress-card { padding: 16px; }
|
||||||
|
|
||||||
|
.applicant-card {
|
||||||
|
grid-template-columns: 60px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portrait {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applicant-copy {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applicant-card h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applicant-profile-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applicant-profile-meta__role {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fact-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fact {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 14px 12px 12px;
|
||||||
|
border-left: 0;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fact:nth-child(2n) {
|
||||||
|
border-left: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fact:last-child:nth-child(odd) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fact:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-fact strong {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-entry-btn { align-self: flex-start; }
|
||||||
|
|
||||||
|
.detail-expense-table table {
|
||||||
|
min-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-action-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-head {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-modal {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
|
||||||
|
.modal-body { padding: 16px 18px; }
|
||||||
|
.ai-composer-actions { flex-direction: column; align-items: stretch; }
|
||||||
|
.ai-preview-actions { flex-direction: column; }
|
||||||
|
|
||||||
|
.attachment-preview-mask {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview-card {
|
||||||
|
width: min(calc(100vw - 28px), 920px);
|
||||||
|
max-height: calc(100vh - 28px);
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview-head {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview-toolbar {
|
||||||
|
order: 2;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview-body {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-insight-pane {
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-advice-meta {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview-image,
|
||||||
|
.attachment-preview-frame {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.validation-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-head h3 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-pill {
|
||||||
|
min-height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-pill.ready {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
5
web/src/assets/workbench-icons/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Workbench Icons
|
||||||
|
|
||||||
|
Icons in this folder are sourced from [Heroicons](https://heroicons.com) (MIT License).
|
||||||
|
|
||||||
|
Used on the Personal Workbench todo and progress lists.
|
||||||
3
web/src/assets/workbench-icons/outline-briefcase.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 806 B |
3
web/src/assets/workbench-icons/outline-document-text.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 316 B |
3
web/src/assets/workbench-icons/outline-shopping-bag.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 10.5V6a3.75 3.75 0 1 0-7.5 0v4.5m11.356-1.993 1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 0 1-1.12-1.243l1.264-12A1.125 1.125 0 0 1 5.513 7.5h12.974c.576 0 1.059.435 1.119 1.007ZM8.625 10.5a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm7.5 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 521 B |
3
web/src/assets/workbench-icons/outline-truck.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 0 1-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 0 0-3.213-9.193 2.056 2.056 0 0 0-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 0 0-10.026 0 1.106 1.106 0 0 0-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 636 B |
3
web/src/assets/workbench-icons/outline-users.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 596 B |