dotfiles/VSCodium/User/History/114d05ac/uap8.py
2026-06-19 14:29:55 +02:00

309 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Fill AIME production order template from Odoo MRP export.
Usage:
python fill_production_order.py <source_xlsx>
The script loads the specified Excel source file, extracts relevant fields,
fills the ODS template, and saves it as a new ODS named after cell A2 of the
source file.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from collections import defaultdict
from copy import deepcopy
from pathlib import Path
import openpyxl
from odf import opendocument
from odf.table import Table, TableCell, TableRow
from odf.text import P
# Relative paths to the template and source file(s).
TEMPLATE_GLOB = "A4004_*.ods"
SOURCE_GLOB = "Production Order (mrp.production)*.xlsx"
# Mapping from component categories in the source to target merged ranges in
# the template. The value is the cell address where the first matching component
# description should be written. Subsequent matches are written to the rows
# directly below the start cell within the allowed range.
CATEGORY_TARGETS = {
"All / Components / Barebone": "C10",
"All / Components / CPU": "C11",
"All / Components / RAM": "C12",
"All / Components / GPU": "C20",
"All / Components / SSD": "C24",
"All / Components / RAID": "C27",
}
# Maximum number of rows available for each category in the template. A value
# of 1 means only the start cell may be used.
CATEGORY_ROW_LIMITS = {
"All / Components / Barebone": 1,
"All / Components / CPU": 1,
"All / Components / RAM": 8,
"All / Components / GPU": 4,
"All / Components / SSD": 2,
"All / Components / RAID": 1,
}
def _col_letter_to_index(letter: str) -> int:
"""Convert an Excel-style column letter to a 0-based index."""
idx = 0
for ch in letter.upper():
idx = idx * 26 + (ord(ch) - ord("A") + 1)
return idx - 1
def _cell_addr_to_indices(addr: str) -> tuple[int, int]:
"""Convert an address like 'C10' to (row, col) zero-based indices."""
match = re.fullmatch(r"([A-Za-z]+)(\d+)", addr)
if not match:
raise ValueError(f"Invalid cell address: {addr}")
col = _col_letter_to_index(match.group(1))
row = int(match.group(2)) - 1
return row, col
def find_source_files(directory: Path) -> list[Path]:
"""Return all source Excel files sorted alphabetically."""
files = sorted(directory.glob(SOURCE_GLOB))
return files
def extract_source_data(source_path: Path) -> dict:
"""Read the source workbook and return extracted values.
Returned dict keys:
- order_name: content of cell A2
- serial: suffix after 'FA-'
- model: model designation after 'AIME-'
- full_model: model + '-' + serial
- categories: mapping category -> list of component names from column E
"""
wb = openpyxl.load_workbook(source_path, data_only=True)
ws = wb.active
order_name = ws.cell(row=2, column=1).value
if order_name is None:
raise ValueError(f"Cell A2 is empty in {source_path}")
order_name = str(order_name).strip()
serial = None
model = None
categories: dict[str, list[str]] = defaultdict(list)
for row in ws.iter_rows(min_row=2, values_only=True):
# row layout from inspection: A=order, B=FA-..., C=AIME-...,
# D=category, E=component name
cell_b = row[1] if len(row) > 1 else None
cell_c = row[2] if len(row) > 2 else None
cell_d = row[3] if len(row) > 3 else None
cell_e = row[4] if len(row) > 4 else None
if isinstance(cell_b, str):
m = re.search(r"FA-([A-Za-z0-9]+)", cell_b)
if m:
serial = m.group(1)
if isinstance(cell_c, str):
m = re.search(r"AIME-([A-Za-z0-9]+)", cell_c)
if m:
model = m.group(1)
if isinstance(cell_d, str):
category = cell_d.strip()
if category in CATEGORY_TARGETS and isinstance(cell_e, str):
categories[category].append(cell_e.strip())
wb.close()
if serial is None:
raise ValueError(f"Could not find 'FA-' serial number in {source_path}")
if model is None:
raise ValueError(f"Could not find 'AIME-' model in {source_path}")
full_model = f"{model}-{serial}"
return {
"order_name": order_name,
"serial": serial,
"model": model,
"full_model": full_model,
"categories": dict(categories),
}
def _get_rows(table: Table) -> list[TableRow]:
"""Return the list of TableRow elements inside a table."""
return list(table.getElementsByType(TableRow))
def _get_cells(row: TableRow) -> list[TableCell]:
"""Return the list of TableCell elements inside a row."""
return list(row.getElementsByType(TableCell))
def _cell_style_name(cell: TableCell) -> str | None:
"""Return the style-name attribute of a cell if present."""
return cell.getAttribute("stylename")
def _ensure_rows(table: Table, min_row_count: int) -> None:
"""Append empty rows until the table has at least ``min_row_count`` rows."""
rows = _get_rows(table)
while len(rows) < min_row_count:
new_row = TableRow()
# Determine the number of columns from the first row.
first_row_cells = _get_cells(rows[0]) if rows else []
for _ in first_row_cells:
cell = TableCell()
cell.addElement(P(text=""))
new_row.addElement(cell)
table.addElement(new_row)
rows = _get_rows(table)
def _set_cell_value(table: Table, row: int, col: int, value: str) -> None:
"""Set the text value of a table cell, preserving style references."""
_ensure_rows(table, row + 1)
rows = _get_rows(table)
target_row = rows[row]
cells = _get_cells(target_row)
if col >= len(cells):
raise IndexError(
f"Row {row + 1} only has {len(cells)} cells, cannot write to column {col + 1}"
)
old_cell = cells[col]
style = _cell_style_name(old_cell)
# Build a fresh cell. Deep-copying ODF elements can recurse on document
# back-references, so we only keep the style name.
new_cell = TableCell()
if style:
new_cell.setAttribute("stylename", style)
# Preserve useful span attributes (e.g. column span, row span).
for attr in ("numbercolumnsspanned", "numberrowsspanned"):
val = old_cell.getAttribute(attr)
if val is not None:
new_cell.setAttribute(attr, val)
new_cell.addElement(P(text=str(value)))
target_row.insertBefore(new_cell, old_cell)
target_row.removeChild(old_cell)
def _find_template(directory: Path) -> Path:
"""Return the first ODS file matching the template name pattern."""
candidates = sorted(directory.glob(TEMPLATE_GLOB))
if not candidates:
raise FileNotFoundError(
f"No ODS template matching '{TEMPLATE_GLOB}' found in {directory}"
)
return candidates[0]
def fill_template(source_path: Path, output_path: Path | None = None) -> Path:
"""Fill the template using data from ``source_path``.
The output file is named after cell A2 of the source file unless an
explicit ``output_path`` is provided.
"""
directory = source_path.parent
data = extract_source_data(source_path)
template_path = _find_template(directory)
doc = opendocument.load(template_path)
# Find the first table in the template.
tables = doc.spreadsheet.getElementsByType(Table)
table = next(iter(tables), None)
if table is None:
raise ValueError("No table found in the template ODS file")
# Write model-serial combination into C10? No C10 is reserved for
# Barebone. The user requested to combine model and serial into a variable
# but did not specify a target cell. We keep the variable available and log
# it; nothing is pasted into the template for it automatically.
print(f"Extracted order: {data['order_name']}")
print(f"Model: {data['model']}, Serial: {data['serial']}")
print(f"Combined: {data['full_model']}")
# Fill component categories.
for category, items in data["categories"].items():
start_addr = CATEGORY_TARGETS[category]
max_rows = CATEGORY_ROW_LIMITS[category]
start_row, start_col = _cell_addr_to_indices(start_addr)
for i, item in enumerate(items[:max_rows]):
target_row = start_row + i
# Write into the left cell of the merged range; ODS merges span
# multiple columns, so writing C is sufficient for C:D.
_set_cell_value(table, target_row, start_col, item)
print(f" {category} -> {start_addr[0]}{start_row + i + 1}: {item}")
if len(items) > max_rows:
print(
f" Warning: {category} has {len(items)} items, "
f"only {max_rows} fit in the template",
file=sys.stderr,
)
# Determine output path.
if output_path is None:
output_path = directory / f"{data['order_name']}.ods"
else:
output_path = Path(output_path)
doc.save(output_path)
print(f"Saved: {output_path}")
return output_path
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Fill production order template from an Odoo export."
)
parser.add_argument(
"source",
nargs="?",
help="Path to the source Excel file. If omitted, the most recent source "
"file matching the production order glob is used.",
)
parser.add_argument(
"-o", "--output", help="Optional explicit output ODS file path"
)
args = parser.parse_args(argv)
directory = Path(__file__).resolve().parent
if args.source:
source_path = Path(args.source).expanduser()
if not source_path.is_absolute():
source_path = directory / source_path
else:
sources = find_source_files(directory)
if not sources:
print(f"No source file matching '{SOURCE_GLOB}' found.", file=sys.stderr)
return 1
source_path = sources[-1]
print(f"Using source file: {source_path.name}")
if not source_path.exists():
print(f"Source file not found: {source_path}", file=sys.stderr)
return 1
fill_template(source_path, args.output)
return 0
if __name__ == "__main__":
sys.exit(main())