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:
caoxiaozhu
2026-06-15 22:55:18 +08:00
parent 792741709a
commit 9f7b8b46a3
85 changed files with 9496 additions and 2555 deletions

View File

@@ -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]):