#!/usr/bin/env python3 """Fill AIME production order template from Odoo MRP export. Usage: python fill_production_order.py 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())