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