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
1062 lines
29 KiB
Go
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
|
|
}
|