Files
silo/internal/api/bom_handlers.go
Forbes b1256a7044 fix: include parent_part_number in BOM API response
BOMEntryResponse was missing the ParentPartNumber field. The database
query populates it correctly, but the API serialization dropped it.
This caused the Calc extension to never populate the hidden
_silo_parent_pn column during pull, so push never called
_update_bom_relationship and no BOM entries were created.
2026-02-01 14:24:35 -06:00

840 lines
24 KiB
Go

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,
}
}
// 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)
}