后端新增预算本体解析模块和风险规则评分回填服务,优化规则 生成本体对齐和提示词构建,增强费用类型关键词和本体验证, 完善报销查询和审计接口,前端预算中心页面增加对话框和本 体工具函数,重构审计页面元数据和视图模型,补充单元测试。
377 lines
10 KiB
Python
377 lines
10 KiB
Python
from __future__ import annotations
|
||
|
||
import re
|
||
from decimal import Decimal
|
||
|
||
from app.services.expense_type_keywords import iter_expense_keywords
|
||
|
||
EXPENSE_TYPE_LABELS = {
|
||
"travel": "差旅",
|
||
"train_ticket": "火车票",
|
||
"flight_ticket": "机票",
|
||
"hotel_ticket": "住宿票",
|
||
"ride_ticket": "乘车",
|
||
"travel_allowance": "出差补贴",
|
||
"hotel": "住宿",
|
||
"transport": "交通",
|
||
"meal": "业务招待",
|
||
"meeting": "会务",
|
||
"entertainment": "招待",
|
||
"marketing": "市场推广",
|
||
"office": "办公用品",
|
||
"training": "培训",
|
||
"software": "软件服务",
|
||
"communication": "通讯",
|
||
"welfare": "福利",
|
||
}
|
||
MAX_DRAFT_CLAIMS_PER_USER = 3
|
||
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
|
||
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
|
||
TRAVEL_DETAIL_ITEM_TYPES = {
|
||
"train_ticket",
|
||
"flight_ticket",
|
||
"hotel_ticket",
|
||
"ride_ticket",
|
||
"travel_allowance",
|
||
}
|
||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES = {"train_ticket", "flight_ticket"}
|
||
DOCUMENT_TYPE_ITEM_TYPE_MAP = {
|
||
"train_ticket": "train_ticket",
|
||
"flight_itinerary": "flight_ticket",
|
||
"hotel_invoice": "hotel_ticket",
|
||
"taxi_receipt": "ride_ticket",
|
||
"transport_receipt": "ride_ticket",
|
||
}
|
||
DOCUMENT_TYPE_SCENE_MAP = {
|
||
"train_ticket": "travel",
|
||
"flight_itinerary": "travel",
|
||
"hotel_invoice": "hotel",
|
||
"taxi_receipt": "transport",
|
||
"transport_receipt": "transport",
|
||
"parking_toll_receipt": "transport",
|
||
"meal_receipt": "meal",
|
||
"office_invoice": "office",
|
||
"meeting_invoice": "meeting",
|
||
"training_invoice": "training",
|
||
}
|
||
DOCUMENT_FACT_ITEM_TYPES = {
|
||
"train_ticket",
|
||
"flight_ticket",
|
||
"hotel_ticket",
|
||
"ride_ticket",
|
||
"ship_ticket",
|
||
"ferry_ticket",
|
||
}
|
||
ROUTE_DESCRIPTION_ITEM_TYPES = {
|
||
"train_ticket",
|
||
"flight_ticket",
|
||
"ship_ticket",
|
||
"ferry_ticket",
|
||
"ride_ticket",
|
||
}
|
||
DOCUMENT_TRIP_DATE_LABELS = {
|
||
"train_ticket": "列车出发时间",
|
||
"flight_itinerary": "起飞日期",
|
||
"taxi_receipt": "乘车时间",
|
||
"transport_receipt": "乘车时间",
|
||
"parking_toll_receipt": "通行日期",
|
||
}
|
||
DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS = {
|
||
"train_ticket": "列车出发时间或乘车日期",
|
||
"flight_itinerary": "起飞日期或航班日期",
|
||
"taxi_receipt": "乘车时间",
|
||
"transport_receipt": "乘车时间",
|
||
"parking_toll_receipt": "通行日期",
|
||
"hotel_invoice": "入住或离店日期",
|
||
}
|
||
DOCUMENT_TRIP_DATE_KEYS = {
|
||
"traveldate",
|
||
"tripdate",
|
||
"journeydate",
|
||
"departuredate",
|
||
"departuretime",
|
||
"departdate",
|
||
"departtime",
|
||
"boardingdate",
|
||
"boardingtime",
|
||
"traindate",
|
||
"traintime",
|
||
"traindeparturetime",
|
||
"scheduleddeparturetime",
|
||
"flightdate",
|
||
"flighttime",
|
||
"ridedate",
|
||
"ridetime",
|
||
"pickuptime",
|
||
"starttime",
|
||
}
|
||
DOCUMENT_GENERIC_DATE_KEYS = {"date", "time", "occurredat", "occurreddate", "businessdate"}
|
||
DOCUMENT_INVOICE_DATE_KEYS = {"issuedat", "issuedate", "invoicedate", "billingdate"}
|
||
DOCUMENT_TRIP_DATE_LABEL_TOKENS = (
|
||
"出发日期",
|
||
"出发时间",
|
||
"列车出发时间",
|
||
"发车日期",
|
||
"发车时间",
|
||
"开车时间",
|
||
"乘车日期",
|
||
"乘车时间",
|
||
"起飞日期",
|
||
"航班日期",
|
||
"行程日期",
|
||
"上车时间",
|
||
"用车时间",
|
||
"通行日期",
|
||
)
|
||
DOCUMENT_GENERIC_DATE_LABEL_TOKENS = ("日期", "时间", "发生时间", "业务发生日期")
|
||
DOCUMENT_INVOICE_DATE_LABEL_TOKENS = ("开票日期", "发票日期")
|
||
DOCUMENT_ROUTE_FORMAT_PATTERN = re.compile(
|
||
r"^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*"
|
||
r"[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$"
|
||
)
|
||
DOCUMENT_ROUTE_TEXT_PATTERN = re.compile(
|
||
r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})\s*(?:至|到|→|->|—|–|-)\s*"
|
||
r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})"
|
||
)
|
||
DOCUMENT_ROUTE_ORIGIN_LABELS = {
|
||
"起点",
|
||
"上车",
|
||
"上车地点",
|
||
"上车地址",
|
||
"出发",
|
||
"出发地",
|
||
"出发站",
|
||
"始发站",
|
||
"乘车起点",
|
||
}
|
||
DOCUMENT_ROUTE_DESTINATION_LABELS = {
|
||
"终点",
|
||
"下车",
|
||
"下车地点",
|
||
"下车地址",
|
||
"到达",
|
||
"到达地",
|
||
"到达站",
|
||
"目的地",
|
||
"乘车终点",
|
||
}
|
||
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES = {"", "other", "travel", "transport", "hotel"}
|
||
LOCATION_REQUIRED_EXPENSE_TYPES = {"travel", "meeting", "entertainment"}
|
||
EXPENSE_SCENE_KEYWORDS = {
|
||
code: tuple(iter_expense_keywords(code))
|
||
for code in (
|
||
"travel",
|
||
"hotel",
|
||
"transport",
|
||
"meal",
|
||
"entertainment",
|
||
"marketing",
|
||
"office",
|
||
"meeting",
|
||
"training",
|
||
"software",
|
||
"communication",
|
||
"welfare",
|
||
)
|
||
}
|
||
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
|
||
"travel": {"travel", "hotel", "transport", "meal"},
|
||
"train_ticket": {"travel"},
|
||
"flight_ticket": {"travel"},
|
||
"hotel_ticket": {"hotel"},
|
||
"ride_ticket": {"transport"},
|
||
"travel_allowance": set(),
|
||
"hotel": {"hotel"},
|
||
"transport": {"transport", "travel"},
|
||
"meal": {"meal", "entertainment"},
|
||
"entertainment": {"entertainment", "meal"},
|
||
"marketing": {"marketing"},
|
||
"office": {"office"},
|
||
"meeting": {"meeting"},
|
||
"training": {"training"},
|
||
"software": {"software"},
|
||
}
|
||
DOCUMENT_SCENE_LABELS = {
|
||
"travel": "差旅",
|
||
"hotel": "住宿",
|
||
"transport": "交通",
|
||
"meal": "业务招待",
|
||
"entertainment": "业务招待",
|
||
"marketing": "市场推广",
|
||
"office": "办公用品",
|
||
"meeting": "会务",
|
||
"training": "培训",
|
||
"software": "软件服务",
|
||
"other": "其他票据",
|
||
}
|
||
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = {
|
||
"link_to_existing_draft",
|
||
"create_new_claim_from_documents",
|
||
}
|
||
PERSISTENT_EXPENSE_REVIEW_ACTIONS = {
|
||
"save_draft",
|
||
"next_step",
|
||
*DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
|
||
}
|
||
RETURN_REASON_OPTIONS = {
|
||
"missing_attachment": "附件缺失或不清晰",
|
||
"invoice_mismatch": "票据类型/金额与明细不一致",
|
||
"over_policy": "超出制度标准或缺少超标说明",
|
||
"business_explanation": "业务事由/地点/人员信息不完整",
|
||
"duplicate_or_abnormal": "疑似重复或异常票据",
|
||
"approval_question": "审批人需要补充说明",
|
||
}
|
||
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
|
||
DOCUMENT_DATE_PATTERN = re.compile(
|
||
r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.]"
|
||
r"(?:3[01]|[12]\d|0?[1-9])日?)"
|
||
)
|
||
SYSTEM_GENERATED_REASON_PREFIXES = (
|
||
"我上传了",
|
||
"请按当前已识别信息",
|
||
"请把当前上传的票据",
|
||
"请基于当前上传的多张票据",
|
||
"我已核对右侧识别结果",
|
||
"请同步修正逐票据识别结果",
|
||
"我已修改识别信息",
|
||
"查看报销草稿",
|
||
"请解释一下当前这笔报销的合规风险和待补充项",
|
||
)
|
||
LEADING_REASON_TIME_PATTERNS = (
|
||
re.compile(
|
||
r"^\s*(?:识别事项(?:有)?[::]\s*)?"
|
||
r"(?:业务发生(?:时间|日期)|费用发生(?:时间|日期)|发生(?:时间|日期)|报销(?:时间|日期)|时间)[::]?\s*"
|
||
r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?"
|
||
r"(?:\s*(?:至|到|~|~|—|-)\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?)?"
|
||
r"\s*[,,。;;、]?\s*"
|
||
),
|
||
re.compile(
|
||
r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?"
|
||
r"(?:\s*(?:至|到|~|~|—|-)\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?)?"
|
||
r"\s*[,,。;;、]\s*"
|
||
),
|
||
)
|
||
AI_REVIEW_LOOKBACK_DAYS = 90
|
||
AI_REVIEW_REPEAT_RISK_WARNING_COUNT = 1
|
||
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT = 2
|
||
TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES = {"travel", "hotel", "transport"}
|
||
TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES = {"flight_itinerary", "train_ticket"}
|
||
TRAVEL_POLICY_CITY_TIERS = {
|
||
"北京": "tier_1",
|
||
"上海": "tier_1",
|
||
"广州": "tier_1",
|
||
"深圳": "tier_1",
|
||
"杭州": "tier_2",
|
||
"南京": "tier_2",
|
||
"苏州": "tier_2",
|
||
"武汉": "tier_2",
|
||
"成都": "tier_2",
|
||
"重庆": "tier_2",
|
||
"西安": "tier_2",
|
||
"天津": "tier_2",
|
||
"宁波": "tier_2",
|
||
"厦门": "tier_2",
|
||
"青岛": "tier_2",
|
||
"长沙": "tier_2",
|
||
"郑州": "tier_2",
|
||
"合肥": "tier_2",
|
||
"济南": "tier_2",
|
||
"沈阳": "tier_2",
|
||
"大连": "tier_2",
|
||
"福州": "tier_2",
|
||
"昆明": "tier_2",
|
||
"海口": "tier_2",
|
||
"三亚": "tier_2",
|
||
"无锡": "tier_2",
|
||
"东莞": "tier_2",
|
||
"佛山": "tier_2",
|
||
}
|
||
TRAVEL_POLICY_CITY_MATCH_ORDER = tuple(
|
||
sorted(TRAVEL_POLICY_CITY_TIERS.keys(), key=lambda item: len(item), reverse=True)
|
||
)
|
||
TRAVEL_POLICY_BAND_LABELS = {
|
||
"junior": "P1-P3",
|
||
"mid": "P4-P5",
|
||
"senior": "P6-P7",
|
||
"manager": "M1-M2",
|
||
"executive": "M3及以上 / D序列",
|
||
}
|
||
TRAVEL_POLICY_HOTEL_LIMITS = {
|
||
"junior": {
|
||
"tier_1": Decimal("450.00"),
|
||
"tier_2": Decimal("380.00"),
|
||
"tier_3": Decimal("320.00"),
|
||
},
|
||
"mid": {
|
||
"tier_1": Decimal("550.00"),
|
||
"tier_2": Decimal("480.00"),
|
||
"tier_3": Decimal("380.00"),
|
||
},
|
||
"senior": {
|
||
"tier_1": Decimal("700.00"),
|
||
"tier_2": Decimal("620.00"),
|
||
"tier_3": Decimal("520.00"),
|
||
},
|
||
"manager": {
|
||
"tier_1": Decimal("900.00"),
|
||
"tier_2": Decimal("820.00"),
|
||
"tier_3": Decimal("720.00"),
|
||
},
|
||
"executive": {
|
||
"tier_1": Decimal("1200.00"),
|
||
"tier_2": Decimal("1000.00"),
|
||
"tier_3": Decimal("900.00"),
|
||
},
|
||
}
|
||
TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS = {
|
||
"junior": {"flight": 1, "train": 1},
|
||
"mid": {"flight": 1, "train": 1},
|
||
"senior": {"flight": 2, "train": 2},
|
||
"manager": {"flight": 3, "train": 3},
|
||
"executive": {"flight": 4, "train": 3},
|
||
}
|
||
TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS = (
|
||
"中转",
|
||
"转机",
|
||
"经停",
|
||
"改签",
|
||
"多地出差",
|
||
"多城市",
|
||
"多站",
|
||
"异地返程",
|
||
"异地结束",
|
||
"临时变更",
|
||
"继续前往",
|
||
"第二站",
|
||
)
|
||
TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS = (
|
||
"超标说明",
|
||
"无直达",
|
||
"展会高峰",
|
||
"会议高峰",
|
||
"协议酒店满房",
|
||
"客户指定",
|
||
"临时改签",
|
||
"行程变更",
|
||
"红眼航班",
|
||
"晚到店",
|
||
)
|
||
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS = (
|
||
("头等舱", 4),
|
||
("公务舱", 3),
|
||
("商务舱", 3),
|
||
("超级经济舱", 2),
|
||
("高端经济舱", 2),
|
||
("明珠经济舱", 2),
|
||
("经济舱", 1),
|
||
)
|
||
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS = (
|
||
("商务座", 3),
|
||
("一等座", 2),
|
||
("软卧", 2),
|
||
("二等座", 1),
|
||
("二等卧", 1),
|
||
("硬卧", 1),
|
||
)
|
||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)")
|