295 lines
9.7 KiB
Python
295 lines
9.7 KiB
Python
#!/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 _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]
|
||
# Clone the existing cell to keep style information, then clear paragraphs.
|
||
new_cell = deepcopy(old_cell)
|
||
for child in list(new_cell.childNodes):
|
||
new_cell.removeChild(child)
|
||
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())
|