feat: 优化差旅报销预审流程与个人工作台 UI 体系

- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 14:01:51 +08:00
parent 92444e7eae
commit ca691f3ee0
107 changed files with 5663 additions and 1542 deletions

View File

@@ -4,7 +4,7 @@ import re
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from sqlalchemy import select
from sqlalchemy import or_, select
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
@@ -20,7 +20,10 @@ from app.services.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
)
from app.services.user_agent_application_dates import expand_application_time_with_days
from app.services.user_agent_application_dates import (
expand_application_time_with_days,
resolve_application_days_from_time_range,
)
from app.services.user_agent_application_locations import normalize_application_location
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
@@ -32,6 +35,43 @@ APPLICATION_CONTEXT_VALUES = {
"preapproval",
}
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
APPLICATION_FIELD_LABELS = (
"申请类型",
"费用类型",
"姓名",
"申请人",
"部门",
"岗位",
"职级",
"直属领导",
*APPLICATION_TIME_LABELS,
"地点",
"业务地点",
"发生地点",
"目的地",
"事由",
"申请事由",
"出差事由",
"原因",
"用途",
"天数",
"出差天数",
"申请天数",
"出行方式",
"交通方式",
"交通工具",
"出行工具",
"用户预估费用",
"预估费用",
"预计总费用",
"预计费用",
"预计金额",
"申请金额",
"预算",
"金额",
"费用",
)
APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船")
APPLICATION_TRANSPORT_KEYWORDS = {
"飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"),
@@ -64,6 +104,18 @@ APPLICATION_SUBMIT_KEYWORDS = (
"直接提交",
)
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "", "好的", "可以", "没问题"}
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "", "null", "none"}
APPLICATION_DUPLICATE_IGNORED_STATUSES = {
"cancelled",
"canceled",
"void",
"voided",
"deleted",
"已取消",
"已作废",
"作废",
"已删除",
}
class UserAgentApplicationMixin:
@@ -119,7 +171,12 @@ class UserAgentApplicationMixin:
step = self._resolve_expense_application_step(payload, facts)
application_claim = None
if step == "submitted":
application_claim = self._create_expense_application_record(payload, facts)
application_claim = self._find_duplicate_expense_application_record(payload, facts)
if application_claim is not None:
step = "duplicate"
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
else:
application_claim = self._create_expense_application_record(payload, facts)
facts["application_no"] = application_claim.claim_no
facts["application_claim_id"] = application_claim.id
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
@@ -128,7 +185,11 @@ class UserAgentApplicationMixin:
citations=[],
suggested_actions=self._build_expense_application_actions(step, facts),
query_payload=None,
draft_payload=self._build_submitted_application_payload(application_claim, facts),
draft_payload=(
self._build_submitted_application_payload(application_claim, facts)
if step == "submitted"
else None
),
review_payload=None,
risk_flags=risk_flags,
requires_confirmation=step == "preview",
@@ -170,6 +231,19 @@ class UserAgentApplicationMixin:
]
)
if step == "duplicate":
application_no = str(facts.get("application_no") or "").strip()
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
time_label = self._resolve_application_time_label(facts)
return "\n\n".join(
[
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
f"已有申请单号:{application_no}",
f"当前节点:{stage}",
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
]
)
return "\n\n".join(
[
"这是费用申请核对结果,请核对:",
@@ -225,13 +299,27 @@ class UserAgentApplicationMixin:
facts[key] = value
context_json = payload.context_json or {}
current_user = getattr(payload, "current_user", None)
context_time = self._resolve_application_time_from_context(context_json)
if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
facts["time"] = context_time
current_user = self._build_application_current_user(payload)
employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
if not facts["applicant"]:
facts["applicant"] = str(
context_json.get("name")
or context_json.get("user_name")
or context_json.get("applicant")
or getattr(current_user, "name", "")
or (employee.name if employee is not None else "")
or current_user.name
or ""
).strip()
if not facts["grade"]:
facts["grade"] = str(
context_json.get("grade")
or context_json.get("employee_grade")
or context_json.get("employeeGrade")
or current_user.grade
or (employee.grade if employee is not None else "")
or ""
).strip()
if not facts["department"]:
@@ -239,7 +327,12 @@ class UserAgentApplicationMixin:
context_json.get("department")
or context_json.get("department_name")
or context_json.get("departmentName")
or getattr(current_user, "department_name", "")
or current_user.department_name
or (
employee.organization_unit.name
if employee is not None and employee.organization_unit is not None
else ""
)
or ""
).strip()
if not facts["position"]:
@@ -247,6 +340,8 @@ class UserAgentApplicationMixin:
context_json.get("position")
or context_json.get("employee_position")
or context_json.get("employeePosition")
or current_user.position
or (employee.position if employee is not None else "")
or ""
).strip()
if not facts["manager_name"]:
@@ -255,7 +350,17 @@ class UserAgentApplicationMixin:
or context_json.get("managerName")
or context_json.get("direct_manager_name")
or context_json.get("directManagerName")
or getattr(current_user, "manager_name", "")
or current_user.manager_name
or (
employee.manager.name
if employee is not None and employee.manager is not None
else ""
)
or (
employee.organization_unit.manager_name
if employee is not None and employee.organization_unit is not None
else ""
)
or ""
).strip()
@@ -266,6 +371,10 @@ class UserAgentApplicationMixin:
facts.get("days", ""),
payload.context_json or {},
)
if self._is_application_missing_value(facts.get("days", "")):
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
if range_days:
facts["days"] = f"{range_days}"
apply_application_system_estimate_to_facts(facts)
return facts
@@ -285,11 +394,12 @@ class UserAgentApplicationMixin:
return value
return ""
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
return {
"application_type": pick("applicationType", "application_type"),
"time": pick("time", "timeRange", "time_range"),
"location": pick("location"),
"reason": pick("reason"),
"reason": reason,
"days": pick("days"),
"transport_mode": pick("transportMode", "transport_mode"),
"amount": pick("amount"),
@@ -313,6 +423,10 @@ class UserAgentApplicationMixin:
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
}
@staticmethod
def _is_application_missing_value(value: object) -> bool:
return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES
def _resolve_expense_application_step(
self,
payload: UserAgentRequest,
@@ -384,10 +498,16 @@ class UserAgentApplicationMixin:
def _resolve_application_time_from_text(message: str) -> str:
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
message,
("发生时间", "业务发生时间", "申请时间", "时间"),
APPLICATION_TIME_LABELS,
)
if labeled:
return labeled
range_match = re.search(
r"(?P<start>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)\s*(?:至|到|~|—||--)\s*(?P<end>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
str(message or ""),
)
if range_match:
return f"{range_match.group('start').rstrip('')}{range_match.group('end').rstrip('')}"
match = re.search(
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
str(message or ""),
@@ -406,11 +526,26 @@ class UserAgentApplicationMixin:
return start_date if start_date == end_date else f"{start_date}{end_date}"
return display_value
@staticmethod
def _should_prefer_context_application_time(current_time: str, context_time: str) -> bool:
current = str(current_time or "").strip()
context = str(context_time or "").strip()
if not context:
return False
if not current:
return True
if "" not in context:
return False
current_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", current)
context_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", context)
return len(current_dates) <= 1 and len(context_dates) >= 2 and current_dates[:1] == context_dates[:1]
@staticmethod
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
label_pattern = "|".join(re.escape(label) for label in labels)
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
match = re.search(
rf"(?:{label_pattern})[:]\s*(?P<value>[^\n;]+)",
rf"(?:{label_pattern})[:]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[:]|[\n;]|$)",
str(message or ""),
)
return match.group("value").strip() if match else ""
@@ -478,7 +613,7 @@ class UserAgentApplicationMixin:
("事由", "申请事由", "出差事由", "原因", "用途"),
)
if labeled:
return labeled
return UserAgentApplicationMixin._cleanup_application_reason_candidate(labeled)
text = str(message or "").strip()
if not text:
@@ -492,7 +627,15 @@ class UserAgentApplicationMixin:
if not candidates:
return ""
return max(candidates, key=len)
business_candidate = next(
(
candidate
for candidate in candidates
if any(keyword in candidate for keyword in APPLICATION_REASON_VERBS)
),
"",
)
return business_candidate or max(candidates, key=len)
@staticmethod
def _cleanup_application_reason_candidate(segment: str) -> str:
@@ -501,10 +644,12 @@ class UserAgentApplicationMixin:
return ""
text = re.sub(
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)[:]\s*",
r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[:]\s*",
"",
text,
)
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s*(?:至|到|~|—||--)\s*20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
return ""
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
return ""
if re.fullmatch(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", text):
@@ -617,8 +762,8 @@ class UserAgentApplicationMixin:
return {
"expense_type": "申请类型",
"amount": "系统预估费用",
"time_range": "发生时间",
"time": "发生时间",
"time_range": "申请时间",
"time": "申请时间",
"location": "地点",
"reason": "申请事由",
"days": "天数",
@@ -656,7 +801,7 @@ class UserAgentApplicationMixin:
@staticmethod
def _resolve_application_prefill_config(field: str) -> tuple[str, str]:
config = {
"time": ("补充发生时间", "申请时间段:"),
"time": ("补充申请时间", "申请时间段:"),
"location": ("补充地点", "地点:"),
"reason": ("补充申请事由", "事由:"),
"days": ("补充天数", "天数:"),
@@ -699,7 +844,17 @@ class UserAgentApplicationMixin:
return "差旅费用申请"
@staticmethod
def _build_application_summary(facts: dict[str, str]) -> str:
def _resolve_application_time_label(facts: dict[str, str]) -> str:
application_type = str(facts.get("application_type") or "").strip()
if "差旅" in application_type or "出差" in application_type:
return "行程时间"
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
return "招待时间"
return "申请时间"
@classmethod
def _build_application_summary(cls, facts: dict[str, str]) -> str:
time_label = cls._resolve_application_time_label(facts)
return "\n".join(
f"{label}{value or '待补充'}"
for label, value in (
@@ -709,7 +864,7 @@ class UserAgentApplicationMixin:
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
("发生时间", facts.get("time", "")),
(time_label, facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
@@ -722,12 +877,14 @@ class UserAgentApplicationMixin:
)
)
@staticmethod
@classmethod
def _build_application_summary_table(
cls,
facts: dict[str, str],
*,
include_empty: bool = True,
) -> str:
time_label = cls._resolve_application_time_label(facts)
rows = [
("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")),
@@ -735,7 +892,7 @@ class UserAgentApplicationMixin:
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
("发生时间", facts.get("time", "")),
(time_label, facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
@@ -816,6 +973,90 @@ class UserAgentApplicationMixin:
self.db.refresh(claim)
return claim
def _find_duplicate_expense_application_record(
self,
payload: UserAgentRequest,
facts: dict[str, str],
) -> ExpenseClaim | None:
current_user = self._build_application_current_user(payload)
access_policy = ExpenseClaimAccessPolicy(self.db)
employee = access_policy.resolve_current_employee(current_user)
employee_id = employee.id if employee is not None else None
employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip()
if employee is not None:
employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip()
employee_filter = ExpenseClaim.employee_name == employee_name
if employee_id is not None:
employee_filter = or_(ExpenseClaim.employee_id == employee_id, employee_filter)
stmt = (
select(ExpenseClaim)
.where(
ExpenseClaim.expense_type == self._resolve_application_expense_type_code(facts),
employee_filter,
)
.order_by(ExpenseClaim.id.desc())
.limit(100)
)
occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
for claim in self.db.scalars(stmt).all():
if self._is_ignored_application_duplicate_status(claim.status):
continue
if self._matches_application_business_time(claim, facts, occurred_at):
return claim
return None
@staticmethod
def _is_ignored_application_duplicate_status(status: str | None) -> bool:
return str(status or "").strip().lower() in APPLICATION_DUPLICATE_IGNORED_STATUSES
@classmethod
def _matches_application_business_time(
cls,
claim: ExpenseClaim,
facts: dict[str, str],
occurred_at: datetime,
) -> bool:
current_time = cls._normalize_application_time_identity(facts.get("time"))
existing_detail = cls._extract_application_detail_from_claim(claim)
existing_time = cls._normalize_application_time_identity(existing_detail.get("time"))
if current_time and existing_time:
return current_time == existing_time
if claim.occurred_at is None:
return False
return claim.occurred_at.date() == occurred_at.date()
@staticmethod
def _normalize_application_time_identity(value: object) -> str:
normalized = str(value or "").strip()
if not normalized:
return ""
normalized = (
normalized.replace("", "")
.replace("~", "")
.replace("", "")
.replace("", "")
.replace("", "")
.replace("/", "-")
)
return re.sub(r"\s+", "", normalized)
@staticmethod
def _extract_application_detail_from_claim(claim: ExpenseClaim) -> dict[str, object]:
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
return {}
for item in flags:
if not isinstance(item, dict):
continue
detail = item.get("application_detail")
if isinstance(detail, dict):
return detail
return {}
@staticmethod
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
return with_risk_business_stage(
@@ -895,6 +1136,24 @@ class UserAgentApplicationMixin:
or context_json.get("departmentName")
or ""
).strip(),
cost_center=str(context_json.get("cost_center") or context_json.get("costCenter") or "").strip(),
position=str(
context_json.get("position")
or context_json.get("employee_position")
or context_json.get("employeePosition")
or ""
).strip(),
grade=str(
context_json.get("grade")
or context_json.get("employee_grade")
or context_json.get("employeeGrade")
or ""
).strip(),
employee_no=str(
context_json.get("employee_no")
or context_json.get("employeeNo")
or ""
).strip(),
manager_name=str(
context_json.get("manager_name")
or context_json.get("managerName")