476 lines
16 KiB
Markdown
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.
|