package api import ( "encoding/csv" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/auth" "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"` ParentPartNumber string `json:"parent_part_number"` 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"` Metadata map[string]any `json:"metadata,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 user := auth.UserFromContext(ctx); user != nil { rel.CreatedBy = &user.Username } 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, Metadata: req.Metadata, } 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 } } var bomUpdatedBy *string if user := auth.UserFromContext(ctx); user != nil { bomUpdatedBy = &user.Username } if err := s.relationships.Update(ctx, rel.ID, req.RelType, req.Quantity, req.Unit, req.ReferenceDesignators, req.ChildRevision, req.Metadata, bomUpdatedBy); 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, ParentPartNumber: e.ParentPartNumber, ChildPartNumber: e.ChildPartNumber, ChildDescription: e.ChildDescription, RelType: e.RelType, Quantity: e.Quantity, Unit: e.Unit, ReferenceDesignators: refDes, ChildRevision: e.ChildRevision, EffectiveRevision: e.EffectiveRevision, Metadata: e.Metadata, } } 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, } } // Flat BOM and cost response types // FlatBOMResponse is the response for GET /api/items/{partNumber}/bom/flat. type FlatBOMResponse struct { PartNumber string `json:"part_number"` FlatBOM []FlatBOMLineResponse `json:"flat_bom"` } // FlatBOMLineResponse represents one consolidated leaf part in a flat BOM. type FlatBOMLineResponse struct { PartNumber string `json:"part_number"` Description string `json:"description"` TotalQuantity float64 `json:"total_quantity"` } // CostResponse is the response for GET /api/items/{partNumber}/bom/cost. type CostResponse struct { PartNumber string `json:"part_number"` TotalCost float64 `json:"total_cost"` CostBreakdown []CostLineResponse `json:"cost_breakdown"` } // CostLineResponse represents one line in the cost breakdown. type CostLineResponse struct { PartNumber string `json:"part_number"` Description string `json:"description"` TotalQuantity float64 `json:"total_quantity"` UnitCost float64 `json:"unit_cost"` ExtendedCost float64 `json:"extended_cost"` } // HandleGetFlatBOM returns a flattened, consolidated BOM with rolled-up // quantities for leaf parts only. func (s *Server) HandleGetFlatBOM(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.GetFlatBOM(ctx, item.ID) if err != nil { if strings.Contains(err.Error(), "cycle detected") { writeJSON(w, http.StatusConflict, map[string]string{ "error": "cycle_detected", "detail": err.Error(), }) return } s.logger.Error().Err(err).Msg("failed to get flat BOM") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to flatten BOM") return } lines := make([]FlatBOMLineResponse, len(entries)) for i, e := range entries { lines[i] = FlatBOMLineResponse{ PartNumber: e.PartNumber, Description: e.Description, TotalQuantity: e.TotalQuantity, } } writeJSON(w, http.StatusOK, FlatBOMResponse{ PartNumber: partNumber, FlatBOM: lines, }) } // HandleGetBOMCost returns the total assembly cost by combining the flat BOM // with each leaf part's standard_cost. func (s *Server) HandleGetBOMCost(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.GetFlatBOM(ctx, item.ID) if err != nil { if strings.Contains(err.Error(), "cycle detected") { writeJSON(w, http.StatusConflict, map[string]string{ "error": "cycle_detected", "detail": err.Error(), }) return } s.logger.Error().Err(err).Msg("failed to get flat BOM for costing") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to flatten BOM") return } var totalCost float64 breakdown := make([]CostLineResponse, len(entries)) for i, e := range entries { unitCost := 0.0 leaf, err := s.items.GetByID(ctx, e.ItemID) if err == nil && leaf != nil && leaf.StandardCost != nil { unitCost = *leaf.StandardCost } extCost := e.TotalQuantity * unitCost totalCost += extCost breakdown[i] = CostLineResponse{ PartNumber: e.PartNumber, Description: e.Description, TotalQuantity: e.TotalQuantity, UnitCost: unitCost, ExtendedCost: extCost, } } writeJSON(w, http.StatusOK, CostResponse{ PartNumber: partNumber, TotalCost: totalCost, CostBreakdown: breakdown, }) } // BOM CSV headers matching the user-specified format. var bomCSVHeaders = []string{ "Item", "Level", "Source", "PN", "Seller Description", "Unit Cost", "QTY", "Ext Cost", "Sourcing Link", } // getMetaString extracts a string value from metadata. func getMetaString(m map[string]any, key string) string { if m == nil { return "" } if v, ok := m[key]; ok { if s, ok := v.(string); ok { return s } } return "" } // getMetaFloat extracts a float64 value from metadata. func getMetaFloat(m map[string]any, key string) (float64, bool) { if m == nil { return 0, false } v, ok := m[key] if !ok { return 0, false } switch n := v.(type) { case float64: return n, true case json.Number: f, err := n.Float64() return f, err == nil } return 0, false } // HandleExportBOMCSV exports the expanded BOM as a CSV file. func (s *Server) HandleExportBOMCSV(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.GetExpandedBOM(ctx, item.ID, 10) if err != nil { s.logger.Error().Err(err).Msg("failed to get expanded BOM") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get BOM") return } w.Header().Set("Content-Type", "text/csv") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-bom.csv"`, partNumber)) writer := csv.NewWriter(w) defer writer.Flush() // Write header if err := writer.Write(bomCSVHeaders); err != nil { s.logger.Error().Err(err).Msg("failed to write CSV header") return } // Write rows for i, e := range entries { unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost") qty := 0.0 if e.Quantity != nil { qty = *e.Quantity } extCost := "" if hasUnitCost && qty > 0 { extCost = fmt.Sprintf("%.2f", unitCost*qty) } unitCostStr := "" if hasUnitCost { unitCostStr = fmt.Sprintf("%.2f", unitCost) } qtyStr := "" if e.Quantity != nil { qtyStr = strconv.FormatFloat(*e.Quantity, 'f', -1, 64) } row := []string{ strconv.Itoa(i + 1), // Item strconv.Itoa(e.Depth), // Level getMetaString(e.Metadata, "source"), // Source e.ChildPartNumber, // PN getMetaString(e.Metadata, "seller_description"), // Seller Description unitCostStr, // Unit Cost qtyStr, // QTY extCost, // Ext Cost getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link } if err := writer.Write(row); err != nil { s.logger.Error().Err(err).Msg("failed to write CSV row") return } } } // HandleImportBOMCSV imports BOM entries from a CSV file. func (s *Server) HandleImportBOMCSV(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 } // Parse multipart form (32MB max) if err := r.ParseMultipartForm(32 << 20); err != nil { writeError(w, http.StatusBadRequest, "invalid_form", "Failed to parse multipart form") return } file, _, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "missing_file", "CSV file is required") return } defer file.Close() dryRun := r.FormValue("dry_run") == "true" clearExisting := r.FormValue("clear_existing") == "true" // Read CSV reader := csv.NewReader(file) reader.TrimLeadingSpace = true headers, err := reader.Read() if err != nil { writeError(w, http.StatusBadRequest, "invalid_csv", "Failed to read CSV headers") return } // Build case-insensitive header index headerIdx := make(map[string]int) for i, h := range headers { headerIdx[strings.ToLower(strings.TrimSpace(h))] = i } // Require PN column pnIdx, hasPn := headerIdx["pn"] if !hasPn { writeError(w, http.StatusBadRequest, "missing_column", "CSV must have a 'PN' column") return } // Clear existing BOM if requested (only on real import) if clearExisting && !dryRun { existing, err := s.relationships.GetBOM(ctx, parent.ID) if err != nil { s.logger.Error().Err(err).Msg("failed to get existing BOM for clearing") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear existing BOM") return } for _, e := range existing { if err := s.relationships.Delete(ctx, e.RelationshipID); err != nil { s.logger.Error().Err(err).Str("id", e.RelationshipID).Msg("failed to delete BOM entry during clear") } } } result := CSVImportResult{} var createdItems []string for { record, err := reader.Read() if err == io.EOF { break } if err != nil { result.TotalRows++ result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: result.TotalRows + 1, // +1 for header Message: fmt.Sprintf("Failed to read row: %s", err.Error()), }) continue } result.TotalRows++ rowNum := result.TotalRows + 1 // +1 for header // Get part number if pnIdx >= len(record) { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Field: "PN", Message: "Row has fewer columns than expected", }) continue } childPN := strings.TrimSpace(record[pnIdx]) if childPN == "" { // Skip blank PN rows silently result.TotalRows-- continue } // Look up child item child, err := s.items.GetByPartNumber(ctx, childPN) if err != nil { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Field: "PN", Message: fmt.Sprintf("Error looking up item: %s", err.Error()), }) continue } if child == nil { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Field: "PN", Message: fmt.Sprintf("Item '%s' not found", childPN), }) continue } // Parse quantity var quantity *float64 if idx, ok := headerIdx["qty"]; ok && idx < len(record) { qtyStr := strings.TrimSpace(record[idx]) if qtyStr != "" { q, err := strconv.ParseFloat(qtyStr, 64) if err != nil { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Field: "QTY", Message: fmt.Sprintf("Invalid quantity '%s'", qtyStr), }) continue } quantity = &q } } // Build metadata from CSV columns metadata := make(map[string]any) if idx, ok := headerIdx["source"]; ok && idx < len(record) { if v := strings.TrimSpace(record[idx]); v != "" { metadata["source"] = v } } if idx, ok := headerIdx["seller description"]; ok && idx < len(record) { if v := strings.TrimSpace(record[idx]); v != "" { metadata["seller_description"] = v } } if idx, ok := headerIdx["unit cost"]; ok && idx < len(record) { if v := strings.TrimSpace(record[idx]); v != "" { // Strip leading $ or currency symbols v = strings.TrimLeft(v, "$£€ ") if f, err := strconv.ParseFloat(v, 64); err == nil { metadata["unit_cost"] = f } } } if idx, ok := headerIdx["sourcing link"]; ok && idx < len(record) { if v := strings.TrimSpace(record[idx]); v != "" { metadata["sourcing_link"] = v } } if len(metadata) == 0 { metadata = nil } // Cycle check hasCycle, err := s.relationships.HasCycle(ctx, parent.ID, child.ID) if err != nil { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Field: "PN", Message: fmt.Sprintf("Error checking for cycles: %s", err.Error()), }) continue } if hasCycle { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Field: "PN", Message: fmt.Sprintf("Adding '%s' would create a cycle", childPN), }) continue } if dryRun { result.SuccessCount++ continue } // Check if relationship already exists (upsert) existing, err := s.relationships.GetByParentAndChild(ctx, parent.ID, child.ID) if err != nil { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Message: fmt.Sprintf("Error checking existing relationship: %s", err.Error()), }) continue } var importUsername *string if user := auth.UserFromContext(ctx); user != nil { importUsername = &user.Username } if existing != nil { // Update existing if err := s.relationships.Update(ctx, existing.ID, nil, quantity, nil, nil, nil, metadata, importUsername); err != nil { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Message: fmt.Sprintf("Failed to update: %s", err.Error()), }) continue } } else { // Create new rel := &db.Relationship{ ParentItemID: parent.ID, ChildItemID: child.ID, RelType: "component", Quantity: quantity, Metadata: metadata, CreatedBy: importUsername, } if err := s.relationships.Create(ctx, rel); err != nil { result.ErrorCount++ result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Message: fmt.Sprintf("Failed to create: %s", err.Error()), }) continue } createdItems = append(createdItems, childPN) } result.SuccessCount++ } result.CreatedItems = createdItems s.logger.Info(). Str("parent", partNumber). Bool("dry_run", dryRun). Int("total", result.TotalRows). Int("success", result.SuccessCount). Int("errors", result.ErrorCount). Msg("BOM CSV import completed") writeJSON(w, http.StatusOK, result) }