feat: add BOM system with API, database repository, and FreeCAD workbench command
Implement the full Bill of Materials stack on top of the existing
relationships table and bom_single_level view from migration 001.
API endpoints (6 new routes under /api/items/{partNumber}/bom):
- GET /bom Single-level BOM for an item
- GET /bom/expanded Multi-level BOM via recursive CTE (depth param)
- GET /bom/where-used Reverse lookup: which parents use this item
- POST /bom Add child to BOM with quantity, ref designators
- PUT /bom/{child} Update relationship type, quantity, ref des
- DELETE /bom/{child} Remove child from BOM
Database layer (internal/db/relationships.go):
- RelationshipRepository with full CRUD operations
- Single-level BOM query joining relationships with items
- Multi-level BOM expansion via recursive CTE (max depth 20)
- Where-used reverse lookup query
- Cycle detection at insert time to prevent circular BOMs
- BOMEntry and BOMTreeEntry types for denormalized query results
Server wiring:
- Added RelationshipRepository to Server struct in handlers.go
- Registered BOM routes in routes.go under /{partNumber} subrouter
FreeCAD workbench (pkg/freecad/silo_commands.py):
- 9 new BOM methods on SiloClient (get, expanded, where-used, add,
update, delete)
- Silo_BOM command class with two-tab dialog:
- BOM tab: table of children with Add/Edit/Remove buttons
- Where Used tab: read-only table of parent assemblies
- Add sub-dialog with fields for part number, type, qty, unit, ref des
- Edit sub-dialog pre-populated with current values
- Remove with confirmation prompt
- silo-bom.svg icon matching existing toolbar style
- Command registered in InitGui.py toolbar
No new migrations required - uses existing relationships table and
bom_single_level view from 001_initial.sql.
This commit is contained in:
440
internal/api/bom_handlers.go
Normal file
440
internal/api/bom_handlers.go
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BOM API request/response types
|
||||||
|
|
||||||
|
// BOMEntryResponse represents a BOM entry in API responses.
|
||||||
|
type BOMEntryResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ChildPartNumber string `json:"child_part_number"`
|
||||||
|
ChildDescription string `json:"child_description"`
|
||||||
|
RelType string `json:"rel_type"`
|
||||||
|
Quantity *float64 `json:"quantity"`
|
||||||
|
Unit *string `json:"unit,omitempty"`
|
||||||
|
ReferenceDesignators []string `json:"reference_designators,omitempty"`
|
||||||
|
ChildRevision *int `json:"child_revision,omitempty"`
|
||||||
|
EffectiveRevision int `json:"effective_revision"`
|
||||||
|
Depth *int `json:"depth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhereUsedResponse represents a where-used entry in API responses.
|
||||||
|
type WhereUsedResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ParentPartNumber string `json:"parent_part_number"`
|
||||||
|
ParentDescription string `json:"parent_description"`
|
||||||
|
RelType string `json:"rel_type"`
|
||||||
|
Quantity *float64 `json:"quantity"`
|
||||||
|
Unit *string `json:"unit,omitempty"`
|
||||||
|
ReferenceDesignators []string `json:"reference_designators,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddBOMEntryRequest represents a request to add a child to a BOM.
|
||||||
|
type AddBOMEntryRequest struct {
|
||||||
|
ChildPartNumber string `json:"child_part_number"`
|
||||||
|
RelType string `json:"rel_type"`
|
||||||
|
Quantity *float64 `json:"quantity"`
|
||||||
|
Unit *string `json:"unit,omitempty"`
|
||||||
|
ReferenceDesignators []string `json:"reference_designators,omitempty"`
|
||||||
|
ChildRevision *int `json:"child_revision,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBOMEntryRequest represents a request to update a BOM entry.
|
||||||
|
type UpdateBOMEntryRequest struct {
|
||||||
|
RelType *string `json:"rel_type,omitempty"`
|
||||||
|
Quantity *float64 `json:"quantity,omitempty"`
|
||||||
|
Unit *string `json:"unit,omitempty"`
|
||||||
|
ReferenceDesignators []string `json:"reference_designators,omitempty"`
|
||||||
|
ChildRevision *int `json:"child_revision,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetBOM returns the single-level BOM for an item.
|
||||||
|
func (s *Server) HandleGetBOM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := s.relationships.GetBOM(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get BOM")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get BOM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]BOMEntryResponse, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
response[i] = bomEntryToResponse(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetExpandedBOM returns the multi-level BOM for an item.
|
||||||
|
// Query param: ?depth=N (default 10, max 20).
|
||||||
|
func (s *Server) HandleGetExpandedBOM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDepth := 10
|
||||||
|
if d := r.URL.Query().Get("depth"); d != "" {
|
||||||
|
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 20 {
|
||||||
|
maxDepth = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := s.relationships.GetExpandedBOM(ctx, item.ID, maxDepth)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get expanded BOM")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get expanded BOM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]BOMEntryResponse, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
resp := bomEntryToResponse(&e.BOMEntry)
|
||||||
|
resp.Depth = &e.Depth
|
||||||
|
response[i] = resp
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetWhereUsed returns all parent assemblies that use the given item.
|
||||||
|
func (s *Server) HandleGetWhereUsed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := s.relationships.GetWhereUsed(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get where-used")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get where-used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]WhereUsedResponse, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
response[i] = whereUsedToResponse(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAddBOMEntry adds a child item to a parent item's BOM.
|
||||||
|
func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
|
||||||
|
parent, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get parent item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get parent item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parent == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Parent item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req AddBOMEntryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ChildPartNumber == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "child_part_number is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
child, err := s.items.GetByPartNumber(ctx, req.ChildPartNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get child item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get child item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if child == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Child item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if relationship already exists
|
||||||
|
existing, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to check existing relationship")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check existing relationship")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
writeError(w, http.StatusConflict, "already_exists", "Relationship already exists between these items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default relationship type
|
||||||
|
relType := req.RelType
|
||||||
|
if relType == "" {
|
||||||
|
relType = "component"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate relationship type
|
||||||
|
switch relType {
|
||||||
|
case "component", "alternate", "reference":
|
||||||
|
// Valid
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_rel_type", "rel_type must be component, alternate, or reference")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rel := &db.Relationship{
|
||||||
|
ParentItemID: parent.ID,
|
||||||
|
ChildItemID: child.ID,
|
||||||
|
RelType: relType,
|
||||||
|
Quantity: req.Quantity,
|
||||||
|
Unit: req.Unit,
|
||||||
|
ReferenceDesignators: req.ReferenceDesignators,
|
||||||
|
ChildRevision: req.ChildRevision,
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.relationships.Create(ctx, rel); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "cycle") {
|
||||||
|
writeError(w, http.StatusBadRequest, "cycle_detected", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error().Err(err).Msg("failed to create relationship")
|
||||||
|
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info().
|
||||||
|
Str("parent", partNumber).
|
||||||
|
Str("child", req.ChildPartNumber).
|
||||||
|
Str("rel_type", relType).
|
||||||
|
Msg("BOM entry added")
|
||||||
|
|
||||||
|
// Return the created entry with full denormalized data
|
||||||
|
entry := &BOMEntryResponse{
|
||||||
|
ID: rel.ID,
|
||||||
|
ChildPartNumber: req.ChildPartNumber,
|
||||||
|
ChildDescription: child.Description,
|
||||||
|
RelType: relType,
|
||||||
|
Quantity: req.Quantity,
|
||||||
|
Unit: req.Unit,
|
||||||
|
ReferenceDesignators: req.ReferenceDesignators,
|
||||||
|
ChildRevision: req.ChildRevision,
|
||||||
|
EffectiveRevision: child.CurrentRevision,
|
||||||
|
}
|
||||||
|
if req.ChildRevision != nil {
|
||||||
|
entry.EffectiveRevision = *req.ChildRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateBOMEntry updates an existing BOM relationship.
|
||||||
|
func (s *Server) HandleUpdateBOMEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
childPartNumber := chi.URLParam(r, "childPartNumber")
|
||||||
|
|
||||||
|
parent, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get parent item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get parent item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parent == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Parent item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
child, err := s.items.GetByPartNumber(ctx, childPartNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get child item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get child item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if child == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Child item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get relationship")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get relationship")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Relationship not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateBOMEntryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rel_type if provided
|
||||||
|
if req.RelType != nil {
|
||||||
|
switch *req.RelType {
|
||||||
|
case "component", "alternate", "reference":
|
||||||
|
// Valid
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_rel_type", "rel_type must be component, alternate, or reference")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.relationships.Update(ctx, rel.ID, req.RelType, req.Quantity, req.Unit, req.ReferenceDesignators, req.ChildRevision, req.Metadata); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to update relationship")
|
||||||
|
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload and return updated entry
|
||||||
|
entries, err := s.relationships.GetBOM(ctx, parent.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.ChildPartNumber == childPartNumber {
|
||||||
|
writeJSON(w, http.StatusOK, bomEntryToResponse(e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return 200 with minimal info
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteBOMEntry removes a child from a parent's BOM.
|
||||||
|
func (s *Server) HandleDeleteBOMEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
partNumber := chi.URLParam(r, "partNumber")
|
||||||
|
childPartNumber := chi.URLParam(r, "childPartNumber")
|
||||||
|
|
||||||
|
parent, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get parent item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get parent item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parent == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Parent item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
child, err := s.items.GetByPartNumber(ctx, childPartNumber)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get child item")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get child item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if child == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Child item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to get relationship")
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get relationship")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rel == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "Relationship not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.relationships.Delete(ctx, rel.ID); err != nil {
|
||||||
|
s.logger.Error().Err(err).Msg("failed to delete relationship")
|
||||||
|
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info().
|
||||||
|
Str("parent", partNumber).
|
||||||
|
Str("child", childPartNumber).
|
||||||
|
Msg("BOM entry removed")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func bomEntryToResponse(e *db.BOMEntry) BOMEntryResponse {
|
||||||
|
refDes := e.ReferenceDesignators
|
||||||
|
if refDes == nil {
|
||||||
|
refDes = []string{}
|
||||||
|
}
|
||||||
|
return BOMEntryResponse{
|
||||||
|
ID: e.RelationshipID,
|
||||||
|
ChildPartNumber: e.ChildPartNumber,
|
||||||
|
ChildDescription: e.ChildDescription,
|
||||||
|
RelType: e.RelType,
|
||||||
|
Quantity: e.Quantity,
|
||||||
|
Unit: e.Unit,
|
||||||
|
ReferenceDesignators: refDes,
|
||||||
|
ChildRevision: e.ChildRevision,
|
||||||
|
EffectiveRevision: e.EffectiveRevision,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func whereUsedToResponse(e *db.BOMEntry) WhereUsedResponse {
|
||||||
|
refDes := e.ReferenceDesignators
|
||||||
|
if refDes == nil {
|
||||||
|
refDes = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return WhereUsedResponse{
|
||||||
|
ID: e.RelationshipID,
|
||||||
|
ParentPartNumber: e.ParentPartNumber,
|
||||||
|
ParentDescription: e.ParentDescription,
|
||||||
|
RelType: e.RelType,
|
||||||
|
Quantity: e.Quantity,
|
||||||
|
Unit: e.Unit,
|
||||||
|
ReferenceDesignators: refDes,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,14 +20,15 @@ import (
|
|||||||
|
|
||||||
// Server holds dependencies for HTTP handlers.
|
// Server holds dependencies for HTTP handlers.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
logger zerolog.Logger
|
logger zerolog.Logger
|
||||||
db *db.DB
|
db *db.DB
|
||||||
items *db.ItemRepository
|
items *db.ItemRepository
|
||||||
projects *db.ProjectRepository
|
projects *db.ProjectRepository
|
||||||
schemas map[string]*schema.Schema
|
relationships *db.RelationshipRepository
|
||||||
schemasDir string
|
schemas map[string]*schema.Schema
|
||||||
partgen *partnum.Generator
|
schemasDir string
|
||||||
storage *storage.Storage
|
partgen *partnum.Generator
|
||||||
|
storage *storage.Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API server.
|
// NewServer creates a new API server.
|
||||||
@@ -40,18 +41,20 @@ func NewServer(
|
|||||||
) *Server {
|
) *Server {
|
||||||
items := db.NewItemRepository(database)
|
items := db.NewItemRepository(database)
|
||||||
projects := db.NewProjectRepository(database)
|
projects := db.NewProjectRepository(database)
|
||||||
|
relationships := db.NewRelationshipRepository(database)
|
||||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
db: database,
|
db: database,
|
||||||
items: items,
|
items: items,
|
||||||
projects: projects,
|
projects: projects,
|
||||||
schemas: schemas,
|
relationships: relationships,
|
||||||
schemasDir: schemasDir,
|
schemas: schemas,
|
||||||
partgen: partgen,
|
schemasDir: schemasDir,
|
||||||
storage: store,
|
partgen: partgen,
|
||||||
|
storage: store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Post("/file", server.HandleUploadFile)
|
r.Post("/file", server.HandleUploadFile)
|
||||||
r.Get("/file", server.HandleDownloadLatestFile)
|
r.Get("/file", server.HandleDownloadLatestFile)
|
||||||
r.Get("/file/{revision}", server.HandleDownloadFile)
|
r.Get("/file/{revision}", server.HandleDownloadFile)
|
||||||
|
|
||||||
|
// BOM / Relationships
|
||||||
|
r.Get("/bom", server.HandleGetBOM)
|
||||||
|
r.Post("/bom", server.HandleAddBOMEntry)
|
||||||
|
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
|
||||||
|
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
||||||
|
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
|
||||||
|
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
430
internal/db/relationships.go
Normal file
430
internal/db/relationships.go
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Relationship represents a BOM relationship between two items.
|
||||||
|
type Relationship struct {
|
||||||
|
ID string
|
||||||
|
ParentItemID string
|
||||||
|
ChildItemID string
|
||||||
|
RelType string // "component", "alternate", "reference"
|
||||||
|
Quantity *float64
|
||||||
|
Unit *string
|
||||||
|
ReferenceDesignators []string
|
||||||
|
ChildRevision *int
|
||||||
|
Metadata map[string]any
|
||||||
|
ParentRevisionID *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// BOMEntry is a denormalized row for BOM display, combining relationship
|
||||||
|
// and item data.
|
||||||
|
type BOMEntry struct {
|
||||||
|
RelationshipID string
|
||||||
|
ParentItemID string
|
||||||
|
ParentPartNumber string
|
||||||
|
ParentDescription string
|
||||||
|
ChildItemID string
|
||||||
|
ChildPartNumber string
|
||||||
|
ChildDescription string
|
||||||
|
RelType string
|
||||||
|
Quantity *float64
|
||||||
|
Unit *string
|
||||||
|
ReferenceDesignators []string
|
||||||
|
ChildRevision *int
|
||||||
|
EffectiveRevision int
|
||||||
|
}
|
||||||
|
|
||||||
|
// BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion.
|
||||||
|
type BOMTreeEntry struct {
|
||||||
|
BOMEntry
|
||||||
|
Depth int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipRepository provides BOM/relationship database operations.
|
||||||
|
type RelationshipRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRelationshipRepository creates a new relationship repository.
|
||||||
|
func NewRelationshipRepository(db *DB) *RelationshipRepository {
|
||||||
|
return &RelationshipRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new relationship. Returns an error if a cycle would be
|
||||||
|
// created or the relationship already exists.
|
||||||
|
func (r *RelationshipRepository) Create(ctx context.Context, rel *Relationship) error {
|
||||||
|
// Check for cycles before inserting
|
||||||
|
hasCycle, err := r.HasCycle(ctx, rel.ParentItemID, rel.ChildItemID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking for cycles: %w", err)
|
||||||
|
}
|
||||||
|
if hasCycle {
|
||||||
|
return fmt.Errorf("adding this relationship would create a cycle")
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadataJSON []byte
|
||||||
|
if rel.Metadata != nil {
|
||||||
|
metadataJSON, err = json.Marshal(rel.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.db.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO relationships (
|
||||||
|
parent_item_id, child_item_id, rel_type, quantity, unit,
|
||||||
|
reference_designators, child_revision, metadata, parent_revision_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
|
||||||
|
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
|
||||||
|
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inserting relationship: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modifies an existing relationship's mutable fields.
|
||||||
|
func (r *RelationshipRepository) Update(ctx context.Context, id string, relType *string, quantity *float64, unit *string, refDes []string, childRevision *int, metadata map[string]any) error {
|
||||||
|
// Build dynamic update query
|
||||||
|
query := "UPDATE relationships SET updated_at = now()"
|
||||||
|
args := []any{}
|
||||||
|
argNum := 1
|
||||||
|
|
||||||
|
if relType != nil {
|
||||||
|
query += fmt.Sprintf(", rel_type = $%d", argNum)
|
||||||
|
args = append(args, *relType)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
if quantity != nil {
|
||||||
|
query += fmt.Sprintf(", quantity = $%d", argNum)
|
||||||
|
args = append(args, *quantity)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
if unit != nil {
|
||||||
|
query += fmt.Sprintf(", unit = $%d", argNum)
|
||||||
|
args = append(args, *unit)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
if refDes != nil {
|
||||||
|
query += fmt.Sprintf(", reference_designators = $%d", argNum)
|
||||||
|
args = append(args, refDes)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
if childRevision != nil {
|
||||||
|
query += fmt.Sprintf(", child_revision = $%d", argNum)
|
||||||
|
args = append(args, *childRevision)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata != nil {
|
||||||
|
metaJSON, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling metadata: %w", err)
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(", metadata = $%d", argNum)
|
||||||
|
args = append(args, metaJSON)
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
query += fmt.Sprintf(" WHERE id = $%d", argNum)
|
||||||
|
args = append(args, id)
|
||||||
|
|
||||||
|
result, err := r.db.pool.Exec(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating relationship: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("relationship not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a relationship by ID.
|
||||||
|
func (r *RelationshipRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
result, err := r.db.pool.Exec(ctx, `DELETE FROM relationships WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting relationship: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("relationship not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a relationship by its ID.
|
||||||
|
func (r *RelationshipRepository) GetByID(ctx context.Context, id string) (*Relationship, error) {
|
||||||
|
rel := &Relationship{}
|
||||||
|
var metadataJSON []byte
|
||||||
|
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, parent_item_id, child_item_id, rel_type, quantity, unit,
|
||||||
|
reference_designators, child_revision, metadata, parent_revision_id,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM relationships
|
||||||
|
WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&rel.ID, &rel.ParentItemID, &rel.ChildItemID, &rel.RelType, &rel.Quantity, &rel.Unit,
|
||||||
|
&rel.ReferenceDesignators, &rel.ChildRevision, &metadataJSON, &rel.ParentRevisionID,
|
||||||
|
&rel.CreatedAt, &rel.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying relationship: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadataJSON != nil {
|
||||||
|
if err := json.Unmarshal(metadataJSON, &rel.Metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshaling metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByParentAndChild retrieves a relationship between two specific items.
|
||||||
|
func (r *RelationshipRepository) GetByParentAndChild(ctx context.Context, parentItemID, childItemID string) (*Relationship, error) {
|
||||||
|
rel := &Relationship{}
|
||||||
|
var metadataJSON []byte
|
||||||
|
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, parent_item_id, child_item_id, rel_type, quantity, unit,
|
||||||
|
reference_designators, child_revision, metadata, parent_revision_id,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM relationships
|
||||||
|
WHERE parent_item_id = $1 AND child_item_id = $2
|
||||||
|
`, parentItemID, childItemID).Scan(
|
||||||
|
&rel.ID, &rel.ParentItemID, &rel.ChildItemID, &rel.RelType, &rel.Quantity, &rel.Unit,
|
||||||
|
&rel.ReferenceDesignators, &rel.ChildRevision, &metadataJSON, &rel.ParentRevisionID,
|
||||||
|
&rel.CreatedAt, &rel.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying relationship: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadataJSON != nil {
|
||||||
|
if err := json.Unmarshal(metadataJSON, &rel.Metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshaling metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBOM returns the single-level BOM for an item (its direct children).
|
||||||
|
func (r *RelationshipRepository) GetBOM(ctx context.Context, parentItemID string) ([]*BOMEntry, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT rel.id, rel.parent_item_id, parent.part_number, parent.description,
|
||||||
|
rel.child_item_id, child.part_number, child.description,
|
||||||
|
rel.rel_type, rel.quantity, rel.unit,
|
||||||
|
rel.reference_designators, rel.child_revision,
|
||||||
|
COALESCE(rel.child_revision, child.current_revision) AS effective_revision
|
||||||
|
FROM relationships rel
|
||||||
|
JOIN items parent ON parent.id = rel.parent_item_id
|
||||||
|
JOIN items child ON child.id = rel.child_item_id
|
||||||
|
WHERE rel.parent_item_id = $1
|
||||||
|
AND parent.archived_at IS NULL
|
||||||
|
AND child.archived_at IS NULL
|
||||||
|
ORDER BY child.part_number
|
||||||
|
`, parentItemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying BOM: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanBOMEntries(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWhereUsed returns all parent assemblies that reference the given item.
|
||||||
|
func (r *RelationshipRepository) GetWhereUsed(ctx context.Context, childItemID string) ([]*BOMEntry, error) {
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
SELECT rel.id, rel.parent_item_id, parent.part_number, parent.description,
|
||||||
|
rel.child_item_id, child.part_number, child.description,
|
||||||
|
rel.rel_type, rel.quantity, rel.unit,
|
||||||
|
rel.reference_designators, rel.child_revision,
|
||||||
|
COALESCE(rel.child_revision, child.current_revision) AS effective_revision
|
||||||
|
FROM relationships rel
|
||||||
|
JOIN items parent ON parent.id = rel.parent_item_id
|
||||||
|
JOIN items child ON child.id = rel.child_item_id
|
||||||
|
WHERE rel.child_item_id = $1
|
||||||
|
AND parent.archived_at IS NULL
|
||||||
|
AND child.archived_at IS NULL
|
||||||
|
ORDER BY parent.part_number
|
||||||
|
`, childItemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying where-used: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanBOMEntries(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExpandedBOM returns a multi-level BOM by recursively walking the
|
||||||
|
// relationship tree. maxDepth limits recursion (capped at 20).
|
||||||
|
func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemID string, maxDepth int) ([]*BOMTreeEntry, error) {
|
||||||
|
if maxDepth <= 0 || maxDepth > 20 {
|
||||||
|
maxDepth = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.pool.Query(ctx, `
|
||||||
|
WITH RECURSIVE bom_tree AS (
|
||||||
|
-- Base case: direct children
|
||||||
|
SELECT rel.id, rel.parent_item_id, parent.part_number AS parent_part_number,
|
||||||
|
parent.description AS parent_description,
|
||||||
|
rel.child_item_id, child.part_number AS child_part_number,
|
||||||
|
child.description AS child_description,
|
||||||
|
rel.rel_type, rel.quantity, rel.unit,
|
||||||
|
rel.reference_designators, rel.child_revision,
|
||||||
|
COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
|
||||||
|
1 AS depth
|
||||||
|
FROM relationships rel
|
||||||
|
JOIN items parent ON parent.id = rel.parent_item_id
|
||||||
|
JOIN items child ON child.id = rel.child_item_id
|
||||||
|
WHERE rel.parent_item_id = $1
|
||||||
|
AND parent.archived_at IS NULL
|
||||||
|
AND child.archived_at IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Recursive case: children of children
|
||||||
|
SELECT rel.id, rel.parent_item_id, parent.part_number,
|
||||||
|
parent.description,
|
||||||
|
rel.child_item_id, child.part_number,
|
||||||
|
child.description,
|
||||||
|
rel.rel_type, rel.quantity, rel.unit,
|
||||||
|
rel.reference_designators, rel.child_revision,
|
||||||
|
COALESCE(rel.child_revision, child.current_revision),
|
||||||
|
bt.depth + 1
|
||||||
|
FROM relationships rel
|
||||||
|
JOIN items parent ON parent.id = rel.parent_item_id
|
||||||
|
JOIN items child ON child.id = rel.child_item_id
|
||||||
|
JOIN bom_tree bt ON bt.child_item_id = rel.parent_item_id
|
||||||
|
WHERE bt.depth < $2
|
||||||
|
AND parent.archived_at IS NULL
|
||||||
|
AND child.archived_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT id, parent_item_id, parent_part_number, parent_description,
|
||||||
|
child_item_id, child_part_number, child_description,
|
||||||
|
rel_type, quantity, unit, reference_designators,
|
||||||
|
child_revision, effective_revision, depth
|
||||||
|
FROM bom_tree
|
||||||
|
ORDER BY depth, child_part_number
|
||||||
|
`, parentItemID, maxDepth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying expanded BOM: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []*BOMTreeEntry
|
||||||
|
for rows.Next() {
|
||||||
|
e := &BOMTreeEntry{}
|
||||||
|
var parentDesc, childDesc *string
|
||||||
|
err := rows.Scan(
|
||||||
|
&e.RelationshipID, &e.ParentItemID, &e.ParentPartNumber, &parentDesc,
|
||||||
|
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
||||||
|
&e.RelType, &e.Quantity, &e.Unit,
|
||||||
|
&e.ReferenceDesignators, &e.ChildRevision,
|
||||||
|
&e.EffectiveRevision, &e.Depth,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning BOM tree entry: %w", err)
|
||||||
|
}
|
||||||
|
if parentDesc != nil {
|
||||||
|
e.ParentDescription = *parentDesc
|
||||||
|
}
|
||||||
|
if childDesc != nil {
|
||||||
|
e.ChildDescription = *childDesc
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCycle checks whether adding a relationship from parentItemID to
|
||||||
|
// childItemID would create a cycle in the BOM graph. It walks upward
|
||||||
|
// from parentItemID to see if childItemID is an ancestor.
|
||||||
|
func (r *RelationshipRepository) HasCycle(ctx context.Context, parentItemID, childItemID string) (bool, error) {
|
||||||
|
// A self-reference is always a cycle (also blocked by DB constraint).
|
||||||
|
if parentItemID == childItemID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the ancestor chain of parentItemID. If childItemID appears
|
||||||
|
// as an ancestor, adding childItemID as a child would create a cycle.
|
||||||
|
var hasCycle bool
|
||||||
|
err := r.db.pool.QueryRow(ctx, `
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT parent_item_id
|
||||||
|
FROM relationships
|
||||||
|
WHERE child_item_id = $1
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT rel.parent_item_id
|
||||||
|
FROM relationships rel
|
||||||
|
JOIN ancestors a ON a.parent_item_id = rel.child_item_id
|
||||||
|
)
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM ancestors WHERE parent_item_id = $2
|
||||||
|
)
|
||||||
|
`, parentItemID, childItemID).Scan(&hasCycle)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("checking for cycle: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasCycle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanBOMEntries reads rows into BOMEntry slices.
|
||||||
|
func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) {
|
||||||
|
var entries []*BOMEntry
|
||||||
|
for rows.Next() {
|
||||||
|
e := &BOMEntry{}
|
||||||
|
var parentDesc, childDesc *string
|
||||||
|
err := rows.Scan(
|
||||||
|
&e.RelationshipID, &e.ParentItemID, &e.ParentPartNumber, &parentDesc,
|
||||||
|
&e.ChildItemID, &e.ChildPartNumber, &childDesc,
|
||||||
|
&e.RelType, &e.Quantity, &e.Unit,
|
||||||
|
&e.ReferenceDesignators, &e.ChildRevision,
|
||||||
|
&e.EffectiveRevision,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning BOM entry: %w", err)
|
||||||
|
}
|
||||||
|
if parentDesc != nil {
|
||||||
|
e.ParentDescription = *parentDesc
|
||||||
|
}
|
||||||
|
if childDesc != nil {
|
||||||
|
e.ChildDescription = *childDesc
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
|||||||
"Silo_Pull",
|
"Silo_Pull",
|
||||||
"Silo_Push",
|
"Silo_Push",
|
||||||
"Silo_Info",
|
"Silo_Info",
|
||||||
|
"Silo_BOM",
|
||||||
"Silo_Settings",
|
"Silo_Settings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
12
pkg/freecad/resources/icons/silo-bom.svg
Normal file
12
pkg/freecad/resources/icons/silo-bom.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Outer box -->
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" fill="#313244"/>
|
||||||
|
<!-- List lines (BOM rows) -->
|
||||||
|
<line x1="8" y1="8" x2="18" y2="8" stroke="#89dceb" stroke-width="1.5"/>
|
||||||
|
<line x1="8" y1="12" x2="18" y2="12" stroke="#89dceb" stroke-width="1.5"/>
|
||||||
|
<line x1="8" y1="16" x2="18" y2="16" stroke="#89dceb" stroke-width="1.5"/>
|
||||||
|
<!-- Hierarchy dots -->
|
||||||
|
<circle cx="6" cy="8" r="1" fill="#cba6f7"/>
|
||||||
|
<circle cx="6" cy="12" r="1" fill="#cba6f7"/>
|
||||||
|
<circle cx="6" cy="16" r="1" fill="#cba6f7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 680 B |
@@ -491,6 +491,79 @@ class SiloClient:
|
|||||||
"PATCH", f"/items/{part_number}/revisions/{revision}", data
|
"PATCH", f"/items/{part_number}/revisions/{revision}", data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# BOM / Relationship methods
|
||||||
|
|
||||||
|
def get_bom(self, part_number: str) -> list:
|
||||||
|
"""Get single-level BOM for an item."""
|
||||||
|
return self._request("GET", f"/items/{part_number}/bom")
|
||||||
|
|
||||||
|
def get_bom_expanded(self, part_number: str, depth: int = 10) -> list:
|
||||||
|
"""Get multi-level BOM for an item."""
|
||||||
|
return self._request("GET", f"/items/{part_number}/bom/expanded?depth={depth}")
|
||||||
|
|
||||||
|
def get_bom_where_used(self, part_number: str) -> list:
|
||||||
|
"""Get assemblies that use this item."""
|
||||||
|
return self._request("GET", f"/items/{part_number}/bom/where-used")
|
||||||
|
|
||||||
|
def add_bom_entry(
|
||||||
|
self,
|
||||||
|
parent_pn: str,
|
||||||
|
child_pn: str,
|
||||||
|
quantity: float = None,
|
||||||
|
unit: str = None,
|
||||||
|
rel_type: str = "component",
|
||||||
|
ref_des: list = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Add a child item to a parent's BOM."""
|
||||||
|
data: Dict[str, Any] = {
|
||||||
|
"child_part_number": child_pn,
|
||||||
|
"rel_type": rel_type,
|
||||||
|
}
|
||||||
|
if quantity is not None:
|
||||||
|
data["quantity"] = quantity
|
||||||
|
if unit:
|
||||||
|
data["unit"] = unit
|
||||||
|
if ref_des:
|
||||||
|
data["reference_designators"] = ref_des
|
||||||
|
return self._request("POST", f"/items/{parent_pn}/bom", data)
|
||||||
|
|
||||||
|
def update_bom_entry(
|
||||||
|
self,
|
||||||
|
parent_pn: str,
|
||||||
|
child_pn: str,
|
||||||
|
quantity: float = None,
|
||||||
|
unit: str = None,
|
||||||
|
rel_type: str = None,
|
||||||
|
ref_des: list = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update a BOM entry."""
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
if quantity is not None:
|
||||||
|
data["quantity"] = quantity
|
||||||
|
if unit is not None:
|
||||||
|
data["unit"] = unit
|
||||||
|
if rel_type is not None:
|
||||||
|
data["rel_type"] = rel_type
|
||||||
|
if ref_des is not None:
|
||||||
|
data["reference_designators"] = ref_des
|
||||||
|
return self._request("PUT", f"/items/{parent_pn}/bom/{child_pn}", data)
|
||||||
|
|
||||||
|
def delete_bom_entry(self, parent_pn: str, child_pn: str) -> None:
|
||||||
|
"""Remove a child from a parent's BOM."""
|
||||||
|
url = f"{self.base_url}/items/{parent_pn}/bom/{child_pn}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="DELETE",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, context=_get_ssl_context())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode()
|
||||||
|
raise RuntimeError(f"API error {e.code}: {error_body}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
|
|
||||||
_client = SiloClient()
|
_client = SiloClient()
|
||||||
|
|
||||||
@@ -1937,6 +2010,379 @@ class Silo_Settings:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Silo_BOM:
|
||||||
|
"""View and manage Bill of Materials for the current item."""
|
||||||
|
|
||||||
|
def GetResources(self):
|
||||||
|
return {
|
||||||
|
"MenuText": "BOM",
|
||||||
|
"ToolTip": "View and manage Bill of Materials",
|
||||||
|
"Pixmap": _icon("bom"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def Activated(self):
|
||||||
|
from PySide import QtCore, QtGui
|
||||||
|
|
||||||
|
doc = FreeCAD.ActiveDocument
|
||||||
|
if not doc:
|
||||||
|
FreeCAD.Console.PrintError("No active document\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
obj = get_tracked_object(doc)
|
||||||
|
if not obj:
|
||||||
|
FreeCAD.Console.PrintError("No tracked Silo item in active document.\n")
|
||||||
|
from PySide import QtGui as _qg
|
||||||
|
|
||||||
|
_qg.QMessageBox.warning(
|
||||||
|
None,
|
||||||
|
"BOM",
|
||||||
|
"This document is not registered with Silo.\n"
|
||||||
|
"Use Silo > New to register it first.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
part_number = obj.SiloPartNumber
|
||||||
|
|
||||||
|
try:
|
||||||
|
item = _client.get_item(part_number)
|
||||||
|
except Exception as e:
|
||||||
|
QtGui.QMessageBox.warning(None, "BOM", f"Failed to get item info:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build the dialog
|
||||||
|
dialog = QtGui.QDialog()
|
||||||
|
dialog.setWindowTitle(f"BOM - {part_number}")
|
||||||
|
dialog.setMinimumWidth(750)
|
||||||
|
dialog.setMinimumHeight(450)
|
||||||
|
layout = QtGui.QVBoxLayout(dialog)
|
||||||
|
|
||||||
|
# Item header
|
||||||
|
header = QtGui.QLabel(f"<b>{part_number}</b> - {item.get('description', '')}")
|
||||||
|
layout.addWidget(header)
|
||||||
|
|
||||||
|
# Tab widget
|
||||||
|
tabs = QtGui.QTabWidget()
|
||||||
|
layout.addWidget(tabs)
|
||||||
|
|
||||||
|
# ── Tab 1: BOM (children of this item) ──
|
||||||
|
bom_widget = QtGui.QWidget()
|
||||||
|
bom_layout = QtGui.QVBoxLayout(bom_widget)
|
||||||
|
|
||||||
|
bom_table = QtGui.QTableWidget()
|
||||||
|
bom_table.setColumnCount(7)
|
||||||
|
bom_table.setHorizontalHeaderLabels(
|
||||||
|
["Part Number", "Description", "Type", "Qty", "Unit", "Ref Des", "Rev"]
|
||||||
|
)
|
||||||
|
bom_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||||
|
bom_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||||
|
bom_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||||
|
bom_table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
bom_layout.addWidget(bom_table)
|
||||||
|
|
||||||
|
# BOM button bar
|
||||||
|
bom_btn_layout = QtGui.QHBoxLayout()
|
||||||
|
add_btn = QtGui.QPushButton("Add")
|
||||||
|
edit_btn = QtGui.QPushButton("Edit")
|
||||||
|
remove_btn = QtGui.QPushButton("Remove")
|
||||||
|
bom_btn_layout.addWidget(add_btn)
|
||||||
|
bom_btn_layout.addWidget(edit_btn)
|
||||||
|
bom_btn_layout.addWidget(remove_btn)
|
||||||
|
bom_btn_layout.addStretch()
|
||||||
|
bom_layout.addLayout(bom_btn_layout)
|
||||||
|
|
||||||
|
tabs.addTab(bom_widget, "BOM")
|
||||||
|
|
||||||
|
# ── Tab 2: Where Used (parents of this item) ──
|
||||||
|
wu_widget = QtGui.QWidget()
|
||||||
|
wu_layout = QtGui.QVBoxLayout(wu_widget)
|
||||||
|
|
||||||
|
wu_table = QtGui.QTableWidget()
|
||||||
|
wu_table.setColumnCount(5)
|
||||||
|
wu_table.setHorizontalHeaderLabels(
|
||||||
|
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
|
||||||
|
)
|
||||||
|
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||||
|
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||||
|
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||||
|
wu_table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
wu_layout.addWidget(wu_table)
|
||||||
|
|
||||||
|
tabs.addTab(wu_widget, "Where Used")
|
||||||
|
|
||||||
|
# ── Data loading ──
|
||||||
|
|
||||||
|
bom_data = []
|
||||||
|
|
||||||
|
def load_bom():
|
||||||
|
nonlocal bom_data
|
||||||
|
try:
|
||||||
|
bom_data = _client.get_bom(part_number)
|
||||||
|
except Exception as exc:
|
||||||
|
FreeCAD.Console.PrintWarning(f"BOM load error: {exc}\n")
|
||||||
|
bom_data = []
|
||||||
|
|
||||||
|
bom_table.setRowCount(len(bom_data))
|
||||||
|
for row, entry in enumerate(bom_data):
|
||||||
|
bom_table.setItem(
|
||||||
|
row, 0, QtGui.QTableWidgetItem(entry.get("child_part_number", ""))
|
||||||
|
)
|
||||||
|
bom_table.setItem(
|
||||||
|
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
|
||||||
|
)
|
||||||
|
bom_table.setItem(
|
||||||
|
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
|
||||||
|
)
|
||||||
|
qty = entry.get("quantity")
|
||||||
|
bom_table.setItem(
|
||||||
|
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
||||||
|
)
|
||||||
|
bom_table.setItem(
|
||||||
|
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
|
||||||
|
)
|
||||||
|
ref_des = entry.get("reference_designators") or []
|
||||||
|
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
||||||
|
bom_table.setItem(
|
||||||
|
row,
|
||||||
|
6,
|
||||||
|
QtGui.QTableWidgetItem(str(entry.get("effective_revision", ""))),
|
||||||
|
)
|
||||||
|
bom_table.resizeColumnsToContents()
|
||||||
|
|
||||||
|
def load_where_used():
|
||||||
|
try:
|
||||||
|
wu_data = _client.get_bom_where_used(part_number)
|
||||||
|
except Exception as exc:
|
||||||
|
FreeCAD.Console.PrintWarning(f"Where-used load error: {exc}\n")
|
||||||
|
wu_data = []
|
||||||
|
|
||||||
|
wu_table.setRowCount(len(wu_data))
|
||||||
|
for row, entry in enumerate(wu_data):
|
||||||
|
wu_table.setItem(
|
||||||
|
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
|
||||||
|
)
|
||||||
|
wu_table.setItem(
|
||||||
|
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
|
||||||
|
)
|
||||||
|
qty = entry.get("quantity")
|
||||||
|
wu_table.setItem(
|
||||||
|
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
||||||
|
)
|
||||||
|
wu_table.setItem(
|
||||||
|
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
|
||||||
|
)
|
||||||
|
ref_des = entry.get("reference_designators") or []
|
||||||
|
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
||||||
|
wu_table.resizeColumnsToContents()
|
||||||
|
|
||||||
|
# ── Button handlers ──
|
||||||
|
|
||||||
|
def on_add():
|
||||||
|
add_dlg = QtGui.QDialog(dialog)
|
||||||
|
add_dlg.setWindowTitle("Add BOM Entry")
|
||||||
|
add_dlg.setMinimumWidth(400)
|
||||||
|
al = QtGui.QFormLayout(add_dlg)
|
||||||
|
|
||||||
|
child_input = QtGui.QLineEdit()
|
||||||
|
child_input.setPlaceholderText("e.g. F01-0001")
|
||||||
|
al.addRow("Child Part Number:", child_input)
|
||||||
|
|
||||||
|
type_combo = QtGui.QComboBox()
|
||||||
|
type_combo.addItems(["component", "alternate", "reference"])
|
||||||
|
al.addRow("Relationship Type:", type_combo)
|
||||||
|
|
||||||
|
qty_input = QtGui.QLineEdit()
|
||||||
|
qty_input.setPlaceholderText("e.g. 4")
|
||||||
|
al.addRow("Quantity:", qty_input)
|
||||||
|
|
||||||
|
unit_input = QtGui.QLineEdit()
|
||||||
|
unit_input.setPlaceholderText("e.g. ea, m, kg")
|
||||||
|
al.addRow("Unit:", unit_input)
|
||||||
|
|
||||||
|
refdes_input = QtGui.QLineEdit()
|
||||||
|
refdes_input.setPlaceholderText("e.g. R1, R2, R3")
|
||||||
|
al.addRow("Ref Designators:", refdes_input)
|
||||||
|
|
||||||
|
btn_box = QtGui.QDialogButtonBox(
|
||||||
|
QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
|
||||||
|
)
|
||||||
|
btn_box.accepted.connect(add_dlg.accept)
|
||||||
|
btn_box.rejected.connect(add_dlg.reject)
|
||||||
|
al.addRow(btn_box)
|
||||||
|
|
||||||
|
if add_dlg.exec_() != QtGui.QDialog.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
child_pn = child_input.text().strip()
|
||||||
|
if not child_pn:
|
||||||
|
return
|
||||||
|
|
||||||
|
qty = None
|
||||||
|
qty_text = qty_input.text().strip()
|
||||||
|
if qty_text:
|
||||||
|
try:
|
||||||
|
qty = float(qty_text)
|
||||||
|
except ValueError:
|
||||||
|
QtGui.QMessageBox.warning(
|
||||||
|
dialog, "BOM", "Quantity must be a number."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
unit = unit_input.text().strip() or None
|
||||||
|
rel_type = type_combo.currentText()
|
||||||
|
|
||||||
|
ref_des = None
|
||||||
|
refdes_text = refdes_input.text().strip()
|
||||||
|
if refdes_text:
|
||||||
|
ref_des = [r.strip() for r in refdes_text.split(",") if r.strip()]
|
||||||
|
|
||||||
|
try:
|
||||||
|
_client.add_bom_entry(
|
||||||
|
part_number,
|
||||||
|
child_pn,
|
||||||
|
quantity=qty,
|
||||||
|
unit=unit,
|
||||||
|
rel_type=rel_type,
|
||||||
|
ref_des=ref_des,
|
||||||
|
)
|
||||||
|
load_bom()
|
||||||
|
except Exception as exc:
|
||||||
|
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to add entry:\n{exc}")
|
||||||
|
|
||||||
|
def on_edit():
|
||||||
|
selected = bom_table.selectedItems()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
row = selected[0].row()
|
||||||
|
if row < 0 or row >= len(bom_data):
|
||||||
|
return
|
||||||
|
|
||||||
|
entry = bom_data[row]
|
||||||
|
child_pn = entry.get("child_part_number", "")
|
||||||
|
|
||||||
|
edit_dlg = QtGui.QDialog(dialog)
|
||||||
|
edit_dlg.setWindowTitle(f"Edit BOM Entry - {child_pn}")
|
||||||
|
edit_dlg.setMinimumWidth(400)
|
||||||
|
el = QtGui.QFormLayout(edit_dlg)
|
||||||
|
|
||||||
|
type_combo = QtGui.QComboBox()
|
||||||
|
type_combo.addItems(["component", "alternate", "reference"])
|
||||||
|
current_type = entry.get("rel_type", "component")
|
||||||
|
idx = type_combo.findText(current_type)
|
||||||
|
if idx >= 0:
|
||||||
|
type_combo.setCurrentIndex(idx)
|
||||||
|
el.addRow("Relationship Type:", type_combo)
|
||||||
|
|
||||||
|
qty_input = QtGui.QLineEdit()
|
||||||
|
qty = entry.get("quantity")
|
||||||
|
if qty is not None:
|
||||||
|
qty_input.setText(str(qty))
|
||||||
|
el.addRow("Quantity:", qty_input)
|
||||||
|
|
||||||
|
unit_input = QtGui.QLineEdit()
|
||||||
|
unit_input.setText(entry.get("unit") or "")
|
||||||
|
el.addRow("Unit:", unit_input)
|
||||||
|
|
||||||
|
refdes_input = QtGui.QLineEdit()
|
||||||
|
ref_des = entry.get("reference_designators") or []
|
||||||
|
refdes_input.setText(", ".join(ref_des))
|
||||||
|
el.addRow("Ref Designators:", refdes_input)
|
||||||
|
|
||||||
|
btn_box = QtGui.QDialogButtonBox(
|
||||||
|
QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
|
||||||
|
)
|
||||||
|
btn_box.accepted.connect(edit_dlg.accept)
|
||||||
|
btn_box.rejected.connect(edit_dlg.reject)
|
||||||
|
el.addRow(btn_box)
|
||||||
|
|
||||||
|
if edit_dlg.exec_() != QtGui.QDialog.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
new_qty = None
|
||||||
|
qty_text = qty_input.text().strip()
|
||||||
|
if qty_text:
|
||||||
|
try:
|
||||||
|
new_qty = float(qty_text)
|
||||||
|
except ValueError:
|
||||||
|
QtGui.QMessageBox.warning(
|
||||||
|
dialog, "BOM", "Quantity must be a number."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_unit = unit_input.text().strip() or None
|
||||||
|
new_type = type_combo.currentText()
|
||||||
|
|
||||||
|
new_ref_des = None
|
||||||
|
refdes_text = refdes_input.text().strip()
|
||||||
|
if refdes_text:
|
||||||
|
new_ref_des = [r.strip() for r in refdes_text.split(",") if r.strip()]
|
||||||
|
else:
|
||||||
|
new_ref_des = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
_client.update_bom_entry(
|
||||||
|
part_number,
|
||||||
|
child_pn,
|
||||||
|
quantity=new_qty,
|
||||||
|
unit=new_unit,
|
||||||
|
rel_type=new_type,
|
||||||
|
ref_des=new_ref_des,
|
||||||
|
)
|
||||||
|
load_bom()
|
||||||
|
except Exception as exc:
|
||||||
|
QtGui.QMessageBox.warning(
|
||||||
|
dialog, "BOM", f"Failed to update entry:\n{exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_remove():
|
||||||
|
selected = bom_table.selectedItems()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
row = selected[0].row()
|
||||||
|
if row < 0 or row >= len(bom_data):
|
||||||
|
return
|
||||||
|
|
||||||
|
entry = bom_data[row]
|
||||||
|
child_pn = entry.get("child_part_number", "")
|
||||||
|
|
||||||
|
reply = QtGui.QMessageBox.question(
|
||||||
|
dialog,
|
||||||
|
"Remove BOM Entry",
|
||||||
|
f"Remove {child_pn} from BOM?",
|
||||||
|
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QtGui.QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_client.delete_bom_entry(part_number, child_pn)
|
||||||
|
load_bom()
|
||||||
|
except Exception as exc:
|
||||||
|
QtGui.QMessageBox.warning(
|
||||||
|
dialog, "BOM", f"Failed to remove entry:\n{exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_btn.clicked.connect(on_add)
|
||||||
|
edit_btn.clicked.connect(on_edit)
|
||||||
|
remove_btn.clicked.connect(on_remove)
|
||||||
|
|
||||||
|
# Close button
|
||||||
|
close_layout = QtGui.QHBoxLayout()
|
||||||
|
close_layout.addStretch()
|
||||||
|
close_btn = QtGui.QPushButton("Close")
|
||||||
|
close_btn.clicked.connect(dialog.accept)
|
||||||
|
close_layout.addWidget(close_btn)
|
||||||
|
layout.addLayout(close_layout)
|
||||||
|
|
||||||
|
# Initial data load
|
||||||
|
load_bom()
|
||||||
|
load_where_used()
|
||||||
|
|
||||||
|
dialog.exec_()
|
||||||
|
|
||||||
|
def IsActive(self):
|
||||||
|
return FreeCAD.ActiveDocument is not None
|
||||||
|
|
||||||
|
|
||||||
# Register commands
|
# Register commands
|
||||||
FreeCADGui.addCommand("Silo_Open", Silo_Open())
|
FreeCADGui.addCommand("Silo_Open", Silo_Open())
|
||||||
FreeCADGui.addCommand("Silo_New", Silo_New())
|
FreeCADGui.addCommand("Silo_New", Silo_New())
|
||||||
@@ -1945,6 +2391,7 @@ FreeCADGui.addCommand("Silo_Commit", Silo_Commit())
|
|||||||
FreeCADGui.addCommand("Silo_Pull", Silo_Pull())
|
FreeCADGui.addCommand("Silo_Pull", Silo_Pull())
|
||||||
FreeCADGui.addCommand("Silo_Push", Silo_Push())
|
FreeCADGui.addCommand("Silo_Push", Silo_Push())
|
||||||
FreeCADGui.addCommand("Silo_Info", Silo_Info())
|
FreeCADGui.addCommand("Silo_Info", Silo_Info())
|
||||||
|
FreeCADGui.addCommand("Silo_BOM", Silo_BOM())
|
||||||
FreeCADGui.addCommand("Silo_TagProjects", Silo_TagProjects())
|
FreeCADGui.addCommand("Silo_TagProjects", Silo_TagProjects())
|
||||||
FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback())
|
FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback())
|
||||||
FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus())
|
FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus())
|
||||||
|
|||||||
Reference in New Issue
Block a user