Add BOM cost tracking

Suggested-default cost on CableType/DeviceTemplate, copied onto
placed Device instances (same pattern as ports) and overridable
per-connection/per-device in the diagram. BOM view shows per-line
and grand-total cost, excluding (and flagging) anything without a
computable cost rather than treating it as zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-04 15:55:57 -05:00
co-authored by Claude Sonnet 5
parent 7cf786e107
commit e8654a39d6
9 changed files with 242 additions and 11 deletions
+63 -3
View File
@@ -1,5 +1,5 @@
import { allCableTypes } from './project'
import type { Device, Project } from './types'
import type { CableType, Connection, Device, Project } from './types'
export interface BomCableLine {
cableTypeId: string
@@ -9,6 +9,11 @@ export interface BomCableLine {
/** 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
}
export interface BomDeviceLine {
@@ -18,17 +23,49 @@ export interface BomDeviceLine {
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): Bom {
const cableTypes = new Map(allCableTypes(project).map((ct) => [ct.id, ct]))
@@ -44,6 +81,8 @@ export function computeBom(project: Project): Bom {
count: 0,
totalLength: 0,
connectionsMissingLength: 0,
totalCost: 0,
connectionsMissingCost: 0,
}
existing.count += 1
if (typeof connection.distance === 'number' && !Number.isNaN(connection.distance)) {
@@ -51,6 +90,12 @@ export function computeBom(project: Project): Bom {
} else {
existing.connectionsMissingLength += 1
}
const cost = connectionCost(connection, cableType)
if (cost !== undefined) {
existing.totalCost += cost
} else {
existing.connectionsMissingCost += 1
}
cableLines.set(connection.cableTypeId, existing)
}
@@ -64,13 +109,28 @@ export function computeBom(project: Project): Bom {
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: [...cableLines.values()].sort((a, b) => a.cableTypeName.localeCompare(b.cableTypeName)),
devices: [...deviceLines.values()].sort((a, b) => a.name.localeCompare(b.name)),
cables,
devices,
totalCableCost,
totalDeviceCost,
grandTotalCost: totalCableCost + totalDeviceCost,
}
}