"""BOM extraction engine for FreeCAD Assembly documents. Extracts cross-document ``App::Link`` components from an Assembly, resolves Silo UUIDs to part numbers, diffs against the server BOM, and applies adds/quantity updates via individual API calls. No GUI dependencies -- usable in both desktop and headless mode. Public API ---------- extract_bom_entries(doc) -> List[BomEntry] resolve_entries(entries, client) -> (resolved, unresolved) diff_bom(local_entries, remote_entries) -> BomDiff apply_bom_diff(diff, parent_pn, client) -> BomResult sync_bom_after_upload(doc, part_number, client) -> Optional[BomResult] """ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple import FreeCAD # --------------------------------------------------------------------------- # Data structures # --------------------------------------------------------------------------- @dataclass class BomEntry: """A single BOM line extracted from an Assembly.""" silo_item_id: Optional[str] # UUID from SiloItemId property part_number: Optional[str] # resolved via get_item_by_uuid() label: str # FreeCAD Label of the linked object doc_path: str # FileName of the linked document quantity: int # summed from ElementCount + individual links consolidation_warning: bool = False # multiple individual links to same source @dataclass class BomDiff: """Result of diffing local assembly BOM against server BOM.""" added: List[Dict[str, Any]] = field(default_factory=list) removed: List[Dict[str, Any]] = field(default_factory=list) quantity_changed: List[Dict[str, Any]] = field(default_factory=list) unchanged: List[Dict[str, Any]] = field(default_factory=list) @dataclass class BomResult: """Summary of a BOM sync operation.""" added_count: int = 0 updated_count: int = 0 unreferenced_count: int = 0 unresolved_count: int = 0 errors: List[str] = field(default_factory=list) # --------------------------------------------------------------------------- # Assembly detection helpers # --------------------------------------------------------------------------- def _find_assembly(doc): """Return the first ``Assembly::AssemblyObject`` in *doc*, or ``None``.""" for obj in doc.Objects: if obj.isDerivedFrom("Assembly::AssemblyObject"): return obj return None def _get_silo_item_id(doc): """Read ``SiloItemId`` from the tracked object in *doc*. Returns ``None`` if the document has no tracked object or no UUID. """ for obj in doc.Objects: if hasattr(obj, "SiloItemId") and obj.SiloItemId: return obj.SiloItemId return None def _link_count(link_obj) -> int: """Return the instance count for an ``App::Link``. ``ElementCount > 0`` means link array; otherwise single link (qty 1). """ element_count = getattr(link_obj, "ElementCount", 0) return element_count if element_count > 0 else 1 # --------------------------------------------------------------------------- # Extraction # --------------------------------------------------------------------------- def extract_bom_entries(doc) -> List[BomEntry]: """Walk the Assembly in *doc* and collect cross-document link entries. Returns an empty list if the document has no Assembly or no cross-document links. Only first-level children are extracted; sub-assemblies commit their own BOMs separately. """ assembly = _find_assembly(doc) if assembly is None: return [] # Key by linked document path to merge duplicates. entries: Dict[str, BomEntry] = {} for obj in assembly.Group: # Accept App::Link (single or array) and App::LinkElement. if not ( obj.isDerivedFrom("App::Link") or obj.isDerivedFrom("App::LinkElement") ): continue linked = obj.getLinkedObject() if linked is None: continue # Skip in-document links (construction / layout geometry). if linked.Document == doc: continue linked_doc = linked.Document doc_path = linked_doc.FileName or linked_doc.Name qty = _link_count(obj) if doc_path in entries: entries[doc_path].quantity += qty entries[doc_path].consolidation_warning = True else: entries[doc_path] = BomEntry( silo_item_id=_get_silo_item_id(linked_doc), part_number=None, label=linked.Label, doc_path=doc_path, quantity=qty, ) return list(entries.values()) # --------------------------------------------------------------------------- # Resolution # --------------------------------------------------------------------------- def resolve_entries( entries: List[BomEntry], client ) -> Tuple[List[BomEntry], List[BomEntry]]: """Resolve ``SiloItemId`` UUIDs to part numbers via the API. Returns ``(resolved, unresolved)``. Unresolved entries have no ``SiloItemId`` or the UUID lookup failed. """ resolved: List[BomEntry] = [] unresolved: List[BomEntry] = [] for entry in entries: if not entry.silo_item_id: unresolved.append(entry) continue try: item = client.get_item_by_uuid(entry.silo_item_id) entry.part_number = item["part_number"] resolved.append(entry) except Exception: unresolved.append(entry) return resolved, unresolved # --------------------------------------------------------------------------- # Diff # --------------------------------------------------------------------------- def diff_bom( local_entries: List[BomEntry], remote_entries: List[Dict[str, Any]], ) -> BomDiff: """Diff local assembly BOM against server BOM. *local_entries*: resolved ``BomEntry`` list. *remote_entries*: raw dicts from ``client.get_bom()`` with keys ``child_part_number`` and ``quantity``. """ local_map = {e.part_number: e.quantity for e in local_entries} remote_map = {e["child_part_number"]: e.get("quantity", 1) for e in remote_entries} diff = BomDiff() for pn, qty in local_map.items(): if pn not in remote_map: diff.added.append({"part_number": pn, "quantity": qty}) elif remote_map[pn] != qty: diff.quantity_changed.append( { "part_number": pn, "local_quantity": qty, "remote_quantity": remote_map[pn], } ) else: diff.unchanged.append({"part_number": pn, "quantity": qty}) for pn, qty in remote_map.items(): if pn not in local_map: diff.removed.append({"part_number": pn, "quantity": qty}) return diff # --------------------------------------------------------------------------- # Apply # --------------------------------------------------------------------------- def apply_bom_diff(diff: BomDiff, parent_pn: str, client) -> BomResult: """Apply adds and quantity updates to the server BOM. Uses individual CRUD calls (Phase 1 fallback). Phase 2 replaces this with a single ``POST /items/{pn}/bom/merge`` call. Removed entries are NEVER deleted -- only warned about. Each call is individually wrapped so one failure does not block others. """ result = BomResult() for entry in diff.added: try: client.add_bom_entry( parent_pn, entry["part_number"], quantity=entry["quantity"], rel_type="component", ) result.added_count += 1 except Exception as e: result.errors.append(f"add {entry['part_number']}: {e}") for entry in diff.quantity_changed: try: client.update_bom_entry( parent_pn, entry["part_number"], quantity=entry["local_quantity"], ) result.updated_count += 1 except Exception as e: result.errors.append(f"update {entry['part_number']}: {e}") result.unreferenced_count = len(diff.removed) if diff.removed: pns = ", ".join(e["part_number"] for e in diff.removed) FreeCAD.Console.PrintWarning( f"BOM sync: {result.unreferenced_count} server entries " f"not in assembly (not deleted): {pns}\n" ) return result # --------------------------------------------------------------------------- # Top-level orchestrator # --------------------------------------------------------------------------- def sync_bom_after_upload(doc, part_number: str, client) -> Optional[BomResult]: """Full BOM sync pipeline: extract, resolve, diff, apply. Returns ``None`` if *doc* is not an assembly or has no cross-document links. Returns a ``BomResult`` with summary counts otherwise. """ entries = extract_bom_entries(doc) if not entries: return None resolved, unresolved = resolve_entries(entries, client) # Log consolidation warnings. for entry in entries: if entry.consolidation_warning: FreeCAD.Console.PrintWarning( f"BOM sync: {entry.label} ({entry.doc_path}) has multiple " f"individual links. Consider using a link array " f"(ElementCount) for cleaner assembly management.\n" ) # Log unresolved components. for entry in unresolved: FreeCAD.Console.PrintWarning( f"BOM sync: {entry.label} ({entry.doc_path}) has no Silo " f"part number -- excluded from BOM.\n" ) if not resolved: result = BomResult() result.unresolved_count = len(unresolved) return result # Fetch current server BOM. try: remote = client.get_bom(part_number) except Exception: remote = [] diff = diff_bom(resolved, remote) result = apply_bom_diff(diff, part_number, client) result.unresolved_count = len(unresolved) return result