Compare commits
1 Commits
af98994a53
...
f67d9a0422
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f67d9a0422 |
317
freecad/bom_sync.py
Normal file
317
freecad/bom_sync.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""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
|
||||||
@@ -780,6 +780,60 @@ def _push_dag_after_upload(doc, part_number, revision_number):
|
|||||||
FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n")
|
FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _update_manifest_revision(file_path, revision_number):
|
||||||
|
"""Write revision_hash into the .kc manifest after a successful upload.
|
||||||
|
|
||||||
|
Failures are logged as warnings -- must never block save.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from kc_format import update_manifest_fields
|
||||||
|
|
||||||
|
update_manifest_fields(
|
||||||
|
file_path,
|
||||||
|
{
|
||||||
|
"revision_hash": str(revision_number),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
FreeCAD.Console.PrintWarning(f"Manifest revision update failed: {e}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _push_bom_after_upload(doc, part_number, revision_number):
|
||||||
|
"""Extract and sync Assembly BOM after a successful upload.
|
||||||
|
|
||||||
|
Only runs for Assembly documents with cross-document links.
|
||||||
|
Failures are logged as warnings -- BOM sync must never block save.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bom_sync import sync_bom_after_upload
|
||||||
|
|
||||||
|
result = sync_bom_after_upload(doc, part_number, _client)
|
||||||
|
if result is None:
|
||||||
|
return # Not an assembly or no cross-doc links
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if result.added_count:
|
||||||
|
parts.append(f"+{result.added_count} added")
|
||||||
|
if result.updated_count:
|
||||||
|
parts.append(f"~{result.updated_count} qty updated")
|
||||||
|
if result.unreferenced_count:
|
||||||
|
parts.append(f"!{result.unreferenced_count} unreferenced")
|
||||||
|
if result.unresolved_count:
|
||||||
|
FreeCAD.Console.PrintWarning(
|
||||||
|
f"BOM sync: {result.unresolved_count} components "
|
||||||
|
f"have no Silo part number\n"
|
||||||
|
)
|
||||||
|
if parts:
|
||||||
|
FreeCAD.Console.PrintMessage(f"BOM synced: {', '.join(parts)}\n")
|
||||||
|
else:
|
||||||
|
FreeCAD.Console.PrintMessage("BOM synced: no changes\n")
|
||||||
|
|
||||||
|
for err in result.errors:
|
||||||
|
FreeCAD.Console.PrintWarning(f"BOM sync error: {err}\n")
|
||||||
|
except Exception as e:
|
||||||
|
FreeCAD.Console.PrintWarning(f"BOM sync failed: {e}\n")
|
||||||
|
|
||||||
|
|
||||||
class Silo_Save:
|
class Silo_Save:
|
||||||
"""Save locally and upload to the server."""
|
"""Save locally and upload to the server."""
|
||||||
|
|
||||||
@@ -851,6 +905,8 @@ class Silo_Save:
|
|||||||
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
|
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
|
||||||
|
|
||||||
_push_dag_after_upload(doc, part_number, new_rev)
|
_push_dag_after_upload(doc, part_number, new_rev)
|
||||||
|
_push_bom_after_upload(doc, part_number, new_rev)
|
||||||
|
_update_manifest_revision(str(file_path), new_rev)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
|
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
|
||||||
@@ -907,6 +963,8 @@ class Silo_Commit:
|
|||||||
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
|
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
|
||||||
|
|
||||||
_push_dag_after_upload(doc, part_number, new_rev)
|
_push_dag_after_upload(doc, part_number, new_rev)
|
||||||
|
_push_bom_after_upload(doc, part_number, new_rev)
|
||||||
|
_update_manifest_revision(str(file_path), new_rev)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
|
||||||
@@ -2410,6 +2468,9 @@ class SiloEventListener(QtCore.QThread):
|
|||||||
dag_updated = QtCore.Signal(str, int, int) # part_number, node_count, edge_count
|
dag_updated = QtCore.Signal(str, int, int) # part_number, node_count, edge_count
|
||||||
dag_validated = QtCore.Signal(str, bool, int) # part_number, valid, failed_count
|
dag_validated = QtCore.Signal(str, bool, int) # part_number, valid, failed_count
|
||||||
|
|
||||||
|
# BOM events
|
||||||
|
bom_merged = QtCore.Signal(str, int, int, int) # pn, added, updated, unreferenced
|
||||||
|
|
||||||
# Job lifecycle events
|
# Job lifecycle events
|
||||||
job_created = QtCore.Signal(str, str, str) # job_id, definition_name, part_number
|
job_created = QtCore.Signal(str, str, str) # job_id, definition_name, part_number
|
||||||
job_claimed = QtCore.Signal(str, str) # job_id, runner_id
|
job_claimed = QtCore.Signal(str, str) # job_id, runner_id
|
||||||
@@ -2584,6 +2645,13 @@ class SiloEventListener(QtCore.QThread):
|
|||||||
bool(payload.get("valid", False)),
|
bool(payload.get("valid", False)),
|
||||||
int(payload.get("failed_count", 0)),
|
int(payload.get("failed_count", 0)),
|
||||||
)
|
)
|
||||||
|
elif event_type == "bom.merged":
|
||||||
|
self.bom_merged.emit(
|
||||||
|
pn,
|
||||||
|
int(payload.get("added", 0)),
|
||||||
|
int(payload.get("updated", 0)),
|
||||||
|
int(payload.get("unreferenced", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _SSEUnsupported(Exception):
|
class _SSEUnsupported(Exception):
|
||||||
@@ -2813,6 +2881,7 @@ class SiloAuthDockWidget:
|
|||||||
self._event_listener.server_mode_changed.connect(self._on_server_mode)
|
self._event_listener.server_mode_changed.connect(self._on_server_mode)
|
||||||
self._event_listener.dag_updated.connect(self._on_dag_updated)
|
self._event_listener.dag_updated.connect(self._on_dag_updated)
|
||||||
self._event_listener.dag_validated.connect(self._on_dag_validated)
|
self._event_listener.dag_validated.connect(self._on_dag_validated)
|
||||||
|
self._event_listener.bom_merged.connect(self._on_bom_merged)
|
||||||
self._event_listener.job_created.connect(self._on_job_created)
|
self._event_listener.job_created.connect(self._on_job_created)
|
||||||
self._event_listener.job_claimed.connect(self._on_job_claimed)
|
self._event_listener.job_claimed.connect(self._on_job_claimed)
|
||||||
self._event_listener.job_progress.connect(self._on_job_progress)
|
self._event_listener.job_progress.connect(self._on_job_progress)
|
||||||
@@ -2990,6 +3059,23 @@ class SiloAuthDockWidget:
|
|||||||
)
|
)
|
||||||
self._append_activity_event(f"{status} \u2013 {part_number}", part_number)
|
self._append_activity_event(f"{status} \u2013 {part_number}", part_number)
|
||||||
|
|
||||||
|
def _on_bom_merged(self, part_number, added, updated, unreferenced):
|
||||||
|
parts = []
|
||||||
|
if added:
|
||||||
|
parts.append(f"+{added} added")
|
||||||
|
if updated:
|
||||||
|
parts.append(f"~{updated} qty changed")
|
||||||
|
if unreferenced:
|
||||||
|
parts.append(f"!{unreferenced} unreferenced")
|
||||||
|
summary = ", ".join(parts) if parts else "no changes"
|
||||||
|
FreeCAD.Console.PrintMessage(
|
||||||
|
f"Silo: BOM merged for {part_number} ({summary})\n"
|
||||||
|
)
|
||||||
|
self._append_activity_event(
|
||||||
|
f"\u2630 {part_number} \u2013 BOM synced ({summary})",
|
||||||
|
part_number,
|
||||||
|
)
|
||||||
|
|
||||||
def _on_job_created(self, job_id, definition_name, part_number):
|
def _on_job_created(self, job_id, definition_name, part_number):
|
||||||
FreeCAD.Console.PrintMessage(
|
FreeCAD.Console.PrintMessage(
|
||||||
f"Silo: Job {definition_name} created for {part_number}\n"
|
f"Silo: Job {definition_name} created for {part_number}\n"
|
||||||
|
|||||||
Submodule silo-client updated: 285bd1fa11...9d07de1bca
Reference in New Issue
Block a user