部署优化
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Artifact-tool build adapter and recoverable atomic publication."""
|
||||
"""Workbook build adapter and recoverable atomic publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,13 +6,17 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
from company_reports.contracts import CompanyReport, ErrorCode, RESULT_SCHEMA_VERSION
|
||||
from company_reports.repository import (
|
||||
FileMetadata,
|
||||
@@ -23,6 +27,21 @@ from company_reports.repository import (
|
||||
|
||||
|
||||
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
REPORT_HEADERS = (
|
||||
"ARRIVAL",
|
||||
"DEPARTURE",
|
||||
"NIGHTS",
|
||||
"BLOCK_CODE",
|
||||
"RES_COMMENT",
|
||||
"Booking Room",
|
||||
"Total Booking Price",
|
||||
)
|
||||
BODY_FONT = "222222"
|
||||
DUPLICATE_FILL = "FFF2CC"
|
||||
DUPLICATE_FONT = "9C6500"
|
||||
REVIEW_FILL = "FCE4D6"
|
||||
REVIEW_FONT = "C65911"
|
||||
HEADER_FILL = "FFFFFF"
|
||||
|
||||
|
||||
class BuildError(RuntimeError):
|
||||
@@ -89,63 +108,196 @@ class ArtifactToolBuilder:
|
||||
artifact_tool_module: Optional[Path] = None,
|
||||
timeout_seconds: int = 120,
|
||||
) -> None:
|
||||
configured = (node_binary or os.environ.get("COMPANY_REPORT_NODE_BINARY", "")).strip()
|
||||
self._node_binary = configured or shutil.which("node") or ""
|
||||
_ = (node_binary, artifact_tool_module, timeout_seconds)
|
||||
self._builder_script = builder_script.resolve()
|
||||
self._artifact_tool_module = (
|
||||
artifact_tool_module.resolve() if artifact_tool_module else None
|
||||
|
||||
@staticmethod
|
||||
def _safe_text(value: Any) -> str:
|
||||
text = str(value or "")
|
||||
return "'" + text if text[:1] in {"=", "+", "-", "@"} else text
|
||||
|
||||
@staticmethod
|
||||
def _date_key(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.date().isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, str) and len(value) >= 10:
|
||||
return value[:10]
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _cell_text(value: Any) -> str:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
@staticmethod
|
||||
def _semantic_sha256(payload: Mapping[str, Any]) -> str:
|
||||
encoded = json.dumps(
|
||||
{
|
||||
"headers": payload["headers"],
|
||||
"periods": payload["periods"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
def _write_sheet(
|
||||
self,
|
||||
worksheet: Worksheet,
|
||||
period: Any,
|
||||
) -> None:
|
||||
worksheet.append(list(REPORT_HEADERS))
|
||||
for row in period.rows:
|
||||
worksheet.append(
|
||||
[
|
||||
row.arrival,
|
||||
row.departure,
|
||||
row.nights,
|
||||
self._safe_text(row.block_code),
|
||||
self._safe_text(row.res_comment),
|
||||
self._safe_text(row.booking_room),
|
||||
self._safe_text(row.total_booking_price),
|
||||
]
|
||||
)
|
||||
|
||||
worksheet.column_dimensions["A"].width = 13
|
||||
worksheet.column_dimensions["B"].width = 13
|
||||
worksheet.column_dimensions["C"].width = 9
|
||||
worksheet.column_dimensions["D"].width = 20
|
||||
worksheet.column_dimensions["E"].width = 25
|
||||
worksheet.column_dimensions["F"].width = 34
|
||||
worksheet.column_dimensions["G"].width = 48
|
||||
worksheet.freeze_panes = "A2"
|
||||
worksheet.auto_filter.ref = f"A1:G{max(1, worksheet.max_row)}"
|
||||
|
||||
thin_bottom = Border(
|
||||
bottom=Side(style="thin", color="7F7F7F"),
|
||||
)
|
||||
self._timeout_seconds = timeout_seconds
|
||||
header_fill = PatternFill("solid", fgColor=HEADER_FILL)
|
||||
duplicate_fill = PatternFill("solid", fgColor=DUPLICATE_FILL)
|
||||
review_fill = PatternFill("solid", fgColor=REVIEW_FILL)
|
||||
for cell in worksheet[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = Font(name="Arial", size=10, bold=True, color="000000")
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
cell.border = thin_bottom
|
||||
worksheet.row_dimensions[1].height = 24
|
||||
|
||||
for row_index in range(2, worksheet.max_row + 1):
|
||||
worksheet.row_dimensions[row_index].height = 30
|
||||
for cell in worksheet[row_index]:
|
||||
cell.font = Font(name="Arial", size=10, color=BODY_FONT)
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
for column in ("A", "B", "C", "D"):
|
||||
worksheet[f"{column}{row_index}"].alignment = Alignment(
|
||||
horizontal="center",
|
||||
vertical="center",
|
||||
)
|
||||
for column in ("E", "F", "G"):
|
||||
worksheet[f"{column}{row_index}"].alignment = Alignment(
|
||||
horizontal="left",
|
||||
vertical="center",
|
||||
wrap_text=True,
|
||||
)
|
||||
worksheet[f"A{row_index}"].number_format = "yyyy-mm-dd"
|
||||
worksheet[f"B{row_index}"].number_format = "yyyy-mm-dd"
|
||||
|
||||
for offset, source_row in enumerate(period.rows, start=2):
|
||||
if source_row.duplicate_group:
|
||||
for column in range(1, 8):
|
||||
worksheet.cell(offset, column).fill = duplicate_fill
|
||||
worksheet[f"E{offset}"].font = Font(
|
||||
name="Arial",
|
||||
size=10,
|
||||
bold=True,
|
||||
color=DUPLICATE_FONT,
|
||||
)
|
||||
if source_row.multi_price_review:
|
||||
worksheet[f"G{offset}"].fill = review_fill
|
||||
worksheet[f"G{offset}"].font = Font(
|
||||
name="Arial",
|
||||
size=10,
|
||||
bold=True,
|
||||
color=REVIEW_FONT,
|
||||
)
|
||||
|
||||
def _validate_workbook(self, output_path: Path, report: CompanyReport) -> int:
|
||||
workbook = load_workbook(output_path, data_only=False)
|
||||
expected_names = [period.sheet_name for period in report.periods]
|
||||
if workbook.sheetnames != expected_names:
|
||||
raise ValueError("worksheet names do not match")
|
||||
formula_count = 0
|
||||
for period in report.periods:
|
||||
worksheet = workbook[period.sheet_name]
|
||||
expected_rows = len(period.rows) + 1
|
||||
if worksheet.max_row != expected_rows or worksheet.max_column != 7:
|
||||
raise ValueError("worksheet dimensions do not match")
|
||||
headers = [worksheet.cell(1, column).value for column in range(1, 8)]
|
||||
if headers != list(REPORT_HEADERS):
|
||||
raise ValueError("worksheet headers do not match")
|
||||
for row_index, row in enumerate(period.rows, start=2):
|
||||
expected = [
|
||||
row.arrival.isoformat(),
|
||||
row.departure.isoformat(),
|
||||
row.nights,
|
||||
self._safe_text(row.block_code),
|
||||
self._safe_text(row.res_comment),
|
||||
self._safe_text(row.booking_room),
|
||||
self._safe_text(row.total_booking_price),
|
||||
]
|
||||
actual = [
|
||||
self._date_key(worksheet.cell(row_index, 1).value),
|
||||
self._date_key(worksheet.cell(row_index, 2).value),
|
||||
worksheet.cell(row_index, 3).value,
|
||||
self._cell_text(worksheet.cell(row_index, 4).value),
|
||||
self._cell_text(worksheet.cell(row_index, 5).value),
|
||||
self._cell_text(worksheet.cell(row_index, 6).value),
|
||||
self._cell_text(worksheet.cell(row_index, 7).value),
|
||||
]
|
||||
if actual != expected:
|
||||
raise ValueError("worksheet values do not match")
|
||||
for cells in worksheet.iter_rows():
|
||||
for cell in cells:
|
||||
if cell.data_type == "f":
|
||||
formula_count += 1
|
||||
if formula_count != 0:
|
||||
raise ValueError("workbook must contain no formulas")
|
||||
return formula_count
|
||||
|
||||
def build(self, report: CompanyReport, work_dir: Path) -> BuiltWorkbook:
|
||||
if not self._node_binary or not self._builder_script.is_file():
|
||||
raise BuildError(
|
||||
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
"the XLSX builder runtime is unavailable",
|
||||
)
|
||||
work_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(work_dir, 0o700)
|
||||
payload_path = work_dir / "workbook-payload.json"
|
||||
output_path = work_dir / report.filename
|
||||
preview_dir = work_dir / "previews"
|
||||
summary_path = work_dir / "workbook-summary.json"
|
||||
_private_json(payload_path, report.to_workbook_payload())
|
||||
try:
|
||||
environment = os.environ.copy()
|
||||
if self._artifact_tool_module is not None:
|
||||
environment["COMPANY_REPORT_ARTIFACT_TOOL_MODULE"] = str(
|
||||
self._artifact_tool_module
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
self._node_binary,
|
||||
str(self._builder_script),
|
||||
str(payload_path),
|
||||
str(output_path),
|
||||
str(preview_dir),
|
||||
str(summary_path),
|
||||
],
|
||||
cwd=self._builder_script.parent,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self._timeout_seconds,
|
||||
env=environment,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise BuildError(
|
||||
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
"the XLSX builder did not complete",
|
||||
) from None
|
||||
if completed.returncode != 0:
|
||||
raise BuildError(
|
||||
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
"the XLSX builder rejected the generated payload",
|
||||
)
|
||||
try:
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
payload = report.to_workbook_payload()
|
||||
_private_json(payload_path, payload)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
workbook = Workbook()
|
||||
for index, period in enumerate(report.periods):
|
||||
worksheet = workbook.active if index == 0 else workbook.create_sheet()
|
||||
worksheet.title = period.sheet_name
|
||||
self._write_sheet(worksheet, period)
|
||||
workbook.save(output_path)
|
||||
os.chmod(output_path, 0o600)
|
||||
formula_count = self._validate_workbook(output_path, report)
|
||||
expected_rows = [len(period.rows) for period in report.periods]
|
||||
expected_names = [period.sheet_name for period in report.periods]
|
||||
summary = {
|
||||
"status": "success",
|
||||
"schema_version": payload["schema_version"],
|
||||
"company": report.company,
|
||||
"filename": report.filename,
|
||||
"sheet_names": expected_names,
|
||||
"row_counts": expected_rows,
|
||||
"formula_count": formula_count,
|
||||
"semantic_sha256": self._semantic_sha256(payload),
|
||||
"preview_count": 0,
|
||||
"byte_size": output_path.stat().st_size,
|
||||
}
|
||||
if (
|
||||
summary.get("status") != "success"
|
||||
or summary.get("company") != report.company
|
||||
@@ -155,17 +307,16 @@ class ArtifactToolBuilder:
|
||||
or summary.get("formula_count") != 0
|
||||
or not isinstance(summary.get("semantic_sha256"), str)
|
||||
or len(summary.get("semantic_sha256")) != 64
|
||||
or summary.get("preview_count") != 3
|
||||
or not output_path.is_file()
|
||||
or output_path.stat().st_size <= 0
|
||||
):
|
||||
raise ValueError("summary mismatch")
|
||||
_private_json(summary_path, summary)
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
raise BuildError(
|
||||
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
"the XLSX builder result could not be validated",
|
||||
) from None
|
||||
os.chmod(output_path, 0o600)
|
||||
return BuiltWorkbook(
|
||||
path=output_path,
|
||||
sha256=sha256_file(output_path),
|
||||
|
||||
Reference in New Issue
Block a user