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

391 lines
13 KiB
Python

#!/usr/bin/env python3
"""Fill AIME production order template from Odoo MRP export.
Usage:
python fill_production_order.py <source_xlsx>
python fill_production_order.py # uses newest matching source file
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 B1:C2
of the resulting template (MODEL-FA without the prefixes).
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import openpyxl
from odf import opendocument
from odf.table import Table, TableCell, TableRow
from odf.text import P
# Relative paths / globs -------------------------------------------------------
TEMPLATE_GLOB = "A4004_*.ods"
SOURCE_GLOB = "Production Order (mrp.production)*.xlsx"
# Mapping from source component category to template target rows.
# Names go into column C (merged C:D). Serial numbers go into column E.
CATEGORY_TARGETS = {
"All / Components / Barebone": {
"name_start": 10,
"name_rows": 1,
"serial_start": 10,
"serial_rows": 1,
},
"All / Components / CPU": {
"name_start": 11,
"name_rows": 1,
"serial_start": 11,
"serial_rows": 1,
},
"All / Components / RAM": {
"name_start": 12,
"name_rows": 8,
"serial_start": 12,
"serial_rows": 8,
},
"All / Components / GPU": {
"name_start": 20,
"name_rows": 4,
"serial_start": 20,
"serial_rows": 4,
},
"All / Components / SSD": {
"name_start": 24,
"name_rows": 2,
"serial_start": 24,
"serial_rows": 4, # E24..E27 according to the spec (SSD shares row 27 with RAID names, but serial rows go to E26/E27)
},
"All / Components / Network": {
"name_start": 26,
"name_rows": 1,
"serial_start": 28,
"serial_rows": 2,
},
"All / Components / RAID": {
"name_start": 27,
"name_rows": 1,
"serial_start": 30,
"serial_rows": 1,
},
}
# Source column indices (0-based).
COL_CAT = 3 # D
COL_NAME = 4 # E
COL_SN = 6 # G
# --- helpers -----------------------------------------------------------------
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_letters = match.group(1).upper()
col = 0
for ch in col_letters:
col = col * 26 + (ord(ch) - ord("A") + 1)
row = int(match.group(2)) - 1
return row, col - 1
def _get_rows(table: Table) -> list[TableRow]:
return list(table.getElementsByType(TableRow))
def _get_cells(row: TableRow) -> list[TableCell]:
return list(row.getElementsByType(TableCell))
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)
if not rows:
return
first_row_cells = _get_cells(rows[0])
while len(rows) < min_row_count:
new_row = TableRow()
for _ in first_row_cells:
cell = TableCell()
cell.addElement(P(text=""))
new_row.addElement(cell)
table.addElement(new_row)
rows = _get_rows(table)
def _cell_style_name(cell: TableCell) -> str | None:
return cell.getAttribute("stylename")
def _set_cell_value(table: Table, row: int, col: int, value: str) -> None:
"""Set the text value of a table cell, preserving style and spans."""
_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)
new_cell = TableCell()
if style:
new_cell.setAttribute("stylename", style)
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) if value is not None else ""))
target_row.insertBefore(new_cell, old_cell)
target_row.removeChild(old_cell)
def _find_template(directory: Path) -> Path:
candidates = sorted(directory.glob(TEMPLATE_GLOB))
if not candidates:
raise FileNotFoundError(
f"No ODS template matching '{TEMPLATE_GLOB}' found in {directory}"
)
return candidates[0]
# --- source parsing ----------------------------------------------------------
def _clean(value) -> str | None:
if value is None:
return None
text = str(value).strip()
return text if text else None
def _collect_rows_for_category(ws, start_row: int, col_cat: int = COL_CAT) -> list[int]:
"""Return consecutive rows that belong to the category starting at start_row.
A category block continues while column D is empty (or the same category) and
column E is empty on subsequent rows. We stop when we hit a row that has a
non-empty category in column D different from the starting category, or a row
that has a non-empty component name in column E (i.e. the start of the next
component).
"""
rows: list[int] = [start_row]
max_row = ws.max_row
category = _clean(ws.cell(row=start_row, column=col_cat + 1).value)
r = start_row + 1
while r <= max_row:
cat_val = _clean(ws.cell(row=r, column=col_cat + 1).value)
name_val = _clean(ws.cell(row=r, column=COL_NAME + 1).value)
# If a new category appears, this block ends.
if cat_val is not None and cat_val != category:
break
# If a component name appears, this is a new component; stop.
if name_val is not None:
break
rows.append(r)
r += 1
return rows
def extract_source_data(source_path: Path) -> dict:
"""Read the source workbook and return extracted values.
The structure of the returned dict:
- fa: serial suffix after 'FA-'
- model: model designation after 'AIME-'
- full_model: MODEL + '-' + FA
- components: dict category -> dict with 'name' and 'serials' list
- mGPU/mRAM/mSSD/mNIC: counters for empty rows below the first GPU/RAM/SSD/NIC
"""
wb = openpyxl.load_workbook(source_path, data_only=True)
ws = wb.active
fa = None
model = None
# B2 -> FA, C2 -> MODEL
b2 = _clean(ws.cell(row=2, column=2).value)
c2 = _clean(ws.cell(row=2, column=3).value)
if isinstance(b2, str):
m = re.search(r"FA-([A-Za-z0-9]+)", b2)
if m:
fa = m.group(1)
if isinstance(c2, str):
m = re.search(r"AIME-([A-Za-z0-9]+)", c2)
if m:
model = m.group(1)
components: dict[str, dict] = {
cat: {"name": None, "serials": []} for cat in CATEGORY_TARGETS
}
# Counters for empty rows below the first row of each category.
counters = {"mGPU": 0, "mRAM": 0, "mSSD": 0, "mNIC": 0}
max_row = ws.max_row
r = 1
while r <= max_row:
category = _clean(ws.cell(row=r, column=COL_CAT + 1).value)
if category not in CATEGORY_TARGETS:
r += 1
continue
name = _clean(ws.cell(row=r, column=COL_NAME + 1).value)
if name:
components[category]["name"] = name
# Collect all serial numbers for this component block.
block_rows = _collect_rows_for_category(ws, r)
serials: list[str] = []
for br in block_rows:
sn = _clean(ws.cell(row=br, column=COL_SN + 1).value)
if sn:
serials.append(sn)
components[category]["serials"] = serials
# Count empty rows BELOW the first row of the block until a non-empty
# serial cell appears. This matches the requested m* behaviour.
counter_key = None
if category == "All / Components / GPU":
counter_key = "mGPU"
elif category == "All / Components / RAM":
counter_key = "mRAM"
elif category == "All / Components / SSD":
counter_key = "mSSD"
elif category == "All / Components / Network":
counter_key = "mNIC"
if counter_key:
cr = r + 1
while cr <= max_row:
sn = _clean(ws.cell(row=cr, column=COL_SN + 1).value)
if sn is not None:
break
# Only count rows that are still part of this component block
# (empty category and empty name).
cat_val = _clean(ws.cell(row=cr, column=COL_CAT + 1).value)
name_val = _clean(ws.cell(row=cr, column=COL_NAME + 1).value)
if cat_val is None and name_val is None:
counters[counter_key] += 1
cr += 1
# Move to the row after the block.
r = block_rows[-1] + 1
wb.close()
if fa 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}-{fa}"
return {
"fa": fa,
"model": model,
"full_model": full_model,
"components": components,
**counters,
}
# --- template filling --------------------------------------------------------
def fill_template(source_path: Path, output_path: Path | None = None) -> Path:
directory = source_path.parent
data = extract_source_data(source_path)
template_path = _find_template(directory)
doc = opendocument.load(template_path)
tables = list(doc.spreadsheet.getElementsByType(Table))
if not tables:
raise ValueError("No table found in the template ODS file")
table = tables[0]
print(f"Source: {source_path.name}")
print(f"FA: {data['fa']}, MODEL: {data['model']}, Combined: {data['full_model']}")
print(f"Counters -> mGPU={data['mGPU']}, mRAM={data['mRAM']}, mSSD={data['mSSD']}, mNIC={data['mNIC']}")
# Write combined model-serial into B1:C2.
_set_cell_value(table, 0, 1, data["full_model"])
_set_cell_value(table, 1, 1, data["full_model"])
# Component names and serial numbers.
for category, comp in data["components"].items():
targets = CATEGORY_TARGETS[category]
# Name(s) -> column C (0-based index 2)
name = comp["name"]
if name:
_set_cell_value(table, targets["name_start"] - 1, 2, name)
print(f" {category} name -> C{targets['name_start']}: {name}")
serials = comp["serials"]
for i, sn in enumerate(serials[: targets["serial_rows"]]):
target_row = targets["serial_start"] - 1 + i
_set_cell_value(table, target_row, 4, sn)
print(f" {category} S/N {i + 1} -> E{target_row + 1}: {sn}")
if len(serials) > targets["serial_rows"]:
print(
f" Warning: {category} has {len(serials)} serials, "
f"only {targets['serial_rows']} fit in the template",
file=sys.stderr,
)
if output_path is None:
output_path = directory / f"{data['full_model']}.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 alphabetically last "
"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 = sorted(directory.glob(SOURCE_GLOB))
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())