# Feature Spec: Auto-Populate Silo BOM from Assembly Links ## Summary On `Origin_Commit`, extract cross-document `App::Link` components from the active Assembly, resolve each to a Silo part number, diff against the existing server-side BOM, and submit a merge job. The client receives merge status via SSE and surfaces it in the Database Activity pane with a link to the web UI for conflict resolution. --- ## Scope **In scope:** - Cross-document `App::Link` extraction with quantity counting - Silo UUID → part number resolution - Multi-level BOM construction (levels enumerated per nesting depth) - Diff/merge against existing server BOM - SSE merge status events in Activity pane - Web UI link for merge conflict resolution - Workflow for components without Silo part numbers - Consolidation warnings for duplicate individual links vs arrays **Out of scope:** - In-document links (treated as construction/layout geometry, not BOM items) - Flat BOM generation (existing server endpoint handles this from the multi-level data) - Recursive commit of sub-assemblies (each assembly commits its own BOM) --- ## Architecture ### Trigger Hook into `Origin_Commit` in the Silo workbench. After the file commit succeeds, the BOM sync runs as a post-commit step. This separates "save my work" from "publish BOM to server." ### Data Flow ``` Origin_Commit │ ├─ 1. Commit file to Silo (existing flow) │ └─ 2. BOM sync (new) │ ├─ a. Walk Assembly tree, collect cross-doc App::Links ├─ b. Count quantities (individual links + ElementCount arrays) ├─ c. Resolve Silo UUIDs → part numbers via API ├─ d. Build multi-level BOM structure ├─ e. GET existing BOM from server ├─ f. Diff local vs server BOM └─ g. POST merge job to server │ └─ Server emits SSE event with merge status │ └─ Activity pane displays result + web UI link ``` --- ## 1. Assembly Link Extraction ### Walk Strategy ```python def extract_bom_links(assembly_obj, level=1): """ Recursively walk an Assembly container, collecting cross-document App::Link objects. In-document links are skipped. Returns list of BomEntry(part_number, quantity, level, children=[]) """ entries = {} # linked_doc_path -> BomEntry for obj in assembly_obj.Group: if not obj.isDerivedFrom("App::Link"): continue linked = obj.LinkedObject if linked is None: continue # Skip in-document links (construction/layout geometry) linked_doc = linked.Document if linked_doc == assembly_obj.Document: continue doc_path = linked_doc.FileName if doc_path in entries: # Multiple individual links to same source - count and warn entries[doc_path].quantity += _link_count(obj) entries[doc_path].consolidation_warning = True else: entries[doc_path] = BomEntry( doc_path=doc_path, silo_uuid=_get_silo_uuid(linked_doc), quantity=_link_count(obj), level=level, consolidation_warning=False, children=[], ) # Recurse into sub-assemblies if _is_assembly(linked): entries[doc_path].children = extract_bom_links(linked, level + 1) return list(entries.values()) ``` ### Quantity Counting ```python def _link_count(link_obj): """ Count instances from a single App::Link. ElementCount > 0 means it's a link array. """ element_count = getattr(link_obj, "ElementCount", 0) return element_count if element_count > 0 else 1 ``` Both patterns are counted: - **Multiple `App::Link` instances** to the same external file → summed - **Single `App::Link` with `ElementCount > 0`** → use ElementCount value When both patterns exist for the same source, emit a **consolidation warning** suggesting the user convert individual links to a single link array. ### In-Document Link Filtering A link is in-document if `linked.Document == assembly.Document`. These are construction references (layout sketches, datum planes used for mate positioning, etc.) and are excluded from BOM extraction. --- ## 2. Silo Part Number Resolution ### UUID Lookup Each document committed via Silo origin has a Silo UUID stored as a custom property. Resolve to part number: ```python def _get_silo_uuid(doc): """Read the Silo UUID property from a FreeCAD document.""" try: return doc.getPropertyByName("SiloUUID") except AttributeError: return None def resolve_part_numbers(entries, api_client): """ Resolve Silo UUIDs to part numbers. Returns (resolved_entries, unresolved_entries). """ resolved = [] unresolved = [] for entry in entries: if entry.silo_uuid is None: unresolved.append(entry) continue item = api_client.get_item_by_uuid(entry.silo_uuid) if item is None: unresolved.append(entry) continue entry.part_number = item["part_number"] entry.name = item["name"] resolved.append(entry) # Recurse children if entry.children: child_resolved, child_unresolved = resolve_part_numbers( entry.children, api_client ) entry.children = child_resolved unresolved.extend(child_unresolved) return resolved, unresolved ``` ### Components Without Part Numbers Unresolved components trigger the **part registration workflow**: 1. After BOM sync, display unresolved components in a dialog 2. For each, offer to create a new Silo item: - Auto-detect whether it's an Assembly or Part from the document type - Pre-fill name from the document label - Apply naming convention for the schema (e.g., `kindred-rd` schema with appropriate category) - Open the part number registration form pre-populated 3. User confirms or skips each 4. Skipped items are excluded from the BOM (not silently dropped — they remain visible as "unregistered" in the Activity pane) 5. On next commit, re-check and prompt again --- ## 3. Multi-Level BOM Construction The BOM preserves assembly hierarchy with enumerated levels: ```python @dataclass class BomEntry: doc_path: str silo_uuid: Optional[str] part_number: Optional[str] name: Optional[str] quantity: int level: int children: List["BomEntry"] consolidation_warning: bool = False ``` **Example structure:** ``` Level 1: ASM-001 Top Assembly Level 2: PRT-100 Base Plate qty: 1 Level 2: ASM-200 Gearbox Sub-Assy qty: 2 Level 3: PRT-201 Housing qty: 1 Level 3: PRT-202 Gear qty: 4 Level 3: PRT-203 Shaft qty: 1 Level 2: PRT-300 Cover qty: 1 Level 2: F01-001 M8x20 Bolt qty: 12 ``` Each assembly commits **only its direct children** (first-level BOM). The server's existing `GET /api/items/{partNumber}/bom/expanded` endpoint handles multi-level expansion, and `GET /api/items/{partNumber}/bom/flat` handles flattened roll-up. --- ## 4. Diff/Merge Logic ### Diff Computation Compare the assembly-derived BOM (local) against the existing server BOM (remote): ```python def diff_bom(local_entries, remote_entries): """ Diff local assembly BOM against server BOM. Returns BomDiff with categorized changes. """ local_map = {e.part_number: e.quantity for e in local_entries} remote_map = {e["child_part_number"]: e["quantity"] for e in remote_entries} diff = BomDiff( added=[], # In local, not in remote removed=[], # In remote, not in local quantity_changed=[], # In both, different quantity unchanged=[], # In both, same quantity ) 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 ``` ### Merge Rules | Scenario | Action | |----------|--------| | **Added** (in assembly, not on server) | Auto-add to server BOM | | **Quantity changed** | Auto-update to assembly quantity | | **Removed** (on server, not in assembly) | **Do NOT delete.** Warn user that unreferenced part numbers exist in last server BOM version. | | **Unchanged** | No action | **Critical rule:** The assembly is authoritative for what exists in the CAD model, but items on the server that aren't in the assembly are **never auto-deleted**. This handles: - Purchased parts tracked in Silo but not modeled in CAD - Parts added via web UI for BOM completeness - Items from other engineering disciplines (electrical, software BOMs) Removed items trigger a warning on next BOM sync: *"N items in the Silo BOM are not referenced by components in this assembly."* This surfaces in the Activity pane with a link to the web UI where the user can review and explicitly remove them if intended. --- ## 5. Server-Side Merge Job ### New Endpoint ``` POST /api/items/{partNumber}/bom/merge ``` **Request body:** ```json { "source": "assembly", "entries": [ {"child_part_number": "PRT-100", "quantity": 1, "source": "assembly"}, {"child_part_number": "ASM-200", "quantity": 2, "source": "assembly"}, {"child_part_number": "PRT-300", "quantity": 1, "source": "assembly"}, {"child_part_number": "F01-001", "quantity": 12, "source": "assembly"} ] } ``` **Response body:** ```json { "status": "merged", "diff": { "added": [ {"part_number": "PRT-300", "quantity": 1} ], "removed": [ {"part_number": "PRT-400", "quantity": 2} ], "quantity_changed": [ { "part_number": "F01-001", "old_quantity": 8, "new_quantity": 12 } ], "unchanged": [ {"part_number": "PRT-100", "quantity": 1}, {"part_number": "ASM-200", "quantity": 2} ] }, "warnings": [ { "type": "unreferenced", "part_number": "PRT-400", "message": "Present in server BOM but not in assembly" } ], "resolve_url": "/items/ASM-001/bom" } ``` The server: 1. Applies adds and quantity changes immediately 2. Flags removed items as unreferenced (does not delete) 3. Emits an SSE event on the activity stream 4. Returns the diff and a `resolve_url` for the web UI ### SSE Event ```json { "type": "bom_merge", "item": "ASM-001", "user": "joseph", "timestamp": "2026-02-08T22:15:00Z", "summary": "BOM updated: 1 added, 1 quantity changed, 1 unreferenced", "diff": { ... }, "resolve_url": "/items/ASM-001/bom" } ``` --- ## 6. Client-Side UX ### Activity Pane Display The Database Activity pane (already connected via SSE) renders BOM merge events: ``` ┌─────────────────────────────────────────────┐ │ 📋 BOM Updated: ASM-001 │ │ +1 added ~1 qty changed ⚠1 unreferenced │ │ [View in Silo →] │ ├─────────────────────────────────────────────┤ │ ⚠ 2 components have no Silo part number │ │ [Register Parts...] │ └─────────────────────────────────────────────┘ ``` ### Consolidation Warnings When multiple individual `App::Link` objects reference the same external document (instead of a single link array), show a non-blocking console warning: ``` [Silo BOM] PRT-100 has 4 individual links. Consider using a link array (ElementCount) for cleaner assembly management. ``` ### Part Registration Workflow For unresolved components, the dialog presents: ``` ┌─ Register Components ──────────────────────┐ │ │ │ The following components have no Silo │ │ part number and will be excluded from │ │ the BOM until registered: │ │ │ │ ☑ Bracket.FCStd [Part ▾] [Register] │ │ ☑ MotorMount.FCStd [Assembly ▾] [Reg.] │ │ ☐ construction_ref.FCStd (skip) │ │ │ │ [Register Selected] [Skip All] │ └─────────────────────────────────────────────┘ ``` Registration uses the existing `POST /api/items` and `POST /api/generate-part-number` endpoints. Schema and category are pre-selected based on document type (Assembly → assembly category, Part → appropriate category). Name is pre-filled from the document label following the project naming convention. --- ## 7. Implementation Plan ### Phase 1: Link Extraction + Resolution **silo-mod workbench** (`mods/silo/`): - `bom_sync.py` — Assembly walker, quantity counter, UUID resolver - Hook into `Origin_Commit` post-commit **Silo server** (`internal/api/`): - `GET /api/items/by-uuid/{uuid}` — New endpoint for UUID → item lookup (or extend existing `GET /api/items/{partNumber}` to accept UUID query param) ### Phase 2: Diff/Merge + Server Endpoint **Silo server:** - `POST /api/items/{partNumber}/bom/merge` — New merge endpoint - SSE event emission for `bom_merge` type - `internal/db/bom.go` — Merge logic with unreferenced flagging **silo-mod workbench:** - Diff computation (can be client-side or server-side; server preferred for consistency) - Activity pane rendering for merge events ### Phase 3: Part Registration Workflow **silo-mod workbench:** - Registration dialog for unresolved components - Part/Assembly type detection - Schema + category pre-selection - Name convention pre-fill ### Phase 4: Web UI Merge Resolution **Silo web UI** (`web/src/`): - BOM page shows unreferenced items with warning banner - Action to remove unreferenced items or mark as intentional (non-CAD BOM entries) - Distinguish between "assembly-sourced" and "manually-added" BOM entries --- ## API Surface (New/Modified) | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/api/items/by-uuid/{uuid}` | Resolve Silo UUID to item (new) | | `POST` | `/api/items/{partNumber}/bom/merge` | Submit assembly BOM merge (new) | | SSE | Activity stream | `bom_merge` event type (new) | All existing BOM endpoints (`GET .../bom`, `GET .../bom/expanded`, `GET .../bom/flat`, etc.) remain unchanged. --- ## Design Decisions 1. **Merge jobs are synchronous.** The server processes the merge inline and returns the diff in the response. Revisit if assembly sizes cause timeout issues in practice. 2. **BOM entries carry a `source` field.** Each entry includes `"source": "assembly"` or `"source": "manual"` so the web UI can distinguish assembly-derived lines from manually-added ones. Manual entries are never overwritten by assembly merges. 3. **Sub-assembly commits walk bottom-up automatically.** When a top-level assembly and its sub-assemblies are all dirty, commit traverses depth-first, committing leaf assemblies before parents. This ensures each assembly's BOM merge references already-committed children.