Files
av-planner/src/domain/bom.ts
T
aarbit cbbe96f575 Add BOM cable length sub-grouping
Per organized-ideas.md §7: cables of the same type sharing the same length
now get their own subtotal (count + total length + cost), revealed by
expanding that cable type's row rather than only showing one blended total.
Closes out the last open item in §7 — cost input, per-diagram override, and
the grand total were already in place.

- domain/bom.ts: computeBom groups each cable type's connections by
  distance too (a separate bucket for connections with no length entered),
  sorted shortest first.
- BomPanel: a chevron toggles the breakdown, only shown when a cable type
  actually has more than one distinct length group (nothing to reveal
  otherwise).

Verified: tsc -b and oxlint clean. No backend involved.
2026-09-11 11:54:33 -05:00

192 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, BomCableLine>()
// 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<string, Map<number | undefined, BomCableLengthLine>>()
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<number | undefined, BomCableLengthLine>()
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<string, BomDeviceLine>()
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,
}
}