Align planner, runtime rules, and policy assets so travel guidance matches the updated reimbursement workflow.
555 lines
23 KiB
Python
555 lines
23 KiB
Python
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
|