479 lines
20 KiB
Python
479 lines
20 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import mimetypes
|
|||
|
|
import re
|
|||
|
|
from dataclasses import asdict, dataclass
|
|||
|
|
from datetime import UTC, datetime
|
|||
|
|
from io import BytesIO
|
|||
|
|
from pathlib import Path
|
|||
|
|
from xml.sax.saxutils import escape
|
|||
|
|
from zipfile import ZIP_DEFLATED, ZipFile
|
|||
|
|
|
|||
|
|
from openpyxl import load_workbook
|
|||
|
|
|
|||
|
|
from app.core.config import SERVER_DIR, get_settings
|
|||
|
|
|
|||
|
|
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
|||
|
|
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
|
|||
|
|
re.DOTALL,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
|
|||
|
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
|||
|
|
FINANCE_RULES_LIBRARY = "finance-rules"
|
|||
|
|
RISK_RULES_LIBRARY = "risk-rules"
|
|||
|
|
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
|||
|
|
SPREADSHEET_MIME_TYPE = (
|
|||
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(slots=True)
|
|||
|
|
class RuleSpreadsheetMeta:
|
|||
|
|
file_name: str
|
|||
|
|
storage_key: str
|
|||
|
|
mime_type: str
|
|||
|
|
size_bytes: int
|
|||
|
|
checksum: str
|
|||
|
|
updated_at: str
|
|||
|
|
updated_by: str
|
|||
|
|
source: str = "upload"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AgentAssetSpreadsheetManager:
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
storage_root: Path | None = None,
|
|||
|
|
rule_root: Path | None = None,
|
|||
|
|
) -> None:
|
|||
|
|
settings = get_settings()
|
|||
|
|
self.storage_root = Path(storage_root or settings.resolved_storage_root_dir).resolve()
|
|||
|
|
self.asset_root = (self.storage_root / "agent_assets").resolve()
|
|||
|
|
self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve()
|
|||
|
|
|
|||
|
|
def ensure_rule_library_dirs(self) -> None:
|
|||
|
|
for library in sorted(RULE_LIBRARY_NAMES):
|
|||
|
|
(self.rule_root / library).mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
def store_spreadsheet(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
asset_id: str,
|
|||
|
|
version: str,
|
|||
|
|
file_name: str,
|
|||
|
|
content: bytes,
|
|||
|
|
actor_name: str,
|
|||
|
|
source: str = "upload",
|
|||
|
|
) -> RuleSpreadsheetMeta:
|
|||
|
|
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
|||
|
|
if not normalized_name:
|
|||
|
|
raise ValueError("规则表文件名不能为空。")
|
|||
|
|
if not content:
|
|||
|
|
raise ValueError("规则表文件内容不能为空。")
|
|||
|
|
|
|||
|
|
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name
|
|||
|
|
target_path = (self.storage_root / relative_path).resolve()
|
|||
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
target_path.write_bytes(content)
|
|||
|
|
|
|||
|
|
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
|
|||
|
|
return RuleSpreadsheetMeta(
|
|||
|
|
file_name=normalized_name,
|
|||
|
|
storage_key=relative_path.as_posix(),
|
|||
|
|
mime_type=mime_type,
|
|||
|
|
size_bytes=len(content),
|
|||
|
|
checksum=hashlib.sha256(content).hexdigest(),
|
|||
|
|
updated_at=datetime.now(UTC).isoformat(),
|
|||
|
|
updated_by=str(actor_name or "system").strip() or "system",
|
|||
|
|
source=source,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def store_rule_library_spreadsheet(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
library: str,
|
|||
|
|
file_name: str,
|
|||
|
|
content: bytes,
|
|||
|
|
actor_name: str,
|
|||
|
|
source: str = "rule-library",
|
|||
|
|
) -> RuleSpreadsheetMeta:
|
|||
|
|
normalized_library = str(library or "").strip()
|
|||
|
|
if normalized_library not in RULE_LIBRARY_NAMES:
|
|||
|
|
raise ValueError("规则库目录不合法。")
|
|||
|
|
|
|||
|
|
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
|||
|
|
if not normalized_name:
|
|||
|
|
raise ValueError("规则表文件名不能为空。")
|
|||
|
|
if not content:
|
|||
|
|
raise ValueError("规则表文件内容不能为空。")
|
|||
|
|
|
|||
|
|
self.ensure_rule_library_dirs()
|
|||
|
|
relative_path = Path("rules") / normalized_library / normalized_name
|
|||
|
|
target_path = (SERVER_DIR / relative_path).resolve()
|
|||
|
|
try:
|
|||
|
|
target_path.relative_to(self.rule_root)
|
|||
|
|
except ValueError:
|
|||
|
|
raise ValueError("规则库文件路径不合法。")
|
|||
|
|
|
|||
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
target_path.write_bytes(content)
|
|||
|
|
|
|||
|
|
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
|
|||
|
|
return RuleSpreadsheetMeta(
|
|||
|
|
file_name=normalized_name,
|
|||
|
|
storage_key=relative_path.as_posix(),
|
|||
|
|
mime_type=mime_type,
|
|||
|
|
size_bytes=len(content),
|
|||
|
|
checksum=hashlib.sha256(content).hexdigest(),
|
|||
|
|
updated_at=datetime.now(UTC).isoformat(),
|
|||
|
|
updated_by=str(actor_name or "system").strip() or "system",
|
|||
|
|
source=source,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def resolve_storage_path(self, storage_key: str) -> Path:
|
|||
|
|
normalized = Path(str(storage_key or "").strip())
|
|||
|
|
if not normalized.parts:
|
|||
|
|
raise FileNotFoundError("规则表文件不存在。")
|
|||
|
|
|
|||
|
|
if normalized.parts[0] == "rules":
|
|||
|
|
resolved = (SERVER_DIR / normalized).resolve()
|
|||
|
|
allowed_root = self.rule_root
|
|||
|
|
else:
|
|||
|
|
resolved = (self.storage_root / normalized).resolve()
|
|||
|
|
allowed_root = self.storage_root
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
resolved.relative_to(allowed_root)
|
|||
|
|
except ValueError:
|
|||
|
|
raise FileNotFoundError("规则表文件不存在。")
|
|||
|
|
return resolved
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def parse_version_markdown(markdown: str) -> RuleSpreadsheetMeta | None:
|
|||
|
|
match = RULE_SPREADSHEET_BLOCK_PATTERN.search(str(markdown or ""))
|
|||
|
|
if match is None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
payload = json.loads(match.group(1))
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if not isinstance(payload, dict):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return RuleSpreadsheetMeta(
|
|||
|
|
file_name=str(payload.get("file_name") or "").strip(),
|
|||
|
|
storage_key=str(payload.get("storage_key") or "").strip(),
|
|||
|
|
mime_type=str(payload.get("mime_type") or SPREADSHEET_MIME_TYPE).strip()
|
|||
|
|
or SPREADSHEET_MIME_TYPE,
|
|||
|
|
size_bytes=int(payload.get("size_bytes") or 0),
|
|||
|
|
checksum=str(payload.get("checksum") or "").strip(),
|
|||
|
|
updated_at=str(payload.get("updated_at") or "").strip(),
|
|||
|
|
updated_by=str(payload.get("updated_by") or "system").strip() or "system",
|
|||
|
|
source=str(payload.get("source") or "upload").strip() or "upload",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def build_version_markdown(
|
|||
|
|
*,
|
|||
|
|
rule_name: str,
|
|||
|
|
version: str,
|
|||
|
|
metadata: RuleSpreadsheetMeta,
|
|||
|
|
) -> str:
|
|||
|
|
sections = [
|
|||
|
|
f"# {rule_name}",
|
|||
|
|
"",
|
|||
|
|
"## 规则载体",
|
|||
|
|
"",
|
|||
|
|
"- 详情类型:Excel 表格",
|
|||
|
|
f"- 当前规则版本:`{version}`",
|
|||
|
|
f"- 表格文件:`{metadata.file_name}`",
|
|||
|
|
f"- 最近更新人:{metadata.updated_by}",
|
|||
|
|
f"- 最近更新时间:{metadata.updated_at}",
|
|||
|
|
"",
|
|||
|
|
"## 使用说明",
|
|||
|
|
"",
|
|||
|
|
"- 管理员可直接在规则中心内联编辑 Excel 表格,并通过 ONLYOFFICE 回写新版本。",
|
|||
|
|
"- 上传新的 Excel 文件后,会自动生成新的规则版本快照。",
|
|||
|
|
"- 切换到历史版本时仅提供预览,不允许直接覆盖历史快照。",
|
|||
|
|
"",
|
|||
|
|
"```rule-spreadsheet",
|
|||
|
|
json.dumps(asdict(metadata), ensure_ascii=False, indent=2),
|
|||
|
|
"```",
|
|||
|
|
]
|
|||
|
|
return "\n".join(sections)
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def build_rule_document_config(
|
|||
|
|
metadata: RuleSpreadsheetMeta,
|
|||
|
|
*,
|
|||
|
|
asset_version: str,
|
|||
|
|
) -> dict[str, object]:
|
|||
|
|
return {
|
|||
|
|
"kind": "spreadsheet",
|
|||
|
|
"file_name": metadata.file_name,
|
|||
|
|
"mime_type": metadata.mime_type,
|
|||
|
|
"size_bytes": metadata.size_bytes,
|
|||
|
|
"checksum": metadata.checksum,
|
|||
|
|
"updated_at": metadata.updated_at,
|
|||
|
|
"updated_by": metadata.updated_by,
|
|||
|
|
"source": metadata.source,
|
|||
|
|
"asset_version": asset_version,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def build_company_travel_rule_template() -> bytes:
|
|||
|
|
standard_rows = [
|
|||
|
|
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
|||
|
|
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"],
|
|||
|
|
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"],
|
|||
|
|
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"],
|
|||
|
|
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"],
|
|||
|
|
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"],
|
|||
|
|
]
|
|||
|
|
instruction_rows = [
|
|||
|
|
["字段", "填写说明"],
|
|||
|
|
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
|
|||
|
|
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
|
|||
|
|
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
|
|||
|
|
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
|
|||
|
|
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
|
|||
|
|
["备注", "记录豁免条件、灰度口径或制度来源。"],
|
|||
|
|
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
|
|||
|
|
]
|
|||
|
|
return _build_xlsx_bytes(
|
|||
|
|
[
|
|||
|
|
("差旅报销标准", standard_rows),
|
|||
|
|
("填表说明", instruction_rows),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
|
|||
|
|
return _build_xlsx_bytes([(sheet_name, [[""]])])
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def rebuild_from_uploaded_content(content: bytes) -> bytes:
|
|||
|
|
if not content:
|
|||
|
|
raise ValueError("待导入的表格内容不能为空。")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
workbook = load_workbook(
|
|||
|
|
filename=BytesIO(content),
|
|||
|
|
read_only=True,
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
|||
|
|
created_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|||
|
|
workbook_buffer = BytesIO()
|
|||
|
|
|
|||
|
|
with ZipFile(workbook_buffer, "w", ZIP_DEFLATED) as archive:
|
|||
|
|
archive.writestr("[Content_Types].xml", _build_content_types_xml(sheets))
|
|||
|
|
archive.writestr("_rels/.rels", _build_root_rels_xml())
|
|||
|
|
archive.writestr("docProps/app.xml", _build_app_xml(sheets))
|
|||
|
|
archive.writestr("docProps/core.xml", _build_core_xml(created_at))
|
|||
|
|
archive.writestr("xl/workbook.xml", _build_workbook_xml(sheets))
|
|||
|
|
archive.writestr("xl/_rels/workbook.xml.rels", _build_workbook_rels_xml(sheets))
|
|||
|
|
archive.writestr("xl/styles.xml", _build_styles_xml())
|
|||
|
|
|
|||
|
|
for index, (_, rows) in enumerate(sheets, start=1):
|
|||
|
|
archive.writestr(
|
|||
|
|
f"xl/worksheets/sheet{index}.xml",
|
|||
|
|
_build_sheet_xml(rows),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return workbook_buffer.getvalue()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
|||
|
|
overrides = [
|
|||
|
|
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
|
|||
|
|
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
|
|||
|
|
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
|
|||
|
|
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
|
|||
|
|
]
|
|||
|
|
overrides.extend(
|
|||
|
|
[
|
|||
|
|
f'<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
|||
|
|
for index, _ in enumerate(sheets, start=1)
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
return (
|
|||
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|||
|
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|||
|
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|||
|
|
'<Default Extension="xml" ContentType="application/xml"/>'
|
|||
|
|
f'{"".join(overrides)}'
|
|||
|
|
"</Types>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_root_rels_xml() -> str:
|
|||
|
|
return (
|
|||
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|||
|
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|||
|
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
|||
|
|
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
|||
|
|
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
|
|||
|
|
"</Relationships>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
|||
|
|
titles = "".join(
|
|||
|
|
[f'<vt:lpstr>{escape(name)}</vt:lpstr>' for name, _ in sheets]
|
|||
|
|
)
|
|||
|
|
sheet_count = len(sheets)
|
|||
|
|
return (
|
|||
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|||
|
|
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
|
|||
|
|
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
|||
|
|
'<Application>Microsoft Excel</Application>'
|
|||
|
|
f"<HeadingPairs><vt:vector size=\"2\" baseType=\"variant\"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant></vt:vector></HeadingPairs>"
|
|||
|
|
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>"
|
|||
|
|
"</Properties>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_core_xml(created_at: str) -> str:
|
|||
|
|
return (
|
|||
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|||
|
|
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
|||
|
|
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
|||
|
|
'xmlns:dcterms="http://purl.org/dc/terms/" '
|
|||
|
|
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
|
|||
|
|
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
|||
|
|
"<dc:creator>X-Financial</dc:creator>"
|
|||
|
|
"<cp:lastModifiedBy>X-Financial</cp:lastModifiedBy>"
|
|||
|
|
f'<dcterms:created xsi:type="dcterms:W3CDTF">{created_at}</dcterms:created>'
|
|||
|
|
f'<dcterms:modified xsi:type="dcterms:W3CDTF">{created_at}</dcterms:modified>'
|
|||
|
|
"</cp:coreProperties>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
|||
|
|
sheet_items = "".join(
|
|||
|
|
[
|
|||
|
|
f'<sheet name="{escape(name)}" sheetId="{index}" r:id="rId{index}"/>'
|
|||
|
|
for index, (name, _) in enumerate(sheets, start=1)
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
return (
|
|||
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|||
|
|
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|||
|
|
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|||
|
|
"<bookViews><workbookView/></bookViews>"
|
|||
|
|
f"<sheets>{sheet_items}</sheets>"
|
|||
|
|
"</workbook>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
|||
|
|
relationships = "".join(
|
|||
|
|
[
|
|||
|
|
f'<Relationship Id="rId{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
|
|||
|
|
for index, _ in enumerate(sheets, start=1)
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
relationships += (
|
|||
|
|
f'<Relationship Id="rId{len(sheets) + 1}" '
|
|||
|
|
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
|
|||
|
|
'Target="styles.xml"/>'
|
|||
|
|
)
|
|||
|
|
return (
|
|||
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|||
|
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|||
|
|
f"{relationships}"
|
|||
|
|
"</Relationships>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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>'
|
|||
|
|
'<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>'
|
|||
|
|
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
|||
|
|
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
|
|||
|
|
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
|||
|
|
'</styleSheet>'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
worksheet_rows: list[str] = []
|
|||
|
|
|
|||
|
|
for row_index, row in enumerate(normalized_rows, start=1):
|
|||
|
|
cells: list[str] = []
|
|||
|
|
for column_index, cell in enumerate(row, start=1):
|
|||
|
|
ref = f"{_column_letter(column_index)}{row_index}"
|
|||
|
|
text = "" if cell is None else str(cell)
|
|||
|
|
preserve = ' xml:space="preserve"' if text.strip() != text or "\n" in text else ""
|
|||
|
|
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>')
|
|||
|
|
|
|||
|
|
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\"/>"
|
|||
|
|
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
|
|||
|
|
"</worksheet>"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
|
|||
|
|
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]):
|
|||
|
|
normalized_rows.pop()
|
|||
|
|
|
|||
|
|
if not normalized_rows:
|
|||
|
|
return [[""]]
|
|||
|
|
|
|||
|
|
max_column = 0
|
|||
|
|
for row in normalized_rows:
|
|||
|
|
for index, cell in enumerate(row, start=1):
|
|||
|
|
if cell not in (None, ""):
|
|||
|
|
max_column = max(max_column, index)
|
|||
|
|
|
|||
|
|
if max_column <= 0:
|
|||
|
|
return [[""]]
|
|||
|
|
|
|||
|
|
return [row[:max_column] for row in normalized_rows]
|