Files
create/docs/BOM_MERGE.md

476 lines
16 KiB
Markdown

# 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.