144 lines
7.1 KiB
Python
144 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Fill the reference template into ONE master workbook (Components_Master.xlsx).
|
|
|
|
The master contains ONLY the type sheets that actually have parts (the components whose
|
|
datasheets were provided) - built from a copy of the template so each kept sheet keeps its
|
|
exact headers, styling, widths and freeze - plus a single **Meta** sheet appended at the
|
|
END with: Template Version, Total Components, Date, Time, and a per-sheet count breakdown.
|
|
Empty template sheets are dropped. Column A = <MPN>_<make>_<typeid>.
|
|
|
|
Shared helpers (part_tag, part_to_row, template_headers, sheet_rows, read_all_rows,
|
|
build_master, resolve_version, MASTER_NAME) are imported by gitea_reconcile.py so the Gitea
|
|
master is built and merged the exact same way.
|
|
|
|
python fill_templates.py parts.json --template <template.xlsx> --dest <dir> [--version v1]
|
|
"""
|
|
import argparse, json, os, re, datetime
|
|
import openpyxl
|
|
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
GREEN="B6D7A8"; GRAY="BFBFBF"
|
|
MASTER_NAME="Components_Master.xlsx"; META_SHEET="Meta"
|
|
def norm(s): return re.sub(r'[^a-z0-9]', '', str(s).lower())
|
|
ALIAS={"induactanceuh":"inductanceuh"} # template header typo -> value key
|
|
|
|
def make_tag(manufacturer):
|
|
w=re.split(r'\s+', str(manufacturer).strip()); first=w[0] if w and w[0] else "NA"
|
|
return re.sub(r'[^A-Za-z0-9]', '', first) or "NA"
|
|
def part_tag(part):
|
|
"""Identity of a part: <MPN>_<make>_<typeid>. Same tag = same part."""
|
|
make=part.get("make") or make_tag(part.get("manufacturer",""))
|
|
return f'{part.get("mpn","")}_{make}_{part.get("typeid","NA")}'
|
|
|
|
def template_headers(template_path, ctype):
|
|
"""Canonical data headers for a type sheet, or None if the template has no such sheet."""
|
|
wb=openpyxl.load_workbook(template_path)
|
|
if ctype not in wb.sheetnames: return None
|
|
ws=wb[ctype]; return [ws.cell(1,c).value for c in range(1, ws.max_column+1)]
|
|
|
|
def template_types(template_path):
|
|
return [s for s in openpyxl.load_workbook(template_path).sheetnames if s!=META_SHEET]
|
|
|
|
def part_to_row(part, headers):
|
|
"""Return (tag, {header: value}) for one part, following the column rules."""
|
|
vals={norm(k):v for k,v in part.get("values",{}).items()}; tag=part_tag(part); row={}
|
|
for c,h in enumerate(headers, start=1):
|
|
key=ALIAS.get(norm(h),norm(h))
|
|
if c==1: row[h]=tag
|
|
elif key=="class": row[h]=part.get("subclass","")
|
|
elif key=="manufacturer":row[h]=part.get("manufacturer", vals.get("manufacturer",""))
|
|
else: row[h]=vals.get(key,"")
|
|
return tag,row
|
|
|
|
def sheet_rows(ws, width):
|
|
"""Existing data rows in a sheet -> (headers, {tag: {header: value}}), bounded to `width`."""
|
|
headers=[ws.cell(1,c).value for c in range(1,width+1)]; rows={}
|
|
for r in range(2, ws.max_row+1):
|
|
tag=ws.cell(r,1).value
|
|
if tag in (None,""): continue
|
|
rows[tag]={headers[c-1]: ws.cell(r,c).value for c in range(1,width+1)}
|
|
return headers, rows
|
|
|
|
def read_all_rows(master_path, template_path):
|
|
"""Read every type sheet of an existing master -> {ctype: {tag: rowdict}} (skips Meta)."""
|
|
wb=openpyxl.load_workbook(master_path); types=set(template_types(template_path)); out={}
|
|
for name in wb.sheetnames:
|
|
if name not in types: continue
|
|
th=template_headers(template_path,name); w=len(th) if th else 0
|
|
if not w: continue
|
|
_,rows=sheet_rows(wb[name], w)
|
|
if rows: out[name]=rows
|
|
return out
|
|
|
|
def _thin(): s=Side(style="thin",color=GRAY); return Border(left=s,right=s,top=s,bottom=s)
|
|
def _hdr(cell):
|
|
cell.font=Font(name="Calibri",bold=True); cell.fill=PatternFill("solid",fgColor=GREEN)
|
|
cell.border=_thin(); cell.alignment=Alignment(horizontal="center",vertical="center")
|
|
|
|
def _write_rows(ws, headers, rows_by_tag):
|
|
for r in range(2, ws.max_row+1):
|
|
for c in range(1, len(headers)+1): ws.cell(r,c).value=None
|
|
r=2
|
|
for tag,row in rows_by_tag.items():
|
|
for c,h in enumerate(headers, start=1): ws.cell(r,c, row.get(h,""))
|
|
r+=1
|
|
|
|
def _build_meta_sheet(wb, version, counts, when):
|
|
if META_SHEET in wb.sheetnames: del wb[META_SHEET]
|
|
ws=wb.create_sheet(META_SHEET) # appended at the end
|
|
for r,(k,v) in enumerate([("Template Version",version),
|
|
("Total Components",sum(counts.values())),
|
|
("Date",when.strftime("%Y-%m-%d")),
|
|
("Time",when.strftime("%H:%M:%S"))], start=1):
|
|
a=ws.cell(r,1,k); a.font=Font(name="Calibri",bold=True)
|
|
a.fill=PatternFill("solid",fgColor=GREEN); a.border=_thin()
|
|
ws.cell(r,2,v).border=_thin()
|
|
r=6
|
|
for c,t in ((1,"Sheet"),(2,"Components")): _hdr(ws.cell(r,c,t))
|
|
for ctype,n in counts.items():
|
|
r+=1; ws.cell(r,1,ctype).border=_thin(); ws.cell(r,2,n).border=_thin()
|
|
ws.column_dimensions["A"].width=20; ws.column_dimensions["B"].width=16
|
|
return ws
|
|
|
|
def resolve_version(template_path, version):
|
|
if version: return version
|
|
vf=os.path.join(os.path.dirname(template_path),"VERSION")
|
|
return "v"+open(vf).read().strip() if os.path.exists(vf) else "v1"
|
|
|
|
def build_master(template_path, rows_by_type, version, dest_dir):
|
|
"""Write the single master workbook from a copy of the template. `rows_by_type` =
|
|
{ctype: {tag: rowdict}} is the COMPLETE desired contents. Only sheets present (with at
|
|
least one row) are kept; all other template sheets are dropped. A Meta sheet is appended
|
|
last. Returns the output path."""
|
|
os.makedirs(dest_dir, exist_ok=True)
|
|
wb=openpyxl.load_workbook(template_path); when=datetime.datetime.now(); counts={}
|
|
for ctype in template_types(template_path):
|
|
ws=wb[ctype]; rows=rows_by_type.get(ctype) or {}
|
|
if rows:
|
|
th=template_headers(template_path,ctype); width=len(th) if th else ws.max_column
|
|
_write_rows(ws, [ws.cell(1,c).value for c in range(1,width+1)], rows)
|
|
counts[ctype]=len(rows)
|
|
_build_meta_sheet(wb, version, counts, when) # append first so a sheet always remains
|
|
for ctype in template_types(template_path):
|
|
if ctype not in counts: del wb[ctype] # drop empty type sheets
|
|
out=os.path.join(dest_dir, MASTER_NAME); wb.save(out); return out
|
|
|
|
def main():
|
|
ap=argparse.ArgumentParser()
|
|
ap.add_argument("parts_json"); ap.add_argument("--template", required=True)
|
|
ap.add_argument("--dest", required=True); ap.add_argument("--version", default=None)
|
|
a=ap.parse_args()
|
|
data=json.load(open(a.parts_json, encoding="utf-8"))
|
|
version=resolve_version(a.template, a.version); by={}
|
|
for p in data.get("parts", []):
|
|
headers=template_headers(a.template, p["type"])
|
|
if headers is None: print(f"! no template sheet for type '{p['type']}' - skipping"); continue
|
|
tag,row=part_to_row(p, headers); by.setdefault(p["type"],{})[tag]=row
|
|
out=build_master(a.template, by, version, a.dest)
|
|
total=sum(len(v) for v in by.values())
|
|
print(f"master: {out} ({total} part(s) across {len(by)} sheet(s) + Meta, template {version})")
|
|
|
|
if __name__=="__main__":
|
|
main()
|