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