261 lines
8.5 KiB
Python
261 lines
8.5 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
|
|||
|
|
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 _set_cell_value(table, row: int, col: int, value: str) -> None:
|
|||
|
|
"""Set the text value of a table cell, preserving style references."""
|
|||
|
|
cell = table.getCellByPosition(col, row)
|
|||
|
|
# Clone the existing cell to keep style information, then clear paragraphs.
|
|||
|
|
new_cell = deepcopy(cell)
|
|||
|
|
for child in list(new_cell.childNodes):
|
|||
|
|
new_cell.removeChild(child)
|
|||
|
|
p = P(text=str(value))
|
|||
|
|
new_cell.addElement(p)
|
|||
|
|
table.setCellByPosition(col, row, new_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())
|