package api import ( "fmt" "io" "net/http" "sort" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/auth" "github.com/kindredsystems/silo/internal/db" "github.com/kindredsystems/silo/internal/ods" "github.com/kindredsystems/silo/internal/partnum" ) // ODS BOM sheet column layout -- matches the real working BOM format. var bomODSHeaders = []string{ "Item", "Level", "Source", "PN", "Description", "Seller Description", "Unit Cost", "QTY", "Ext Cost", "Sourcing Link", "Schema", } // Hidden property columns appended after visible columns. var bomODSPropertyHeaders = []string{ "Manufacturer", "Manufacturer PN", "Supplier", "Supplier PN", "Lead Time (days)", "Min Order Qty", "Lifecycle Status", "RoHS Compliant", "Country of Origin", "Material", "Finish", "Notes", "Long Description", } // Mapping from property header to JSONB key in revision properties or item fields. var propertyKeyMap = map[string]string{ "Manufacturer": "manufacturer", "Manufacturer PN": "manufacturer_pn", "Supplier": "supplier", "Supplier PN": "supplier_pn", "Lead Time (days)": "lead_time_days", "Min Order Qty": "minimum_order_qty", "Lifecycle Status": "lifecycle_status", "RoHS Compliant": "rohs_compliant", "Country of Origin": "country_of_origin", "Material": "material", "Finish": "finish", "Notes": "notes", "Long Description": "long_description", } // HandleExportODS exports items as an ODS file. func (s *Server) HandleExportODS(w http.ResponseWriter, r *http.Request) { ctx := r.Context() opts := db.ListOptions{ ItemType: r.URL.Query().Get("type"), Search: r.URL.Query().Get("search"), Project: r.URL.Query().Get("project"), Limit: 10000, } includeProps := r.URL.Query().Get("include_properties") == "true" items, err := s.items.List(ctx, opts) if err != nil { s.logger.Error().Err(err).Msg("failed to list items for ODS export") writeError(w, http.StatusInternalServerError, "export_failed", err.Error()) return } // Build item properties map propKeys := make(map[string]bool) itemProps := make(map[string]map[string]any) if includeProps { for _, item := range items { revisions, err := s.items.GetRevisions(ctx, item.ID) if err != nil { continue } for _, rev := range revisions { if rev.RevisionNumber == item.CurrentRevision && rev.Properties != nil { itemProps[item.PartNumber] = rev.Properties for k := range rev.Properties { propKeys[k] = true } break } } } } // Build headers headers := make([]string, len(csvColumns)) copy(headers, csvColumns) sortedPropKeys := make([]string, 0, len(propKeys)) for k := range propKeys { if !strings.HasPrefix(k, "_") { sortedPropKeys = append(sortedPropKeys, k) } } sort.Strings(sortedPropKeys) headers = append(headers, sortedPropKeys...) // Build header row cells headerCells := make([]ods.Cell, len(headers)) for i, h := range headers { headerCells[i] = ods.HeaderCell(h) } // Build data rows var rows []ods.Row rows = append(rows, ods.Row{Cells: headerCells}) for _, item := range items { category := parseCategory(item.PartNumber) projects, err := s.projects.GetProjectsForItem(ctx, item.ID) projectCodes := "" if err == nil && len(projects) > 0 { codes := make([]string, len(projects)) for i, p := range projects { codes[i] = p.Code } projectCodes = strings.Join(codes, ",") } cells := []ods.Cell{ ods.StringCell(item.PartNumber), ods.StringCell(item.ItemType), ods.StringCell(item.Description), ods.IntCell(item.CurrentRevision), ods.StringCell(item.CreatedAt.Format(time.RFC3339)), ods.StringCell(item.UpdatedAt.Format(time.RFC3339)), ods.StringCell(category), ods.StringCell(projectCodes), ods.StringCell(item.SourcingType), } if item.SourcingLink != nil { cells = append(cells, ods.StringCell(*item.SourcingLink)) } else { cells = append(cells, ods.EmptyCell()) } if item.LongDescription != nil { cells = append(cells, ods.StringCell(*item.LongDescription)) } else { cells = append(cells, ods.EmptyCell()) } if item.StandardCost != nil { cells = append(cells, ods.CurrencyCell(*item.StandardCost)) } else { cells = append(cells, ods.EmptyCell()) } // Property columns if includeProps { props := itemProps[item.PartNumber] for _, key := range sortedPropKeys { if props != nil { if val, ok := props[key]; ok { cells = append(cells, ods.StringCell(formatPropertyValue(val))) continue } } cells = append(cells, ods.EmptyCell()) } } rows = append(rows, ods.Row{Cells: cells}) } wb := &ods.Workbook{ Meta: map[string]string{ "type": "items", "exported_at": time.Now().UTC().Format(time.RFC3339), }, Sheets: []ods.Sheet{ {Name: "Items", Rows: rows}, }, } data, err := ods.Write(wb) if err != nil { s.logger.Error().Err(err).Msg("failed to write ODS") writeError(w, http.StatusInternalServerError, "export_failed", err.Error()) return } filename := fmt.Sprintf("silo-export-%s.ods", time.Now().Format("2006-01-02")) w.Header().Set("Content-Type", "application/vnd.oasis.opendocument.spreadsheet") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) w.Header().Set("Content-Length", strconv.Itoa(len(data))) w.Write(data) s.logger.Info().Int("count", len(items)).Msg("exported items to ODS") } // HandleODSTemplate returns an ODS import template. func (s *Server) HandleODSTemplate(w http.ResponseWriter, r *http.Request) { schemaName := r.URL.Query().Get("schema") if schemaName == "" { schemaName = "kindred-rd" } sch, ok := s.schemas[schemaName] if !ok { writeError(w, http.StatusNotFound, "not_found", "Schema not found") return } headers := []string{"category", "description", "projects"} if sch.PropertySchemas != nil && sch.PropertySchemas.Defaults != nil { propNames := make([]string, 0, len(sch.PropertySchemas.Defaults)) for name := range sch.PropertySchemas.Defaults { propNames = append(propNames, name) } sort.Strings(propNames) headers = append(headers, propNames...) } headerCells := make([]ods.Cell, len(headers)) for i, h := range headers { headerCells[i] = ods.HeaderCell(h) } exampleCells := make([]ods.Cell, len(headers)) exampleCells[0] = ods.StringCell("F01") exampleCells[1] = ods.StringCell("Example Item Description") exampleCells[2] = ods.StringCell("PROJ1,PROJ2") wb := &ods.Workbook{ Meta: map[string]string{"type": "template", "schema": schemaName}, Sheets: []ods.Sheet{ { Name: "Import", Rows: []ods.Row{ {Cells: headerCells}, {Cells: exampleCells}, }, }, }, } data, err := ods.Write(wb) if err != nil { s.logger.Error().Err(err).Msg("failed to write ODS template") writeError(w, http.StatusInternalServerError, "export_failed", err.Error()) return } filename := fmt.Sprintf("silo-import-template-%s.ods", schemaName) w.Header().Set("Content-Type", "application/vnd.oasis.opendocument.spreadsheet") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) w.Header().Set("Content-Length", strconv.Itoa(len(data))) w.Write(data) } // HandleImportODS imports items from an ODS file. func (s *Server) HandleImportODS(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseMultipartForm(10 << 20); err != nil { writeError(w, http.StatusBadRequest, "invalid_form", err.Error()) return } file, _, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "missing_file", "ODS file is required") return } defer file.Close() data, err := io.ReadAll(file) if err != nil { writeError(w, http.StatusBadRequest, "read_failed", err.Error()) return } dryRun := r.FormValue("dry_run") == "true" skipExisting := r.FormValue("skip_existing") == "true" schemaName := r.FormValue("schema") if schemaName == "" { schemaName = "kindred-rd" } wb, err := ods.Read(data) if err != nil { writeError(w, http.StatusBadRequest, "invalid_ods", fmt.Sprintf("Failed to parse ODS: %v", err)) return } if len(wb.Sheets) == 0 || len(wb.Sheets[0].Rows) < 2 { writeError(w, http.StatusBadRequest, "invalid_ods", "ODS must have at least a header row and one data row") return } sheet := wb.Sheets[0] headerRow := sheet.Rows[0] // Build column index colIndex := make(map[string]int) for i, cell := range headerRow.Cells { colIndex[strings.ToLower(strings.TrimSpace(cell.Value))] = i } if _, ok := colIndex["category"]; !ok { writeError(w, http.StatusBadRequest, "missing_column", "Required column 'category' not found") return } result := CSVImportResult{ Errors: make([]CSVImportErr, 0), CreatedItems: make([]string, 0), } for rowIdx := 1; rowIdx < len(sheet.Rows); rowIdx++ { row := sheet.Rows[rowIdx] if row.IsBlank { continue } result.TotalRows++ rowNum := rowIdx + 1 getCellValue := func(col string) string { if idx, ok := colIndex[col]; ok && idx < len(row.Cells) { return strings.TrimSpace(row.Cells[idx].Value) } return "" } category := getCellValue("category") description := getCellValue("description") partNumber := getCellValue("part_number") projectsStr := getCellValue("projects") var projectCodes []string if projectsStr != "" { for _, code := range strings.Split(projectsStr, ",") { code = strings.TrimSpace(strings.ToUpper(code)) if code != "" { projectCodes = append(projectCodes, code) } } } if category == "" { result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Field: "category", Message: "Category code is required", }) result.ErrorCount++ continue } // Build properties from extra columns properties := make(map[string]any) properties["category"] = strings.ToUpper(category) for col, idx := range colIndex { if isStandardColumn(col) { continue } if idx < len(row.Cells) && row.Cells[idx].Value != "" { properties[col] = parsePropertyValue(row.Cells[idx].Value) } } if partNumber != "" { existing, _ := s.items.GetByPartNumber(ctx, partNumber) if existing != nil { if skipExisting { continue } result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Field: "part_number", Message: fmt.Sprintf("Part number '%s' already exists", partNumber), }) result.ErrorCount++ continue } } if dryRun { result.SuccessCount++ continue } if partNumber == "" { input := partnum.Input{ SchemaName: schemaName, Values: map[string]string{"category": strings.ToUpper(category)}, } partNumber, err = s.partgen.Generate(ctx, input) if err != nil { result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Message: fmt.Sprintf("Failed to generate part number: %v", err), }) result.ErrorCount++ continue } } itemType := "part" if len(category) > 0 { switch category[0] { case 'A', 'a': itemType = "assembly" case 'T', 't': itemType = "tooling" } } // Parse extended fields sourcingType := getCellValue("sourcing_type") sourcingLink := getCellValue("sourcing_link") longDesc := getCellValue("long_description") stdCostStr := getCellValue("standard_cost") item := &db.Item{ PartNumber: partNumber, ItemType: itemType, Description: description, } if user := auth.UserFromContext(ctx); user != nil { item.CreatedBy = &user.Username } if sourcingType != "" { item.SourcingType = sourcingType } if sourcingLink != "" { item.SourcingLink = &sourcingLink } if longDesc != "" { item.LongDescription = &longDesc } if stdCostStr != "" { if cost, err := strconv.ParseFloat(strings.TrimLeft(stdCostStr, "$"), 64); err == nil { item.StandardCost = &cost } } if err := s.items.Create(ctx, item, properties); err != nil { result.Errors = append(result.Errors, CSVImportErr{ Row: rowNum, Message: fmt.Sprintf("Failed to create item: %v", err), }) result.ErrorCount++ continue } if len(projectCodes) > 0 { if err := s.projects.SetItemProjects(ctx, item.ID, projectCodes); err != nil { s.logger.Warn().Err(err).Str("part_number", partNumber).Msg("failed to tag item with projects") } } result.SuccessCount++ result.CreatedItems = append(result.CreatedItems, partNumber) } s.logger.Info(). Int("total", result.TotalRows). Int("success", result.SuccessCount). Int("errors", result.ErrorCount). Bool("dry_run", dryRun). Msg("ODS import completed") writeJSON(w, http.StatusOK, result) if !dryRun && result.SuccessCount > 0 { s.broker.Publish("item.created", mustMarshal(map[string]any{ "bulk": true, "count": result.SuccessCount, "items": result.CreatedItems, })) } } // HandleExportBOMODS exports the expanded BOM as a formatted ODS file. func (s *Server) HandleExportBOMODS(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 } // Fetch item properties for property columns itemPropsCache := make(map[string]map[string]any) // partNumber -> properties allPNs := []string{item.PartNumber} for _, e := range entries { allPNs = append(allPNs, e.ChildPartNumber) } for _, pn := range allPNs { dbItem, err := s.items.GetByPartNumber(ctx, pn) if err != nil || dbItem == nil { continue } revisions, err := s.items.GetRevisions(ctx, dbItem.ID) if err != nil { continue } for _, rev := range revisions { if rev.RevisionNumber == dbItem.CurrentRevision && rev.Properties != nil { itemPropsCache[pn] = rev.Properties break } } } // Determine schema name schemaName := "RD" for name := range s.schemas { if name == "kindred-rd" { schemaName = "RD" break } } // Build columns: visible + hidden properties + hidden sync allHeaders := make([]string, 0, len(bomODSHeaders)+len(bomODSPropertyHeaders)) allHeaders = append(allHeaders, bomODSHeaders...) allHeaders = append(allHeaders, bomODSPropertyHeaders...) columns := make([]ods.Column, len(allHeaders)) // Visible columns visibleWidths := []string{"3cm", "1.5cm", "1.5cm", "2.5cm", "5cm", "5cm", "2.5cm", "1.5cm", "2.5cm", "5cm", "1.5cm"} for i := 0; i < len(bomODSHeaders) && i < len(visibleWidths); i++ { columns[i] = ods.Column{Width: visibleWidths[i]} } // Hidden property columns for i := len(bomODSHeaders); i < len(allHeaders); i++ { columns[i] = ods.Column{Hidden: true} } // Header row headerCells := make([]ods.Cell, len(allHeaders)) for i, h := range allHeaders { headerCells[i] = ods.HeaderCell(h) } var rows []ods.Row rows = append(rows, ods.Row{Cells: headerCells}) // Top-level assembly row topCost := s.calculateBOMCost(entries) topRow := buildBOMRow(item.Description, 0, "M", item.PartNumber, item, nil, topCost, 1, schemaName, itemPropsCache[item.PartNumber]) rows = append(rows, topRow) // Group entries by their immediate parent to create sections // Track which depth-1 entries are sub-assemblies (have children) lastParentPNAtDepth1 := "" for i, e := range entries { // Section header: if this is a depth-1 entry, it's a direct child if e.Depth == 1 { if lastParentPNAtDepth1 != "" { // Blank separator between sections rows = append(rows, ods.Row{IsBlank: true}) } lastParentPNAtDepth1 = e.ChildPartNumber } // Get the child item for extended fields childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber) unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost") if !hasUnitCost && childItem != nil && childItem.StandardCost != nil { unitCost = *childItem.StandardCost hasUnitCost = true } qty := 0.0 if e.Quantity != nil { qty = *e.Quantity } // Use item name for depth-1 entries as section label itemLabel := "" if e.Depth == 1 { itemLabel = e.ChildDescription if itemLabel == "" && childItem != nil { itemLabel = childItem.Description } } source := e.Source if source == "" && childItem != nil { st := childItem.SourcingType if st == "manufactured" { source = "M" } else if st == "purchased" { source = "P" } } row := buildBOMRow(itemLabel, e.Depth, source, e.ChildPartNumber, childItem, e.Metadata, unitCost, qty, schemaName, itemPropsCache[e.ChildPartNumber]) if !hasUnitCost { // Clear Unit Cost cell if we don't have one row.Cells[6] = ods.EmptyCell() } // Ext Cost formula (row index is len(rows)+1 since ODS is 1-indexed) rowNum := len(rows) + 1 row.Cells[8] = ods.FormulaCell(fmt.Sprintf("of:=[.G%d]*[.H%d]", rowNum, rowNum)) rows = append(rows, row) // Check if next entry goes back to depth 1 or we're at the end -- add separator isLast := i == len(entries)-1 nextIsNewSection := !isLast && entries[i+1].Depth == 1 if isLast || nextIsNewSection { // Separator already handled at the start of depth-1 } } meta := map[string]string{ "type": "bom", "parent_pn": item.PartNumber, "schema": schemaName, "exported_at": time.Now().UTC().Format(time.RFC3339), } // Add project tag if item belongs to a project projects, err := s.projects.GetProjectsForItem(ctx, item.ID) if err == nil && len(projects) > 0 { meta["project"] = projects[0].Code } wb := &ods.Workbook{ Meta: meta, Sheets: []ods.Sheet{ {Name: "BOM", Columns: columns, Rows: rows}, }, } odsData, err := ods.Write(wb) if err != nil { s.logger.Error().Err(err).Msg("failed to write BOM ODS") writeError(w, http.StatusInternalServerError, "export_failed", err.Error()) return } filename := fmt.Sprintf("%s-bom.ods", partNumber) w.Header().Set("Content-Type", "application/vnd.oasis.opendocument.spreadsheet") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) w.Header().Set("Content-Length", strconv.Itoa(len(odsData))) w.Write(odsData) } // HandleProjectSheetODS exports a multi-sheet project workbook. func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) { ctx := r.Context() code := chi.URLParam(r, "code") project, err := s.projects.GetByCode(ctx, code) if err != nil || project == nil { writeError(w, http.StatusNotFound, "not_found", "Project not found") return } items, err := s.projects.GetItemsForProject(ctx, project.ID) if err != nil { s.logger.Error().Err(err).Msg("failed to get project items") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get items") return } // Sheet 1: Items list itemHeaders := []string{ "PN", "Type", "Description", "Revision", "Category", "Source", "Sourcing Link", "Unit Cost", "Long Description", } itemHeaderCells := make([]ods.Cell, len(itemHeaders)) for i, h := range itemHeaders { itemHeaderCells[i] = ods.HeaderCell(h) } var itemRows []ods.Row itemRows = append(itemRows, ods.Row{Cells: itemHeaderCells}) for _, item := range items { cells := []ods.Cell{ ods.StringCell(item.PartNumber), ods.StringCell(item.ItemType), ods.StringCell(item.Description), ods.IntCell(item.CurrentRevision), ods.StringCell(parseCategory(item.PartNumber)), ods.StringCell(item.SourcingType), } if item.SourcingLink != nil { cells = append(cells, ods.StringCell(*item.SourcingLink)) } else { cells = append(cells, ods.EmptyCell()) } if item.StandardCost != nil { cells = append(cells, ods.CurrencyCell(*item.StandardCost)) } else { cells = append(cells, ods.EmptyCell()) } if item.LongDescription != nil { cells = append(cells, ods.StringCell(*item.LongDescription)) } else { cells = append(cells, ods.EmptyCell()) } itemRows = append(itemRows, ods.Row{Cells: cells}) } sheets := []ods.Sheet{ {Name: "Items", Rows: itemRows}, } // Find top-level assembly for BOM sheet (look for assemblies in the project) for _, item := range items { if item.ItemType == "assembly" { bomEntries, err := s.relationships.GetExpandedBOM(ctx, item.ID, 10) if err != nil || len(bomEntries) == 0 { continue } // Build a simple BOM sheet for this assembly bomHeaderCells := make([]ods.Cell, len(bomODSHeaders)) for i, h := range bomODSHeaders { bomHeaderCells[i] = ods.HeaderCell(h) } var bomRows []ods.Row bomRows = append(bomRows, ods.Row{Cells: bomHeaderCells}) for _, e := range bomEntries { childItem, _ := s.items.GetByPartNumber(ctx, e.ChildPartNumber) unitCost, hasUnitCost := getMetaFloat(e.Metadata, "unit_cost") if !hasUnitCost && childItem != nil && childItem.StandardCost != nil { unitCost = *childItem.StandardCost hasUnitCost = true } qty := 0.0 if e.Quantity != nil { qty = *e.Quantity } source := e.Source if source == "" && childItem != nil { if childItem.SourcingType == "manufactured" { source = "M" } else if childItem.SourcingType == "purchased" { source = "P" } } itemLabel := "" if e.Depth == 1 { if childItem != nil { itemLabel = childItem.Description } } cells := []ods.Cell{ ods.StringCell(itemLabel), ods.IntCell(e.Depth), ods.StringCell(source), ods.StringCell(e.ChildPartNumber), ods.StringCell(e.ChildDescription), ods.StringCell(getMetaString(e.Metadata, "seller_description")), } if hasUnitCost { cells = append(cells, ods.CurrencyCell(unitCost)) } else { cells = append(cells, ods.EmptyCell()) } if qty > 0 { cells = append(cells, ods.FloatCell(qty)) } else { cells = append(cells, ods.EmptyCell()) } // Ext Cost formula rowNum := len(bomRows) + 1 cells = append(cells, ods.FormulaCell(fmt.Sprintf("of:=[.G%d]*[.H%d]", rowNum, rowNum))) cells = append(cells, ods.StringCell(getMetaString(e.Metadata, "sourcing_link"))) cells = append(cells, ods.StringCell("RD")) bomRows = append(bomRows, ods.Row{Cells: cells}) } sheets = append([]ods.Sheet{ {Name: fmt.Sprintf("BOM-%s", item.PartNumber), Rows: bomRows}, }, sheets...) break // Only include first assembly BOM } } meta := map[string]string{ "type": "project", "project": code, "exported_at": time.Now().UTC().Format(time.RFC3339), } wb := &ods.Workbook{Meta: meta, Sheets: sheets} odsData, err := ods.Write(wb) if err != nil { s.logger.Error().Err(err).Msg("failed to write project ODS") writeError(w, http.StatusInternalServerError, "export_failed", err.Error()) return } filename := fmt.Sprintf("%s.ods", code) w.Header().Set("Content-Type", "application/vnd.oasis.opendocument.spreadsheet") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) w.Header().Set("Content-Length", strconv.Itoa(len(odsData))) w.Write(odsData) } // SheetDiffResponse represents the result of diffing an ODS against the database. type SheetDiffResponse struct { SheetType string `json:"sheet_type"` ParentPN string `json:"parent_part_number,omitempty"` Project string `json:"project,omitempty"` NewRows []SheetDiffRow `json:"new_rows"` ModifiedRows []SheetDiffRow `json:"modified_rows"` Conflicts []SheetDiffRow `json:"conflicts"` UnchangedCount int `json:"unchanged_count"` } // SheetDiffRow represents a single row in the diff. type SheetDiffRow struct { Row int `json:"row"` PartNumber string `json:"part_number,omitempty"` Category string `json:"category,omitempty"` Description string `json:"description,omitempty"` Changes map[string]any `json:"changes,omitempty"` } // HandleSheetDiff accepts an ODS upload and diffs it against the database. func (s *Server) HandleSheetDiff(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseMultipartForm(10 << 20); err != nil { writeError(w, http.StatusBadRequest, "invalid_form", err.Error()) return } file, _, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "missing_file", "ODS file is required") return } defer file.Close() data, err := io.ReadAll(file) if err != nil { writeError(w, http.StatusBadRequest, "read_failed", err.Error()) return } wb, err := ods.Read(data) if err != nil { writeError(w, http.StatusBadRequest, "invalid_ods", fmt.Sprintf("Failed to parse ODS: %v", err)) return } if len(wb.Sheets) == 0 || len(wb.Sheets[0].Rows) < 2 { writeError(w, http.StatusBadRequest, "invalid_ods", "No data found") return } sheet := wb.Sheets[0] headerRow := sheet.Rows[0] // Build column index from headers colIndex := make(map[string]int) for i, cell := range headerRow.Cells { colIndex[strings.ToLower(strings.TrimSpace(cell.Value))] = i } // Detect sheet type sheetType := "items" if _, ok := colIndex["level"]; ok { sheetType = "bom" } resp := SheetDiffResponse{ SheetType: sheetType, ParentPN: wb.Meta["parent_pn"], Project: wb.Meta["project"], NewRows: make([]SheetDiffRow, 0), ModifiedRows: make([]SheetDiffRow, 0), Conflicts: make([]SheetDiffRow, 0), } pnCol := "pn" if _, ok := colIndex["part_number"]; ok { pnCol = "part_number" } for rowIdx := 1; rowIdx < len(sheet.Rows); rowIdx++ { row := sheet.Rows[rowIdx] if row.IsBlank { continue } getCellValue := func(col string) string { if idx, ok := colIndex[col]; ok && idx < len(row.Cells) { return strings.TrimSpace(row.Cells[idx].Value) } return "" } pn := getCellValue(pnCol) if pn == "" { // New row desc := getCellValue("description") cat := getCellValue("category") if desc != "" || cat != "" { resp.NewRows = append(resp.NewRows, SheetDiffRow{ Row: rowIdx + 1, Category: cat, Description: desc, }) } continue } // Existing item -- compare dbItem, err := s.items.GetByPartNumber(ctx, pn) if err != nil || dbItem == nil { resp.NewRows = append(resp.NewRows, SheetDiffRow{ Row: rowIdx + 1, PartNumber: pn, Description: getCellValue("description"), }) continue } changes := make(map[string]any) desc := getCellValue("description") if desc != "" && desc != dbItem.Description { changes["description"] = desc } costStr := getCellValue("unit cost") if costStr == "" { costStr = getCellValue("standard_cost") } if costStr != "" { costStr = strings.TrimLeft(costStr, "$") if cost, err := strconv.ParseFloat(costStr, 64); err == nil { if dbItem.StandardCost == nil || *dbItem.StandardCost != cost { changes["standard_cost"] = cost } } } if len(changes) > 0 { resp.ModifiedRows = append(resp.ModifiedRows, SheetDiffRow{ Row: rowIdx + 1, PartNumber: pn, Changes: changes, }) } else { resp.UnchangedCount++ } } writeJSON(w, http.StatusOK, resp) } // buildBOMRow creates an ODS row for a BOM entry with all columns (visible + hidden properties). func buildBOMRow(itemLabel string, depth int, source, pn string, item *db.Item, metadata map[string]any, unitCost, qty float64, schemaName string, props map[string]any) ods.Row { description := "" sellerDesc := getMetaString(metadata, "seller_description") sourcingLink := getMetaString(metadata, "sourcing_link") if item != nil { description = item.Description if sourcingLink == "" && item.SourcingLink != nil { sourcingLink = *item.SourcingLink } } cells := []ods.Cell{ ods.StringCell(itemLabel), // Item ods.IntCell(depth), // Level ods.StringCell(source), // Source ods.StringCell(pn), // PN ods.StringCell(description), // Description ods.StringCell(sellerDesc), // Seller Description ods.CurrencyCell(unitCost), // Unit Cost ods.FloatCell(qty), // QTY ods.EmptyCell(), // Ext Cost (formula set by caller) ods.StringCell(sourcingLink), // Sourcing Link ods.StringCell(schemaName), // Schema } // Hidden property columns for _, header := range bomODSPropertyHeaders { key := propertyKeyMap[header] value := "" // Check item fields first for specific keys if item != nil { switch key { case "long_description": if item.LongDescription != nil { value = *item.LongDescription } } } // Then check revision properties if value == "" && props != nil { if v, ok := props[key]; ok { value = formatPropertyValue(v) } } // Then check BOM metadata if value == "" && metadata != nil { if v, ok := metadata[key]; ok { value = formatPropertyValue(v) } } cells = append(cells, ods.StringCell(value)) } return ods.Row{Cells: cells} } // calculateBOMCost sums up unit_cost * quantity for all direct children (depth 1). func (s *Server) calculateBOMCost(entries []*db.BOMTreeEntry) float64 { total := 0.0 for _, e := range entries { if e.Depth != 1 { continue } unitCost, ok := getMetaFloat(e.Metadata, "unit_cost") if !ok { continue } qty := 1.0 if e.Quantity != nil { qty = *e.Quantity } total += unitCost * qty } return total }