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:
@@ -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]):
|
||||
|
||||
Reference in New Issue
Block a user