Files
X-Financial/server/src/app/services/travel_reimbursement_calculator.py
caoxiaozhu 8f65661809 feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
2026-05-21 09:28:33 +08:00

594 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'))}"