Files
X-Financial/server/src/app/services/travel_reimbursement_calculator.py

594 lines
16 KiB
Python
Raw Normal View History

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'))}"