diff --git a/internal/api/audit_handlers.go b/internal/api/audit_handlers.go index 9ca9c9f..825a755 100644 --- a/internal/api/audit_handlers.go +++ b/internal/api/audit_handlers.go @@ -101,6 +101,8 @@ var manufacturedWeights = map[string]float64{ // Weight 1: engineering detail (category-specific default) "sourcing_type": 1, "lifecycle_status": 1, + // Weight 1: engineering detail + "has_files": 1, // Weight 0.5: less relevant for in-house "manufacturer": 0.5, "supplier": 0.5, @@ -207,6 +209,7 @@ func scoreItem( categoryProps map[string]schema.PropertyDefinition, hasBOM bool, bomChildCount int, + hasFiles bool, categoryName string, projects []string, includeFields bool, @@ -276,6 +279,7 @@ func scoreItem( // Score has_bom for manufactured/assembly items. if sourcingType == "manufactured" || isAssembly { processField("has_bom", "computed", "boolean", hasBOM) + processField("has_files", "computed", "boolean", hasFiles) } // Score property fields from schema. @@ -412,6 +416,13 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request) return } + fileStats, err := s.items.BatchGetFileStats(ctx, itemIDs) + if err != nil { + s.logger.Error().Err(err).Msg("failed to batch get file stats") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats") + return + } + // Look up the schema for category resolution. sch := s.schemas["kindred-rd"] var catSegment *schema.Segment @@ -440,9 +451,10 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request) bomCount := bomCounts[item.ID] hasBOM := bomCount > 0 + hasFiles := fileStats[item.ID].Count > 0 projects := projectCodes[item.ID] - result := scoreItem(item, categoryProps, hasBOM, bomCount, categoryName, projects, false) + result := scoreItem(item, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, false) allResults = append(allResults, *result) } @@ -544,6 +556,15 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) { } projects := projectCodes[item.ID] + // Get file stats. + fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}) + if err != nil { + s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get file stats for audit") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats") + return + } + hasFiles := fileStats[item.ID].Count > 0 + // Category resolution. cat := extractCategory(item.PartNumber) categoryName := cat @@ -561,7 +582,7 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) { categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat) } - result := scoreItem(iwp, categoryProps, hasBOM, bomCount, categoryName, projects, true) + result := scoreItem(iwp, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, true) writeJSON(w, http.StatusOK, result) } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 1c6a6d5..fb8c3de 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -260,6 +260,8 @@ type ItemResponse struct { LongDescription *string `json:"long_description,omitempty"` StandardCost *float64 `json:"standard_cost,omitempty"` ThumbnailKey *string `json:"thumbnail_key,omitempty"` + FileCount int `json:"file_count"` + FilesTotalSize int64 `json:"files_total_size"` Properties map[string]any `json:"properties,omitempty"` } @@ -304,9 +306,20 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) { return } + // Batch-fetch file attachment stats + ids := make([]string, len(items)) + for i, item := range items { + ids[i] = item.ID + } + fileStats, _ := s.items.BatchGetFileStats(ctx, ids) + response := make([]ItemResponse, len(items)) for i, item := range items { response[i] = itemToResponse(item) + if fs, ok := fileStats[item.ID]; ok { + response[i].FileCount = fs.Count + response[i].FilesTotalSize = fs.TotalSize + } } writeJSON(w, http.StatusOK, response) @@ -482,7 +495,15 @@ func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, itemToResponse(item)) + response := itemToResponse(item) + if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil { + if fs, ok := fileStats[item.ID]; ok { + response.FileCount = fs.Count + response.FilesTotalSize = fs.TotalSize + } + } + + writeJSON(w, http.StatusOK, response) } // HandleGetItem retrieves an item by part number. @@ -504,6 +525,14 @@ func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) { response := itemToResponse(item) + // File attachment stats + if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil { + if fs, ok := fileStats[item.ID]; ok { + response.FileCount = fs.Count + response.FilesTotalSize = fs.TotalSize + } + } + // Include properties from current revision if requested if r.URL.Query().Get("include") == "properties" { revisions, err := s.items.GetRevisions(ctx, item.ID) diff --git a/internal/api/sse_handler.go b/internal/api/sse_handler.go index 64da978..3734e85 100644 --- a/internal/api/sse_handler.go +++ b/internal/api/sse_handler.go @@ -16,9 +16,12 @@ func (s *Server) HandleEvents(w http.ResponseWriter, r *http.Request) { return } - // Disable the write deadline for this long-lived connection. - // The server's WriteTimeout (15s) would otherwise kill it. + // Disable read and write deadlines for this long-lived connection. + // The server's ReadTimeout/WriteTimeout (15s) would otherwise kill it. rc := http.NewResponseController(w) + if err := rc.SetReadDeadline(time.Time{}); err != nil { + s.logger.Warn().Err(err).Msg("failed to disable read deadline for SSE") + } if err := rc.SetWriteDeadline(time.Time{}); err != nil { s.logger.Warn().Err(err).Msg("failed to disable write deadline for SSE") } diff --git a/internal/db/audit_queries.go b/internal/db/audit_queries.go index 2e57aa2..211bd55 100644 --- a/internal/db/audit_queries.go +++ b/internal/db/audit_queries.go @@ -134,6 +134,43 @@ func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (m return result, nil } +// FileStats holds aggregated file attachment statistics for an item. +type FileStats struct { + Count int + TotalSize int64 +} + +// BatchGetFileStats returns a map of item ID to file attachment statistics +// for the given item IDs. Items not in the map have no files. +func (r *ItemRepository) BatchGetFileStats(ctx context.Context, itemIDs []string) (map[string]FileStats, error) { + if len(itemIDs) == 0 { + return map[string]FileStats{}, nil + } + + rows, err := r.db.pool.Query(ctx, ` + SELECT item_id, COUNT(*), COALESCE(SUM(size), 0) + FROM item_files + WHERE item_id = ANY($1) + GROUP BY item_id + `, itemIDs) + if err != nil { + return nil, fmt.Errorf("batch getting file stats: %w", err) + } + defer rows.Close() + + result := make(map[string]FileStats) + for rows.Next() { + var itemID string + var fs FileStats + if err := rows.Scan(&itemID, &fs.Count, &fs.TotalSize); err != nil { + return nil, fmt.Errorf("scanning file stats: %w", err) + } + result[itemID] = fs + } + + return result, nil +} + // BatchGetProjectCodes returns a map of item ID to project code list for // the given item IDs. func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0df3662..e6dc1f0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -19,6 +19,8 @@ export interface Item { sourcing_link?: string; long_description?: string; standard_cost?: number; + file_count: number; + files_total_size: number; properties?: Record; } @@ -210,6 +212,38 @@ export interface UpdateBOMEntryRequest { metadata?: Record; } +// BOM Merge +export interface MergeBOMResponse { + status: string; + diff: MergeBOMDiff; + warnings: MergeWarning[]; + resolve_url: string; +} + +export interface MergeBOMDiff { + added: MergeDiffEntry[]; + removed: MergeDiffEntry[]; + quantity_changed: MergeQtyChange[]; + unchanged: MergeDiffEntry[]; +} + +export interface MergeDiffEntry { + part_number: string; + quantity: number | null; +} + +export interface MergeQtyChange { + part_number: string; + old_quantity: number | null; + new_quantity: number | null; +} + +export interface MergeWarning { + type: string; + part_number: string; + message: string; +} + // Schema properties export interface PropertyDef { type: string; diff --git a/web/src/components/items/BOMTab.tsx b/web/src/components/items/BOMTab.tsx index 3cffebf..b9f6352 100644 --- a/web/src/components/items/BOMTab.tsx +++ b/web/src/components/items/BOMTab.tsx @@ -46,6 +46,7 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) { const unitCost = (e: BOMEntry) => Number(meta(e).unit_cost) || 0; const extCost = (e: BOMEntry) => unitCost(e) * (e.quantity ?? 0); const totalCost = entries.reduce((sum, e) => sum + extCost(e), 0); + const assemblyCount = entries.filter((e) => e.source === "assembly").length; const formToRequest = () => ({ child_part_number: form.child_part_number, @@ -139,12 +140,15 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) { /> - setForm({ ...form, source: e.target.value })} - placeholder="Source" style={inputStyle} - /> + > + + + + + {isEditor && assemblyCount > 0 && ( +
+ {assemblyCount} assembly-sourced{" "} + {assemblyCount === 1 ? "entry" : "entries"}. Entries removed from the + FreeCAD assembly will remain here until manually deleted. +
+ )} +
{e.child_part_number} - + + ); case "projects": return (
{e.source ?? ""} + {e.source === "assembly" ? ( + assembly + ) : e.source === "manual" ? ( + manual + ) : ( + "—" + )} + ); + case "files": + return ( + 0 + ? `${item.file_count} file${item.file_count !== 1 ? "s" : ""}, ${formatSize(item.files_total_size)}` + : "No files" + } + > + {item.file_count > 0 ? item.file_count : "—"} +