部署优化

This commit is contained in:
andy
2026-08-03 20:59:59 +08:00
parent 5294edab89
commit 727643f1f2
18 changed files with 358 additions and 130 deletions

View File

@@ -22,14 +22,13 @@ Supported companies: `LianTai`, `QBD`, `DY-AI-Easy-KB`, `FengRun`, `HanaTour`.
python3 -m pip install -r requirements-company-reports.txt
export COMPANY_REPORT_DATABASE_URL='postgresql://USER:PASSWORD@HOST:PORT/booking_test'
export COMPANY_REPORT_NODE_BINARY='/absolute/path/to/node'
export COMPANY_REPORT_ARTIFACT_TOOL_MODULE='/absolute/path/to/artifact_tool.mjs'
python3 -m company_reports generate \
--month 2026-07 \
--as-of 2026-07-10
```
The workbook builder uses Python/openpyxl. No Node.js or `@oai/artifact-tool` package is required for company reports.
The DSN can fall back to `ARR_DATABASE_URL`; never place a real DSN in source, prompts, output JSON or browser code.
Scheduling convention:

View File

@@ -71,11 +71,11 @@ def _parser() -> SafeArgumentParser:
)
generate.add_argument(
"--node-binary",
help="Node.js executable; defaults to COMPANY_REPORT_NODE_BINARY or PATH",
help="deprecated compatibility option; ignored by the openpyxl builder",
)
generate.add_argument(
"--artifact-tool-module",
help="absolute path to artifact_tool.mjs when package resolution is unavailable",
help="deprecated compatibility option; ignored by the openpyxl builder",
)
return parser

View File

@@ -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),

View File

@@ -1,8 +1,5 @@
{
"name": "company-report-xlsx-builder",
"private": true,
"type": "module",
"dependencies": {
"@oai/artifact-tool": "2.8.33"
}
"type": "module"
}