Refine travel reimbursement steward flow
Align planner, runtime rules, and policy assets so travel guidance matches the updated reimbursement workflow.
This commit is contained in:
@@ -20,7 +20,9 @@ from app.schemas.steward import (
|
||||
StewardSlotDecisionResponse,
|
||||
StewardThinkingEvent,
|
||||
)
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.steward_flow_state import StewardFlowStateService
|
||||
from app.services.steward_intent_agent import StewardIntentAgent
|
||||
from app.services.steward_planner import StewardPlannerService
|
||||
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
|
||||
@@ -44,7 +46,8 @@ DbSession = Annotated[Session, Depends(get_db)]
|
||||
)
|
||||
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
|
||||
try:
|
||||
return _build_steward_planner(db).build_plan(payload)
|
||||
plan = _build_steward_planner(db).build_plan(payload)
|
||||
return _attach_conversation_state(db, payload, plan)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
@@ -72,7 +75,9 @@ def create_steward_runtime_decision(
|
||||
payload: StewardRuntimeDecisionRequest,
|
||||
db: DbSession,
|
||||
) -> StewardRuntimeDecisionResponse:
|
||||
return StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(payload)
|
||||
hydrated_payload = _hydrate_runtime_decision_payload(db, payload)
|
||||
decision = StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(hydrated_payload)
|
||||
return _attach_runtime_conversation_state(db, hydrated_payload, decision)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -82,7 +87,7 @@ def create_steward_runtime_decision(
|
||||
)
|
||||
async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse:
|
||||
return StreamingResponse(
|
||||
_iter_steward_plan_events(payload, _build_steward_planner(db)),
|
||||
_iter_steward_plan_events(payload, _build_steward_planner(db), db),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
@@ -90,6 +95,7 @@ async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> Str
|
||||
async def _iter_steward_plan_events(
|
||||
payload: StewardPlanRequest,
|
||||
planner: StewardPlannerService,
|
||||
db: Session,
|
||||
) -> AsyncIterator[str]:
|
||||
yield _encode_stream_event(
|
||||
"thinking",
|
||||
@@ -105,6 +111,7 @@ async def _iter_steward_plan_events(
|
||||
|
||||
try:
|
||||
plan = planner.build_plan(payload)
|
||||
plan = _attach_conversation_state(db, payload, plan)
|
||||
except ValueError as exc:
|
||||
yield _encode_stream_event("error", {"message": str(exc)})
|
||||
return
|
||||
@@ -124,3 +131,131 @@ def _build_steward_planner(db: Session) -> StewardPlannerService:
|
||||
return StewardPlannerService(
|
||||
intent_agent=StewardIntentAgent(RuntimeChatService(db)),
|
||||
)
|
||||
|
||||
|
||||
def _attach_conversation_state(
|
||||
db: Session,
|
||||
payload: StewardPlanRequest,
|
||||
plan: StewardPlanResponse,
|
||||
) -> StewardPlanResponse:
|
||||
context_json = dict(payload.context_json or {})
|
||||
context_json["session_type"] = str(context_json.get("session_type") or "steward").strip() or "steward"
|
||||
conversation_service = AgentConversationService(db)
|
||||
conversation = conversation_service.get_or_create_conversation(
|
||||
conversation_id=_resolve_conversation_id(context_json),
|
||||
user_id=payload.user_id,
|
||||
source="user_message",
|
||||
context_json=context_json,
|
||||
)
|
||||
current_state = _resolve_current_steward_state(conversation.state_json, context_json)
|
||||
steward_state = StewardFlowStateService().merge_plan(current_state, plan)
|
||||
conversation = conversation_service.update_state(
|
||||
conversation_id=conversation.conversation_id,
|
||||
run_id=None,
|
||||
scenario="steward",
|
||||
intent="plan",
|
||||
context_json={
|
||||
**context_json,
|
||||
"steward_state": steward_state,
|
||||
},
|
||||
) or conversation
|
||||
conversation_service.append_message(
|
||||
conversation_id=conversation.conversation_id,
|
||||
role="user",
|
||||
content=payload.message,
|
||||
message_json={"source": "steward_plan_request"},
|
||||
)
|
||||
conversation_service.append_message(
|
||||
conversation_id=conversation.conversation_id,
|
||||
role="assistant",
|
||||
content=plan.summary,
|
||||
message_json={
|
||||
"source": "steward_plan_response",
|
||||
"plan_id": plan.plan_id,
|
||||
"steward_state": steward_state,
|
||||
},
|
||||
)
|
||||
return plan.model_copy(
|
||||
update={
|
||||
"conversation_id": conversation.conversation_id,
|
||||
"steward_state": steward_state,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _attach_runtime_conversation_state(
|
||||
db: Session,
|
||||
payload: StewardRuntimeDecisionRequest,
|
||||
decision: StewardRuntimeDecisionResponse,
|
||||
) -> StewardRuntimeDecisionResponse:
|
||||
steward_state = decision.steward_state
|
||||
if not isinstance(steward_state, dict) or not steward_state:
|
||||
return decision
|
||||
context_json = dict(payload.context_json or {})
|
||||
conversation_id = _resolve_conversation_id(context_json)
|
||||
if not conversation_id:
|
||||
return decision
|
||||
|
||||
conversation_service = AgentConversationService(db)
|
||||
conversation_service.update_state(
|
||||
conversation_id=conversation_id,
|
||||
run_id=None,
|
||||
scenario="steward",
|
||||
intent="runtime_decision",
|
||||
context_json={
|
||||
**context_json,
|
||||
"steward_state": steward_state,
|
||||
},
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
def _hydrate_runtime_decision_payload(
|
||||
db: Session,
|
||||
payload: StewardRuntimeDecisionRequest,
|
||||
) -> StewardRuntimeDecisionRequest:
|
||||
context_json = dict(payload.context_json or {})
|
||||
runtime_state = dict(payload.runtime_state or {})
|
||||
if isinstance(runtime_state.get("steward_state"), dict) and runtime_state["steward_state"]:
|
||||
return payload
|
||||
if isinstance(context_json.get("steward_state"), dict) and context_json["steward_state"]:
|
||||
return payload
|
||||
|
||||
conversation_id = _resolve_conversation_id(context_json)
|
||||
if not conversation_id:
|
||||
return payload
|
||||
conversation = AgentConversationService(db).get_conversation(conversation_id)
|
||||
stored_state = conversation.state_json.get("steward_state") if conversation and isinstance(conversation.state_json, dict) else None
|
||||
if not isinstance(stored_state, dict) or not stored_state:
|
||||
return payload
|
||||
|
||||
runtime_state["steward_state"] = stored_state
|
||||
conversation_state = dict(context_json.get("conversation_state") or {})
|
||||
conversation_state["steward_state"] = stored_state
|
||||
context_json["conversation_state"] = conversation_state
|
||||
return payload.model_copy(
|
||||
update={
|
||||
"runtime_state": runtime_state,
|
||||
"context_json": context_json,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_conversation_id(context_json: dict[str, Any]) -> str | None:
|
||||
return str(
|
||||
context_json.get("conversation_id")
|
||||
or context_json.get("conversationId")
|
||||
or ""
|
||||
).strip() or None
|
||||
|
||||
|
||||
def _resolve_current_steward_state(
|
||||
conversation_state: dict[str, Any] | None,
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
state_json = conversation_state if isinstance(conversation_state, dict) else {}
|
||||
stored_state = state_json.get("steward_state")
|
||||
if isinstance(stored_state, dict) and stored_state:
|
||||
return stored_state
|
||||
incoming_state = context_json.get("steward_state") or context_json.get("stewardState")
|
||||
return incoming_state if isinstance(incoming_state, dict) else {}
|
||||
|
||||
@@ -198,6 +198,9 @@ class TravelReimbursementCalculatorRequest(BaseModel):
|
||||
days: int = Field(ge=1, le=365)
|
||||
location: str = Field(min_length=1, max_length=120)
|
||||
grade: str | None = Field(default=None, max_length=30)
|
||||
transport_mode: str | None = Field(default=None, max_length=30)
|
||||
origin_location: str | None = Field(default=None, max_length=120)
|
||||
travel_date: date | None = None
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorResponse(BaseModel):
|
||||
@@ -215,6 +218,17 @@ class TravelReimbursementCalculatorResponse(BaseModel):
|
||||
basic_allowance_rate: Decimal
|
||||
total_allowance_rate: Decimal
|
||||
allowance_amount: Decimal
|
||||
transport_mode: str = ""
|
||||
transport_origin: str = ""
|
||||
transport_destination: str = ""
|
||||
transport_estimated_amount: Decimal = Decimal("0.00")
|
||||
transport_estimate_basis: str = ""
|
||||
transport_estimate_confidence: str = ""
|
||||
transport_estimate_source: str = ""
|
||||
transport_estimate_rule_code: str = ""
|
||||
transport_estimate_rule_name: str = ""
|
||||
transport_estimate_rule_version: str = ""
|
||||
travel_date: date | None = None
|
||||
total_amount: Decimal
|
||||
rule_name: str
|
||||
rule_version: str
|
||||
|
||||
@@ -8,11 +8,13 @@ from pydantic import BaseModel, Field
|
||||
StewardTaskType = Literal["expense_application", "reimbursement"]
|
||||
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
|
||||
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
|
||||
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardSlotNextAction = Literal["ask_user", "render_preview"]
|
||||
StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"]
|
||||
StewardRuntimeNextAction = Literal[
|
||||
"plan_new_tasks",
|
||||
"continue_selected_flow",
|
||||
"submit_current_application",
|
||||
"continue_next_task",
|
||||
"fill_current_slot",
|
||||
@@ -29,6 +31,8 @@ StewardTaskStatus = Literal[
|
||||
"blocked",
|
||||
]
|
||||
StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"]
|
||||
StewardFlowId = Literal["travel_application", "travel_reimbursement"]
|
||||
StewardPendingFlowStatus = Literal["none", "pending", "confirmed", "rejected"]
|
||||
|
||||
|
||||
class StewardAttachmentInput(BaseModel):
|
||||
@@ -90,15 +94,39 @@ class StewardConfirmationAction(BaseModel):
|
||||
payload: dict[str, Any] = Field(default_factory=dict, description="确认后继续执行所需载荷。")
|
||||
|
||||
|
||||
class StewardCandidateFlow(BaseModel):
|
||||
flow_id: StewardFlowId = Field(description="候选业务流程。")
|
||||
label: str = Field(description="用户可见候选流程名称。")
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="候选流程置信度。")
|
||||
reason: str = Field(default="", description="候选流程依据。")
|
||||
ontology_fields: dict[str, str] = Field(default_factory=dict, description="候选流程可继承的 canonical ontology 字段。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="候选流程仍缺失的 canonical ontology 字段。")
|
||||
|
||||
|
||||
class StewardPendingFlowConfirmation(BaseModel):
|
||||
status: StewardPendingFlowStatus = Field(default="none", description="候选流程确认状态。")
|
||||
source_message: str = Field(default="", description="触发候选流程确认的用户原始输入。")
|
||||
reason: str = Field(default="", description="需要确认流程方向的原因。")
|
||||
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="候选业务流程。")
|
||||
|
||||
|
||||
class StewardPlanResponse(BaseModel):
|
||||
plan_id: str = Field(description="小财管家计划 ID。")
|
||||
plan_status: str = Field(default="needs_confirmation", description="计划状态。")
|
||||
planning_source: StewardPlanningSource = Field(default="rule_fallback", description="计划生成来源。")
|
||||
next_action: StewardPlanNextAction = Field(default="confirm_task", description="计划完成后的下一步动作。")
|
||||
conversation_id: str = Field(default="", description="持久化会话 ID。")
|
||||
steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家跨轮业务状态。")
|
||||
summary: str = Field(description="计划摘要。")
|
||||
thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。")
|
||||
tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。")
|
||||
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
|
||||
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
|
||||
pending_flow_confirmation: StewardPendingFlowConfirmation = Field(
|
||||
default_factory=StewardPendingFlowConfirmation,
|
||||
description="申请/报销流程不明确时等待用户确认的候选流程。",
|
||||
)
|
||||
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="等待用户确认的候选流程快捷列表。")
|
||||
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
|
||||
|
||||
|
||||
@@ -146,4 +174,18 @@ class StewardRuntimeDecisionResponse(BaseModel):
|
||||
question: str = Field(default="", description="需要追问用户时展示的问题。")
|
||||
response_text: str = Field(default="", description="无需调用工具时给用户的简短回复。")
|
||||
rationale: str = Field(default="", description="面向用户的简短判断依据,不暴露推理链。")
|
||||
steward_state: dict[str, Any] = Field(default_factory=dict, description="小财管家更新后的跨轮业务状态。")
|
||||
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
|
||||
|
||||
|
||||
class StewardFlowStatePatch(BaseModel):
|
||||
active_flow: StewardFlowId = Field(description="本轮对话正在推进的业务流程。")
|
||||
flow_id: StewardFlowId = Field(description="需要合并字段的目标业务流程。")
|
||||
intent: str = Field(default="", description="本轮识别出的业务意图。")
|
||||
status: str = Field(default="collecting", description="流程状态。")
|
||||
fields: dict[str, Any] = Field(default_factory=dict, description="待写入流程的本体字段 patch。")
|
||||
missing_fields: list[str] = Field(default_factory=list, description="仍缺失的 canonical ontology 字段。")
|
||||
application_claim_id: str = Field(default="", description="出差申请流程已生成的申请单 ID。")
|
||||
linked_application_claim_id: str = Field(default="", description="报销流程关联的申请单 ID。")
|
||||
attachments: list[dict[str, Any]] = Field(default_factory=list, description="流程关联附件摘要。")
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list, description="字段来源证据。")
|
||||
|
||||
62
server/src/app/services/agent_asset_finance_spreadsheets.py
Normal file
62
server/src/app/services/agent_asset_finance_spreadsheets.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.agent_asset_travel_spreadsheets import build_styled_workbook
|
||||
|
||||
|
||||
def build_communication_expense_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
"通信费报销标准",
|
||||
[
|
||||
"序号",
|
||||
"适用对象",
|
||||
"岗位/职级范围",
|
||||
"月度报销上限",
|
||||
"票据要求",
|
||||
"申请阶段预算口径",
|
||||
"审批/例外说明",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
[
|
||||
1,
|
||||
"一线销售/客户成功",
|
||||
"销售经理、客户成功经理、项目驻场岗位",
|
||||
200,
|
||||
"运营商通信费发票或电子账单",
|
||||
"按月度上限占用预算",
|
||||
"超出上限需直属领导审批并说明客户项目",
|
||||
"仅覆盖因公通信支出",
|
||||
],
|
||||
[
|
||||
2,
|
||||
"项目交付/实施",
|
||||
"实施顾问、项目经理、现场支持岗位",
|
||||
150,
|
||||
"运营商通信费发票或电子账单",
|
||||
"按月度上限占用预算",
|
||||
"长期驻场可按项目专项审批调整",
|
||||
"需关联项目或客户",
|
||||
],
|
||||
[
|
||||
3,
|
||||
"管理岗位",
|
||||
"部门负责人及以上",
|
||||
120,
|
||||
"运营商通信费发票或电子账单",
|
||||
"按月度上限占用预算",
|
||||
"超出上限需补充业务说明",
|
||||
"按自然月核算",
|
||||
],
|
||||
[
|
||||
4,
|
||||
"普通员工",
|
||||
"未单列岗位",
|
||||
80,
|
||||
"运营商通信费发票或电子账单",
|
||||
"按月度上限占用预算",
|
||||
"原则上不支持超额报销",
|
||||
"特殊岗位需先维护适用对象",
|
||||
],
|
||||
],
|
||||
column_widths=[8, 22, 30, 16, 30, 24, 38, 28],
|
||||
)
|
||||
@@ -14,6 +14,16 @@ from zipfile import ZIP_DEFLATED, ZipFile
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.core.config import SERVER_DIR, get_settings
|
||||
from app.services.agent_asset_finance_spreadsheets import build_communication_expense_workbook
|
||||
from app.services.agent_asset_travel_spreadsheets import (
|
||||
build_travel_allowance_workbook,
|
||||
build_travel_grade_mapping_workbook,
|
||||
build_travel_lodging_workbook_from_source,
|
||||
build_travel_season_mapping_workbook,
|
||||
build_travel_transport_class_workbook,
|
||||
build_travel_transport_estimate_workbook,
|
||||
build_xlsx_bytes_from_source_sheet,
|
||||
)
|
||||
|
||||
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
||||
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
|
||||
@@ -21,11 +31,29 @@ RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "差旅住宿费标准.xlsx"
|
||||
COMPANY_TRAVEL_SOURCE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE = "rule.expense.company_travel_allowance_reimbursement"
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME = "出差补助标准.xlsx"
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_CODE = "rule.expense.company_travel_transport_class"
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME = "交通工具等级标准.xlsx"
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE = "rule.expense.company_travel_transport_estimate"
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME = "交通费用预估表.xlsx"
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE = "rule.expense.company_travel_grade_mapping"
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME = "差旅职级映射表.xlsx"
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE = "rule.expense.company_travel_season_mapping"
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME = "地区淡旺季映射表.xlsx"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE = "rule.expense.company_communication_expense_reimbursement"
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME = "公司通信费报销规则.xlsx"
|
||||
COMPANY_PREAPPROVAL_RULE_CODE = "rule.expense.company_preapproval_requirement"
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME = "公司费用申请审批规则.xlsx"
|
||||
TRAVEL_SPREADSHEET_RULE_CODES = {
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
}
|
||||
FINANCE_RULES_LIBRARY = "finance-rules"
|
||||
RISK_RULES_LIBRARY = "risk-rules"
|
||||
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
||||
@@ -284,65 +312,79 @@ class AgentAssetSpreadsheetManager:
|
||||
|
||||
@staticmethod
|
||||
def build_company_travel_rule_template() -> bytes:
|
||||
standard_rows = [
|
||||
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
||||
[
|
||||
"长途交通",
|
||||
"飞机、高铁、火车等跨城出行",
|
||||
"行程单、车票、发票",
|
||||
"据实报销",
|
||||
"超预算需直属领导审批",
|
||||
"优先选择公共交通",
|
||||
],
|
||||
[
|
||||
"住宿费",
|
||||
"出差住宿",
|
||||
"酒店发票、入住清单",
|
||||
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
|
||||
"超标需总监审批",
|
||||
"协议酒店优先",
|
||||
],
|
||||
[
|
||||
"市内交通",
|
||||
"出租车、网约车、地铁、公交",
|
||||
"发票或电子行程单",
|
||||
"150/天",
|
||||
"超限需补充说明",
|
||||
"夜间或无公共交通场景可豁免",
|
||||
],
|
||||
[
|
||||
"餐补",
|
||||
"出差期间日常补助",
|
||||
"无需票据",
|
||||
"120/天",
|
||||
"系统自动核定",
|
||||
"当天往返默认不享受",
|
||||
],
|
||||
[
|
||||
"招待餐费",
|
||||
"客户接待或项目宴请",
|
||||
"餐饮发票、参与人清单",
|
||||
"300/人",
|
||||
"需业务负责人审批",
|
||||
"需关联客户或项目",
|
||||
],
|
||||
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
|
||||
|
||||
@staticmethod
|
||||
def build_travel_lodging_rule_template() -> bytes:
|
||||
lodging_rows = [
|
||||
["地区(城市)", "城市级别", "P0", "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "备注"],
|
||||
["北京", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"],
|
||||
["上海", "一线城市", 450, 450, 450, 450, 450, 450, 450, 500, 500, "中心城区按本标准执行"],
|
||||
["广州", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "广交会期间可按例外流程说明"],
|
||||
["深圳", "一线城市", 430, 430, 430, 430, 450, 450, 450, 500, 500, "旺季需补充超标说明"],
|
||||
["杭州", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
|
||||
["南京", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
|
||||
["成都", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
|
||||
["武汉", "二线城市", 380, 380, 380, 380, 430, 430, 430, 480, 480, ""],
|
||||
["其他地区", "其他地区", 320, 320, 320, 320, 380, 380, 380, 450, 450, "未单列城市按其他地区执行"],
|
||||
]
|
||||
instruction_rows = [
|
||||
["字段", "填写说明"],
|
||||
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
|
||||
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
|
||||
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
|
||||
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
|
||||
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
|
||||
["备注", "记录豁免条件、灰度口径或制度来源。"],
|
||||
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
|
||||
]
|
||||
return _build_xlsx_bytes(
|
||||
[
|
||||
("差旅报销标准", standard_rows),
|
||||
("填表说明", instruction_rows),
|
||||
]
|
||||
source_path = (
|
||||
SERVER_DIR
|
||||
/ "rules"
|
||||
/ FINANCE_RULES_LIBRARY
|
||||
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
|
||||
)
|
||||
return build_travel_lodging_workbook_from_source(source_path, lodging_rows)
|
||||
|
||||
@staticmethod
|
||||
def build_travel_allowance_rule_template() -> bytes:
|
||||
return build_travel_allowance_workbook()
|
||||
|
||||
@staticmethod
|
||||
def build_travel_transport_rule_template() -> bytes:
|
||||
return build_travel_transport_class_workbook()
|
||||
|
||||
@staticmethod
|
||||
def build_travel_grade_mapping_template() -> bytes:
|
||||
return build_travel_grade_mapping_workbook()
|
||||
|
||||
@staticmethod
|
||||
def build_travel_season_mapping_template() -> bytes:
|
||||
source_path = (
|
||||
SERVER_DIR
|
||||
/ "rules"
|
||||
/ FINANCE_RULES_LIBRARY
|
||||
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
|
||||
)
|
||||
return build_travel_season_mapping_workbook(source_path)
|
||||
|
||||
@staticmethod
|
||||
def build_travel_transport_estimate_rule_template() -> bytes:
|
||||
return build_travel_transport_estimate_workbook()
|
||||
|
||||
@staticmethod
|
||||
def build_company_communication_rule_template() -> bytes:
|
||||
return build_communication_expense_workbook()
|
||||
|
||||
@staticmethod
|
||||
def _build_travel_source_sheet(
|
||||
sheet_name: str,
|
||||
*,
|
||||
fallback_rows: list[list[object]],
|
||||
) -> bytes:
|
||||
source_path = (
|
||||
SERVER_DIR
|
||||
/ "rules"
|
||||
/ FINANCE_RULES_LIBRARY
|
||||
/ COMPANY_TRAVEL_SOURCE_RULE_FILENAME
|
||||
)
|
||||
if source_path.exists():
|
||||
try:
|
||||
return build_xlsx_bytes_from_source_sheet(source_path, sheet_name)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
return _build_xlsx_bytes([(sheet_name, fallback_rows)])
|
||||
|
||||
@staticmethod
|
||||
def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
||||
@@ -350,7 +392,17 @@ class AgentAssetSpreadsheetManager:
|
||||
|
||||
@staticmethod
|
||||
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
|
||||
return _build_xlsx_bytes([(sheet_name, [[""]])])
|
||||
return _build_xlsx_bytes(
|
||||
[
|
||||
(
|
||||
sheet_name,
|
||||
[
|
||||
["规则项", "适用条件", "标准/阈值", "所需材料", "审批要求", "备注"],
|
||||
["", "", "", "", "", ""],
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def rebuild_from_uploaded_content(content: bytes) -> bytes:
|
||||
@@ -360,23 +412,20 @@ class AgentAssetSpreadsheetManager:
|
||||
try:
|
||||
workbook = load_workbook(
|
||||
filename=BytesIO(content),
|
||||
read_only=True,
|
||||
read_only=False,
|
||||
data_only=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError("无法解析上传的 Excel 表格。") from exc
|
||||
|
||||
sheets: list[tuple[str, list[list[object]]]] = []
|
||||
for worksheet in workbook.worksheets:
|
||||
rows = [
|
||||
list(row)
|
||||
for row in worksheet.iter_rows(values_only=True)
|
||||
]
|
||||
sheets.append((worksheet.title, _trim_empty_table(rows)))
|
||||
|
||||
if not sheets:
|
||||
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
|
||||
return _build_xlsx_bytes(sheets)
|
||||
try:
|
||||
if not workbook.worksheets:
|
||||
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
|
||||
rebuilt_buffer = BytesIO()
|
||||
workbook.save(rebuilt_buffer)
|
||||
return rebuilt_buffer.getvalue()
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
|
||||
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
||||
@@ -544,7 +593,7 @@ def _build_styles_xml() -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||||
'<fonts count="1"><font><sz val="13"/><name val="Microsoft YaHei"/></font></fonts>'
|
||||
'<fills count="2"><fill><patternFill patternType="none"/></fill>'
|
||||
'<fill><patternFill patternType="gray125"/></fill></fills>'
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
||||
@@ -562,6 +611,14 @@ def _build_styles_xml() -> str:
|
||||
def _build_sheet_xml(rows: list[list[object]]) -> str:
|
||||
normalized_rows = rows or [[""]]
|
||||
max_column_count = max((len(row) for row in normalized_rows), default=1)
|
||||
column_widths = _build_sheet_column_widths(normalized_rows, max_column_count)
|
||||
column_xml = "".join(
|
||||
(
|
||||
f'<col min="{index}" max="{index}" width="{width}" '
|
||||
'customWidth="1" bestFit="1"/>'
|
||||
)
|
||||
for index, width in enumerate(column_widths, start=1)
|
||||
)
|
||||
worksheet_rows: list[str] = []
|
||||
|
||||
for row_index, row in enumerate(normalized_rows, start=1):
|
||||
@@ -573,15 +630,18 @@ def _build_sheet_xml(rows: list[list[object]]) -> str:
|
||||
cells.append(
|
||||
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>'
|
||||
)
|
||||
worksheet_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
|
||||
worksheet_rows.append(
|
||||
f'<row r="{row_index}" ht="25" customHeight="1">{"".join(cells)}</row>'
|
||||
)
|
||||
|
||||
dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
f'<dimension ref="{dimension}"/>'
|
||||
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>"
|
||||
"<sheetFormatPr defaultRowHeight=\"18\"/>"
|
||||
'<sheetViews><sheetView workbookViewId="0" zoomScale="120" zoomScaleNormal="120"/></sheetViews>'
|
||||
"<sheetFormatPr defaultRowHeight=\"25\" customHeight=\"1\"/>"
|
||||
f"<cols>{column_xml}</cols>"
|
||||
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
|
||||
"</worksheet>"
|
||||
)
|
||||
@@ -596,6 +656,31 @@ def _column_letter(index: int) -> str:
|
||||
return result
|
||||
|
||||
|
||||
def _build_sheet_column_widths(
|
||||
rows: list[list[object]],
|
||||
max_column_count: int,
|
||||
) -> list[str]:
|
||||
widths: list[str] = []
|
||||
for column_index in range(max_column_count):
|
||||
max_text_width = 0.0
|
||||
for row in rows[:120]:
|
||||
value = row[column_index] if column_index < len(row) else ""
|
||||
text = "" if value is None else str(value)
|
||||
if not text:
|
||||
continue
|
||||
max_text_width = max(max_text_width, _estimate_display_width(text))
|
||||
width = min(max(max_text_width + 4, 16), 42)
|
||||
widths.append(f"{width:.1f}")
|
||||
return widths
|
||||
|
||||
|
||||
def _estimate_display_width(text: str) -> float:
|
||||
width = 0.0
|
||||
for char in text:
|
||||
width += 2.0 if ord(char) > 127 else 1.0
|
||||
return width
|
||||
|
||||
|
||||
def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
|
||||
normalized_rows = [list(row) for row in rows]
|
||||
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):
|
||||
|
||||
@@ -13,8 +13,18 @@ from app.schemas.agent_asset import (
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
RULE_LIBRARY_NAMES,
|
||||
SPREADSHEET_MIME_TYPE,
|
||||
@@ -133,7 +143,7 @@ class AgentAssetSpreadsheetHelperMixin:
|
||||
}
|
||||
if config_json.get("rule_document") != expected_document:
|
||||
config_json["detail_mode"] = "spreadsheet"
|
||||
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
|
||||
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
|
||||
config_json["rule_library"] = library
|
||||
config_json["rule_document"] = expected_document
|
||||
asset.config_json = config_json
|
||||
@@ -160,7 +170,7 @@ class AgentAssetSpreadsheetHelperMixin:
|
||||
)
|
||||
config_json = dict(asset.config_json or {})
|
||||
config_json["detail_mode"] = "spreadsheet"
|
||||
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
|
||||
config_json["tag"] = str(config_json.get("tag") or "基础规则").strip() or "基础规则"
|
||||
config_json["rule_library"] = library
|
||||
config_json["rule_document"] = {
|
||||
**self.spreadsheet_manager.build_rule_document_config(
|
||||
@@ -187,6 +197,16 @@ class AgentAssetSpreadsheetHelperMixin:
|
||||
return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
|
||||
if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE:
|
||||
return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_ALLOWANCE_RULE_CODE:
|
||||
return COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_TRANSPORT_RULE_CODE:
|
||||
return COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE:
|
||||
return COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE:
|
||||
return COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
|
||||
if asset.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE:
|
||||
return COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
|
||||
fallback = Path(str(asset.name or "规则表").strip()).name
|
||||
return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx"
|
||||
|
||||
|
||||
554
server/src/app/services/agent_asset_travel_spreadsheets.py
Normal file
554
server/src/app/services/agent_asset_travel_spreadsheets.py
Normal file
@@ -0,0 +1,554 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import copy
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
|
||||
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
|
||||
|
||||
|
||||
LODGING_SHEET_NAME = "差旅住宿费标准"
|
||||
ALLOWANCE_SHEET_NAME = "出差补助标准"
|
||||
TRANSPORT_CLASS_SHEET_NAME = "交通工具等级标准"
|
||||
TRANSPORT_ESTIMATE_SHEET_NAME = "交通费用预估表"
|
||||
|
||||
|
||||
TRAVEL_GRADE_LABELS = {
|
||||
"P0": "实习/见习",
|
||||
"P1": "基础员工",
|
||||
"P2": "初级员工",
|
||||
"P3": "普通员工",
|
||||
"P4": "资深员工/主管",
|
||||
"P5": "基层经理",
|
||||
"P6": "中层经理",
|
||||
"P7": "高层经理",
|
||||
"P8": "董事会",
|
||||
}
|
||||
|
||||
|
||||
def build_travel_lodging_workbook_from_source(
|
||||
source_path: Path,
|
||||
fallback_rows: list[list[object]],
|
||||
) -> bytes:
|
||||
rows: list[list[object]] = []
|
||||
if source_path.exists():
|
||||
workbook = load_workbook(source_path, read_only=True, data_only=True)
|
||||
try:
|
||||
if LODGING_SHEET_NAME in workbook.sheetnames:
|
||||
rows = _extract_lodging_rows(
|
||||
list(workbook[LODGING_SHEET_NAME].iter_rows(values_only=True))
|
||||
)
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
if not rows:
|
||||
rows = _fallback_lodging_rows(fallback_rows)
|
||||
|
||||
return build_styled_workbook(
|
||||
LODGING_SHEET_NAME,
|
||||
["序号", "地区", "地区(城市)", *TRAVEL_GRADE_KEYS, "常规超标限额"],
|
||||
[
|
||||
[
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
*_expand_lodging_grade_amounts(row),
|
||||
row[7],
|
||||
]
|
||||
for row in rows
|
||||
],
|
||||
column_widths=[8, 14, 28, *([12] * len(TRAVEL_GRADE_KEYS)), 16],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_grade_mapping_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
"差旅职级映射表",
|
||||
["序号", "职级", "职级名称", "住宿标准列", "交通标准行", "适用说明", "备注"],
|
||||
[
|
||||
[index, grade, TRAVEL_GRADE_LABELS[grade], grade, grade, _grade_usage_note(grade), ""]
|
||||
for index, grade in enumerate(TRAVEL_GRADE_KEYS, start=1)
|
||||
],
|
||||
column_widths=[8, 14, 28, 14, 14, 32, 32],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_allowance_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
ALLOWANCE_SHEET_NAME,
|
||||
["序号", "补助区域", "伙食补助/天", "基本补助/天", "补助合计/天", "适用说明", "备注"],
|
||||
[
|
||||
[1, "直辖市/特区", 65, 35, 100, "北京、上海、天津、重庆、深圳等地区", "按出差自然日计算"],
|
||||
[2, "其他地区", 55, 35, 90, "未单列的境内城市和地区", "申请阶段用于预算占用"],
|
||||
[3, "新疆-乌鲁木齐", 75, 45, 120, "乌鲁木齐市", "按高原/远途地区补助口径执行"],
|
||||
[4, "新疆-其他", 65, 40, 105, "新疆除乌鲁木齐外地区", "按远途地区补助口径执行"],
|
||||
[5, "西藏", 80, 50, 130, "西藏自治区", "按高原地区补助口径执行"],
|
||||
[6, "港澳台", 120, 80, 200, "香港、澳门、台湾地区", "需按出入境及外币票据要求补充材料"],
|
||||
[7, "国外", 180, 120, 300, "境外国家和地区", "外币折算按财务汇率口径执行"],
|
||||
],
|
||||
column_widths=[8, 18, 16, 16, 16, 34, 34],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_transport_class_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
TRANSPORT_CLASS_SHEET_NAME,
|
||||
[
|
||||
"序号",
|
||||
"职级",
|
||||
"职级说明",
|
||||
"飞机标准",
|
||||
"火车标准",
|
||||
"轮船标准",
|
||||
"适用说明",
|
||||
"超标处理",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
[
|
||||
index,
|
||||
grade,
|
||||
TRAVEL_GRADE_LABELS[grade],
|
||||
"经济舱",
|
||||
"二等座/硬卧/硬座" if grade != "P8" else "二等座/软卧/硬卧",
|
||||
"二等舱",
|
||||
"按已审批出差申请执行" if grade in {"P6", "P7", "P8"} else "优先选择火车或高铁;确需飞机时按经济舱执行",
|
||||
"超出标准需说明原因并走审批" if grade != "P8" else "超出标准需董事会或授权审批确认",
|
||||
"申请阶段按交通费用预估表占用预算" if grade != "P8" else "P8 为董事会级别",
|
||||
]
|
||||
for index, grade in enumerate(TRAVEL_GRADE_KEYS, start=1)
|
||||
],
|
||||
column_widths=[8, 18, 34, 14, 22, 14, 42, 34, 34],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_season_mapping_workbook(source_path: Path) -> bytes:
|
||||
rows: list[list[object]] = []
|
||||
if source_path.exists():
|
||||
workbook = load_workbook(source_path, read_only=True, data_only=True)
|
||||
try:
|
||||
if LODGING_SHEET_NAME in workbook.sheetnames:
|
||||
lodging_rows = _extract_lodging_rows(
|
||||
list(workbook[LODGING_SHEET_NAME].iter_rows(values_only=True))
|
||||
)
|
||||
rows = [
|
||||
[row[0], row[1], row[2], row[3], row[7], row[8]]
|
||||
for row in lodging_rows
|
||||
]
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
if not rows:
|
||||
rows = [[1, "北京", "北京", "", 500, ""]]
|
||||
|
||||
return build_styled_workbook(
|
||||
"地区淡旺季映射表",
|
||||
["序号", "地区", "地区(城市)", "旺季期间(月)", "常规超标限额", "旺季超标限额"],
|
||||
rows,
|
||||
column_widths=[8, 14, 28, 18, 16, 16],
|
||||
)
|
||||
|
||||
|
||||
def build_travel_transport_estimate_workbook() -> bytes:
|
||||
return build_styled_workbook(
|
||||
TRANSPORT_ESTIMATE_SHEET_NAME,
|
||||
[
|
||||
"序号",
|
||||
"出发城市",
|
||||
"目的地",
|
||||
"目的地范围",
|
||||
"交通方式",
|
||||
"单程预估金额",
|
||||
"往返预估金额",
|
||||
"置信度",
|
||||
"预算占用口径",
|
||||
"来源说明",
|
||||
],
|
||||
[
|
||||
[1, "武汉", "北京", "高频城市", "火车", 520, 1040, "基础规则", "往返二等座/硬卧预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[2, "武汉", "北京", "高频城市", "飞机", 700, 1400, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
|
||||
[3, "武汉", "上海", "高频城市", "火车", 360, 720, "基础规则", "往返二等座预估", "参考历史票据样例与 12306 公布票价查询口径"],
|
||||
[4, "武汉", "上海", "高频城市", "飞机", 600, 1200, "基础规则", "往返经济舱预估", "参考高频航线公开往返价格,按申请预算保守占用"],
|
||||
[5, "武汉", "广州", "高频城市", "火车", 470, 940, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[6, "武汉", "广州", "高频城市", "飞机", 650, 1300, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
|
||||
[7, "武汉", "深圳", "高频城市", "火车", 540, 1080, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[8, "武汉", "深圳", "高频城市", "飞机", 700, 1400, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
|
||||
[9, "武汉", "杭州", "高频城市", "火车", 330, 660, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[10, "武汉", "南京", "高频城市", "火车", 260, 520, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[11, "武汉", "成都", "普通城市", "火车", 350, 700, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[12, "武汉", "成都", "普通城市", "飞机", 600, 1200, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和高频航线公开价格"],
|
||||
[13, "武汉", "西安", "普通城市", "火车", 300, 600, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[14, "武汉", "厦门", "沿海城市", "火车", 450, 900, "基础规则", "往返二等座预估", "参考 12306 公布票价查询口径,申请阶段占用预算用"],
|
||||
[15, "武汉", "厦门", "沿海城市", "飞机", 650, 1300, "基础规则", "往返经济舱预估", "参考民航经济舱市场均价和沿海航线公开价格"],
|
||||
[16, "武汉", "三亚", "远途地区", "飞机", 900, 1800, "基础规则", "往返经济舱预估", "参考旅游/远途航线公开价格,申请阶段占用预算用"],
|
||||
[17, "武汉", "乌鲁木齐", "远途地区", "飞机", 1600, 3200, "基础规则", "往返经济舱预估", "远途航线按预算保守占用"],
|
||||
[18, "武汉", "拉萨", "远途地区", "飞机", 1800, 3600, "基础规则", "往返经济舱预估", "远途航线按预算保守占用"],
|
||||
[19, "*", "", "高频城市", "火车", 520, 1040, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
|
||||
[20, "*", "", "高频城市", "飞机", 650, 1300, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
|
||||
[21, "*", "", "沿海城市", "火车", 520, 1040, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
|
||||
[22, "*", "", "沿海城市", "飞机", 700, 1400, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
|
||||
[23, "*", "", "远途地区", "火车", 900, 1800, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
|
||||
[24, "*", "", "远途地区", "飞机", 1600, 3200, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
|
||||
[25, "*", "", "普通城市", "火车", 360, 720, "兜底", "往返二等座/硬卧预估", "未命中精确城市对时使用"],
|
||||
[26, "*", "", "普通城市", "飞机", 600, 1200, "兜底", "往返经济舱预估", "未命中精确城市对时使用"],
|
||||
[27, "*", "", "普通城市", "轮船", 320, 640, "兜底", "往返二等舱预估", "水路交通暂无实时接口时使用"],
|
||||
],
|
||||
column_widths=[8, 14, 18, 16, 12, 16, 16, 12, 24, 42],
|
||||
)
|
||||
|
||||
|
||||
def build_xlsx_bytes_from_source_sheet(source_path: Path, sheet_name: str) -> bytes:
|
||||
source_workbook = load_workbook(source_path, read_only=False, data_only=False)
|
||||
try:
|
||||
if sheet_name not in source_workbook.sheetnames:
|
||||
raise ValueError("原始规则表中没有对应工作表。")
|
||||
|
||||
source_sheet = source_workbook[sheet_name]
|
||||
target_workbook = Workbook()
|
||||
target_sheet = target_workbook.active
|
||||
target_sheet.title = sheet_name
|
||||
_copy_worksheet(source_sheet, target_sheet)
|
||||
_clarify_travel_source_sheet_headers(sheet_name, target_sheet)
|
||||
_remove_redundant_title_row(target_sheet, sheet_name)
|
||||
target_sheet.sheet_view.zoomScale = 120
|
||||
target_sheet.sheet_view.zoomScaleNormal = 120
|
||||
|
||||
workbook_buffer = BytesIO()
|
||||
target_workbook.save(workbook_buffer)
|
||||
target_workbook.close()
|
||||
return workbook_buffer.getvalue()
|
||||
finally:
|
||||
source_workbook.close()
|
||||
|
||||
|
||||
def build_styled_workbook(
|
||||
sheet_name: str,
|
||||
headers: list[str],
|
||||
rows: list[list[object]],
|
||||
*,
|
||||
column_widths: list[int],
|
||||
) -> bytes:
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.title = sheet_name
|
||||
|
||||
header_fill = PatternFill(fill_type="solid", fgColor="FFD9EAF7")
|
||||
thin_side = Side(style="thin", color="FF7F9DB9")
|
||||
table_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
|
||||
for column_index, header in enumerate(headers, start=1):
|
||||
cell = worksheet.cell(row=1, column=column_index, value=header)
|
||||
cell.font = Font(name="Microsoft YaHei", size=12, bold=True, color="FF0F172A")
|
||||
cell.fill = header_fill
|
||||
cell.border = table_border
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
worksheet.row_dimensions[1].height = 30
|
||||
|
||||
for row_index, row in enumerate(rows, start=2):
|
||||
for column_index, value in enumerate(row, start=1):
|
||||
cell = worksheet.cell(row=row_index, column=column_index, value=value)
|
||||
cell.font = Font(name="Microsoft YaHei", size=11, color="FF0F172A")
|
||||
cell.border = table_border
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
worksheet.row_dimensions[row_index].height = 30
|
||||
|
||||
for column_index, width in enumerate(column_widths, start=1):
|
||||
worksheet.column_dimensions[_column_letter(column_index)].width = width
|
||||
|
||||
worksheet.freeze_panes = "A2"
|
||||
worksheet.sheet_view.zoomScale = 120
|
||||
worksheet.sheet_view.zoomScaleNormal = 120
|
||||
|
||||
workbook_buffer = BytesIO()
|
||||
workbook.save(workbook_buffer)
|
||||
workbook.close()
|
||||
return workbook_buffer.getvalue()
|
||||
|
||||
|
||||
def _extract_lodging_rows(source_rows: list[tuple[Any, ...]]) -> list[list[object]]:
|
||||
header_index = -1
|
||||
indexes: dict[str, int] = {}
|
||||
expected_headers = {
|
||||
"seq": "序号",
|
||||
"region": "地区",
|
||||
"city": "地区(城市)",
|
||||
"peak_period": "旺季期间",
|
||||
"p7": "公司级管理人员、高层经理(P7及以上)",
|
||||
"p4": "中层经理、基层经理(P4-P6、外聘专家)",
|
||||
"p1": "其他员工",
|
||||
"regular_limit": "超标限额",
|
||||
"peak_limit": "旺季超标限额",
|
||||
}
|
||||
for row_index, row in enumerate(source_rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "地区(城市)" not in values:
|
||||
continue
|
||||
for key, label in expected_headers.items():
|
||||
if label in values:
|
||||
indexes[key] = values.index(label)
|
||||
header_index = row_index
|
||||
break
|
||||
|
||||
if header_index < 0 or "city" not in indexes:
|
||||
return []
|
||||
|
||||
rows: list[list[object]] = []
|
||||
for row in source_rows[header_index + 1 :]:
|
||||
region = _row_value(row, indexes.get("region", -1))
|
||||
raw_city = _row_value(row, indexes.get("city", -1))
|
||||
cities = _split_location_names(raw_city)
|
||||
if not cities:
|
||||
continue
|
||||
period_by_city, shared_period = _parse_peak_periods(
|
||||
_row_value(row, indexes.get("peak_period", -1))
|
||||
)
|
||||
for city in cities:
|
||||
period = period_by_city.get(_normalize_period_key(city), shared_period)
|
||||
rows.append(
|
||||
[
|
||||
_row_value(row, indexes.get("seq", -1)),
|
||||
region,
|
||||
city,
|
||||
period,
|
||||
_row_value(row, indexes.get("p7", -1)),
|
||||
_row_value(row, indexes.get("p4", -1)),
|
||||
_row_value(row, indexes.get("p1", -1)),
|
||||
_row_value(row, indexes.get("regular_limit", -1)),
|
||||
_row_value(row, indexes.get("peak_limit", -1)) if period else "",
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _fallback_lodging_rows(fallback_rows: list[list[object]]) -> list[list[object]]:
|
||||
rows: list[list[object]] = []
|
||||
for index, row in enumerate(fallback_rows[1:], start=1):
|
||||
if len(row) >= 11:
|
||||
junior_amount = row[5]
|
||||
manager_amount = row[8]
|
||||
executive_amount = row[10]
|
||||
else:
|
||||
junior_amount = row[2] if len(row) > 2 else ""
|
||||
manager_amount = row[3] if len(row) > 3 else ""
|
||||
executive_amount = row[4] if len(row) > 4 else ""
|
||||
rows.append(
|
||||
[
|
||||
index,
|
||||
"",
|
||||
row[0] if len(row) > 0 else "",
|
||||
"",
|
||||
executive_amount,
|
||||
manager_amount,
|
||||
junior_amount,
|
||||
executive_amount,
|
||||
"",
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _expand_lodging_grade_amounts(row: list[object]) -> list[object]:
|
||||
executive_amount = row[4] if len(row) > 4 else ""
|
||||
manager_amount = row[5] if len(row) > 5 else ""
|
||||
junior_amount = row[6] if len(row) > 6 else ""
|
||||
return [
|
||||
junior_amount,
|
||||
junior_amount,
|
||||
junior_amount,
|
||||
junior_amount,
|
||||
manager_amount,
|
||||
manager_amount,
|
||||
manager_amount,
|
||||
executive_amount,
|
||||
executive_amount,
|
||||
]
|
||||
|
||||
|
||||
def _grade_usage_note(grade: str) -> str:
|
||||
if grade == "P8":
|
||||
return "最高职级,适用于董事会"
|
||||
if grade in {"P6", "P7"}:
|
||||
return "适用于中高层管理人员"
|
||||
if grade in {"P4", "P5"}:
|
||||
return "适用于主管及基层管理人员"
|
||||
return "适用于员工序列"
|
||||
|
||||
|
||||
def _split_location_names(value: object) -> list[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
text = re.sub(r"[((].*?[))]", "", text)
|
||||
text = re.sub(r"^\s*\d+\s*个中心城区[、,,]?", "", text)
|
||||
text = re.sub(r"[;;,,/]+", "、", text)
|
||||
names: list[str] = []
|
||||
for part in text.split("、"):
|
||||
cleaned = _normalize_location_name(part)
|
||||
if not cleaned or cleaned == "中心城区":
|
||||
continue
|
||||
names.append(cleaned)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _parse_peak_periods(value: object) -> tuple[dict[str, str], str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ({}, "")
|
||||
period_by_city: dict[str, str] = {}
|
||||
for part in re.split(r"[;;]", text):
|
||||
if ":" not in part and ":" not in part:
|
||||
continue
|
||||
city, period = re.split(r"[::]", part, maxsplit=1)
|
||||
normalized_city = _normalize_period_key(city)
|
||||
normalized_period = _normalize_peak_period(period)
|
||||
if normalized_city and normalized_period:
|
||||
period_by_city[normalized_city] = normalized_period
|
||||
if period_by_city:
|
||||
return (period_by_city, "")
|
||||
return ({}, _normalize_peak_period(text))
|
||||
|
||||
|
||||
def _normalize_peak_period(value: object) -> str:
|
||||
text = str(value or "").strip()
|
||||
text = re.sub(r"\s+", "", text)
|
||||
text = re.sub(r"(月|上旬|中旬|下旬)", "", text)
|
||||
text = re.sub(r"[、,;;]+", ",", text)
|
||||
text = re.sub(r"[^0-9,\-]", "", text)
|
||||
text = re.sub(r",{2,}", ",", text).strip(",")
|
||||
return text
|
||||
|
||||
|
||||
def _normalize_period_key(value: object) -> str:
|
||||
return _normalize_location_name(value).removesuffix("市")
|
||||
|
||||
|
||||
def _normalize_location_name(value: object) -> str:
|
||||
text = str(value or "").strip()
|
||||
text = re.sub(r"\s+", "", text)
|
||||
text = text.removesuffix("市")
|
||||
if text != "其他地区":
|
||||
text = text.removesuffix("地区")
|
||||
return text
|
||||
|
||||
|
||||
def _row_value(row: tuple[Any, ...], index: int) -> object:
|
||||
if index < 0 or index >= len(row):
|
||||
return ""
|
||||
return "" if row[index] is None else row[index]
|
||||
|
||||
|
||||
def _copy_worksheet(source_sheet, target_sheet) -> None:
|
||||
target_sheet.freeze_panes = source_sheet.freeze_panes
|
||||
target_sheet.sheet_format = copy(source_sheet.sheet_format)
|
||||
target_sheet.sheet_properties = copy(source_sheet.sheet_properties)
|
||||
target_sheet.page_margins = copy(source_sheet.page_margins)
|
||||
target_sheet.page_setup = copy(source_sheet.page_setup)
|
||||
target_sheet.print_options = copy(source_sheet.print_options)
|
||||
|
||||
for row in source_sheet.iter_rows():
|
||||
for source_cell in row:
|
||||
target_cell = target_sheet[source_cell.coordinate]
|
||||
target_cell.value = source_cell.value
|
||||
if source_cell.has_style:
|
||||
target_cell.font = copy(source_cell.font)
|
||||
target_cell.fill = copy(source_cell.fill)
|
||||
target_cell.border = copy(source_cell.border)
|
||||
target_cell.alignment = copy(source_cell.alignment)
|
||||
target_cell.protection = copy(source_cell.protection)
|
||||
target_cell.number_format = source_cell.number_format
|
||||
if source_cell.hyperlink:
|
||||
target_cell._hyperlink = copy(source_cell.hyperlink)
|
||||
if source_cell.comment:
|
||||
target_cell.comment = copy(source_cell.comment)
|
||||
|
||||
for merged_range in source_sheet.merged_cells.ranges:
|
||||
target_sheet.merge_cells(str(merged_range))
|
||||
|
||||
for key, source_dimension in source_sheet.column_dimensions.items():
|
||||
target_dimension = target_sheet.column_dimensions[key]
|
||||
target_dimension.width = source_dimension.width
|
||||
target_dimension.hidden = source_dimension.hidden
|
||||
target_dimension.bestFit = source_dimension.bestFit
|
||||
target_dimension.outlineLevel = source_dimension.outlineLevel
|
||||
target_dimension.collapsed = source_dimension.collapsed
|
||||
|
||||
for index, source_dimension in source_sheet.row_dimensions.items():
|
||||
target_dimension = target_sheet.row_dimensions[index]
|
||||
target_dimension.height = source_dimension.height
|
||||
target_dimension.hidden = source_dimension.hidden
|
||||
target_dimension.outlineLevel = source_dimension.outlineLevel
|
||||
target_dimension.collapsed = source_dimension.collapsed
|
||||
|
||||
|
||||
def _clarify_travel_source_sheet_headers(sheet_name: str, worksheet) -> None:
|
||||
if sheet_name == "交通工具等级标准":
|
||||
worksheet["A4"] = "P5+"
|
||||
worksheet["A5"] = "P1-P4"
|
||||
worksheet.row_dimensions[4].height = max(worksheet.row_dimensions[4].height or 0, 42)
|
||||
worksheet.row_dimensions[5].height = max(worksheet.row_dimensions[5].height or 0, 42)
|
||||
worksheet.column_dimensions["A"].width = max(worksheet.column_dimensions["A"].width or 0, 18)
|
||||
|
||||
|
||||
def _remove_redundant_title_row(worksheet, title: str) -> None:
|
||||
first_cell_value = str(worksheet["A1"].value or "").strip()
|
||||
if first_cell_value != str(title or "").strip():
|
||||
return
|
||||
|
||||
has_other_first_row_values = any(
|
||||
str(worksheet.cell(row=1, column=column_index).value or "").strip()
|
||||
for column_index in range(2, worksheet.max_column + 1)
|
||||
)
|
||||
if has_other_first_row_values:
|
||||
return
|
||||
|
||||
shifted_merged_ranges: list[tuple[int, int, int, int]] = []
|
||||
for merged_range in list(worksheet.merged_cells.ranges):
|
||||
range_text = str(merged_range)
|
||||
min_col = merged_range.min_col
|
||||
min_row = merged_range.min_row
|
||||
max_col = merged_range.max_col
|
||||
max_row = merged_range.max_row
|
||||
worksheet.unmerge_cells(range_text)
|
||||
if min_row <= 1:
|
||||
continue
|
||||
shifted_merged_ranges.append((min_col, min_row - 1, max_col, max_row - 1))
|
||||
|
||||
old_freeze_panes = worksheet.freeze_panes
|
||||
worksheet.delete_rows(1, 1)
|
||||
for min_col, min_row, max_col, max_row in shifted_merged_ranges:
|
||||
worksheet.merge_cells(
|
||||
start_row=min_row,
|
||||
start_column=min_col,
|
||||
end_row=max_row,
|
||||
end_column=max_col,
|
||||
)
|
||||
worksheet.freeze_panes = _shift_freeze_panes_after_deleted_first_row(old_freeze_panes)
|
||||
|
||||
|
||||
def _shift_freeze_panes_after_deleted_first_row(freeze_panes: object) -> str | None:
|
||||
if not freeze_panes:
|
||||
return None
|
||||
|
||||
coordinate = str(freeze_panes)
|
||||
match = re.fullmatch(r"([A-Z]+)([0-9]+)", coordinate)
|
||||
if not match:
|
||||
return coordinate
|
||||
|
||||
column, row_text = match.groups()
|
||||
row_index = int(row_text)
|
||||
if row_index <= 1:
|
||||
return None
|
||||
return f"{column}{row_index - 1}"
|
||||
|
||||
|
||||
def _column_letter(index: int) -> str:
|
||||
value = max(1, int(index))
|
||||
result = ""
|
||||
while value > 0:
|
||||
value, remainder = divmod(value - 1, 26)
|
||||
result = f"{chr(65 + remainder)}{result}"
|
||||
return result
|
||||
@@ -74,7 +74,7 @@ class AgentAssetService(
|
||||
) -> list[AgentAssetListItem]:
|
||||
self._ensure_ready()
|
||||
if asset_type in {None, "", AgentAssetType.RULE.value}:
|
||||
self.sync_platform_risk_rules_from_library()
|
||||
self.sync_rule_assets_from_libraries()
|
||||
assets = self.repository.list(
|
||||
asset_type=asset_type, status=status, domain=domain, keyword=keyword
|
||||
)
|
||||
@@ -94,7 +94,7 @@ class AgentAssetService(
|
||||
) -> PageResult[AgentAssetListItem]:
|
||||
self._ensure_ready()
|
||||
if asset_type in {None, "", AgentAssetType.RULE.value}:
|
||||
self.sync_platform_risk_rules_from_library()
|
||||
self.sync_rule_assets_from_libraries()
|
||||
assets = self.repository.list(
|
||||
asset_type=asset_type,
|
||||
status=status,
|
||||
@@ -552,6 +552,13 @@ class AgentAssetService(
|
||||
self.db.commit()
|
||||
return manifest_count
|
||||
|
||||
def sync_rule_assets_from_libraries(self) -> int:
|
||||
foundation = AgentFoundationService(self.db)
|
||||
synced_count = foundation.sync_finance_rule_assets_from_catalog()
|
||||
synced_count += foundation.sync_platform_risk_rules_from_library()
|
||||
self.db.commit()
|
||||
return synced_count
|
||||
|
||||
def _validate_version_payload(
|
||||
self, asset: AgentAsset, payload: AgentAssetVersionCreate
|
||||
) -> None:
|
||||
|
||||
@@ -19,6 +19,7 @@ STATEFUL_CONTEXT_KEYS = (
|
||||
"ocr_summary",
|
||||
"ocr_documents",
|
||||
"review_form_values",
|
||||
"steward_state",
|
||||
"business_time_context",
|
||||
)
|
||||
REVIEW_FLOW_CONTEXT_KEYS = {
|
||||
|
||||
@@ -270,7 +270,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
@@ -296,7 +296,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
@@ -320,7 +320,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "申请规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
@@ -729,7 +729,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
),
|
||||
AgentAssetReview(
|
||||
@@ -737,7 +737,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -368,7 +368,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
@@ -391,7 +391,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
@@ -415,7 +415,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "申请规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
@@ -453,7 +453,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
**(company_travel_rule.config_json or {}),
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
@@ -489,7 +489,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@@ -523,7 +523,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
**(company_communication_rule.config_json or {}),
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
@@ -569,7 +569,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@@ -591,7 +591,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
**(company_preapproval_rule.config_json or {}),
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": "申请规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
@@ -640,7 +640,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
reviewer="顾承宣",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版费用申请审批规则表已确认,可作为财务规则使用。",
|
||||
review_note="首版费用申请审批规则表已确认,可作为申请规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def build_preapproval_rule_workbook_sheets() -> list[tuple[str, list[list[object]]]]:
|
||||
return [
|
||||
(
|
||||
"费用申请审批规则",
|
||||
[
|
||||
[
|
||||
"费用类型代码",
|
||||
"费用类型",
|
||||
"触发条件",
|
||||
"阈值金额",
|
||||
"前置要求",
|
||||
"审批要求",
|
||||
"风险动作",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
"meal/entertainment",
|
||||
"业务招待费",
|
||||
"单次费用金额大于 500 元",
|
||||
500,
|
||||
"必须先提交费用申请单,并说明客户、参与人和招待事由",
|
||||
"申请单需按审批链完成审批后方可报销",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"适配 meal 与 entertainment 两个本体费用类型",
|
||||
],
|
||||
[
|
||||
"office",
|
||||
"办公用品费",
|
||||
"单次或批量采购金额大于 2000 元",
|
||||
2000,
|
||||
"必须先提交办公采购或费用申请单",
|
||||
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"覆盖办公用品、办公耗材、低值易耗品等场景",
|
||||
],
|
||||
[
|
||||
"all",
|
||||
"通用大额费用",
|
||||
"任意费用金额大于 2000 元",
|
||||
2000,
|
||||
"必须进入费用申请和审批流程",
|
||||
"至少完成直属领导审批;按预算和基础规则继续流转",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"差旅、通信等已有专项规则时可同时适用专项规则",
|
||||
],
|
||||
],
|
||||
),
|
||||
(
|
||||
"字段说明",
|
||||
[
|
||||
["字段", "说明"],
|
||||
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
|
||||
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
|
||||
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
|
||||
["审批要求", "说明申请单进入审批链后的最低审批要求"],
|
||||
["风险动作", "说明报销阶段未满足规则时的系统处理"],
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -5,6 +5,10 @@ from pathlib import Path
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.agent_enums import (
|
||||
AgentAssetContentType,
|
||||
AgentAssetDomain,
|
||||
AgentAssetType,
|
||||
AgentReviewStatus,
|
||||
AgentAssetStatus,
|
||||
)
|
||||
from app.core.logging import get_logger
|
||||
@@ -12,22 +16,38 @@ from app.models.agent_asset import AgentAsset
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
||||
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
|
||||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
AgentAssetSpreadsheetManager,
|
||||
)
|
||||
from app.services.agent_foundation_constants import (
|
||||
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON,
|
||||
COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
)
|
||||
from app.services.finance_rule_catalog import (
|
||||
DEPRECATED_FINANCE_RULE_CODES,
|
||||
DEPRECATED_FINANCE_RULE_REPLACEMENTS,
|
||||
)
|
||||
from app.services.agent_foundation_preapproval_spreadsheet import (
|
||||
build_preapproval_rule_workbook_sheets,
|
||||
)
|
||||
|
||||
logger = get_logger("app.services.agent_foundation")
|
||||
|
||||
@@ -44,25 +64,131 @@ class AgentFoundationSpreadsheetMixin:
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
name="差旅住宿报销标准",
|
||||
description="按地区和职级维护差旅住宿费报销上限。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="差旅住宿费标准",
|
||||
expense_types=["travel", "hotel", "transport"],
|
||||
expense_types=["hotel"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_lodging_rule_template(),
|
||||
rule_template_label="差旅住宿 Excel 模板",
|
||||
travel_policy_component="lodging",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
|
||||
name="出差补助报销标准",
|
||||
description="按地区维护伙食补助、基本出差补贴和补助合计。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="出差补助标准",
|
||||
expense_types=["travel"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_allowance_rule_template(),
|
||||
rule_template_label="出差补助 Excel 模板",
|
||||
travel_policy_component="allowance",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_TRANSPORT_RULE_CODE,
|
||||
name="交通工具等级标准",
|
||||
description="按员工职级维护飞机、火车等长途交通工具等级。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="交通工具等级标准",
|
||||
expense_types=["travel", "transport"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_TRANSPORT_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_rule_template(),
|
||||
rule_template_label="交通工具等级 Excel 模板",
|
||||
travel_policy_component="transport",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
name="交通费用预估表",
|
||||
description="按出发城市、目的地和交通方式维护申请阶段预算占用的交通费用预估金额。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="交通费用预估表",
|
||||
expense_types=["travel", "transport"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_transport_estimate_rule_template(),
|
||||
rule_template_label="交通费用预估 Excel 模板",
|
||||
travel_policy_component="transport_estimate",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
|
||||
name="差旅职级映射表",
|
||||
description="明确 P0-P8 九级职级与住宿、交通规则列之间的对应关系,其中 P8 为董事会。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="差旅职级映射表",
|
||||
expense_types=["hotel", "travel", "transport"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_grade_mapping_template(),
|
||||
rule_template_label="差旅职级映射 Excel 模板",
|
||||
travel_policy_component="grade_mapping",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||||
name="地区淡旺季映射表",
|
||||
description="明确住宿标准中旺季地区、旺季月份和旺季超标限额的对应关系。",
|
||||
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="地区淡旺季映射表",
|
||||
expense_types=["hotel", "travel"],
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_travel_season_mapping_template(),
|
||||
rule_template_label="地区淡旺季映射 Excel 模板",
|
||||
travel_policy_component="season_mapping",
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
name="公司通信费报销规则",
|
||||
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
||||
scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="通信费报销标准",
|
||||
expense_types=["communication"],
|
||||
version=COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
file_name=COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||
workbook_content=AgentAssetSpreadsheetManager.build_company_communication_rule_template(),
|
||||
rule_template_label="通信费报销 Excel 模板",
|
||||
finance_rule_code="expense.communication.policy",
|
||||
refresh_workbook_content=True,
|
||||
)
|
||||
)
|
||||
synced_count += int(
|
||||
self._ensure_core_finance_rule_asset(
|
||||
code=COMPANY_PREAPPROVAL_RULE_CODE,
|
||||
name="公司费用申请审批规则",
|
||||
description="通过 Excel 明细表维护业务招待、办公用品和通用大额费用的事前申请与审批阈值。",
|
||||
scenario_category=COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON[0],
|
||||
finance_rule_sheet="费用申请审批规则",
|
||||
expense_types=["meal", "entertainment", "office", "all"],
|
||||
version=COMPANY_PREAPPROVAL_RULE_VERSION,
|
||||
reviewer="顾承宣",
|
||||
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
workbook_content=None,
|
||||
rule_template_label="费用申请审批 Excel 模板",
|
||||
finance_rule_code="expense.preapproval.policy",
|
||||
tag="申请规则",
|
||||
)
|
||||
)
|
||||
return synced_count
|
||||
@@ -71,30 +197,183 @@ class AgentFoundationSpreadsheetMixin:
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
name: str,
|
||||
description: str,
|
||||
scenario_category: str,
|
||||
finance_rule_sheet: str,
|
||||
expense_types: list[str],
|
||||
version: str,
|
||||
reviewer: str,
|
||||
file_name: str,
|
||||
workbook_content: bytes | None,
|
||||
rule_template_label: str,
|
||||
finance_rule_code: str | None = None,
|
||||
travel_policy_component: str = "",
|
||||
tag: str = "基础规则",
|
||||
refresh_workbook_content: bool = False,
|
||||
) -> bool:
|
||||
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
|
||||
created_asset = asset is None
|
||||
if asset is None:
|
||||
return False
|
||||
asset = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=code,
|
||||
name=name,
|
||||
description=description,
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=[scenario_category],
|
||||
owner="财务制度管理组",
|
||||
reviewer=reviewer,
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=version,
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": tag,
|
||||
"rule_tag": tag,
|
||||
"tags": [tag],
|
||||
"rule_tags": [tag],
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": scenario_category,
|
||||
"ai_review_category": scenario_category,
|
||||
"finance_rule_code": code,
|
||||
"finance_rule_sheet": finance_rule_sheet,
|
||||
"expense_types": expense_types,
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": rule_template_label,
|
||||
},
|
||||
)
|
||||
else:
|
||||
asset.name = name
|
||||
asset.description = description
|
||||
asset.owner = asset.owner or "财务制度管理组"
|
||||
asset.reviewer = asset.reviewer or reviewer
|
||||
if not str(asset.current_version or "").strip():
|
||||
asset.current_version = version
|
||||
if not str(asset.working_version or "").strip():
|
||||
asset.working_version = asset.current_version
|
||||
if not str(asset.published_version or "").strip():
|
||||
asset.published_version = asset.current_version
|
||||
if not str(asset.status or "").strip() or asset.status == AgentAssetStatus.DISABLED.value:
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
|
||||
asset.scenario_json = [scenario_category]
|
||||
asset.config_json = {
|
||||
config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"tag": tag,
|
||||
"rule_tag": tag,
|
||||
"tags": [tag],
|
||||
"rule_tags": [tag],
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"scenario_category": scenario_category,
|
||||
"ai_review_category": scenario_category,
|
||||
"finance_rule_code": code,
|
||||
"finance_rule_code": finance_rule_code or code,
|
||||
"finance_rule_sheet": finance_rule_sheet,
|
||||
"expense_types": expense_types,
|
||||
"business_stage": ["expense_application", "reimbursement"],
|
||||
"budget_required": True,
|
||||
"rule_template_label": rule_template_label,
|
||||
}
|
||||
if travel_policy_component:
|
||||
config_json["travel_policy_component"] = travel_policy_component
|
||||
asset.config_json = config_json
|
||||
rule_document = (asset.config_json or {}).get("rule_document")
|
||||
has_rule_document = isinstance(rule_document, dict) and bool(
|
||||
str(rule_document.get("storage_key") or "").strip()
|
||||
)
|
||||
if workbook_content is not None and (
|
||||
created_asset or not has_rule_document or refresh_workbook_content
|
||||
):
|
||||
self._ensure_finance_rule_asset_document(
|
||||
asset,
|
||||
version=version,
|
||||
reviewer=reviewer,
|
||||
file_name=file_name,
|
||||
content=workbook_content,
|
||||
force_live_document=refresh_workbook_content,
|
||||
)
|
||||
return True
|
||||
|
||||
def _ensure_finance_rule_asset_document(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
*,
|
||||
version: str,
|
||||
reviewer: str,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
force_live_document: bool = False,
|
||||
) -> None:
|
||||
manager = AgentAssetSpreadsheetManager()
|
||||
manager.ensure_rule_library_dirs()
|
||||
rule_document = (asset.config_json or {}).get("rule_document")
|
||||
storage_key = (
|
||||
str(rule_document.get("storage_key") or "").strip()
|
||||
if isinstance(rule_document, dict)
|
||||
else ""
|
||||
)
|
||||
should_seed_file = force_live_document or not storage_key
|
||||
if storage_key:
|
||||
try:
|
||||
current_path = manager.resolve_storage_path(storage_key)
|
||||
except FileNotFoundError:
|
||||
current_path = None
|
||||
should_seed_file = should_seed_file or current_path is None or not current_path.exists()
|
||||
|
||||
if should_seed_file:
|
||||
metadata = manager.store_rule_library_spreadsheet(
|
||||
library=FINANCE_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
content=content,
|
||||
actor_name="系统初始化",
|
||||
source="rule-library",
|
||||
)
|
||||
asset.config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"rule_document": {
|
||||
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||
metadata,
|
||||
asset_version=version,
|
||||
),
|
||||
"storage_key": metadata.storage_key,
|
||||
},
|
||||
}
|
||||
else:
|
||||
metadata = manager.store_rule_library_spreadsheet_snapshot(
|
||||
library=FINANCE_RULES_LIBRARY,
|
||||
asset_id=asset.id,
|
||||
version=version,
|
||||
file_name=file_name,
|
||||
content=content,
|
||||
actor_name="系统初始化",
|
||||
source="rule-library-version",
|
||||
)
|
||||
|
||||
self._ensure_asset_version(
|
||||
asset,
|
||||
version=version,
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=asset.name,
|
||||
version=version,
|
||||
metadata=metadata,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note=f"初始化{asset.name} Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
self._ensure_asset_review(
|
||||
asset,
|
||||
version=version,
|
||||
reviewer=reviewer,
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为基础规则使用。",
|
||||
reviewed_at=None,
|
||||
)
|
||||
|
||||
def _hide_deprecated_finance_rule_assets(self) -> None:
|
||||
for code in DEPRECATED_FINANCE_RULE_CODES:
|
||||
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
|
||||
@@ -105,16 +384,16 @@ class AgentFoundationSpreadsheetMixin:
|
||||
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
|
||||
if replacement == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||
deprecated_reason = (
|
||||
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
|
||||
"交通/住宿细分并入公司差旅费报销规则,不再作为独立基础规则展示。"
|
||||
)
|
||||
elif replacement == COMPANY_PREAPPROVAL_RULE_CODE:
|
||||
deprecated_reason = (
|
||||
"申请审批阈值已并入公司费用申请审批规则,不再作为独立财务规则展示。"
|
||||
"申请审批阈值已并入公司费用申请审批规则,不再作为独立基础规则展示。"
|
||||
)
|
||||
else:
|
||||
deprecated_reason = (
|
||||
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
|
||||
"不再作为独立财务规则表展示。"
|
||||
"不再作为独立基础规则表展示。"
|
||||
)
|
||||
asset.config_json = {
|
||||
**(asset.config_json or {}),
|
||||
@@ -196,7 +475,10 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
"detail_mode": "spreadsheet",
|
||||
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"rule_tag": "基础规则",
|
||||
"tags": ["基础规则"],
|
||||
"rule_tags": ["基础规则"],
|
||||
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
|
||||
@@ -224,7 +506,10 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
"detail_mode": "spreadsheet",
|
||||
|
||||
"tag": "财务规则",
|
||||
"tag": "基础规则",
|
||||
"rule_tag": "基础规则",
|
||||
"tags": ["基础规则"],
|
||||
"rule_tags": ["基础规则"],
|
||||
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
|
||||
@@ -299,65 +584,9 @@ class AgentFoundationSpreadsheetMixin:
|
||||
file_name=COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||||
|
||||
fallback_sheet_name="费用申请审批规则",
|
||||
tag="申请规则",
|
||||
|
||||
workbook_sheets=[
|
||||
(
|
||||
"费用申请审批规则",
|
||||
[
|
||||
[
|
||||
"费用类型代码",
|
||||
"费用类型",
|
||||
"触发条件",
|
||||
"阈值金额",
|
||||
"前置要求",
|
||||
"审批要求",
|
||||
"风险动作",
|
||||
"备注",
|
||||
],
|
||||
[
|
||||
"meal/entertainment",
|
||||
"业务招待费",
|
||||
"单次费用金额大于 500 元",
|
||||
500,
|
||||
"必须先提交费用申请单,并说明客户、参与人和招待事由",
|
||||
"申请单需按审批链完成审批后方可报销",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"适配 meal 与 entertainment 两个本体费用类型",
|
||||
],
|
||||
[
|
||||
"office",
|
||||
"办公用品费",
|
||||
"单次或批量采购金额大于 2000 元",
|
||||
2000,
|
||||
"必须先提交办公采购或费用申请单",
|
||||
"申请单需经直属领导审批;如触发预算管控则继续预算复核",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"覆盖办公用品、办公耗材、低值易耗品等场景",
|
||||
],
|
||||
[
|
||||
"all",
|
||||
"通用大额费用",
|
||||
"任意费用金额大于 2000 元",
|
||||
2000,
|
||||
"必须进入费用申请和审批流程",
|
||||
"至少完成直属领导审批;按预算和财务规则继续流转",
|
||||
"报销阶段未关联已通过申请单时标记高风险",
|
||||
"差旅、通信等已有专项规则时可同时适用专项规则",
|
||||
],
|
||||
],
|
||||
),
|
||||
(
|
||||
"字段说明",
|
||||
[
|
||||
["字段", "说明"],
|
||||
["费用类型代码", "使用系统本体费用类型,不新增非本体字段"],
|
||||
["阈值金额", "单位为人民币元,执行时按 claim.amount 进行数值比较"],
|
||||
["前置要求", "说明是否需要事前申请以及申请单需要包含的信息"],
|
||||
["审批要求", "说明申请单进入审批链后的最低审批要求"],
|
||||
["风险动作", "说明报销阶段未满足规则时的系统处理"],
|
||||
],
|
||||
),
|
||||
],
|
||||
workbook_sheets=build_preapproval_rule_workbook_sheets(),
|
||||
|
||||
)
|
||||
|
||||
@@ -385,7 +614,7 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
return live_path.read_bytes()
|
||||
|
||||
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
|
||||
return AgentAssetSpreadsheetManager.build_travel_lodging_rule_template()
|
||||
|
||||
def _ensure_finance_rule_spreadsheet_seed(
|
||||
|
||||
@@ -404,6 +633,7 @@ class AgentFoundationSpreadsheetMixin:
|
||||
fallback_sheet_name: str,
|
||||
|
||||
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
|
||||
tag: str = "基础规则",
|
||||
|
||||
):
|
||||
|
||||
@@ -473,7 +703,10 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
"detail_mode": "spreadsheet",
|
||||
|
||||
"tag": "财务规则",
|
||||
"tag": tag,
|
||||
"rule_tag": tag,
|
||||
"tags": [tag],
|
||||
"rule_tags": [tag],
|
||||
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
|
||||
@@ -501,7 +734,10 @@ class AgentFoundationSpreadsheetMixin:
|
||||
|
||||
"detail_mode": "spreadsheet",
|
||||
|
||||
"tag": "财务规则",
|
||||
"tag": tag,
|
||||
"rule_tag": tag,
|
||||
"tags": [tag],
|
||||
"rule_tags": [tag],
|
||||
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
|
||||
|
||||
@@ -11,6 +11,33 @@ from app.services.expense_claim_risk_stage import (
|
||||
)
|
||||
|
||||
|
||||
def _normalize_basic_rule_refs(value: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
refs: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
code = str(item.get("code") or item.get("rule_code") or "").strip()
|
||||
sheet = str(item.get("sheet") or item.get("rule_sheet") or "").strip()
|
||||
if not code and not sheet:
|
||||
continue
|
||||
key = (code, sheet)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
refs.append(
|
||||
{
|
||||
"code": code,
|
||||
"sheet": sheet,
|
||||
"name": str(item.get("name") or "").strip(),
|
||||
"component": str(item.get("component") or "").strip(),
|
||||
}
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
def build_platform_risk_flag(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
@@ -55,6 +82,42 @@ def build_platform_risk_flag(
|
||||
metadata.get("actionability") or manifest.get("actionability"),
|
||||
default_actionability,
|
||||
)
|
||||
finance_rule_code = str(
|
||||
manifest.get("finance_rule_code")
|
||||
or metadata.get("finance_rule_code")
|
||||
or manifest.get("basic_rule_code")
|
||||
or metadata.get("basic_rule_code")
|
||||
or ""
|
||||
).strip()
|
||||
finance_rule_sheet = str(
|
||||
manifest.get("finance_rule_sheet")
|
||||
or metadata.get("finance_rule_sheet")
|
||||
or manifest.get("basic_rule_sheet")
|
||||
or metadata.get("basic_rule_sheet")
|
||||
or ""
|
||||
).strip()
|
||||
basic_rule_code = str(
|
||||
manifest.get("basic_rule_code")
|
||||
or metadata.get("basic_rule_code")
|
||||
or finance_rule_code
|
||||
).strip()
|
||||
basic_rule_sheet = str(
|
||||
manifest.get("basic_rule_sheet")
|
||||
or metadata.get("basic_rule_sheet")
|
||||
or finance_rule_sheet
|
||||
).strip()
|
||||
basic_rule_refs = _normalize_basic_rule_refs(
|
||||
manifest.get("basic_rule_refs") or metadata.get("basic_rule_refs")
|
||||
)
|
||||
if not basic_rule_refs and (basic_rule_code or basic_rule_sheet):
|
||||
basic_rule_refs = [
|
||||
{
|
||||
"code": basic_rule_code,
|
||||
"sheet": basic_rule_sheet,
|
||||
"name": "",
|
||||
"component": "",
|
||||
}
|
||||
]
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
@@ -63,8 +126,11 @@ def build_platform_risk_flag(
|
||||
"rule_type": "risk",
|
||||
"rule_code": str(manifest.get("rule_code") or "").strip(),
|
||||
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
|
||||
"finance_rule_code": str(manifest.get("finance_rule_code") or "").strip(),
|
||||
"finance_rule_sheet": str(manifest.get("finance_rule_sheet") or "").strip(),
|
||||
"basic_rule_code": basic_rule_code,
|
||||
"basic_rule_sheet": basic_rule_sheet,
|
||||
"basic_rule_refs": basic_rule_refs,
|
||||
"finance_rule_code": finance_rule_code,
|
||||
"finance_rule_sheet": finance_rule_sheet,
|
||||
"severity": severity,
|
||||
"action": action,
|
||||
"label": label,
|
||||
|
||||
@@ -14,7 +14,9 @@ from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetT
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||||
AgentAssetSpreadsheetManager,
|
||||
TRAVEL_SPREADSHEET_RULE_CODES,
|
||||
)
|
||||
from app.services.expense_rule_runtime_defaults import (
|
||||
DEFAULT_SCENE_MATRIX_CONFIG,
|
||||
@@ -39,6 +41,14 @@ from app.services.expense_rule_runtime_standards import (
|
||||
build_scene_submission_standard_markdown,
|
||||
build_travel_risk_control_standard_markdown,
|
||||
)
|
||||
from app.services.expense_rule_runtime_spreadsheet_extractors import (
|
||||
extract_hotel_season_limits,
|
||||
extract_normalized_transport_class_limits,
|
||||
extract_normalized_travel_allowance_limits,
|
||||
map_transport_grade_row_to_bands,
|
||||
transport_class_level_for_text,
|
||||
)
|
||||
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
|
||||
|
||||
class ExpenseRuleRuntimeService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
@@ -59,16 +69,18 @@ class ExpenseRuleRuntimeService:
|
||||
assets = []
|
||||
|
||||
asset_ids = {asset.id for asset in assets}
|
||||
travel_spreadsheet_asset = self.db.scalar(
|
||||
select(AgentAsset)
|
||||
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
|
||||
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
|
||||
.where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
|
||||
.limit(1)
|
||||
travel_spreadsheet_assets = list(
|
||||
self.db.scalars(
|
||||
select(AgentAsset)
|
||||
.where(AgentAsset.asset_type == AgentAssetType.RULE.value)
|
||||
.where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value)
|
||||
.where(AgentAsset.code.in_(TRAVEL_SPREADSHEET_RULE_CODES))
|
||||
.order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc())
|
||||
).all()
|
||||
)
|
||||
if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids:
|
||||
assets.append(travel_spreadsheet_asset)
|
||||
for travel_spreadsheet_asset in travel_spreadsheet_assets:
|
||||
if travel_spreadsheet_asset.id not in asset_ids:
|
||||
assets.append(travel_spreadsheet_asset)
|
||||
|
||||
spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = []
|
||||
for asset in assets:
|
||||
@@ -76,7 +88,7 @@ class ExpenseRuleRuntimeService:
|
||||
if version is None:
|
||||
continue
|
||||
is_travel_spreadsheet_asset = (
|
||||
str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||||
str(asset.code or "").strip() in TRAVEL_SPREADSHEET_RULE_CODES
|
||||
and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet"
|
||||
)
|
||||
runtime_payload = self._extract_runtime_payload(
|
||||
@@ -173,7 +185,7 @@ class ExpenseRuleRuntimeService:
|
||||
asset: AgentAsset,
|
||||
version: AgentAssetVersion,
|
||||
) -> None:
|
||||
if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||
if str(asset.code or "").strip() not in TRAVEL_SPREADSHEET_RULE_CODES:
|
||||
return
|
||||
if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet":
|
||||
return
|
||||
@@ -183,7 +195,9 @@ class ExpenseRuleRuntimeService:
|
||||
rule_document = (asset.config_json or {}).get("rule_document")
|
||||
if not isinstance(rule_document, dict):
|
||||
rule_document = {}
|
||||
storage_key = str(metadata.storage_key if metadata is not None else "").strip()
|
||||
storage_key = str(rule_document.get("storage_key") or "").strip()
|
||||
if not storage_key and metadata is not None:
|
||||
storage_key = str(metadata.storage_key or "").strip()
|
||||
if storage_key:
|
||||
try:
|
||||
workbook_path = manager.resolve_storage_path(storage_key)
|
||||
@@ -217,24 +231,48 @@ class ExpenseRuleRuntimeService:
|
||||
try:
|
||||
standards = self._extract_travel_amount_standards_from_workbook(workbook)
|
||||
hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook)
|
||||
hotel_season_limits = self._extract_hotel_season_limits_from_workbook(workbook)
|
||||
allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook)
|
||||
transport_limits = self._extract_transport_class_limits_from_workbook(workbook)
|
||||
transport_estimates = self._extract_transport_estimates_from_workbook(workbook)
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
standard_rule_version = str(
|
||||
rule_document.get("asset_version") or asset.current_version or version.version
|
||||
).strip()
|
||||
if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None:
|
||||
if (
|
||||
hotel_city_limits
|
||||
or hotel_season_limits.get("hotel_peak_periods")
|
||||
or hotel_season_limits.get("hotel_peak_city_limits")
|
||||
or allowance_limits
|
||||
or transport_limits
|
||||
or transport_estimates
|
||||
) and catalog.travel_policy is not None:
|
||||
payload = catalog.travel_policy.model_dump()
|
||||
payload["standard_rule_code"] = asset.code
|
||||
payload["standard_rule_name"] = asset.name
|
||||
payload["standard_rule_version"] = standard_rule_version
|
||||
if str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
|
||||
payload["standard_rule_code"] = asset.code
|
||||
payload["standard_rule_name"] = asset.name
|
||||
payload["standard_rule_version"] = standard_rule_version
|
||||
if str(asset.code or "").strip() == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE:
|
||||
payload["transport_estimate_rule_code"] = asset.code
|
||||
payload["transport_estimate_rule_name"] = asset.name
|
||||
payload["transport_estimate_rule_version"] = standard_rule_version
|
||||
if hotel_city_limits:
|
||||
payload["hotel_city_limits"] = {
|
||||
**payload.get("hotel_city_limits", {}),
|
||||
**hotel_city_limits,
|
||||
}
|
||||
if hotel_season_limits.get("hotel_peak_periods"):
|
||||
payload["hotel_peak_periods"] = {
|
||||
**payload.get("hotel_peak_periods", {}),
|
||||
**hotel_season_limits["hotel_peak_periods"],
|
||||
}
|
||||
if hotel_season_limits.get("hotel_peak_city_limits"):
|
||||
payload["hotel_peak_city_limits"] = {
|
||||
**payload.get("hotel_peak_city_limits", {}),
|
||||
**hotel_season_limits["hotel_peak_city_limits"],
|
||||
}
|
||||
if allowance_limits:
|
||||
payload["allowance_limits"] = {
|
||||
**payload.get("allowance_limits", {}),
|
||||
@@ -245,6 +283,12 @@ class ExpenseRuleRuntimeService:
|
||||
**payload.get("transport_limits", {}),
|
||||
**transport_limits,
|
||||
}
|
||||
if transport_estimates:
|
||||
existing_estimates = list(payload.get("transport_estimates") or [])
|
||||
payload["transport_estimates"] = [
|
||||
*existing_estimates,
|
||||
*transport_estimates,
|
||||
]
|
||||
catalog.travel_policy = RuntimeTravelPolicy(**payload)
|
||||
|
||||
for expense_type, amount in standards.items():
|
||||
@@ -317,6 +361,10 @@ class ExpenseRuleRuntimeService:
|
||||
continue
|
||||
for column_index, header in enumerate(values):
|
||||
compact = re.sub(r"\s+", "", header)
|
||||
grade_key = ExpenseRuleRuntimeService._extract_exact_grade_header(compact)
|
||||
if grade_key:
|
||||
band_indexes[grade_key] = column_index
|
||||
continue
|
||||
if any(keyword in compact for keyword in ("P1-P3", "其他员工")):
|
||||
band_indexes["junior"] = column_index
|
||||
if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")):
|
||||
@@ -347,6 +395,17 @@ class ExpenseRuleRuntimeService:
|
||||
city_entry[band] = amount
|
||||
return city_limits
|
||||
|
||||
@staticmethod
|
||||
def _extract_exact_grade_header(value: str) -> str:
|
||||
compact = re.sub(r"\s+", "", str(value or "").upper())
|
||||
if not compact or any(token in compact for token in ("-", "+", "及以上")):
|
||||
return ""
|
||||
match = re.match(r"^(P[0-8])(?:级|董事会)?$", compact)
|
||||
if match is None:
|
||||
return ""
|
||||
grade_key = match.group(1)
|
||||
return grade_key if grade_key in TRAVEL_GRADE_KEYS else ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]:
|
||||
allowance_limits: dict[str, dict[str, Decimal]] = {}
|
||||
@@ -355,6 +414,12 @@ class ExpenseRuleRuntimeService:
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
normalized_limits = extract_normalized_travel_allowance_limits(rows)
|
||||
if normalized_limits:
|
||||
for allowance_key, region_amounts in normalized_limits.items():
|
||||
allowance_limits.setdefault(allowance_key, {}).update(region_amounts)
|
||||
continue
|
||||
|
||||
header_index = -1
|
||||
type_index = -1
|
||||
region_indexes: dict[str, int] = {}
|
||||
@@ -393,6 +458,19 @@ class ExpenseRuleRuntimeService:
|
||||
allowance_limits[allowance_key] = entry
|
||||
return allowance_limits
|
||||
|
||||
@staticmethod
|
||||
def _extract_hotel_season_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Any]]:
|
||||
peak_periods: dict[str, str] = {}
|
||||
peak_limits: dict[str, Decimal] = {}
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
limits = extract_hotel_season_limits(rows)
|
||||
peak_periods.update(limits.get("hotel_peak_periods") or {})
|
||||
peak_limits.update(limits.get("hotel_peak_city_limits") or {})
|
||||
return {"hotel_peak_periods": peak_periods, "hotel_peak_city_limits": peak_limits}
|
||||
|
||||
@staticmethod
|
||||
def _map_allowance_type_to_key(value: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
@@ -412,6 +490,12 @@ class ExpenseRuleRuntimeService:
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
normalized_limits = extract_normalized_transport_class_limits(rows)
|
||||
if normalized_limits:
|
||||
for grade_band, transport_levels in normalized_limits.items():
|
||||
limits.setdefault(grade_band, {}).update(transport_levels)
|
||||
continue
|
||||
|
||||
employee_index = -1
|
||||
flight_index = -1
|
||||
train_index = -1
|
||||
@@ -434,11 +518,11 @@ class ExpenseRuleRuntimeService:
|
||||
|
||||
for row in rows:
|
||||
employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else ""
|
||||
bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text)
|
||||
bands = map_transport_grade_row_to_bands(employee_text)
|
||||
if not bands:
|
||||
continue
|
||||
flight_level = (
|
||||
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||
transport_class_level_for_text(
|
||||
row[flight_index] if len(row) > flight_index else None,
|
||||
kind="flight",
|
||||
)
|
||||
@@ -446,7 +530,7 @@ class ExpenseRuleRuntimeService:
|
||||
else None
|
||||
)
|
||||
train_level = (
|
||||
ExpenseRuleRuntimeService._transport_class_level_for_text(
|
||||
transport_class_level_for_text(
|
||||
row[train_index] if len(row) > train_index else None,
|
||||
kind="train",
|
||||
)
|
||||
@@ -462,39 +546,121 @@ class ExpenseRuleRuntimeService:
|
||||
return limits
|
||||
|
||||
@staticmethod
|
||||
def _map_transport_grade_row_to_bands(value: str) -> list[str]:
|
||||
normalized = re.sub(r"\s+", "", str(value or "").upper())
|
||||
if not normalized or normalized.startswith("注"):
|
||||
return []
|
||||
bands: list[str] = []
|
||||
if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")):
|
||||
bands.extend(["junior", "mid"])
|
||||
if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")):
|
||||
bands.extend(["mid", "senior", "manager", "executive"])
|
||||
return list(dict.fromkeys(bands))
|
||||
def _extract_transport_estimates_from_workbook(workbook: Any) -> list[dict[str, object]]:
|
||||
estimates: list[dict[str, object]] = []
|
||||
for sheet in workbook.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
header_index = -1
|
||||
indexes: dict[str, int] = {}
|
||||
header_aliases = {
|
||||
"origin_city": ("出发城市", "出发地", "起点城市"),
|
||||
"destination_city": ("目的地", "到达城市", "目的城市"),
|
||||
"location_band": ("目的地范围", "城市范围", "地区范围"),
|
||||
"transport_mode": ("交通方式", "出行方式", "交通工具"),
|
||||
"one_way_amount": ("单程预估金额", "单程金额", "单程费用"),
|
||||
"round_trip_amount": ("往返预估金额", "往返金额", "往返费用", "预算占用金额"),
|
||||
"confidence": ("置信度", "匹配级别"),
|
||||
"basis": ("预算占用口径", "预估依据", "口径说明"),
|
||||
}
|
||||
for index, row in enumerate(rows[:10]):
|
||||
values = [str(value or "").strip() for value in row]
|
||||
if "交通方式" not in values and "出行方式" not in values:
|
||||
continue
|
||||
for key, aliases in header_aliases.items():
|
||||
for alias in aliases:
|
||||
if alias in values:
|
||||
indexes[key] = values.index(alias)
|
||||
break
|
||||
if "transport_mode" in indexes and (
|
||||
"round_trip_amount" in indexes or "one_way_amount" in indexes
|
||||
):
|
||||
header_index = index
|
||||
break
|
||||
|
||||
if header_index < 0:
|
||||
continue
|
||||
|
||||
for row in rows[header_index + 1 :]:
|
||||
mode = ExpenseRuleRuntimeService._map_transport_mode_text(
|
||||
ExpenseRuleRuntimeService._row_text(row, indexes.get("transport_mode", -1))
|
||||
)
|
||||
if not mode:
|
||||
continue
|
||||
one_way_amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||
row[indexes["one_way_amount"]]
|
||||
if "one_way_amount" in indexes and len(row) > indexes["one_way_amount"]
|
||||
else None
|
||||
)
|
||||
round_trip_amount = ExpenseRuleRuntimeService._coerce_decimal_cell(
|
||||
row[indexes["round_trip_amount"]]
|
||||
if "round_trip_amount" in indexes and len(row) > indexes["round_trip_amount"]
|
||||
else None
|
||||
)
|
||||
if round_trip_amount is None and one_way_amount is not None:
|
||||
round_trip_amount = (one_way_amount * Decimal("2")).quantize(Decimal("0.01"))
|
||||
if round_trip_amount is None or round_trip_amount <= Decimal("0.00"):
|
||||
continue
|
||||
estimates.append(
|
||||
{
|
||||
"origin_city": ExpenseRuleRuntimeService._row_text(
|
||||
row, indexes.get("origin_city", -1)
|
||||
),
|
||||
"destination_city": ExpenseRuleRuntimeService._row_text(
|
||||
row, indexes.get("destination_city", -1)
|
||||
),
|
||||
"location_band": ExpenseRuleRuntimeService._map_location_band_text(
|
||||
ExpenseRuleRuntimeService._row_text(
|
||||
row, indexes.get("location_band", -1)
|
||||
)
|
||||
),
|
||||
"transport_mode": mode,
|
||||
"one_way_amount": one_way_amount or Decimal("0.00"),
|
||||
"round_trip_amount": round_trip_amount,
|
||||
"confidence": ExpenseRuleRuntimeService._row_text(
|
||||
row, indexes.get("confidence", -1)
|
||||
)
|
||||
or "basic_rule",
|
||||
"basis": ExpenseRuleRuntimeService._row_text(
|
||||
row, indexes.get("basis", -1)
|
||||
),
|
||||
}
|
||||
)
|
||||
return estimates
|
||||
|
||||
@staticmethod
|
||||
def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
|
||||
def _row_text(row: Any, index: int) -> str:
|
||||
if index < 0 or len(row) <= index:
|
||||
return ""
|
||||
return str(row[index] or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _map_transport_mode_text(value: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
if any(keyword in normalized for keyword in ("飞机", "机票", "航班", "经济舱")):
|
||||
return "飞机"
|
||||
if any(keyword in normalized for keyword in ("火车", "高铁", "动车", "铁路", "二等座", "硬卧")):
|
||||
return "火车"
|
||||
if any(keyword in normalized for keyword in ("轮船", "船票", "客轮", "渡轮", "邮轮")):
|
||||
return "轮船"
|
||||
return normalized if normalized in {"飞机", "火车", "轮船"} else ""
|
||||
|
||||
@staticmethod
|
||||
def _map_location_band_text(value: str) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
if not normalized:
|
||||
return None
|
||||
if kind == "flight":
|
||||
if any(keyword in normalized for keyword in ("头等舱",)):
|
||||
return 4
|
||||
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
|
||||
return 3
|
||||
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
|
||||
return 2
|
||||
if "经济舱" in normalized:
|
||||
return 1
|
||||
if kind == "train":
|
||||
if "商务座" in normalized:
|
||||
return 3
|
||||
if any(keyword in normalized for keyword in ("一等座", "软卧")):
|
||||
return 2
|
||||
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
|
||||
return 1
|
||||
return None
|
||||
return ""
|
||||
if any(keyword in normalized for keyword in ("高频", "一线", "核心", "重点")):
|
||||
return "premium"
|
||||
if any(keyword in normalized for keyword in ("远途", "偏远", "新疆", "西藏", "海南", "港澳台", "海外")):
|
||||
return "remote"
|
||||
if any(keyword in normalized for keyword in ("沿海", "海滨", "港口")):
|
||||
return "coastal"
|
||||
if any(keyword in normalized for keyword in ("普通", "默认", "其他")):
|
||||
return "default"
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _extract_city_names_from_cell(value: str) -> list[str]:
|
||||
|
||||
@@ -230,11 +230,15 @@ DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = {
|
||||
"晚到店",
|
||||
],
|
||||
"band_labels": {
|
||||
"junior": "P1-P3",
|
||||
"mid": "P4-P5",
|
||||
"senior": "P6-P7",
|
||||
"manager": "M1-M2",
|
||||
"executive": "M3及以上 / D序列",
|
||||
"P0": "P0 实习/见习",
|
||||
"P1": "P1 基础员工",
|
||||
"P2": "P2 初级员工",
|
||||
"P3": "P3 普通员工",
|
||||
"P4": "P4 资深员工/主管",
|
||||
"P5": "P5 基层经理",
|
||||
"P6": "P6 中层经理",
|
||||
"P7": "P7 高层经理",
|
||||
"P8": "P8 董事会",
|
||||
},
|
||||
"city_tiers": {
|
||||
"北京": "tier_1",
|
||||
@@ -267,18 +271,26 @@ DEFAULT_TRAVEL_POLICY_CONFIG: dict[str, Any] = {
|
||||
"佛山": "tier_2",
|
||||
},
|
||||
"hotel_limits": {
|
||||
"junior": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
|
||||
"mid": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
|
||||
"senior": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"},
|
||||
"manager": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"},
|
||||
"executive": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"},
|
||||
"P0": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
|
||||
"P1": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
|
||||
"P2": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
|
||||
"P3": {"tier_1": "450.00", "tier_2": "380.00", "tier_3": "320.00"},
|
||||
"P4": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
|
||||
"P5": {"tier_1": "550.00", "tier_2": "480.00", "tier_3": "380.00"},
|
||||
"P6": {"tier_1": "700.00", "tier_2": "620.00", "tier_3": "520.00"},
|
||||
"P7": {"tier_1": "900.00", "tier_2": "820.00", "tier_3": "720.00"},
|
||||
"P8": {"tier_1": "1200.00", "tier_2": "1000.00", "tier_3": "900.00"},
|
||||
},
|
||||
"transport_limits": {
|
||||
"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},
|
||||
"P0": {"flight": 1, "train": 1},
|
||||
"P1": {"flight": 1, "train": 1},
|
||||
"P2": {"flight": 1, "train": 1},
|
||||
"P3": {"flight": 1, "train": 1},
|
||||
"P4": {"flight": 1, "train": 1},
|
||||
"P5": {"flight": 1, "train": 1},
|
||||
"P6": {"flight": 2, "train": 2},
|
||||
"P7": {"flight": 3, "train": 3},
|
||||
"P8": {"flight": 4, "train": 3},
|
||||
},
|
||||
"flight_classes": [
|
||||
{"keyword": "头等舱", "level": 4},
|
||||
|
||||
@@ -46,6 +46,17 @@ class TravelClassConfig(BaseModel):
|
||||
level: int
|
||||
|
||||
|
||||
class TravelTransportEstimateConfig(BaseModel):
|
||||
origin_city: str = ""
|
||||
destination_city: str = ""
|
||||
location_band: str = ""
|
||||
transport_mode: str
|
||||
one_way_amount: Decimal = Decimal("0")
|
||||
round_trip_amount: Decimal = Decimal("0")
|
||||
confidence: str = "basic_rule"
|
||||
basis: str = ""
|
||||
|
||||
|
||||
class TravelPolicyConfig(BaseModel):
|
||||
kind: Literal["travel_policy"]
|
||||
version: int = 1
|
||||
@@ -57,11 +68,17 @@ class TravelPolicyConfig(BaseModel):
|
||||
city_tiers: dict[str, str] = Field(default_factory=dict)
|
||||
hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
hotel_peak_periods: dict[str, str] = Field(default_factory=dict)
|
||||
hotel_peak_city_limits: dict[str, Decimal] = Field(default_factory=dict)
|
||||
allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict)
|
||||
standard_rule_code: str = ""
|
||||
standard_rule_name: str = ""
|
||||
standard_rule_version: str = ""
|
||||
transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||
transport_estimates: list[TravelTransportEstimateConfig] = Field(default_factory=list)
|
||||
transport_estimate_rule_code: str = ""
|
||||
transport_estimate_rule_name: str = ""
|
||||
transport_estimate_rule_version: str = ""
|
||||
flight_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||
train_classes: list[TravelClassConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.services.travel_policy_grades import TRAVEL_GRADE_KEYS
|
||||
|
||||
|
||||
def extract_normalized_travel_allowance_limits(
|
||||
rows: list[tuple[Any, ...]],
|
||||
) -> dict[str, dict[str, Decimal]]:
|
||||
header_aliases = {
|
||||
"region": ("补助区域", "适用区域", "地区", "区域"),
|
||||
"meal": ("伙食补助/天", "伙食补助", "餐补", "餐费补助"),
|
||||
"basic": ("基本补助/天", "基本出差补贴", "基本补助"),
|
||||
"total": ("补助合计/天", "补助合计", "合计", "总计"),
|
||||
}
|
||||
indexes = _find_header_indexes(rows, header_aliases)
|
||||
header_index = indexes.pop("__header_index__", -1)
|
||||
if header_index < 0 or "region" not in indexes:
|
||||
return {}
|
||||
|
||||
allowance_limits: dict[str, dict[str, Decimal]] = {}
|
||||
for row in rows[header_index + 1 :]:
|
||||
region = _row_text(row, indexes["region"])
|
||||
if not region:
|
||||
continue
|
||||
for allowance_key in ("meal", "basic", "total"):
|
||||
column_index = indexes.get(allowance_key)
|
||||
if column_index is None:
|
||||
continue
|
||||
amount = _coerce_decimal_cell(row[column_index] if len(row) > column_index else None)
|
||||
if amount is not None:
|
||||
allowance_limits.setdefault(allowance_key, {})[region] = amount
|
||||
return allowance_limits
|
||||
|
||||
|
||||
def extract_normalized_transport_class_limits(
|
||||
rows: list[tuple[Any, ...]],
|
||||
) -> dict[str, dict[str, int]]:
|
||||
header_aliases = {
|
||||
"employee": ("职级范围", "员工职级", "职级"),
|
||||
"flight": ("飞机标准", "飞机", "航班标准"),
|
||||
"train": ("火车标准", "火车", "铁路标准"),
|
||||
}
|
||||
indexes = _find_header_indexes(rows, header_aliases)
|
||||
header_index = indexes.pop("__header_index__", -1)
|
||||
if header_index < 0 or "employee" not in indexes:
|
||||
return {}
|
||||
|
||||
limits: dict[str, dict[str, int]] = {}
|
||||
for row in rows[header_index + 1 :]:
|
||||
employee_text = _row_text(row, indexes["employee"])
|
||||
bands = map_transport_grade_row_to_bands(employee_text)
|
||||
if not bands:
|
||||
continue
|
||||
flight_level = (
|
||||
transport_class_level_for_text(
|
||||
row[indexes["flight"]] if len(row) > indexes["flight"] else None,
|
||||
kind="flight",
|
||||
)
|
||||
if "flight" in indexes
|
||||
else None
|
||||
)
|
||||
train_level = (
|
||||
transport_class_level_for_text(
|
||||
row[indexes["train"]] if len(row) > indexes["train"] else None,
|
||||
kind="train",
|
||||
)
|
||||
if "train" in indexes
|
||||
else None
|
||||
)
|
||||
for band in bands:
|
||||
entry = limits.setdefault(band, {})
|
||||
if flight_level is not None:
|
||||
entry["flight"] = flight_level
|
||||
if train_level is not None:
|
||||
entry["train"] = train_level
|
||||
return limits
|
||||
|
||||
|
||||
def extract_hotel_season_limits(rows: list[tuple[Any, ...]]) -> dict[str, dict[str, Any]]:
|
||||
header_aliases = {
|
||||
"city": ("地区(城市)", "城市", "地区"),
|
||||
"peak_period": ("旺季期间(月)", "旺季期间", "旺季月份"),
|
||||
"peak_limit": ("旺季超标限额", "旺季住宿上限", "旺季限额"),
|
||||
}
|
||||
indexes = _find_header_indexes_for_aliases(rows, header_aliases, required=("city", "peak_period", "peak_limit"))
|
||||
header_index = indexes.pop("__header_index__", -1)
|
||||
if header_index < 0:
|
||||
return {"hotel_peak_periods": {}, "hotel_peak_city_limits": {}}
|
||||
|
||||
peak_periods: dict[str, str] = {}
|
||||
peak_limits: dict[str, Decimal] = {}
|
||||
for row in rows[header_index + 1 :]:
|
||||
period = _normalize_peak_period_text(_row_text(row, indexes["peak_period"]))
|
||||
peak_limit = _coerce_decimal_cell(row[indexes["peak_limit"]] if len(row) > indexes["peak_limit"] else None)
|
||||
if not period or peak_limit is None:
|
||||
continue
|
||||
for city in _split_city_cell(_row_text(row, indexes["city"])):
|
||||
peak_periods[city] = period
|
||||
peak_limits[city] = peak_limit
|
||||
return {"hotel_peak_periods": peak_periods, "hotel_peak_city_limits": peak_limits}
|
||||
|
||||
|
||||
def map_transport_grade_row_to_bands(value: str) -> list[str]:
|
||||
normalized = re.sub(r"\s+", "", str(value or "").upper())
|
||||
if not normalized or normalized.startswith("注"):
|
||||
return []
|
||||
if "-" not in normalized and "+" not in normalized and "及以上" not in normalized:
|
||||
exact_match = re.search(r"P\s*([0-8])", normalized)
|
||||
if exact_match:
|
||||
grade_key = f"P{int(exact_match.group(1))}"
|
||||
return [grade_key] if grade_key in TRAVEL_GRADE_KEYS else []
|
||||
bands: list[str] = []
|
||||
if any(
|
||||
keyword in normalized
|
||||
for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")
|
||||
):
|
||||
bands.extend(["junior", "mid"])
|
||||
if any(
|
||||
keyword in normalized
|
||||
for keyword in (
|
||||
"P5",
|
||||
"P6",
|
||||
"P7",
|
||||
"P5及以上",
|
||||
"中层经理",
|
||||
"高层经理",
|
||||
"公司级",
|
||||
"M3",
|
||||
"外聘专家",
|
||||
)
|
||||
):
|
||||
bands.extend(["mid", "senior", "manager", "executive"])
|
||||
return list(dict.fromkeys(bands))
|
||||
|
||||
|
||||
def transport_class_level_for_text(value: Any, *, kind: str) -> int | None:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
if not normalized:
|
||||
return None
|
||||
if kind == "flight":
|
||||
if any(keyword in normalized for keyword in ("头等舱",)):
|
||||
return 4
|
||||
if any(keyword in normalized for keyword in ("公务舱", "商务舱")):
|
||||
return 3
|
||||
if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")):
|
||||
return 2
|
||||
if "经济舱" in normalized:
|
||||
return 1
|
||||
if kind == "train":
|
||||
if "商务座" in normalized:
|
||||
return 3
|
||||
if any(keyword in normalized for keyword in ("一等座", "软卧")):
|
||||
return 2
|
||||
if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")):
|
||||
return 1
|
||||
return None
|
||||
|
||||
|
||||
def _find_header_indexes(
|
||||
rows: list[tuple[Any, ...]],
|
||||
header_aliases: dict[str, tuple[str, ...]],
|
||||
) -> dict[str, int]:
|
||||
for index, row in enumerate(rows[:10]):
|
||||
indexes: dict[str, int] = {}
|
||||
values = [str(value or "").strip() for value in row]
|
||||
for key, aliases in header_aliases.items():
|
||||
for alias in aliases:
|
||||
if alias in values:
|
||||
indexes[key] = values.index(alias)
|
||||
break
|
||||
if _has_required_normalized_header(indexes):
|
||||
indexes["__header_index__"] = index
|
||||
return indexes
|
||||
return {}
|
||||
|
||||
|
||||
def _find_header_indexes_for_aliases(
|
||||
rows: list[tuple[Any, ...]],
|
||||
header_aliases: dict[str, tuple[str, ...]],
|
||||
*,
|
||||
required: tuple[str, ...],
|
||||
) -> dict[str, int]:
|
||||
for index, row in enumerate(rows[:10]):
|
||||
indexes: dict[str, int] = {}
|
||||
values = [str(value or "").strip() for value in row]
|
||||
for key, aliases in header_aliases.items():
|
||||
for alias in aliases:
|
||||
if alias in values:
|
||||
indexes[key] = values.index(alias)
|
||||
break
|
||||
if all(key in indexes for key in required):
|
||||
indexes["__header_index__"] = index
|
||||
return indexes
|
||||
return {}
|
||||
|
||||
|
||||
def _has_required_normalized_header(indexes: dict[str, int]) -> bool:
|
||||
return (
|
||||
"region" in indexes
|
||||
and any(key in indexes for key in ("meal", "basic", "total"))
|
||||
) or ("employee" in indexes and any(key in indexes for key in ("flight", "train")))
|
||||
|
||||
|
||||
def _row_text(row: tuple[Any, ...], index: int) -> str:
|
||||
if index < 0 or len(row) <= index:
|
||||
return ""
|
||||
return str(row[index] or "").strip()
|
||||
|
||||
|
||||
def _coerce_decimal_cell(value: Any) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value).strip()).quantize(Decimal("0.01"))
|
||||
except (ArithmeticError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _split_city_cell(value: str) -> list[str]:
|
||||
normalized = re.sub(r"[;;,,、/]+", "、", str(value or "").strip())
|
||||
if not normalized:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for part in normalized.split("、"):
|
||||
cleaned = re.sub(r"\s+", "", part)
|
||||
cleaned = re.sub(r"[((].*?[))]", "", cleaned)
|
||||
if cleaned and len(cleaned) <= 12:
|
||||
names.append(cleaned.removesuffix("市"))
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _normalize_peak_period_text(value: str) -> str:
|
||||
text = re.sub(r"\s+", "", str(value or ""))
|
||||
text = re.sub(r"(月|上旬|中旬|下旬)", "", text)
|
||||
text = re.sub(r"[、,;;]+", ",", text)
|
||||
text = re.sub(r"[^0-9,\-]", "", text)
|
||||
return re.sub(r",{2,}", ",", text).strip(",")
|
||||
@@ -34,6 +34,57 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"applicationAmount",
|
||||
"application_amount_label",
|
||||
"applicationAmountLabel",
|
||||
"application_budget_occupied_amount",
|
||||
"applicationBudgetOccupiedAmount",
|
||||
"application_policy_total_amount",
|
||||
"applicationPolicyTotalAmount",
|
||||
),
|
||||
"transport_estimated_amount": (
|
||||
"application_transport_estimated_amount",
|
||||
"applicationTransportEstimatedAmount",
|
||||
"transportEstimatedAmount",
|
||||
"transport_estimate_amount",
|
||||
"transportEstimateAmount",
|
||||
),
|
||||
"train_estimated_amount": (
|
||||
"application_train_estimated_amount",
|
||||
"applicationTrainEstimatedAmount",
|
||||
"trainEstimatedAmount",
|
||||
),
|
||||
"flight_estimated_amount": (
|
||||
"application_flight_estimated_amount",
|
||||
"applicationFlightEstimatedAmount",
|
||||
"flightEstimatedAmount",
|
||||
),
|
||||
"hotel_amount": (
|
||||
"application_hotel_amount",
|
||||
"applicationHotelAmount",
|
||||
"lodging_amount",
|
||||
"lodgingAmount",
|
||||
"hotelAmount",
|
||||
),
|
||||
"allowance_amount": (
|
||||
"application_allowance_amount",
|
||||
"applicationAllowanceAmount",
|
||||
"subsidy_amount",
|
||||
"subsidyAmount",
|
||||
"allowanceAmount",
|
||||
),
|
||||
"policy_total_amount": (
|
||||
"application_policy_total_amount",
|
||||
"applicationPolicyTotalAmount",
|
||||
"application_budget_occupied_amount",
|
||||
"applicationBudgetOccupiedAmount",
|
||||
"policyTotalAmount",
|
||||
"budget_occupied_amount",
|
||||
"budgetOccupiedAmount",
|
||||
),
|
||||
"reimbursement_amount": (
|
||||
"application_reimbursement_amount",
|
||||
"applicationReimbursementAmount",
|
||||
"actual_reimbursement_amount",
|
||||
"actualReimbursementAmount",
|
||||
"reimbursementAmount",
|
||||
),
|
||||
"transport_mode": (
|
||||
"transport_type",
|
||||
@@ -42,6 +93,15 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"application_transport_mode",
|
||||
"applicationTransportMode",
|
||||
),
|
||||
"document_type": ("documentType", "ocr_document_type", "ocrDocumentType"),
|
||||
"invoice_no": ("invoiceNo", "invoice_number", "invoiceNumber", "ocr_invoice_no", "ocrInvoiceNo"),
|
||||
"invoice_date": ("invoiceDate", "issue_date", "issueDate", "ocr_invoice_date", "ocrInvoiceDate"),
|
||||
"ticket_no": ("ticketNo", "ticket_number", "ticketNumber", "ocr_ticket_no", "ocrTicketNo"),
|
||||
"ticket_type": ("ticketType", "ocr_ticket_type", "ocrTicketType"),
|
||||
"origin_location": ("originLocation", "departure_location", "departureLocation", "from_city", "fromCity"),
|
||||
"destination_location": ("destinationLocation", "arrival_location", "arrivalLocation", "to_city", "toCity"),
|
||||
"hotel_name": ("hotelName", "ocr_hotel_name", "ocrHotelName"),
|
||||
"hotel_nights": ("hotelNights", "stay_nights", "stayNights"),
|
||||
"attachments": ("attachment_names", "attachmentNames"),
|
||||
"customer_name": ("customerName",),
|
||||
"merchant_name": ("merchantName",),
|
||||
@@ -54,6 +114,10 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"manager_name": ("managerName", "direct_manager_name", "directManagerName"),
|
||||
"finance_owner_name": ("financeOwnerName",),
|
||||
"finance_approver_name": ("financeApproverName",),
|
||||
"basic_rule_code": ("basicRuleCode", "finance_rule_code", "financeRuleCode"),
|
||||
"basic_rule_sheet": ("basicRuleSheet", "finance_rule_sheet", "financeRuleSheet"),
|
||||
"basic_rule_name": ("basicRuleName", "finance_rule_name", "financeRuleName"),
|
||||
"basic_rule_version": ("basicRuleVersion", "finance_rule_version", "financeRuleVersion"),
|
||||
}
|
||||
|
||||
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
|
||||
@@ -69,14 +133,36 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
|
||||
"employee_location",
|
||||
"employee_risk_profile",
|
||||
"document_id",
|
||||
"invoice_no",
|
||||
"invoice_date",
|
||||
"ticket_no",
|
||||
"ticket_type",
|
||||
"origin_location",
|
||||
"destination_location",
|
||||
"hotel_name",
|
||||
"hotel_nights",
|
||||
"application_claim_id",
|
||||
"application_claim_no",
|
||||
"application_status",
|
||||
"application_amount",
|
||||
"application_approved_amount",
|
||||
"application_budget_occupied_amount",
|
||||
"application_reimbursement_amount",
|
||||
"application_expense_type",
|
||||
"application_days",
|
||||
"application_date",
|
||||
"application_required",
|
||||
"preapproval_required",
|
||||
"application_lodging_daily_cap",
|
||||
"application_subsidy_daily_cap",
|
||||
"application_transport_policy",
|
||||
"application_policy_estimate",
|
||||
"application_transport_estimated_amount",
|
||||
"application_train_estimated_amount",
|
||||
"application_flight_estimated_amount",
|
||||
"application_hotel_amount",
|
||||
"application_allowance_amount",
|
||||
"application_policy_total_amount",
|
||||
"application_rule_name",
|
||||
"application_rule_version",
|
||||
"original_amount",
|
||||
|
||||
@@ -2,6 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from app.schemas.ontology import OntologyParseResult
|
||||
|
||||
PREAPPROVAL_RULE_CODES = [
|
||||
"risk.application.meal_high_value_without_preapproval",
|
||||
"risk.application.office_bulk_without_purchase",
|
||||
"risk.application.large_expense_without_preapproval",
|
||||
]
|
||||
|
||||
APPLICATION_REQUIRED_RULE_CODES = [
|
||||
*PREAPPROVAL_RULE_CODES,
|
||||
"risk.application.travel_large_without_preapproval",
|
||||
"risk.application.marketing_without_campaign",
|
||||
]
|
||||
|
||||
RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
|
||||
"location_mismatch": ["risk.travel.destination_receipt_location"],
|
||||
"base_location_overlap": ["risk.travel.base_location_overlap"],
|
||||
@@ -18,6 +30,9 @@ RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
|
||||
"meal_as_travel": ["risk.expense.meal_localized_as_travel"],
|
||||
"consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"],
|
||||
"reason_too_brief": ["risk.expense.reason_too_brief"],
|
||||
"application_required": APPLICATION_REQUIRED_RULE_CODES,
|
||||
"application_absent": APPLICATION_REQUIRED_RULE_CODES,
|
||||
"preapproval_absent": PREAPPROVAL_RULE_CODES,
|
||||
}
|
||||
|
||||
TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
|
||||
@@ -32,6 +47,29 @@ TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
|
||||
"meal_as_travel": ("餐费", "差旅餐", "本地餐"),
|
||||
"consecutive_transport_receipts": ("连续交通", "多张车票", "打车"),
|
||||
"reason_too_brief": ("事由", "说明太短", "理由不足"),
|
||||
"application_required": (
|
||||
"前置申请",
|
||||
"事前申请",
|
||||
"事前审批",
|
||||
"费用申请",
|
||||
"申请审批",
|
||||
"无申请",
|
||||
"未申请",
|
||||
"缺少申请",
|
||||
"没有申请",
|
||||
"未审批",
|
||||
),
|
||||
"preapproval_absent": (
|
||||
"无前置申请",
|
||||
"未做申请",
|
||||
"未提交申请",
|
||||
"未走审批",
|
||||
"大额费用",
|
||||
"业务招待",
|
||||
"招待费",
|
||||
"办公采购",
|
||||
"办公用品",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
378
server/src/app/services/steward_flow_state.py
Normal file
378
server/src/app/services/steward_flow_state.py
Normal file
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.steward import (
|
||||
StewardCandidateFlow,
|
||||
StewardFlowStatePatch,
|
||||
StewardPendingFlowConfirmation,
|
||||
StewardPlanResponse,
|
||||
StewardTask,
|
||||
)
|
||||
from app.services.ontology_field_registry import (
|
||||
CANONICAL_ONTOLOGY_FIELDS,
|
||||
normalize_ontology_form_values,
|
||||
)
|
||||
|
||||
|
||||
class StewardFlowStateService:
|
||||
"""维护小财管家跨轮对话的本体业务状态。"""
|
||||
|
||||
EVENT_LIMIT = 80
|
||||
|
||||
def merge_state(
|
||||
self,
|
||||
current_state: dict[str, Any] | None,
|
||||
patch: StewardFlowStatePatch,
|
||||
) -> dict[str, Any]:
|
||||
state = self._normalize_state(current_state)
|
||||
flow = dict(state["flows"].get(patch.flow_id) or {})
|
||||
existing_fields = dict(flow.get("fields") or {})
|
||||
next_fields = {
|
||||
**existing_fields,
|
||||
**self._normalize_fields(patch.fields),
|
||||
}
|
||||
|
||||
flow.update(
|
||||
{
|
||||
"flow_id": patch.flow_id,
|
||||
"status": str(patch.status or "collecting").strip() or "collecting",
|
||||
"intent": str(patch.intent or flow.get("intent") or "").strip(),
|
||||
"fields": next_fields,
|
||||
"missing_fields": self._normalize_missing_fields(patch.missing_fields),
|
||||
}
|
||||
)
|
||||
if patch.application_claim_id:
|
||||
flow["application_claim_id"] = patch.application_claim_id
|
||||
if patch.linked_application_claim_id:
|
||||
flow["linked_application_claim_id"] = patch.linked_application_claim_id
|
||||
if patch.attachments:
|
||||
flow["attachments"] = self._merge_attachments(flow.get("attachments"), patch.attachments)
|
||||
|
||||
state["active_flow"] = patch.active_flow
|
||||
state["flows"][patch.flow_id] = flow
|
||||
state["events"] = self._append_event(state.get("events"), patch, flow)
|
||||
return state
|
||||
|
||||
def merge_plan(
|
||||
self,
|
||||
current_state: dict[str, Any] | None,
|
||||
plan: StewardPlanResponse,
|
||||
) -> dict[str, Any]:
|
||||
state = self._normalize_state(current_state)
|
||||
if plan.pending_flow_confirmation.status == "pending":
|
||||
state = self._merge_pending_flow_confirmation(
|
||||
state,
|
||||
plan.pending_flow_confirmation,
|
||||
next_action=plan.next_action,
|
||||
)
|
||||
for task in plan.tasks:
|
||||
state = self.merge_state(
|
||||
state,
|
||||
self._build_patch_from_task(
|
||||
task,
|
||||
linked_application_claim_id=self._resolve_application_claim_id(state),
|
||||
attachments=self._resolve_task_attachments(plan, task.task_id),
|
||||
),
|
||||
)
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _normalize_state(current_state: dict[str, Any] | None) -> dict[str, Any]:
|
||||
source = current_state if isinstance(current_state, dict) else {}
|
||||
flows = source.get("flows") if isinstance(source.get("flows"), dict) else {}
|
||||
events = source.get("events") if isinstance(source.get("events"), list) else []
|
||||
return {
|
||||
"version": str(source.get("version") or "steward.flow_state.v2"),
|
||||
"active_flow": str(source.get("active_flow") or "").strip(),
|
||||
"flows": dict(flows),
|
||||
"pending_flow_confirmation": (
|
||||
dict(source.get("pending_flow_confirmation"))
|
||||
if isinstance(source.get("pending_flow_confirmation"), dict)
|
||||
else {
|
||||
"status": "none",
|
||||
"source_message": "",
|
||||
"reason": "",
|
||||
"candidate_flows": [],
|
||||
}
|
||||
),
|
||||
"next_action": str(source.get("next_action") or "").strip(),
|
||||
"events": list(events),
|
||||
}
|
||||
|
||||
def confirm_flow(
|
||||
self,
|
||||
current_state: dict[str, Any],
|
||||
flow_id: str,
|
||||
) -> dict[str, Any]:
|
||||
state = self._normalize_state(current_state)
|
||||
if flow_id not in {"travel_application", "travel_reimbursement"}:
|
||||
return state
|
||||
|
||||
flows = state["flows"] if isinstance(state.get("flows"), dict) else {}
|
||||
flow = dict(flows.get(flow_id) or {})
|
||||
flow["flow_id"] = flow_id
|
||||
flow["status"] = "collecting" if flow.get("missing_fields") else "ready_for_confirmation"
|
||||
flows[flow_id] = flow
|
||||
state["flows"] = flows
|
||||
state["active_flow"] = flow_id
|
||||
state["next_action"] = "continue_selected_flow"
|
||||
|
||||
pending = dict(state.get("pending_flow_confirmation") or {})
|
||||
pending["status"] = "confirmed"
|
||||
pending["confirmed_flow_id"] = flow_id
|
||||
state["pending_flow_confirmation"] = pending
|
||||
state["events"] = self._append_flow_confirmation_event(state.get("events"), flow_id)
|
||||
return state
|
||||
|
||||
def _merge_pending_flow_confirmation(
|
||||
self,
|
||||
state: dict[str, Any],
|
||||
pending: StewardPendingFlowConfirmation,
|
||||
*,
|
||||
next_action: str,
|
||||
) -> dict[str, Any]:
|
||||
candidate_flows = [
|
||||
self._serialize_candidate_flow(candidate)
|
||||
for candidate in pending.candidate_flows
|
||||
]
|
||||
state["version"] = "steward.flow_state.v2"
|
||||
state["active_flow"] = ""
|
||||
state["next_action"] = str(next_action or "confirm_flow")
|
||||
state["pending_flow_confirmation"] = {
|
||||
"status": pending.status,
|
||||
"source_message": pending.source_message,
|
||||
"reason": pending.reason,
|
||||
"candidate_flows": candidate_flows,
|
||||
}
|
||||
flows = state["flows"] if isinstance(state.get("flows"), dict) else {}
|
||||
for candidate in pending.candidate_flows:
|
||||
flow = dict(flows.get(candidate.flow_id) or {})
|
||||
flow.update(
|
||||
{
|
||||
"flow_id": candidate.flow_id,
|
||||
"intent": self._resolve_candidate_intent(candidate.flow_id),
|
||||
"status": "pending_flow_confirmation",
|
||||
"fields": self._normalize_fields(candidate.ontology_fields),
|
||||
"missing_fields": self._normalize_missing_fields(candidate.missing_fields),
|
||||
"confidence": candidate.confidence,
|
||||
"evidence": [
|
||||
{
|
||||
"source": "pending_flow_confirmation",
|
||||
"field": key,
|
||||
"text": candidate.reason or pending.reason,
|
||||
}
|
||||
for key in candidate.ontology_fields
|
||||
],
|
||||
}
|
||||
)
|
||||
flows[candidate.flow_id] = flow
|
||||
state["flows"] = flows
|
||||
state["events"] = self._append_pending_flow_event(state.get("events"), pending)
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _serialize_candidate_flow(candidate: StewardCandidateFlow) -> dict[str, Any]:
|
||||
return {
|
||||
"flow_id": candidate.flow_id,
|
||||
"label": candidate.label,
|
||||
"confidence": candidate.confidence,
|
||||
"reason": candidate.reason,
|
||||
"ontology_fields": dict(candidate.ontology_fields),
|
||||
"missing_fields": list(candidate.missing_fields),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_candidate_intent(flow_id: str) -> str:
|
||||
return (
|
||||
"travel_application_create"
|
||||
if flow_id == "travel_application"
|
||||
else "travel_reimbursement_draft"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_patch_from_task(
|
||||
task: StewardTask,
|
||||
*,
|
||||
linked_application_claim_id: str = "",
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
) -> StewardFlowStatePatch:
|
||||
if task.task_type == "expense_application":
|
||||
flow_id = "travel_application"
|
||||
intent = "travel_application_create"
|
||||
link_id = ""
|
||||
else:
|
||||
flow_id = "travel_reimbursement"
|
||||
intent = "travel_reimbursement_draft"
|
||||
link_id = linked_application_claim_id
|
||||
return StewardFlowStatePatch(
|
||||
active_flow=flow_id,
|
||||
flow_id=flow_id,
|
||||
intent=intent,
|
||||
status="collecting" if task.missing_fields else "ready_for_confirmation",
|
||||
fields=task.ontology_fields,
|
||||
missing_fields=task.missing_fields,
|
||||
linked_application_claim_id=link_id,
|
||||
attachments=attachments or [],
|
||||
evidence=[
|
||||
{
|
||||
"source": "steward_plan",
|
||||
"field": key,
|
||||
"text": task.summary,
|
||||
}
|
||||
for key in task.ontology_fields
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_claim_id(state: dict[str, Any]) -> str:
|
||||
flows = state.get("flows") if isinstance(state.get("flows"), dict) else {}
|
||||
application_flow = flows.get("travel_application") if isinstance(flows, dict) else {}
|
||||
if not isinstance(application_flow, dict):
|
||||
return ""
|
||||
return str(application_flow.get("application_claim_id") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_task_attachments(
|
||||
plan: StewardPlanResponse,
|
||||
task_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
attachments: list[dict[str, Any]] = []
|
||||
for group in plan.attachment_groups:
|
||||
if group.target_task_id != task_id:
|
||||
continue
|
||||
for name in group.attachment_names:
|
||||
normalized = str(name or "").strip()
|
||||
if normalized:
|
||||
attachments.append({"name": normalized, "source": "steward_attachment_group"})
|
||||
return attachments
|
||||
|
||||
@staticmethod
|
||||
def _normalize_fields(fields: dict[str, Any]) -> dict[str, str]:
|
||||
normalized = normalize_ontology_form_values(fields)
|
||||
return {
|
||||
key: value
|
||||
for key, value in normalized.items()
|
||||
if key in CANONICAL_ONTOLOGY_FIELDS and str(value or "").strip()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_missing_fields(fields: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for field in fields:
|
||||
key = str(field or "").strip()
|
||||
if key in CANONICAL_ONTOLOGY_FIELDS and key not in normalized:
|
||||
normalized.append(key)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _merge_attachments(
|
||||
current_attachments: Any,
|
||||
incoming_attachments: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
attachments = [
|
||||
dict(item)
|
||||
for item in current_attachments
|
||||
if isinstance(item, dict)
|
||||
] if isinstance(current_attachments, list) else []
|
||||
seen = {
|
||||
str(item.get("file_id") or item.get("name") or "").strip()
|
||||
for item in attachments
|
||||
if str(item.get("file_id") or item.get("name") or "").strip()
|
||||
}
|
||||
for item in incoming_attachments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = str(item.get("file_id") or item.get("name") or "").strip()
|
||||
if key and key in seen:
|
||||
continue
|
||||
attachments.append(dict(item))
|
||||
if key:
|
||||
seen.add(key)
|
||||
return attachments
|
||||
|
||||
def _append_event(
|
||||
self,
|
||||
current_events: Any,
|
||||
patch: StewardFlowStatePatch,
|
||||
flow: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
events = [
|
||||
dict(item)
|
||||
for item in current_events
|
||||
if isinstance(item, dict)
|
||||
] if isinstance(current_events, list) else []
|
||||
events.append(
|
||||
{
|
||||
"sequence": len(events) + 1,
|
||||
"flow_id": patch.flow_id,
|
||||
"active_flow": patch.active_flow,
|
||||
"intent": str(patch.intent or flow.get("intent") or "").strip(),
|
||||
"status": str(flow.get("status") or "").strip(),
|
||||
"fields": self._normalize_fields(patch.fields),
|
||||
"missing_fields": list(flow.get("missing_fields") or []),
|
||||
"evidence": [
|
||||
dict(item)
|
||||
for item in patch.evidence
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
}
|
||||
)
|
||||
return events[-self.EVENT_LIMIT :]
|
||||
|
||||
def _append_pending_flow_event(
|
||||
self,
|
||||
current_events: Any,
|
||||
pending: StewardPendingFlowConfirmation,
|
||||
) -> list[dict[str, Any]]:
|
||||
events = [
|
||||
dict(item)
|
||||
for item in current_events
|
||||
if isinstance(item, dict)
|
||||
] if isinstance(current_events, list) else []
|
||||
events.append(
|
||||
{
|
||||
"sequence": len(events) + 1,
|
||||
"flow_id": "",
|
||||
"active_flow": "",
|
||||
"intent": "pending_flow_confirmation",
|
||||
"status": pending.status,
|
||||
"fields": {},
|
||||
"missing_fields": [],
|
||||
"candidate_flows": [
|
||||
self._serialize_candidate_flow(candidate)
|
||||
for candidate in pending.candidate_flows
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"source": "steward_plan",
|
||||
"text": pending.source_message,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
return events[-self.EVENT_LIMIT :]
|
||||
|
||||
def _append_flow_confirmation_event(
|
||||
self,
|
||||
current_events: Any,
|
||||
flow_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
events = [
|
||||
dict(item)
|
||||
for item in current_events
|
||||
if isinstance(item, dict)
|
||||
] if isinstance(current_events, list) else []
|
||||
events.append(
|
||||
{
|
||||
"sequence": len(events) + 1,
|
||||
"flow_id": flow_id,
|
||||
"active_flow": flow_id,
|
||||
"intent": "flow_confirmed",
|
||||
"status": "confirmed",
|
||||
"fields": {},
|
||||
"missing_fields": [],
|
||||
"evidence": [{"source": "runtime_user_selection", "text": flow_id}],
|
||||
}
|
||||
)
|
||||
return events[-self.EVENT_LIMIT :]
|
||||
@@ -108,6 +108,9 @@ class StewardIntentAgent:
|
||||
"用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时,"
|
||||
"即使没有出现“申请”两个字,也必须优先识别为 expense_application。"
|
||||
"用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。"
|
||||
"如果用户只描述出差时间、地点和事由,但没有明确申请、报销、提交、保存草稿等动作,"
|
||||
"且无法从上下文判断流程方向,必须返回 pending_flow_confirmation.status=pending,"
|
||||
"candidate_flows 同时给出 travel_application 和 travel_reimbursement,tasks 保持空数组。"
|
||||
"所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields;"
|
||||
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
|
||||
"相对日期必须以 base_date 为准转换为明确日期。"
|
||||
@@ -180,6 +183,56 @@ class StewardIntentAgent:
|
||||
],
|
||||
},
|
||||
},
|
||||
"pending_flow_confirmation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["none", "pending"],
|
||||
},
|
||||
"source_message": {"type": "string"},
|
||||
"reason": {"type": "string"},
|
||||
"candidate_flows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"flow_id": {
|
||||
"type": "string",
|
||||
"enum": ["travel_application", "travel_reimbursement"],
|
||||
},
|
||||
"label": {"type": "string"},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
},
|
||||
"reason": {"type": "string"},
|
||||
"ontology_fields": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
"missing_fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": canonical_fields,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"flow_id",
|
||||
"label",
|
||||
"confidence",
|
||||
"reason",
|
||||
"ontology_fields",
|
||||
"missing_fields",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["status", "source_message", "reason", "candidate_flows"],
|
||||
},
|
||||
"attachment_groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import Any
|
||||
from app.schemas.steward import (
|
||||
StewardAttachmentGroup,
|
||||
StewardAttachmentInput,
|
||||
StewardCandidateFlow,
|
||||
StewardPendingFlowConfirmation,
|
||||
StewardPlanRequest,
|
||||
StewardPlanResponse,
|
||||
StewardTask,
|
||||
@@ -31,7 +33,18 @@ class StewardModelPlanBuilder:
|
||||
request: StewardPlanRequest,
|
||||
base_date: date,
|
||||
) -> StewardPlanResponse | None:
|
||||
pending_flow_confirmation = self._build_pending_flow_confirmation(
|
||||
intent_result.payload,
|
||||
request=request,
|
||||
base_date=base_date,
|
||||
)
|
||||
tasks = self._build_tasks_from_model_payload(intent_result.payload, request, base_date)
|
||||
if not tasks and pending_flow_confirmation.status == "pending":
|
||||
return self._build_pending_flow_plan(
|
||||
pending_flow_confirmation,
|
||||
intent_result,
|
||||
request=request,
|
||||
)
|
||||
if not tasks:
|
||||
return None
|
||||
|
||||
@@ -54,11 +67,33 @@ class StewardModelPlanBuilder:
|
||||
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
|
||||
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
|
||||
planning_source="llm_function_call",
|
||||
next_action="confirm_task" if confirmation_groups else "delegate_task",
|
||||
summary=self.planner._build_summary(tasks, attachment_groups),
|
||||
thinking_events=thinking_events,
|
||||
tasks=tasks,
|
||||
attachment_groups=attachment_groups,
|
||||
confirmation_groups=confirmation_groups,
|
||||
pending_flow_confirmation=pending_flow_confirmation,
|
||||
candidate_flows=pending_flow_confirmation.candidate_flows,
|
||||
model_call_traces=intent_result.model_call_traces,
|
||||
)
|
||||
|
||||
def _build_pending_flow_plan(
|
||||
self,
|
||||
pending_flow_confirmation: StewardPendingFlowConfirmation,
|
||||
intent_result: StewardIntentAgentResult,
|
||||
*,
|
||||
request: StewardPlanRequest,
|
||||
) -> StewardPlanResponse:
|
||||
return StewardPlanResponse(
|
||||
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
|
||||
plan_status="needs_flow_confirmation",
|
||||
planning_source="llm_function_call",
|
||||
next_action="confirm_flow",
|
||||
summary=self._build_pending_flow_summary(pending_flow_confirmation),
|
||||
thinking_events=self._build_pending_flow_thinking_events(intent_result.payload, request),
|
||||
pending_flow_confirmation=pending_flow_confirmation,
|
||||
candidate_flows=pending_flow_confirmation.candidate_flows,
|
||||
model_call_traces=intent_result.model_call_traces,
|
||||
)
|
||||
|
||||
@@ -144,6 +179,134 @@ class StewardModelPlanBuilder:
|
||||
|
||||
return tasks
|
||||
|
||||
def _build_pending_flow_confirmation(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
request: StewardPlanRequest,
|
||||
base_date: date,
|
||||
) -> StewardPendingFlowConfirmation:
|
||||
raw_pending = payload.get("pending_flow_confirmation")
|
||||
raw_candidates = payload.get("candidate_flows")
|
||||
if isinstance(raw_pending, dict):
|
||||
raw_candidates = raw_pending.get("candidate_flows", raw_candidates)
|
||||
status = self.planner._clean_text(raw_pending.get("status")) or "pending"
|
||||
source_message = self.planner._clean_text(raw_pending.get("source_message")) or request.message
|
||||
reason = self.planner._clean_text(raw_pending.get("reason"))
|
||||
else:
|
||||
status = "pending" if isinstance(raw_candidates, list) and raw_candidates else "none"
|
||||
source_message = request.message
|
||||
reason = ""
|
||||
candidates = self._build_candidate_flows(raw_candidates, request=request, base_date=base_date)
|
||||
if status != "pending" or not candidates:
|
||||
return StewardPendingFlowConfirmation()
|
||||
return StewardPendingFlowConfirmation(
|
||||
status="pending",
|
||||
source_message=source_message,
|
||||
reason=reason or "当前话术同时可能进入申请或报销流程,需要先请用户确认。",
|
||||
candidate_flows=candidates,
|
||||
)
|
||||
|
||||
def _build_candidate_flows(
|
||||
self,
|
||||
raw_candidates: Any,
|
||||
*,
|
||||
request: StewardPlanRequest,
|
||||
base_date: date,
|
||||
) -> list[StewardCandidateFlow]:
|
||||
if not isinstance(raw_candidates, list):
|
||||
return []
|
||||
candidates: list[StewardCandidateFlow] = []
|
||||
for raw_candidate in raw_candidates:
|
||||
if not isinstance(raw_candidate, dict):
|
||||
continue
|
||||
flow_id = self.planner._clean_text(raw_candidate.get("flow_id"))
|
||||
if flow_id not in {"travel_application", "travel_reimbursement"}:
|
||||
continue
|
||||
task_type = "expense_application" if flow_id == "travel_application" else "reimbursement"
|
||||
fields = self._sanitize_model_ontology_fields(
|
||||
raw_candidate.get("ontology_fields"),
|
||||
request=request,
|
||||
base_date=base_date,
|
||||
)
|
||||
if not fields:
|
||||
fields = self.planner._extract_ontology_fields(
|
||||
request.message,
|
||||
task_type,
|
||||
base_date,
|
||||
request,
|
||||
)
|
||||
missing_fields = self._sanitize_model_missing_fields(
|
||||
raw_candidate.get("missing_fields"),
|
||||
task_type=task_type,
|
||||
fields=fields,
|
||||
)
|
||||
label = self.planner._clean_text(raw_candidate.get("label")) or (
|
||||
"补办出差申请" if flow_id == "travel_application" else "发起费用报销"
|
||||
)
|
||||
candidates.append(
|
||||
StewardCandidateFlow(
|
||||
flow_id=flow_id, # type: ignore[arg-type]
|
||||
label=label,
|
||||
confidence=self._clamp_confidence(raw_candidate.get("confidence"), default=0.5),
|
||||
reason=self.planner._clean_text(raw_candidate.get("reason")),
|
||||
ontology_fields=fields,
|
||||
missing_fields=missing_fields,
|
||||
)
|
||||
)
|
||||
return candidates[:2]
|
||||
|
||||
def _build_pending_flow_thinking_events(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
request: StewardPlanRequest,
|
||||
) -> list[StewardThinkingEvent]:
|
||||
events = [
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_agent_function_call",
|
||||
stage="llm_function_call",
|
||||
title="识别财务事项",
|
||||
content="我识别到这句话包含出差事项,但还需要确认你要进入申请流程还是报销流程。",
|
||||
)
|
||||
]
|
||||
raw_events = payload.get("thinking_events")
|
||||
if isinstance(raw_events, list):
|
||||
for raw_event in raw_events[:4]:
|
||||
if not isinstance(raw_event, dict):
|
||||
continue
|
||||
title = self.planner._clean_text(raw_event.get("title"))
|
||||
content = self.planner._clean_text(raw_event.get("content"))
|
||||
if not title or not content:
|
||||
continue
|
||||
events.append(
|
||||
StewardThinkingEvent(
|
||||
event_id=f"intent_agent_model_{len(events):03d}",
|
||||
stage=self.planner._clean_text(raw_event.get("stage")) or "flow_confirmation",
|
||||
title=title,
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
if len(events) == 1:
|
||||
events.append(
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_agent_pending_flow",
|
||||
stage="flow_confirmation",
|
||||
title="等待确认流程方向",
|
||||
content=f"当前输入“{request.message}”缺少明确动作词,需要先由你选择补办出差申请或发起费用报销。",
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
@staticmethod
|
||||
def _build_pending_flow_summary(pending_flow_confirmation: StewardPendingFlowConfirmation) -> str:
|
||||
candidate_labels = [item.label for item in pending_flow_confirmation.candidate_flows if item.label]
|
||||
if len(candidate_labels) >= 2:
|
||||
return (
|
||||
f"我识别到这是一次财务事项,但还不能确定你要做的是"
|
||||
f"**{candidate_labels[0]}**还是**{candidate_labels[1]}**。请先选择一个方向。"
|
||||
)
|
||||
return "我识别到这是一次财务事项,但还需要先确认具体流程方向。"
|
||||
|
||||
def _sanitize_model_ontology_fields(
|
||||
self,
|
||||
raw_fields: Any,
|
||||
|
||||
@@ -9,7 +9,9 @@ from typing import Any
|
||||
from app.schemas.steward import (
|
||||
StewardAttachmentGroup,
|
||||
StewardAttachmentInput,
|
||||
StewardCandidateFlow,
|
||||
StewardConfirmationAction,
|
||||
StewardPendingFlowConfirmation,
|
||||
StewardPlanRequest,
|
||||
StewardPlanResponse,
|
||||
StewardTask,
|
||||
@@ -107,7 +109,7 @@ class StewardPlannerService:
|
||||
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
|
||||
model_call_traces: list[dict[str, Any]] = []
|
||||
fallback_reason = ""
|
||||
if self.intent_agent is not None:
|
||||
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
|
||||
try:
|
||||
intent_result = self.intent_agent.detect(
|
||||
request,
|
||||
@@ -122,6 +124,17 @@ class StewardPlannerService:
|
||||
base_date=base_date,
|
||||
)
|
||||
if llm_plan is not None:
|
||||
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
|
||||
return self._build_pending_flow_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=(
|
||||
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
|
||||
"服务端已改为候选流程确认,避免误入申请流程。"
|
||||
),
|
||||
planning_source="llm_function_call",
|
||||
)
|
||||
return llm_plan
|
||||
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
|
||||
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
|
||||
@@ -136,6 +149,16 @@ class StewardPlannerService:
|
||||
fallback_reason=fallback_reason,
|
||||
)
|
||||
|
||||
def _should_use_model_intent_recognition(
|
||||
self,
|
||||
message: str,
|
||||
base_date: date,
|
||||
request: StewardPlanRequest,
|
||||
) -> bool:
|
||||
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
|
||||
return False
|
||||
return self._has_multiple_financial_demands(message)
|
||||
|
||||
def _build_rule_fallback_plan(
|
||||
self,
|
||||
request: StewardPlanRequest,
|
||||
@@ -145,6 +168,13 @@ class StewardPlannerService:
|
||||
fallback_reason: str = "",
|
||||
) -> StewardPlanResponse:
|
||||
message = self._clean_text(request.message)
|
||||
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
|
||||
return self._build_pending_flow_fallback_plan(
|
||||
request,
|
||||
base_date=base_date,
|
||||
model_call_traces=model_call_traces,
|
||||
fallback_reason=fallback_reason,
|
||||
)
|
||||
task_drafts = self._extract_task_drafts(message)
|
||||
tasks = [self._build_task(draft, base_date, request) for draft in task_drafts]
|
||||
if not tasks:
|
||||
@@ -169,6 +199,7 @@ class StewardPlannerService:
|
||||
plan_id=plan_id,
|
||||
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
|
||||
planning_source="rule_fallback",
|
||||
next_action="confirm_task" if confirmation_groups else "delegate_task",
|
||||
summary=self._build_summary(tasks, attachment_groups),
|
||||
thinking_events=thinking_events,
|
||||
tasks=tasks,
|
||||
@@ -177,6 +208,91 @@ class StewardPlannerService:
|
||||
model_call_traces=model_call_traces or [],
|
||||
)
|
||||
|
||||
def _build_pending_flow_fallback_plan(
|
||||
self,
|
||||
request: StewardPlanRequest,
|
||||
*,
|
||||
base_date: date,
|
||||
model_call_traces: list[dict[str, Any]] | None = None,
|
||||
fallback_reason: str = "",
|
||||
planning_source: str = "rule_fallback",
|
||||
) -> StewardPlanResponse:
|
||||
candidates = self._build_rule_candidate_flows(request, base_date)
|
||||
pending = StewardPendingFlowConfirmation(
|
||||
status="pending",
|
||||
source_message=request.message,
|
||||
reason="当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。",
|
||||
candidate_flows=candidates,
|
||||
)
|
||||
thinking_events = []
|
||||
if fallback_reason:
|
||||
thinking_events.append(
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_agent_rule_fallback",
|
||||
stage="rule_fallback",
|
||||
title="意图识别智能体进入兜底模式",
|
||||
content=fallback_reason,
|
||||
)
|
||||
)
|
||||
thinking_events.append(
|
||||
StewardThinkingEvent(
|
||||
event_id="intent_pending_flow_confirmation",
|
||||
stage="flow_confirmation",
|
||||
title="需要确认流程方向",
|
||||
content="我识别到时间、地点和出差事由,但没有识别到明确的申请或报销动作,需要先请你选择流程方向。",
|
||||
)
|
||||
)
|
||||
return StewardPlanResponse(
|
||||
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
|
||||
plan_status="needs_flow_confirmation",
|
||||
planning_source=planning_source, # type: ignore[arg-type]
|
||||
next_action="confirm_flow",
|
||||
summary=(
|
||||
"我识别到这是一次出差事项,但还不能确定你要做的是"
|
||||
"**补办出差申请**还是**发起费用报销**。请先选择一个方向。"
|
||||
),
|
||||
thinking_events=thinking_events,
|
||||
pending_flow_confirmation=pending,
|
||||
candidate_flows=candidates,
|
||||
model_call_traces=model_call_traces or [],
|
||||
)
|
||||
|
||||
def _build_rule_candidate_flows(
|
||||
self,
|
||||
request: StewardPlanRequest,
|
||||
base_date: date,
|
||||
) -> list[StewardCandidateFlow]:
|
||||
application_fields = self._extract_ontology_fields(
|
||||
request.message,
|
||||
"expense_application",
|
||||
base_date,
|
||||
request,
|
||||
)
|
||||
reimbursement_fields = self._extract_ontology_fields(
|
||||
request.message,
|
||||
"reimbursement",
|
||||
base_date,
|
||||
request,
|
||||
)
|
||||
return [
|
||||
StewardCandidateFlow(
|
||||
flow_id="travel_application",
|
||||
label="补办出差申请",
|
||||
confidence=0.52,
|
||||
reason="用户描述了出差时间、地点和事由,但没有明确说要报销。",
|
||||
ontology_fields=application_fields,
|
||||
missing_fields=self._resolve_missing_fields("expense_application", application_fields),
|
||||
),
|
||||
StewardCandidateFlow(
|
||||
flow_id="travel_reimbursement",
|
||||
label="发起费用报销",
|
||||
confidence=0.48,
|
||||
reason="用户描述的也可能是已发生出差事项,需要进入报销材料整理。",
|
||||
ontology_fields=reimbursement_fields,
|
||||
missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields),
|
||||
),
|
||||
]
|
||||
|
||||
def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]:
|
||||
drafts: list[PlannedTaskDraft] = []
|
||||
first_reimbursement = self._find_first_reimbursement_index(message)
|
||||
@@ -202,6 +318,24 @@ class StewardPlannerService:
|
||||
|
||||
return drafts
|
||||
|
||||
def _has_multiple_financial_demands(self, message: str) -> bool:
|
||||
task_drafts = self._extract_task_drafts(message)
|
||||
if len(task_drafts) > 1:
|
||||
return True
|
||||
|
||||
compact = re.sub(r"\s+", "", message)
|
||||
if not compact:
|
||||
return False
|
||||
|
||||
application_signal = self._looks_like_application(compact) or self._looks_like_future_travel_application(compact)
|
||||
reimbursement_signal = self._find_first_reimbursement_index(compact) >= 0
|
||||
if application_signal and reimbursement_signal:
|
||||
return True
|
||||
|
||||
connector_signal = re.search(r"并且|同时|另外|还有|还要|以及|再", compact)
|
||||
repeated_reimbursement_signal = len(list(REIMBURSEMENT_PATTERN.finditer(compact))) > 1
|
||||
return bool(connector_signal and repeated_reimbursement_signal)
|
||||
|
||||
@staticmethod
|
||||
def _find_first_reimbursement_index(message: str) -> int:
|
||||
candidates = [message.find(item) for item in ("我要报销", "还需要报销", "需要报销", "报销")]
|
||||
@@ -238,6 +372,35 @@ class StewardPlannerService:
|
||||
)
|
||||
return bool((business_signal or route_signal) and (time_signal or planned_route_signal))
|
||||
|
||||
def _looks_like_ambiguous_travel_flow(
|
||||
self,
|
||||
text: str,
|
||||
base_date: date,
|
||||
request: StewardPlanRequest,
|
||||
) -> bool:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
if not compact or request.attachments:
|
||||
return False
|
||||
if re.search(r"申请|报销|草稿|提交|审批|保存|发起|创建", compact):
|
||||
return False
|
||||
if not re.search(r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", compact):
|
||||
return False
|
||||
if not self._extract_time_range(compact, base_date):
|
||||
return False
|
||||
if not self._extract_location(compact):
|
||||
return False
|
||||
return not self._is_future_or_current_time_range(compact, base_date)
|
||||
|
||||
def _is_future_or_current_time_range(self, segment: str, base_date: date) -> bool:
|
||||
normalized = self._extract_time_range(segment, base_date)
|
||||
if not normalized:
|
||||
return False
|
||||
try:
|
||||
parsed = date.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed >= base_date
|
||||
|
||||
def _build_task(
|
||||
self,
|
||||
draft: PlannedTaskDraft,
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.steward import (
|
||||
StewardFlowStatePatch,
|
||||
StewardRuntimeDecisionRequest,
|
||||
StewardRuntimeDecisionResponse,
|
||||
)
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
from app.services.steward_flow_state import StewardFlowStateService
|
||||
|
||||
|
||||
STEWARD_RUNTIME_DECISION_FUNCTION_NAME = "submit_steward_runtime_decision"
|
||||
|
||||
RUNTIME_NEXT_ACTIONS = {
|
||||
"plan_new_tasks",
|
||||
"continue_selected_flow",
|
||||
"submit_current_application",
|
||||
"continue_next_task",
|
||||
"fill_current_slot",
|
||||
@@ -22,6 +26,16 @@ RUNTIME_NEXT_ACTIONS = {
|
||||
"no_op",
|
||||
}
|
||||
|
||||
FIELD_LABELS = {
|
||||
"transport_mode": "出行方式",
|
||||
"expense_type": "费用类型",
|
||||
"time_range": "时间",
|
||||
"location": "地点",
|
||||
"reason": "事由",
|
||||
"amount": "金额",
|
||||
"attachments": "附件",
|
||||
}
|
||||
|
||||
|
||||
class StewardRuntimeDecisionAgent:
|
||||
"""用小财管家运行时上下文判断用户当前输入应落到哪个等待动作。"""
|
||||
@@ -31,6 +45,9 @@ class StewardRuntimeDecisionAgent:
|
||||
|
||||
def decide(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse:
|
||||
normalized_request = self._normalize_request(request)
|
||||
selected_flow_decision = self._build_selected_flow_decision(normalized_request, [])
|
||||
if selected_flow_decision is not None:
|
||||
return selected_flow_decision
|
||||
result = self.runtime_chat_service.complete_with_tool_call(
|
||||
self._build_messages(normalized_request),
|
||||
tools=[self._build_tool_schema()],
|
||||
@@ -47,18 +64,104 @@ class StewardRuntimeDecisionAgent:
|
||||
if result.tool_call is not None and result.tool_call.name == STEWARD_RUNTIME_DECISION_FUNCTION_NAME:
|
||||
response = self._build_response_from_model_payload(result.tool_call.arguments, normalized_request, traces)
|
||||
if response is not None:
|
||||
return response
|
||||
return self._build_rule_fallback(normalized_request, traces)
|
||||
return self._attach_updated_steward_state(response, normalized_request)
|
||||
return self._attach_updated_steward_state(
|
||||
self._build_rule_fallback(normalized_request, traces),
|
||||
normalized_request,
|
||||
)
|
||||
|
||||
def _build_selected_flow_decision(
|
||||
self,
|
||||
request: StewardRuntimeDecisionRequest,
|
||||
traces: list[dict[str, Any]],
|
||||
) -> StewardRuntimeDecisionResponse | None:
|
||||
selected_flow_id = self._resolve_selected_pending_flow_id(
|
||||
request.runtime_state,
|
||||
request.user_message,
|
||||
)
|
||||
if not selected_flow_id:
|
||||
return None
|
||||
next_state = StewardFlowStateService().confirm_flow(
|
||||
request.runtime_state.get("steward_state") if isinstance(request.runtime_state.get("steward_state"), dict) else {},
|
||||
selected_flow_id,
|
||||
)
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="continue_selected_flow",
|
||||
target_task_id=selected_flow_id,
|
||||
response_text=self._build_selected_flow_response_text(selected_flow_id),
|
||||
rationale="已按你选择的候选流程继续处理。",
|
||||
steward_state=next_state,
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_request(request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionRequest:
|
||||
context_json = request.context_json if isinstance(request.context_json, dict) else {}
|
||||
runtime_state = request.runtime_state if isinstance(request.runtime_state, dict) else {}
|
||||
return StewardRuntimeDecisionRequest(
|
||||
user_message=str(request.user_message or "").strip(),
|
||||
session_type=str(request.session_type or "steward").strip() or "steward",
|
||||
runtime_state=request.runtime_state if isinstance(request.runtime_state, dict) else {},
|
||||
context_json=request.context_json if isinstance(request.context_json, dict) else {},
|
||||
runtime_state=StewardRuntimeDecisionAgent._hydrate_runtime_state(runtime_state, context_json),
|
||||
context_json=context_json,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _hydrate_runtime_state(
|
||||
runtime_state: dict[str, Any],
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
hydrated = dict(runtime_state or {})
|
||||
steward_state = StewardRuntimeDecisionAgent._resolve_steward_state(context_json)
|
||||
if steward_state:
|
||||
hydrated.setdefault("steward_state", steward_state)
|
||||
if StewardRuntimeDecisionAgent._has_runtime_anchor(hydrated) or not steward_state:
|
||||
return hydrated
|
||||
|
||||
active_flow = str(steward_state.get("active_flow") or "").strip()
|
||||
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
|
||||
flow = flows.get(active_flow) if isinstance(flows, dict) else None
|
||||
if not isinstance(flow, dict):
|
||||
return hydrated
|
||||
|
||||
missing_fields = [
|
||||
str(item or "").strip()
|
||||
for item in list(flow.get("missing_fields") or [])
|
||||
if str(item or "").strip()
|
||||
]
|
||||
hydrated["current_task"] = {
|
||||
"task_id": active_flow,
|
||||
"task_type": "expense_application" if active_flow == "travel_application" else "reimbursement",
|
||||
"ontology_fields": dict(flow.get("fields") or {}),
|
||||
"missing_fields": missing_fields,
|
||||
}
|
||||
if missing_fields:
|
||||
hydrated["waiting_for"] = "steward_flow_field_completion"
|
||||
else:
|
||||
hydrated["waiting_for"] = "steward_flow_confirmation"
|
||||
return hydrated
|
||||
|
||||
@staticmethod
|
||||
def _resolve_steward_state(context_json: dict[str, Any]) -> dict[str, Any]:
|
||||
direct_state = context_json.get("steward_state") or context_json.get("stewardState")
|
||||
if isinstance(direct_state, dict) and direct_state:
|
||||
return direct_state
|
||||
conversation_state = context_json.get("conversation_state")
|
||||
if isinstance(conversation_state, dict):
|
||||
nested_state = conversation_state.get("steward_state") or conversation_state.get("stewardState")
|
||||
if isinstance(nested_state, dict) and nested_state:
|
||||
return nested_state
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _has_runtime_anchor(runtime_state: dict[str, Any]) -> bool:
|
||||
if str(runtime_state.get("waiting_for") or "").strip():
|
||||
return True
|
||||
for key in ("pending_application", "pending_steward_action", "pending_slot_action", "current_task"):
|
||||
if isinstance(runtime_state.get(key), dict) and runtime_state[key]:
|
||||
return True
|
||||
return bool(runtime_state.get("remaining_tasks") or runtime_state.get("completed_tasks"))
|
||||
|
||||
@staticmethod
|
||||
def _build_messages(request: StewardRuntimeDecisionRequest) -> list[dict[str, Any]]:
|
||||
payload = {
|
||||
@@ -177,6 +280,34 @@ class StewardRuntimeDecisionAgent:
|
||||
rationale="模型运行时决策暂不可用,我先按当前待确认的下一项任务继续处理。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
if waiting_for == "steward_flow_field_completion":
|
||||
current_task = state.get("current_task") if isinstance(state.get("current_task"), dict) else {}
|
||||
missing_fields = [
|
||||
str(item or "").strip()
|
||||
for item in list(current_task.get("missing_fields") or [])
|
||||
if str(item or "").strip()
|
||||
]
|
||||
field_key = missing_fields[0] if missing_fields else ""
|
||||
if field_key and request.user_message:
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="fill_current_slot",
|
||||
target_task_id=str(current_task.get("task_id") or ""),
|
||||
field_key=field_key,
|
||||
field_value=request.user_message,
|
||||
rationale="模型运行时决策暂不可用,我先把你的补充写入当前小财管家流程字段。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
if field_key:
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
next_action="ask_user",
|
||||
target_task_id=str(current_task.get("task_id") or ""),
|
||||
field_key=field_key,
|
||||
question=f"请补充{FIELD_LABELS.get(field_key, field_key)}。",
|
||||
rationale="当前小财管家流程仍缺少必要字段。",
|
||||
model_call_traces=traces,
|
||||
)
|
||||
if waiting_for:
|
||||
return StewardRuntimeDecisionResponse(
|
||||
decision_source="rule_fallback",
|
||||
@@ -192,6 +323,104 @@ class StewardRuntimeDecisionAgent:
|
||||
model_call_traces=traces,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_selected_pending_flow_id(runtime_state: dict[str, Any], user_message: str) -> str:
|
||||
steward_state = runtime_state.get("steward_state")
|
||||
if not isinstance(steward_state, dict):
|
||||
return ""
|
||||
pending = steward_state.get("pending_flow_confirmation")
|
||||
if not isinstance(pending, dict) or pending.get("status") != "pending":
|
||||
return ""
|
||||
message = re.sub(r"\s+", "", str(user_message or ""))
|
||||
if not message:
|
||||
return ""
|
||||
candidates = pending.get("candidate_flows") if isinstance(pending.get("candidate_flows"), list) else []
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
flow_id = str(candidate.get("flow_id") or "").strip()
|
||||
label = re.sub(r"\s+", "", str(candidate.get("label") or ""))
|
||||
if flow_id == "travel_application" and (
|
||||
message in {"补办出差申请", "出差申请", "申请", "补申请"}
|
||||
or (label and message == label)
|
||||
):
|
||||
return flow_id
|
||||
if flow_id == "travel_reimbursement" and (
|
||||
message in {"发起费用报销", "费用报销", "报销", "发起报销"}
|
||||
or (label and message == label)
|
||||
):
|
||||
return flow_id
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_selected_flow_response_text(flow_id: str) -> str:
|
||||
if flow_id == "travel_application":
|
||||
return "已确认按 **补办出差申请** 继续,我会基于当前出差信息整理申请材料。"
|
||||
return "已确认按 **发起费用报销** 继续,我会基于当前出差信息整理报销材料。"
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
def _attach_updated_steward_state(
|
||||
self,
|
||||
response: StewardRuntimeDecisionResponse,
|
||||
request: StewardRuntimeDecisionRequest,
|
||||
) -> StewardRuntimeDecisionResponse:
|
||||
steward_state = request.runtime_state.get("steward_state")
|
||||
if not isinstance(steward_state, dict) or not steward_state:
|
||||
return response
|
||||
if response.next_action == "continue_selected_flow":
|
||||
flow_id = self._resolve_target_flow_id(response, steward_state)
|
||||
if flow_id:
|
||||
next_state = StewardFlowStateService().confirm_flow(steward_state, flow_id)
|
||||
return response.model_copy(update={"steward_state": next_state})
|
||||
return response.model_copy(update={"steward_state": steward_state})
|
||||
if response.next_action != "fill_current_slot" or not response.field_key:
|
||||
return response.model_copy(update={"steward_state": steward_state})
|
||||
|
||||
flow_id = self._resolve_target_flow_id(response, steward_state)
|
||||
if not flow_id:
|
||||
return response.model_copy(update={"steward_state": steward_state})
|
||||
current_flow = self._resolve_flow(steward_state, flow_id)
|
||||
remaining_missing_fields = [
|
||||
key
|
||||
for key in list(current_flow.get("missing_fields") or [])
|
||||
if str(key or "").strip() and str(key or "").strip() != response.field_key
|
||||
]
|
||||
next_state = StewardFlowStateService().merge_state(
|
||||
steward_state,
|
||||
StewardFlowStatePatch(
|
||||
active_flow=flow_id, # type: ignore[arg-type]
|
||||
flow_id=flow_id, # type: ignore[arg-type]
|
||||
intent=str(current_flow.get("intent") or "").strip(),
|
||||
status="collecting" if remaining_missing_fields else "ready_for_confirmation",
|
||||
fields={response.field_key: response.field_value},
|
||||
missing_fields=remaining_missing_fields,
|
||||
evidence=[
|
||||
{
|
||||
"source": "runtime_user_message",
|
||||
"field": response.field_key,
|
||||
"text": request.user_message,
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
return response.model_copy(update={"steward_state": next_state})
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target_flow_id(
|
||||
response: StewardRuntimeDecisionResponse,
|
||||
steward_state: dict[str, Any],
|
||||
) -> str:
|
||||
target = str(response.target_task_id or "").strip()
|
||||
if target in {"travel_application", "travel_reimbursement"}:
|
||||
return target
|
||||
active_flow = str(steward_state.get("active_flow") or "").strip()
|
||||
return active_flow if active_flow in {"travel_application", "travel_reimbursement"} else ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_flow(steward_state: dict[str, Any], flow_id: str) -> dict[str, Any]:
|
||||
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
|
||||
flow = flows.get(flow_id) if isinstance(flows, dict) else {}
|
||||
return dict(flow) if isinstance(flow, dict) else {}
|
||||
|
||||
78
server/src/app/services/travel_policy_grades.py
Normal file
78
server/src/app/services/travel_policy_grades.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
TRAVEL_GRADE_KEYS = tuple(f"P{level}" for level in range(9))
|
||||
|
||||
|
||||
def resolve_travel_policy_grade_key(grade: str | None) -> str | None:
|
||||
normalized = str(grade or "").strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
if "董事会" in normalized:
|
||||
return "P8"
|
||||
|
||||
p_match = re.search(r"P\s*([0-8])", normalized)
|
||||
if p_match:
|
||||
return f"P{int(p_match.group(1))}"
|
||||
|
||||
level_match = re.search(r"(?<!\d)([0-8])\s*级", normalized)
|
||||
if level_match:
|
||||
return f"P{int(level_match.group(1))}"
|
||||
|
||||
m_match = re.search(r"M\s*(\d+)", normalized)
|
||||
if m_match:
|
||||
level = int(m_match.group(1))
|
||||
if level <= 1:
|
||||
return "P5"
|
||||
if level <= 2:
|
||||
return "P6"
|
||||
return "P7"
|
||||
|
||||
if normalized.startswith("D"):
|
||||
return "P8"
|
||||
|
||||
if any(keyword in normalized for keyword in ("外聘专家", "专家")):
|
||||
return "P6"
|
||||
if "高层经理" in normalized or "公司级" in normalized:
|
||||
return "P7"
|
||||
if "中层经理" in normalized:
|
||||
return "P6"
|
||||
if "基层经理" in normalized:
|
||||
return "P5"
|
||||
if "主管" in normalized or "资深" in normalized:
|
||||
return "P4"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def travel_policy_grade_key_candidates(grade_key: str | None) -> list[str]:
|
||||
raw_key = str(grade_key or "").strip()
|
||||
normalized = raw_key.upper()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
candidates = [normalized]
|
||||
legacy_key = raw_key.lower()
|
||||
if legacy_key in {"junior", "mid", "senior", "manager", "executive"}:
|
||||
candidates.append(legacy_key)
|
||||
legacy = _legacy_grade_band(normalized)
|
||||
if legacy and legacy not in candidates:
|
||||
candidates.append(legacy)
|
||||
return candidates
|
||||
|
||||
|
||||
def _legacy_grade_band(grade_key: str) -> str:
|
||||
match = re.fullmatch(r"P([0-8])", grade_key)
|
||||
if not match:
|
||||
return ""
|
||||
level = int(match.group(1))
|
||||
if level <= 3:
|
||||
return "junior"
|
||||
if level <= 5:
|
||||
return "mid"
|
||||
if level <= 7:
|
||||
return "senior"
|
||||
return "executive"
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
@@ -16,326 +17,12 @@ from app.schemas.reimbursement import (
|
||||
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 = {"吉林"}
|
||||
from app.services.travel_policy_grades import travel_policy_grade_key_candidates
|
||||
from app.services.travel_reimbursement_regions import (
|
||||
AMBIGUOUS_PROVINCE_CITY_NAMES,
|
||||
OTHER_REGION_LOCATION_KEYWORDS,
|
||||
OTHER_REGION_PROVINCE_KEYWORDS,
|
||||
)
|
||||
|
||||
|
||||
class TravelReimbursementCalculatorService:
|
||||
@@ -359,40 +46,76 @@ class TravelReimbursementCalculatorService:
|
||||
|
||||
grade_band = ExpenseClaimService._resolve_travel_policy_band(grade)
|
||||
if not grade_band:
|
||||
raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位。")
|
||||
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)
|
||||
hotel_rate = self._resolve_hotel_rate(
|
||||
policy,
|
||||
grade_band,
|
||||
matched_city,
|
||||
city_tier,
|
||||
payload.travel_date,
|
||||
)
|
||||
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)
|
||||
origin_city = self._resolve_origin_city(payload, current_user, policy)
|
||||
transport_mode = self._normalize_transport_mode(payload.transport_mode)
|
||||
transport_estimate = self._resolve_transport_estimate(
|
||||
policy,
|
||||
origin_city=origin_city,
|
||||
destination_city=matched_city or matched_other_region,
|
||||
destination_text=location,
|
||||
transport_mode=transport_mode,
|
||||
)
|
||||
transport_estimated_amount = Decimal(
|
||||
transport_estimate.get("amount") or Decimal("0.00")
|
||||
).quantize(Decimal("0.01"))
|
||||
|
||||
hotel_amount = hotel_rate * Decimal(days)
|
||||
allowance_amount = total_allowance_rate * Decimal(days)
|
||||
total_amount = hotel_amount + allowance_amount
|
||||
total_amount = hotel_amount + allowance_amount + transport_estimated_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)}"
|
||||
)
|
||||
if transport_estimated_amount > Decimal("0.00"):
|
||||
formula_text = (
|
||||
f"交通 {self._format_money(transport_estimated_amount)} + "
|
||||
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
|
||||
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
|
||||
f"{self._format_money(total_amount)}"
|
||||
)
|
||||
summary_tail = (
|
||||
f"交通费用按“{transport_estimate.get('basis') or '交通费用预估表'}”"
|
||||
f"预估 {self._format_money(transport_estimated_amount)} 元。"
|
||||
f"按 {days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
|
||||
f"补贴合计 {self._format_money(allowance_amount)} 元,"
|
||||
f"申请预算占用参考总金额为 {self._format_money(total_amount)} 元。"
|
||||
)
|
||||
else:
|
||||
formula_text = (
|
||||
f"住宿 {self._format_money(hotel_rate)} × {days} 天 + "
|
||||
f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = "
|
||||
f"{self._format_money(total_amount)}"
|
||||
)
|
||||
summary_tail = (
|
||||
f"按 {days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元,"
|
||||
f"补贴合计 {self._format_money(allowance_amount)} 元,"
|
||||
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"当前职级 {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)} 元。"
|
||||
f"{summary_tail}"
|
||||
)
|
||||
|
||||
return TravelReimbursementCalculatorResponse(
|
||||
@@ -410,6 +133,19 @@ class TravelReimbursementCalculatorService:
|
||||
basic_allowance_rate=basic_rate,
|
||||
total_allowance_rate=total_allowance_rate,
|
||||
allowance_amount=allowance_amount,
|
||||
transport_mode=transport_mode or str(transport_estimate.get("transport_mode") or "").strip(),
|
||||
transport_origin=str(transport_estimate.get("origin_city") or origin_city or "").strip(),
|
||||
transport_destination=str(
|
||||
transport_estimate.get("destination_city") or display_city or location
|
||||
).strip(),
|
||||
transport_estimated_amount=transport_estimated_amount,
|
||||
transport_estimate_basis=str(transport_estimate.get("basis") or "").strip(),
|
||||
transport_estimate_confidence=str(transport_estimate.get("confidence") or "").strip(),
|
||||
transport_estimate_source=str(transport_estimate.get("source") or "").strip(),
|
||||
transport_estimate_rule_code=str(policy.transport_estimate_rule_code or "").strip(),
|
||||
transport_estimate_rule_name=str(policy.transport_estimate_rule_name or "").strip(),
|
||||
transport_estimate_rule_version=str(policy.transport_estimate_rule_version or "").strip(),
|
||||
travel_date=payload.travel_date,
|
||||
total_amount=total_amount,
|
||||
rule_name=rule_name,
|
||||
rule_version=rule_version,
|
||||
@@ -510,6 +246,152 @@ class TravelReimbursementCalculatorService:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
def _resolve_origin_city(
|
||||
self,
|
||||
payload: TravelReimbursementCalculatorRequest,
|
||||
current_user: CurrentUserContext,
|
||||
policy: RuntimeTravelPolicy,
|
||||
) -> str:
|
||||
origin_location = str(payload.origin_location or "").strip()
|
||||
if not origin_location:
|
||||
employee = self._resolve_current_employee(current_user)
|
||||
origin_location = str(employee.location or "").strip() if employee is not None else ""
|
||||
if not origin_location:
|
||||
origin_location = "武汉"
|
||||
return (
|
||||
self._resolve_city(origin_location, policy)
|
||||
or self._resolve_other_region(origin_location)
|
||||
or origin_location
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_transport_mode(value: str | None) -> str:
|
||||
normalized = re.sub(r"\s+", "", str(value or ""))
|
||||
if any(keyword in normalized for keyword in ("飞机", "机票", "航班", "乘机", "坐飞机")):
|
||||
return "飞机"
|
||||
if any(keyword in normalized for keyword in ("火车", "高铁", "动车", "铁路", "列车")):
|
||||
return "火车"
|
||||
if any(keyword in normalized for keyword in ("轮船", "船票", "客轮", "渡轮", "邮轮", "坐船")):
|
||||
return "轮船"
|
||||
return normalized if normalized in {"飞机", "火车", "轮船"} else ""
|
||||
|
||||
def _resolve_transport_estimate(
|
||||
self,
|
||||
policy: RuntimeTravelPolicy,
|
||||
*,
|
||||
origin_city: str,
|
||||
destination_city: str,
|
||||
destination_text: str,
|
||||
transport_mode: str,
|
||||
) -> dict[str, object]:
|
||||
if self._normalize_city_key(origin_city) == self._normalize_city_key(destination_city):
|
||||
return {}
|
||||
|
||||
location_band = self._resolve_transport_location_band(
|
||||
destination_city or destination_text
|
||||
)
|
||||
candidate_modes = [transport_mode] if transport_mode else ["火车", "飞机", "轮船"]
|
||||
matched = None
|
||||
matched_mode = ""
|
||||
for candidate_mode in candidate_modes:
|
||||
candidates: list[tuple[int, object]] = []
|
||||
for estimate in policy.transport_estimates:
|
||||
if str(estimate.transport_mode or "").strip() != candidate_mode:
|
||||
continue
|
||||
origin_score = self._transport_origin_match_score(
|
||||
str(estimate.origin_city or ""), origin_city
|
||||
)
|
||||
if origin_score <= 0:
|
||||
continue
|
||||
destination_score = self._transport_destination_match_score(
|
||||
str(estimate.destination_city or ""),
|
||||
destination_city or destination_text,
|
||||
str(estimate.location_band or ""),
|
||||
location_band,
|
||||
)
|
||||
if destination_score <= 0:
|
||||
continue
|
||||
candidates.append((origin_score + destination_score, estimate))
|
||||
if candidates:
|
||||
_, matched = sorted(candidates, key=lambda item: item[0], reverse=True)[0]
|
||||
matched_mode = candidate_mode
|
||||
break
|
||||
if matched is None:
|
||||
return {}
|
||||
amount = Decimal(matched.round_trip_amount or Decimal("0.00")).quantize(Decimal("0.01"))
|
||||
if amount <= Decimal("0.00"):
|
||||
return {}
|
||||
origin_label = str(matched.origin_city or "").strip()
|
||||
if origin_label in {"*", "默认", "通用"}:
|
||||
origin_label = origin_city
|
||||
destination_label = str(matched.destination_city or "").strip() or (
|
||||
destination_city or self._transport_location_band_label(location_band)
|
||||
)
|
||||
basis = str(matched.basis or "").strip()
|
||||
if not basis:
|
||||
basis = f"{origin_label}-{destination_label}{matched_mode}往返预估"
|
||||
return {
|
||||
"amount": amount,
|
||||
"origin_city": origin_label,
|
||||
"destination_city": destination_label,
|
||||
"transport_mode": matched_mode,
|
||||
"basis": basis,
|
||||
"confidence": str(matched.confidence or "basic_rule").strip(),
|
||||
"source": "basic_rule_transport_estimate",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_city_key(value: str) -> str:
|
||||
return re.sub(r"(省|市|区|县|自治州|特别行政区)$", "", str(value or "").strip())
|
||||
|
||||
def _transport_origin_match_score(self, configured_origin: str, origin_city: str) -> int:
|
||||
normalized = self._normalize_city_key(configured_origin)
|
||||
if not normalized or normalized in {"*", "默认", "通用"}:
|
||||
return 10
|
||||
origin_key = self._normalize_city_key(origin_city)
|
||||
return 30 if normalized == origin_key or normalized in origin_key or origin_key in normalized else 0
|
||||
|
||||
def _transport_destination_match_score(
|
||||
self,
|
||||
configured_destination: str,
|
||||
destination_city: str,
|
||||
configured_band: str,
|
||||
location_band: str,
|
||||
) -> int:
|
||||
destination_key = self._normalize_city_key(destination_city)
|
||||
configured_key = self._normalize_city_key(configured_destination)
|
||||
if configured_key and (
|
||||
configured_key == destination_key
|
||||
or configured_key in destination_key
|
||||
or destination_key in configured_key
|
||||
):
|
||||
return 70
|
||||
if configured_key:
|
||||
return 0
|
||||
if configured_band and configured_band == location_band:
|
||||
return 40
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _resolve_transport_location_band(location: str) -> str:
|
||||
text = str(location or "").strip()
|
||||
if any(keyword in text for keyword in ("新疆", "西藏", "青海", "甘肃", "宁夏", "内蒙古", "海南", "三亚", "海口", "香港", "澳门", "台湾", "海外", "国外")):
|
||||
return "remote"
|
||||
if any(keyword in text for keyword in ("北京", "上海", "广州", "深圳", "杭州", "南京", "苏州", "成都", "重庆", "天津")):
|
||||
return "premium"
|
||||
if any(keyword in text for keyword in ("厦门", "福州", "青岛", "大连", "宁波", "舟山")):
|
||||
return "coastal"
|
||||
return "default"
|
||||
|
||||
@staticmethod
|
||||
def _transport_location_band_label(location_band: str) -> str:
|
||||
return {
|
||||
"premium": "高频城市",
|
||||
"remote": "远途地区",
|
||||
"coastal": "沿海城市",
|
||||
"default": "普通城市",
|
||||
}.get(str(location_band or "").strip(), "普通城市")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str:
|
||||
normalized = str(location or "").strip()
|
||||
@@ -536,17 +418,67 @@ class TravelReimbursementCalculatorService:
|
||||
grade_band: str,
|
||||
matched_city: str,
|
||||
city_tier: str,
|
||||
travel_date: date | None = None,
|
||||
) -> 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])
|
||||
base_rate = Decimal("0")
|
||||
for candidate in travel_policy_grade_key_candidates(grade_band):
|
||||
if city_limits.get(candidate) is not None:
|
||||
base_rate = Decimal(city_limits[candidate])
|
||||
break
|
||||
|
||||
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")
|
||||
if base_rate <= Decimal("0"):
|
||||
for candidate in travel_policy_grade_key_candidates(grade_band):
|
||||
band_limits = policy.hotel_limits.get(candidate, {})
|
||||
if band_limits.get(city_tier) is not None:
|
||||
base_rate = Decimal(band_limits[city_tier])
|
||||
break
|
||||
if band_limits.get("tier_3") is not None:
|
||||
base_rate = Decimal(band_limits["tier_3"])
|
||||
break
|
||||
peak_rate = TravelReimbursementCalculatorService._resolve_peak_hotel_rate(
|
||||
policy,
|
||||
matched_city,
|
||||
travel_date,
|
||||
)
|
||||
return max(base_rate, peak_rate)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_peak_hotel_rate(
|
||||
policy: RuntimeTravelPolicy,
|
||||
matched_city: str,
|
||||
travel_date: date | None,
|
||||
) -> Decimal:
|
||||
if not matched_city or travel_date is None:
|
||||
return Decimal("0")
|
||||
period = (getattr(policy, "hotel_peak_periods", {}) or {}).get(matched_city, "")
|
||||
if not period or not TravelReimbursementCalculatorService._month_in_peak_period(travel_date.month, period):
|
||||
return Decimal("0")
|
||||
peak_rate = (getattr(policy, "hotel_peak_city_limits", {}) or {}).get(matched_city)
|
||||
return Decimal(peak_rate or Decimal("0"))
|
||||
|
||||
@staticmethod
|
||||
def _month_in_peak_period(month: int, period: str) -> bool:
|
||||
for part in re.split(r"[,,、;;]+", str(period or "")):
|
||||
if not part:
|
||||
continue
|
||||
if "-" not in part:
|
||||
try:
|
||||
if int(part) == month:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
continue
|
||||
start_text, end_text = part.split("-", 1)
|
||||
try:
|
||||
start, end = int(start_text), int(end_text)
|
||||
except ValueError:
|
||||
continue
|
||||
if start <= end and start <= month <= end:
|
||||
return True
|
||||
if start > end and (month >= start or month <= end):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _resolve_allowance_region(location: str, matched_city: str) -> str:
|
||||
|
||||
321
server/src/app/services/travel_reimbursement_regions.py
Normal file
321
server/src/app/services/travel_reimbursement_regions.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
OTHER_REGION_LOCATION_KEYWORDS = {
|
||||
"河北",
|
||||
"石家庄",
|
||||
"唐山",
|
||||
"秦皇岛",
|
||||
"邯郸",
|
||||
"邢台",
|
||||
"保定",
|
||||
"张家口",
|
||||
"承德",
|
||||
"沧州",
|
||||
"廊坊",
|
||||
"衡水",
|
||||
"山西",
|
||||
"太原",
|
||||
"大同",
|
||||
"长治",
|
||||
"晋城",
|
||||
"晋中",
|
||||
"运城",
|
||||
"临汾",
|
||||
"吕梁",
|
||||
"内蒙古",
|
||||
"呼和浩特",
|
||||
"包头",
|
||||
"赤峰",
|
||||
"通辽",
|
||||
"鄂尔多斯",
|
||||
"辽宁",
|
||||
"鞍山",
|
||||
"抚顺",
|
||||
"本溪",
|
||||
"丹东",
|
||||
"锦州",
|
||||
"营口",
|
||||
"盘锦",
|
||||
"吉林",
|
||||
"长春",
|
||||
"吉林市",
|
||||
"四平",
|
||||
"通化",
|
||||
"白山",
|
||||
"松原",
|
||||
"延边",
|
||||
"黑龙江",
|
||||
"哈尔滨",
|
||||
"齐齐哈尔",
|
||||
"牡丹江",
|
||||
"佳木斯",
|
||||
"大庆",
|
||||
"江苏",
|
||||
"常州",
|
||||
"南通",
|
||||
"连云港",
|
||||
"淮安",
|
||||
"盐城",
|
||||
"扬州",
|
||||
"镇江",
|
||||
"泰州",
|
||||
"宿迁",
|
||||
"浙江",
|
||||
"温州",
|
||||
"嘉兴",
|
||||
"湖州",
|
||||
"绍兴",
|
||||
"金华",
|
||||
"衢州",
|
||||
"舟山",
|
||||
"台州",
|
||||
"丽水",
|
||||
"安徽",
|
||||
"芜湖",
|
||||
"蚌埠",
|
||||
"淮南",
|
||||
"马鞍山",
|
||||
"淮北",
|
||||
"铜陵",
|
||||
"安庆",
|
||||
"黄山",
|
||||
"滁州",
|
||||
"阜阳",
|
||||
"宿州",
|
||||
"六安",
|
||||
"亳州",
|
||||
"池州",
|
||||
"宣城",
|
||||
"福建",
|
||||
"泉州",
|
||||
"漳州",
|
||||
"莆田",
|
||||
"三明",
|
||||
"南平",
|
||||
"龙岩",
|
||||
"宁德",
|
||||
"江西",
|
||||
"南昌",
|
||||
"景德镇",
|
||||
"萍乡",
|
||||
"九江",
|
||||
"新余",
|
||||
"鹰潭",
|
||||
"赣州",
|
||||
"吉安",
|
||||
"宜春",
|
||||
"抚州",
|
||||
"上饶",
|
||||
"山东",
|
||||
"淄博",
|
||||
"枣庄",
|
||||
"东营",
|
||||
"烟台",
|
||||
"潍坊",
|
||||
"济宁",
|
||||
"泰安",
|
||||
"威海",
|
||||
"日照",
|
||||
"临沂",
|
||||
"德州",
|
||||
"聊城",
|
||||
"滨州",
|
||||
"菏泽",
|
||||
"河南",
|
||||
"洛阳",
|
||||
"开封",
|
||||
"平顶山",
|
||||
"安阳",
|
||||
"鹤壁",
|
||||
"新乡",
|
||||
"焦作",
|
||||
"濮阳",
|
||||
"许昌",
|
||||
"漯河",
|
||||
"三门峡",
|
||||
"南阳",
|
||||
"商丘",
|
||||
"信阳",
|
||||
"周口",
|
||||
"驻马店",
|
||||
"湖北",
|
||||
"黄石",
|
||||
"十堰",
|
||||
"宜昌",
|
||||
"襄阳",
|
||||
"鄂州",
|
||||
"荆门",
|
||||
"孝感",
|
||||
"荆州",
|
||||
"黄冈",
|
||||
"咸宁",
|
||||
"随州",
|
||||
"恩施",
|
||||
"湖南",
|
||||
"株洲",
|
||||
"湘潭",
|
||||
"衡阳",
|
||||
"邵阳",
|
||||
"岳阳",
|
||||
"常德",
|
||||
"张家界",
|
||||
"益阳",
|
||||
"郴州",
|
||||
"永州",
|
||||
"怀化",
|
||||
"娄底",
|
||||
"湘西",
|
||||
"广东",
|
||||
"惠州",
|
||||
"江门",
|
||||
"湛江",
|
||||
"茂名",
|
||||
"肇庆",
|
||||
"梅州",
|
||||
"汕尾",
|
||||
"河源",
|
||||
"阳江",
|
||||
"清远",
|
||||
"潮州",
|
||||
"揭阳",
|
||||
"云浮",
|
||||
"广西",
|
||||
"南宁",
|
||||
"柳州",
|
||||
"桂林",
|
||||
"梧州",
|
||||
"北海",
|
||||
"防城港",
|
||||
"钦州",
|
||||
"贵港",
|
||||
"玉林",
|
||||
"百色",
|
||||
"贺州",
|
||||
"河池",
|
||||
"来宾",
|
||||
"崇左",
|
||||
"海南",
|
||||
"儋州",
|
||||
"四川",
|
||||
"自贡",
|
||||
"攀枝花",
|
||||
"泸州",
|
||||
"德阳",
|
||||
"绵阳",
|
||||
"广元",
|
||||
"遂宁",
|
||||
"内江",
|
||||
"乐山",
|
||||
"南充",
|
||||
"眉山",
|
||||
"宜宾",
|
||||
"广安",
|
||||
"达州",
|
||||
"雅安",
|
||||
"巴中",
|
||||
"资阳",
|
||||
"阿坝",
|
||||
"甘孜",
|
||||
"凉山",
|
||||
"贵州",
|
||||
"贵阳",
|
||||
"遵义",
|
||||
"六盘水",
|
||||
"安顺",
|
||||
"毕节",
|
||||
"铜仁",
|
||||
"黔东南",
|
||||
"黔南",
|
||||
"黔西南",
|
||||
"云南",
|
||||
"曲靖",
|
||||
"玉溪",
|
||||
"保山",
|
||||
"昭通",
|
||||
"丽江",
|
||||
"普洱",
|
||||
"临沧",
|
||||
"楚雄",
|
||||
"红河",
|
||||
"文山",
|
||||
"西双版纳",
|
||||
"大理",
|
||||
"德宏",
|
||||
"怒江",
|
||||
"迪庆",
|
||||
"陕西",
|
||||
"宝鸡",
|
||||
"咸阳",
|
||||
"铜川",
|
||||
"渭南",
|
||||
"延安",
|
||||
"汉中",
|
||||
"榆林",
|
||||
"安康",
|
||||
"商洛",
|
||||
"甘肃",
|
||||
"兰州",
|
||||
"嘉峪关",
|
||||
"金昌",
|
||||
"白银",
|
||||
"天水",
|
||||
"武威",
|
||||
"张掖",
|
||||
"平凉",
|
||||
"酒泉",
|
||||
"庆阳",
|
||||
"定西",
|
||||
"陇南",
|
||||
"临夏",
|
||||
"甘南",
|
||||
"青海",
|
||||
"西宁",
|
||||
"海东",
|
||||
"海北",
|
||||
"黄南",
|
||||
"海南州",
|
||||
"果洛",
|
||||
"玉树",
|
||||
"海西",
|
||||
"宁夏",
|
||||
"银川",
|
||||
"石嘴山",
|
||||
"吴忠",
|
||||
"固原",
|
||||
"中卫",
|
||||
}
|
||||
|
||||
OTHER_REGION_PROVINCE_KEYWORDS = {
|
||||
"河北",
|
||||
"山西",
|
||||
"内蒙古",
|
||||
"辽宁",
|
||||
"吉林",
|
||||
"黑龙江",
|
||||
"江苏",
|
||||
"浙江",
|
||||
"安徽",
|
||||
"福建",
|
||||
"江西",
|
||||
"山东",
|
||||
"河南",
|
||||
"湖北",
|
||||
"湖南",
|
||||
"广东",
|
||||
"广西",
|
||||
"海南",
|
||||
"四川",
|
||||
"贵州",
|
||||
"云南",
|
||||
"陕西",
|
||||
"甘肃",
|
||||
"青海",
|
||||
"宁夏",
|
||||
"新疆",
|
||||
"西藏",
|
||||
"台湾",
|
||||
"香港",
|
||||
"澳门",
|
||||
}
|
||||
|
||||
AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"}
|
||||
Reference in New Issue
Block a user