import { allCableTypes } from './project' import type { CableType, Catalog, Connection, Device, Project } from './types' /** One length within a cable type's group — e.g. "3 × 10ft". `distance` * is undefined for the bucket of connections that have no length entered * (kept separate rather than folded into a 0ft bucket, same reasoning as * connectionsMissingLength on the parent line). */ export interface BomCableLengthLine { distance: number | undefined count: number totalLength: number totalCost: number connectionsMissingCost: number } export interface BomCableLine { cableTypeId: string cableTypeName: string unit: string count: number /** Sum of user-entered distances; connections with no distance are excluded. */ totalLength: number connectionsMissingLength: number /** Sum of each connection's cost (its own override, or `costPerUnit * * distance` when both are known); connections with no computable cost are * excluded rather than counted as zero. */ totalCost: number connectionsMissingCost: number /** This type's connections grouped by length, most common use case for * "how many N-foot cables do I need to buy" — see organized-ideas.md §7. * Sorted shortest first, with the no-length-entered bucket (if any) last. */ lengths: BomCableLengthLine[] } export interface BomDeviceLine { key: string name: string category: string manufacturer?: string model?: string count: number /** Sum of `device.cost` across instances; devices with no cost set are * excluded rather than counted as zero. */ totalCost: number devicesMissingCost: number } export interface Bom { cables: BomCableLine[] devices: BomDeviceLine[] /** Sum of all cable line totals (missing-cost connections already excluded). */ totalCableCost: number /** Sum of all device line totals (missing-cost devices already excluded). */ totalDeviceCost: number /** `totalCableCost + totalDeviceCost`. Missing costs are excluded, not * treated as zero, so this is a lower bound whenever anything is flagged * as missing below. */ grandTotalCost: number } function deviceGroupKey(device: Device): string { return device.templateId ?? `${device.category}:${device.manufacturer ?? ''}:${device.model ?? device.name}` } /** A connection's cost for BOM purposes: its own override if set, else * `costPerUnit * distance` if both the cable type's rate and the * connection's distance are known. Undefined means "not computable" — * callers must exclude it from totals rather than treating it as zero. */ function connectionCost(connection: Connection, cableType: CableType | undefined): number | undefined { if (typeof connection.cost === 'number' && !Number.isNaN(connection.cost)) { return connection.cost } if ( cableType && typeof cableType.costPerUnit === 'number' && !Number.isNaN(cableType.costPerUnit) && typeof connection.distance === 'number' && !Number.isNaN(connection.distance) ) { return cableType.costPerUnit * connection.distance } return undefined } export function computeBom(project: Project, catalog: Catalog): Bom { const cableTypes = new Map(allCableTypes(catalog).map((ct) => [ct.id, ct])) const cableLines = new Map() // Nested by cableTypeId, then by distance (undefined key for "no length // entered") — a separate map from cableLines since a Map key can't itself // be a nested Map, and it's convenient to keep the length-line objects // addressable by (cableTypeId, distance) while building them up. const lengthLines = new Map>() for (const connection of project.connections) { const cableType = cableTypes.get(connection.cableTypeId) const name = cableType?.name ?? 'Unknown Cable' const unit = cableType?.unit ?? 'ft' const existing = cableLines.get(connection.cableTypeId) ?? { cableTypeId: connection.cableTypeId, cableTypeName: name, unit, count: 0, totalLength: 0, connectionsMissingLength: 0, totalCost: 0, connectionsMissingCost: 0, lengths: [], } existing.count += 1 const distance = typeof connection.distance === 'number' && !Number.isNaN(connection.distance) ? connection.distance : undefined if (distance !== undefined) { existing.totalLength += distance } else { existing.connectionsMissingLength += 1 } const cost = connectionCost(connection, cableType) if (cost !== undefined) { existing.totalCost += cost } else { existing.connectionsMissingCost += 1 } cableLines.set(connection.cableTypeId, existing) const byLength = lengthLines.get(connection.cableTypeId) ?? new Map() const lengthLine = byLength.get(distance) ?? { distance, count: 0, totalLength: 0, totalCost: 0, connectionsMissingCost: 0, } lengthLine.count += 1 if (distance !== undefined) lengthLine.totalLength += distance if (cost !== undefined) { lengthLine.totalCost += cost } else { lengthLine.connectionsMissingCost += 1 } byLength.set(distance, lengthLine) lengthLines.set(connection.cableTypeId, byLength) } for (const [cableTypeId, line] of cableLines) { const byLength = [...(lengthLines.get(cableTypeId)?.values() ?? [])] // Shortest first; the no-length-entered bucket (distance undefined) // always sorts last rather than mixing in as "smallest". byLength.sort((a, b) => { if (a.distance === undefined) return 1 if (b.distance === undefined) return -1 return a.distance - b.distance }) line.lengths = byLength } const deviceLines = new Map() for (const device of project.devices) { const key = deviceGroupKey(device) const existing = deviceLines.get(key) ?? { key, name: device.name, category: device.category, manufacturer: device.manufacturer, model: device.model, count: 0, totalCost: 0, devicesMissingCost: 0, } existing.count += 1 if (typeof device.cost === 'number' && !Number.isNaN(device.cost)) { existing.totalCost += device.cost } else { existing.devicesMissingCost += 1 } deviceLines.set(key, existing) } const cables = [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName)) const devices = [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name)) const totalCableCost = cables.reduce((sum, line) => sum + line.totalCost, 0) const totalDeviceCost = devices.reduce((sum, line) => sum + line.totalCost, 0) return { cables, devices, totalCableCost, totalDeviceCost, grandTotalCost: totalCableCost + totalDeviceCost, } }