Files
silo/internal/api/ods.go
Forbes 163dc9f0f0 feat(db): add source column to relationships table (#44)
Promote BOM source from metadata JSONB to a dedicated VARCHAR(20)
column with CHECK constraint ('manual' or 'assembly').

- Add migration 012_bom_source.sql (column, data migration, cleanup)
- Add Source field to Relationship and BOMEntry structs
- Update all SQL queries (GetBOM, GetWhereUsed, GetExpandedBOM, Create)
- Update API response/request types with source field
- Update CSV/ODS export to read e.Source instead of metadata
- Update CSV import to set source on relationship directly
- Update frontend types and BOMTab to use top-level source field
2026-02-08 18:45:41 -06:00

1062 lines
29 KiB
Go

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
}