Files
create/docs/BOM_MERGE.md

16 KiB

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

Walk Strategy

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

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.

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:

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:

@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):

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:

{
  "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:

{
  "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

{
  "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

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.