@protoboardai/ext-release 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +290 -2
- package/evals/release-after-hand-edit/case.json +59 -0
- package/lib/bom.ts +444 -0
- package/lib/collect.ts +100 -0
- package/lib/config.ts +45 -0
- package/lib/cutlist.ts +70 -0
- package/lib/documents.ts +287 -0
- package/lib/glb.ts +85 -0
- package/lib/helpers.ts +49 -0
- package/lib/html.ts +126 -0
- package/lib/manifest.ts +159 -0
- package/lib/offers.ts +123 -0
- package/lib/preconditions.ts +141 -0
- package/lib/product.ts +64 -0
- package/lib/release.ts +279 -0
- package/lib/render.ts +203 -0
- package/lib/sections/baseline.ts +164 -0
- package/lib/sections/bom.ts +101 -0
- package/lib/sections/model.ts +78 -0
- package/lib/sections/testplan.ts +76 -0
- package/lib/sections/tests.ts +630 -0
- package/lib/sections/workorder.ts +369 -0
- package/lib/testing.ts +43 -0
- package/lib/types.ts +251 -0
- package/lib/units.ts +141 -0
- package/lib/util.ts +31 -0
- package/ops/release.ts +21 -0
- package/ops/sections.ts +23 -0
- package/package.json +41 -4
- package/protoboard-extension.json +44 -0
- package/skills/release/SKILL.md +191 -0
package/lib/bom.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bills of materials by unit and the whole-product ordering list (PK2,
|
|
3
|
+
* release-package-and-evals.md §2 "Bill of materials").
|
|
4
|
+
*
|
|
5
|
+
* Unit BOM: the unit's module breakdown in tree order (sub-assembly → board →
|
|
6
|
+
* components), one row per instance, quantities per build of the unit. A
|
|
7
|
+
* board's components are its UHD children; the circuit provider's `bom.csv`
|
|
8
|
+
* (keyed by UHD instance path) is merged in by that key for value, package
|
|
9
|
+
* and MPN where the model does not state them. A series passive's value and
|
|
10
|
+
* MPN are the placement's (`ChildModuleRef.overrides`, resolved by UHD's
|
|
11
|
+
* `passiveInstance`), not the definition's default.
|
|
12
|
+
*
|
|
13
|
+
* Ordering list (root unit only): every item to obtain for the whole
|
|
14
|
+
* product, one line per definition, or per definition and MPN for a series
|
|
15
|
+
* passive placed at several values. The quantity rule:
|
|
16
|
+
*
|
|
17
|
+
* needed = quantity per product × release.build
|
|
18
|
+
* spares = max(ceil(needed × percent / 100), min) by the first matching rule:
|
|
19
|
+
* release.spares[definition id], release.spares[tag or category],
|
|
20
|
+
* then the defaults (SPARE_DEFAULTS): mechanical hardware 10 %, at
|
|
21
|
+
* least 2 (small parts are lost at the bench); components placed on
|
|
22
|
+
* a board 5 %, at least 2 (placement attrition); everything else 0.
|
|
23
|
+
* order = needed + spares, rounded up to whole packs (the offer's own,
|
|
24
|
+
* else the pack_quantity the model states) and to the chosen
|
|
25
|
+
* offer's minimum order quantity; more where a later price break
|
|
26
|
+
* makes more pieces cost less in all.
|
|
27
|
+
*
|
|
28
|
+
* A bought line is quoted the way the bill of materials quotes it
|
|
29
|
+
* (docs/specs/bom.md; ext-sourcing `quoteHeldLines`): the offers the project
|
|
30
|
+
* holds, the offer a person chose for the line in parts/bom.json, else the
|
|
31
|
+
* best by the bill's rules; every price in US dollars, an offer in another
|
|
32
|
+
* currency converted with the project's dated rate table and marked
|
|
33
|
+
* (`supplier_unit_price`, `supplier_currency`, `fx_rate`, `fx_date`). The
|
|
34
|
+
* only difference from the bill is the quantity: a release adds spares.
|
|
35
|
+
*
|
|
36
|
+
* Parts that ship with another part (`usage_note` with `included_with`) are
|
|
37
|
+
* listed with order 0. Custom parts (a capability provider designs them) and
|
|
38
|
+
* cables not included with a part are fabrication lines; boards are
|
|
39
|
+
* fabrication lines and their components are bought.
|
|
40
|
+
*/
|
|
41
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
42
|
+
import { join } from "node:path";
|
|
43
|
+
import { overridableKeys, type ModuleDef } from "@deltarobotics/uhd";
|
|
44
|
+
import { boardChildRole, boardOptions, formatPassive, parseCsv, placementPart, revisionLabel, type BoardChildRole, type CapabilityBinding, type Instance, type Project, type ReleaseConfig } from "@protoboardai/core";
|
|
45
|
+
import { DEFAULT_FX, fxRate, type FxTable } from "@protoboardai/core/fx";
|
|
46
|
+
import { bomLineKey, quoteHeldLines, type HeldLine } from "@protoboardai/ext-sourcing/lib/cost.ts";
|
|
47
|
+
import { isCable } from "./cutlist.ts";
|
|
48
|
+
import { unitOfInstance } from "./units.ts";
|
|
49
|
+
import { readOffersFile, sourcingFor, type OffersFile } from "./offers.ts";
|
|
50
|
+
import type { Unit } from "./types.ts";
|
|
51
|
+
import { byCode, num, toCsv } from "./util.ts";
|
|
52
|
+
|
|
53
|
+
export const SPARE_DEFAULTS: { match: string; percent: number; min: number; why: string }[] = [
|
|
54
|
+
{ match: "hardware", percent: 10, min: 2, why: "mechanical hardware: small parts are lost at the bench" },
|
|
55
|
+
{ match: "board-component", percent: 5, min: 2, why: "components placed on a board: placement attrition" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export type ItemKind = "unit" | "assembly" | "kit" | "board" | "group" | "component" | "custom" | "cable" | "part";
|
|
59
|
+
export type Action = "build" | "fabricate" | "buy" | "included";
|
|
60
|
+
|
|
61
|
+
export interface UnitBomRow {
|
|
62
|
+
level: number;
|
|
63
|
+
path: string;
|
|
64
|
+
item: string;
|
|
65
|
+
definition: string;
|
|
66
|
+
kind: ItemKind;
|
|
67
|
+
action: Action;
|
|
68
|
+
manufacturer: string;
|
|
69
|
+
mpn: string;
|
|
70
|
+
value: string;
|
|
71
|
+
package: string;
|
|
72
|
+
qty_per_unit: number;
|
|
73
|
+
qty_per_product: number;
|
|
74
|
+
tier: string;
|
|
75
|
+
origin: string;
|
|
76
|
+
note: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const UNIT_BOM_COLUMNS = ["level", "path", "item", "definition", "kind", "action", "manufacturer", "mpn", "value", "package", "qty_per_unit", "qty_per_product", "tier", "origin", "note"];
|
|
80
|
+
|
|
81
|
+
export interface OrderLine {
|
|
82
|
+
line: number;
|
|
83
|
+
definition: string;
|
|
84
|
+
item: string;
|
|
85
|
+
manufacturer: string;
|
|
86
|
+
mpn: string;
|
|
87
|
+
/** A passive's value as placed, e.g. "4.7 kΩ ±1 %". */
|
|
88
|
+
value: string;
|
|
89
|
+
action: Exclude<Action, "build">;
|
|
90
|
+
process: string;
|
|
91
|
+
qty_per_product: number;
|
|
92
|
+
build: number;
|
|
93
|
+
needed: number;
|
|
94
|
+
spares: number;
|
|
95
|
+
spares_rule: string;
|
|
96
|
+
order: number;
|
|
97
|
+
pack_qty: number | null;
|
|
98
|
+
packs: number | null;
|
|
99
|
+
moq: number | null;
|
|
100
|
+
supplier: string;
|
|
101
|
+
supplier_sku: string;
|
|
102
|
+
/** Per piece, in US dollars. */
|
|
103
|
+
unit_price: number | null;
|
|
104
|
+
/** "USD" on a priced line: an ordering list is in one currency. */
|
|
105
|
+
currency: string;
|
|
106
|
+
/** order × unit_price, in US dollars. */
|
|
107
|
+
extended: number | null;
|
|
108
|
+
/** The price as the supplier states it, when it was converted. */
|
|
109
|
+
supplier_unit_price: number | null;
|
|
110
|
+
supplier_currency: string;
|
|
111
|
+
/** Dollars per unit of the supplier's currency, and the date of the rate table. */
|
|
112
|
+
fx_rate: number | null;
|
|
113
|
+
fx_date: string;
|
|
114
|
+
stock: number | null;
|
|
115
|
+
lead_time_weeks: number | null;
|
|
116
|
+
offer_as_of: string;
|
|
117
|
+
offer_source: string;
|
|
118
|
+
offer_url: string;
|
|
119
|
+
/** What to know about the quote: converted, a stale price, a full reel, chosen by a person in the bill, raised to a price break, or no offer that fits (a reel for fewer than a reel). */
|
|
120
|
+
offer_flag: string;
|
|
121
|
+
/** Full reels held but not quoted. */
|
|
122
|
+
offer_alternatives: string;
|
|
123
|
+
alternates: string;
|
|
124
|
+
lifecycle: string;
|
|
125
|
+
lifecycle_source: string;
|
|
126
|
+
tier: string;
|
|
127
|
+
origin: string;
|
|
128
|
+
units: string;
|
|
129
|
+
note: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const ORDER_COLUMNS = [
|
|
133
|
+
"line", "definition", "item", "manufacturer", "mpn", "value", "action", "process", "qty_per_product", "build", "needed", "spares", "spares_rule", "order", "pack_qty", "packs", "moq",
|
|
134
|
+
"supplier", "supplier_sku", "unit_price", "currency", "extended", "supplier_unit_price", "supplier_currency", "fx_rate", "fx_date", "stock", "lead_time_weeks", "offer_as_of", "offer_source", "offer_url", "offer_flag", "offer_alternatives", "alternates", "lifecycle", "lifecycle_source", "tier", "origin", "units", "note",
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
const params = (def: ModuleDef) => (def.traits ?? []).map((t) => (t.params ?? {}) as Record<string, unknown>);
|
|
138
|
+
/** The part this one ships with, when the model says so. */
|
|
139
|
+
export const includedWith = (def: ModuleDef) => params(def).find((x) => typeof x.included_with === "string")?.included_with as string | undefined;
|
|
140
|
+
const isHardware = (def: ModuleDef) => (def.categories ?? []).includes("hardware") || (def.tags ?? []).includes("fastener");
|
|
141
|
+
|
|
142
|
+
export function originLabel(p: Project, id: string): { tier: string; origin: string } {
|
|
143
|
+
const rec = p.definitions.get(id);
|
|
144
|
+
if (!rec) return { tier: "", origin: "" };
|
|
145
|
+
const o = rec.origin;
|
|
146
|
+
return { tier: o.tier, origin: o.kind === "catalog" ? `catalog ${o.provider ?? ""}@${revisionLabel(o.revision) ?? o.version ?? "?"}`.trim() : `${o.source.file}${o.source.line ? `:${o.source.line}` : ""}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface Classified {
|
|
150
|
+
kind: ItemKind;
|
|
151
|
+
action: Action;
|
|
152
|
+
process: string;
|
|
153
|
+
binding?: CapabilityBinding;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** What an instance of `def` is in a BOM. `onBoard`: a direct child of a circuit-bound board. */
|
|
157
|
+
export function classify(p: Project, def: ModuleDef, opts: { onBoard?: boolean; unit?: boolean } = {}): Classified {
|
|
158
|
+
if (opts.unit) return { kind: "unit", action: "build", process: "" };
|
|
159
|
+
const binding = p.capabilities.find((b) => b.module === def.id && !b.problem);
|
|
160
|
+
if (binding?.capability === "circuit") return { kind: "board", action: "fabricate", process: "PCB fabrication and assembly", binding };
|
|
161
|
+
const incl = includedWith(def);
|
|
162
|
+
if (isCable(def)) return { kind: "cable", action: incl ? "included" : "fabricate", process: incl ? "" : "cable assembly", ...(binding ? { binding } : {}) };
|
|
163
|
+
if (def.children?.length) return { kind: def.kind === "harness" ? "kit" : "assembly", action: "build", process: "" };
|
|
164
|
+
// a purchased part researched in the project (parts/local) has its body drawn through a cad provider, but it
|
|
165
|
+
// is bought (or ships with another part), not made: its CAD is a model of the part, not a fabrication file
|
|
166
|
+
if (binding && p.definitions.get(def.id)?.origin.tier === "project-researched") return { kind: "part", action: incl ? "included" : "buy", process: "" };
|
|
167
|
+
if (binding) return { kind: "custom", action: "fabricate", process: binding.process ? `${binding.process} (${binding.provider.replace(/^@[^/]+\//, "")})` : "custom part", binding };
|
|
168
|
+
if (opts.onBoard) return { kind: "component", action: incl ? "included" : "buy", process: "" };
|
|
169
|
+
return { kind: "part", action: incl ? "included" : "buy", process: "" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** What an instance is in its parent's board reading (core boardChildRole), when its parent is a board or a sub-module on one. */
|
|
173
|
+
function roleInParent(p: Project, inst: Instance, byPath: Map<string, Instance>): BoardChildRole | undefined {
|
|
174
|
+
const parent = inst.parent !== undefined ? byPath.get(inst.parent) : undefined;
|
|
175
|
+
const parentDef = parent ? p.lookup(parent.definition) : undefined;
|
|
176
|
+
const def = p.lookup(inst.definition);
|
|
177
|
+
const ref = parentDef?.children?.find((c) => c.id === inst.path.split("/").pop());
|
|
178
|
+
return def && parentDef && ref ? boardChildRole(def, ref, parentDef, p.lookup, boardOptions(p)) : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The circuit-bound board an instance sits on, through the sub-modules expanded onto it
|
|
183
|
+
* (docs/specs/nested-boards.md §10), and its path below that board (the key of the provider's bom.csv).
|
|
184
|
+
* Undefined for an instance not on a board, or on a boundary (a daughterboard, a child board).
|
|
185
|
+
*/
|
|
186
|
+
export function boardPlacement(p: Project, path: string): { binding: CapabilityBinding; rel: string; role: BoardChildRole } | undefined {
|
|
187
|
+
const byPath = new Map(p.instances.map((i) => [i.path, i]));
|
|
188
|
+
const self = byPath.get(path);
|
|
189
|
+
if (!self) return undefined;
|
|
190
|
+
const role = roleInParent(p, self, byPath);
|
|
191
|
+
let cur = self;
|
|
192
|
+
const segs: string[] = [];
|
|
193
|
+
while (cur.parent !== undefined) {
|
|
194
|
+
segs.unshift(cur.path.split("/").pop()!);
|
|
195
|
+
const parent = byPath.get(cur.parent);
|
|
196
|
+
const parentDef = parent ? p.lookup(parent.definition) : undefined;
|
|
197
|
+
if (!parent || !parentDef) return undefined;
|
|
198
|
+
const binding = p.capabilities.find((b) => b.module === parentDef.id && b.capability === "circuit" && !b.problem);
|
|
199
|
+
if (binding) return role && role !== "harness" && role !== "board" && role !== "mated" ? { binding, rel: segs.join("/"), role } : undefined;
|
|
200
|
+
// keep walking only through a sub-module expanded onto its own parent
|
|
201
|
+
if (roleInParent(p, parent, byPath) !== "submodule") return undefined;
|
|
202
|
+
cur = parent;
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A circuit provider's bom.csv rows by instance (relative to the board). */
|
|
208
|
+
function boardBom(p: Project, b: CapabilityBinding): Map<string, Record<string, string>> {
|
|
209
|
+
const f = join(p.root, b.out, "bom.csv");
|
|
210
|
+
if (!existsSync(f)) return new Map();
|
|
211
|
+
const [header = [], ...rows] = parseCsv(readFileSync(f, "utf8")).rows;
|
|
212
|
+
return new Map(rows.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? ""]))).map((r) => [r.instance, r]));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const pick = (row: Record<string, string> | undefined, ...keys: string[]) => keys.map((k) => row?.[k]).find((v) => v) ?? "";
|
|
216
|
+
|
|
217
|
+
/** One unit's module breakdown. */
|
|
218
|
+
export function unitBom(p: Project, unit: Unit): UnitBomRow[] {
|
|
219
|
+
const rows: UnitBomRow[] = [];
|
|
220
|
+
const byPath = new Map(p.instances.map((i) => [i.path, i]));
|
|
221
|
+
const boards = new Map<string, Map<string, Record<string, string>>>();
|
|
222
|
+
for (const m of unit.members) {
|
|
223
|
+
const def = p.lookup(m.definition);
|
|
224
|
+
if (!def) continue;
|
|
225
|
+
// on a board, directly or inside its sub-modules (docs/specs/nested-boards.md §10)
|
|
226
|
+
const placed_ = m.unit ? undefined : boardPlacement(p, m.instance);
|
|
227
|
+
const parentBinding = placed_?.binding;
|
|
228
|
+
const group = placed_?.role === "submodule";
|
|
229
|
+
const c: Classified = group ? { kind: "group", action: "build", process: "" } : classify(p, def, { onBoard: !!parentBinding, unit: !!m.unit });
|
|
230
|
+
let provider: Record<string, string> | undefined;
|
|
231
|
+
if (parentBinding && !group) {
|
|
232
|
+
if (!boards.has(parentBinding.module!)) boards.set(parentBinding.module!, boardBom(p, parentBinding));
|
|
233
|
+
provider = boards.get(parentBinding.module!)!.get(placed_!.rel);
|
|
234
|
+
}
|
|
235
|
+
const incl = includedWith(def);
|
|
236
|
+
const placed = placementPart(def, byPath.get(m.instance)?.overrides);
|
|
237
|
+
rows.push({
|
|
238
|
+
level: m.depth,
|
|
239
|
+
path: m.path,
|
|
240
|
+
item: m.name,
|
|
241
|
+
definition: def.id,
|
|
242
|
+
kind: c.kind,
|
|
243
|
+
action: c.action,
|
|
244
|
+
manufacturer: def.manufacturer ?? pick(provider, "manufacturer"),
|
|
245
|
+
mpn: placed.mpn ?? pick(provider, "mpn", "manufacturer_part_number", "mfr_part_number"),
|
|
246
|
+
value: placed.value ? formatPassive(placed.value) : pick(provider, "value"),
|
|
247
|
+
package: pick(provider, "package"),
|
|
248
|
+
qty_per_unit: m.quantity,
|
|
249
|
+
qty_per_product: m.quantity * unit.perProduct,
|
|
250
|
+
...originLabel(p, def.id),
|
|
251
|
+
note: m.unit ? `built as unit ${m.unit} (units/${m.unit}/)` : group ? `a group of ${parentBinding!.module}'s components, placed with the board` : incl ? `included with ${incl}` : c.action === "fabricate" ? c.process : "",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return rows;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export const unitBomCsv = (rows: UnitBomRow[]) => toCsv(UNIT_BOM_COLUMNS, rows as unknown as Record<string, unknown>[]);
|
|
258
|
+
|
|
259
|
+
function spareRule(def: ModuleDef, onBoard: boolean, config: ReleaseConfig): { percent: number; min: number; rule: string } {
|
|
260
|
+
const own = config.spares ?? {};
|
|
261
|
+
if (own[def.id]) return { percent: own[def.id].percent ?? 0, min: own[def.id].min ?? 0, rule: `release.spares.${def.id}` };
|
|
262
|
+
for (const k of [...(def.tags ?? []), ...(def.categories ?? [])]) if (own[k]) return { percent: own[k].percent ?? 0, min: own[k].min ?? 0, rule: `release.spares.${k}` };
|
|
263
|
+
if (onBoard && own["board-component"]) return { percent: own["board-component"].percent ?? 0, min: own["board-component"].min ?? 0, rule: "release.spares.board-component" };
|
|
264
|
+
const d = isHardware(def) ? SPARE_DEFAULTS[0] : onBoard ? SPARE_DEFAULTS[1] : undefined;
|
|
265
|
+
return d ? { percent: d.percent, min: d.min, rule: `default ${d.match} ${d.percent} %, min ${d.min}` } : { percent: 0, min: 0, rule: "none" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The whole-product ordering list. Quantities come from the instance tree
|
|
270
|
+
* (each instance's quantity multiplied through its ancestors), so they do not
|
|
271
|
+
* depend on how the package is split into units; `units` names the units
|
|
272
|
+
* that consume each line.
|
|
273
|
+
*/
|
|
274
|
+
export function orderingList(p: Project, units: Unit[], config: ReleaseConfig, offers: OffersFile | undefined = readOffersFile(p.root)): OrderLine[] {
|
|
275
|
+
const build = config.build ?? 1;
|
|
276
|
+
const byPath = new Map(p.instances.map((i) => [i.path, i]));
|
|
277
|
+
const total = (path: string): number => (path === "" ? 1 : (byPath.get(path)?.quantity ?? 1) * total(byPath.get(path)?.parent ?? ""));
|
|
278
|
+
const lines = new Map<string, { def: ModuleDef; c: Classified; qty: number; onBoard: boolean; units: Set<string>; mpn: string; value: string }>();
|
|
279
|
+
for (const inst of p.instances) {
|
|
280
|
+
if (inst.path === "") continue;
|
|
281
|
+
const def = p.lookup(inst.definition);
|
|
282
|
+
if (!def) continue;
|
|
283
|
+
const placedOn = boardPlacement(p, inst.path);
|
|
284
|
+
if (placedOn?.role === "submodule") continue; // a group on a board: its parts are the lines
|
|
285
|
+
const onBoard = !!placedOn;
|
|
286
|
+
const c = classify(p, def, { onBoard });
|
|
287
|
+
if (c.action === "build") continue; // assemblies and kits: their contents are the lines
|
|
288
|
+
const { mpn, value } = placementOf(def, inst);
|
|
289
|
+
const key = `${def.id}\u0000${mpn || value}`;
|
|
290
|
+
const line = lines.get(key) ?? { def, c, qty: 0, onBoard, units: new Set<string>(), mpn, value };
|
|
291
|
+
line.qty += total(inst.path);
|
|
292
|
+
line.units.add(unitOfInstance(units, inst.path)?.id ?? "system");
|
|
293
|
+
lines.set(key, line);
|
|
294
|
+
}
|
|
295
|
+
const sorted = [...lines.values()].sort((a, b) => order(a.c.action) - order(b.c.action) || byCode(a.def.id, b.def.id) || byCode(a.mpn, b.mpn));
|
|
296
|
+
// first the quantities, then every bought line quoted at once, the way the bill of materials quotes
|
|
297
|
+
const rows = sorted.map((l) => {
|
|
298
|
+
const needed = l.qty * build;
|
|
299
|
+
const included = l.c.action === "included";
|
|
300
|
+
const rule = included ? { percent: 0, min: 0, rule: "included" } : spareRule(l.def, l.onBoard, config);
|
|
301
|
+
const spares = included ? 0 : Math.max(Math.ceil((needed * rule.percent) / 100), rule.min);
|
|
302
|
+
const s = sourcingFor(p, l.def, offers, overridableKeys(l.def).length ? l.mpn : undefined);
|
|
303
|
+
const key = bomLineKey(l.def.id, l.mpn || (l.def.part_number ?? ""), l.def.part_number);
|
|
304
|
+
return { ...l, needed, included, rule, spares, s, key };
|
|
305
|
+
});
|
|
306
|
+
const quoted = quoteHeldLines(
|
|
307
|
+
p.root,
|
|
308
|
+
rows
|
|
309
|
+
.filter((r) => r.c.action === "buy")
|
|
310
|
+
.map((r): HeldLine => ({ key: r.key, definition: r.def.id, item: r.def.name ?? r.def.id, manufacturer: r.def.manufacturer ?? "", mpn: r.mpn || (r.def.part_number ?? ""), pieces: r.needed + r.spares, ...(r.s.packQuantity ? { packQuantity: r.s.packQuantity } : {}), ...(overridableKeys(r.def).length ? { exact: true } : {}) })),
|
|
311
|
+
);
|
|
312
|
+
const fx = quoted.bom.fx;
|
|
313
|
+
const usd = (v: number) => `${v.toFixed(2)} USD`;
|
|
314
|
+
const out: OrderLine[] = [];
|
|
315
|
+
for (const { def, c, qty, units: used, mpn, value, needed, included, rule, spares, s, key } of rows) {
|
|
316
|
+
const line = c.action === "buy" ? quoted.line(key) : undefined;
|
|
317
|
+
const q = line?.status === "priced" ? line.quote : undefined;
|
|
318
|
+
const offer = q ? line!.offers.find((o) => o.id === q.offer) : undefined;
|
|
319
|
+
// without a price, the quantity is the need and its spares in the model's packs
|
|
320
|
+
let orderQty = included ? 0 : needed + spares;
|
|
321
|
+
let pack = s.packQuantity ?? null;
|
|
322
|
+
let packs = pack && !included ? Math.ceil(orderQty / pack) : null;
|
|
323
|
+
if (packs !== null) orderQty = packs * pack!;
|
|
324
|
+
if (q) {
|
|
325
|
+
orderQty = q.buy;
|
|
326
|
+
pack = q.packSize > 1 ? q.packSize : null;
|
|
327
|
+
packs = pack ? q.packs : null;
|
|
328
|
+
}
|
|
329
|
+
const reels = (line?.quotes ?? []).filter((x) => x !== q && x.orderCost !== undefined && (x.fit === "reel" || x.fit === "reel-over"));
|
|
330
|
+
const supplierOf = (id: string) => line!.offers.find((o) => o.id === id)!.supplier;
|
|
331
|
+
const unfit = !q && reels.length ? `no offer fits ${needed + spares}: ${supplierOf(reels[0].offer)} sells only a full reel of ${reels[0].buy} (${usd(reels[0].orderCost!)}); not quoted` : "";
|
|
332
|
+
const flag = q
|
|
333
|
+
? [
|
|
334
|
+
line!.choice === "pinned" ? "chosen in the bill of materials (parts/bom.json)" : "",
|
|
335
|
+
q.native ? `converted from ${q.native.currency} at ${q.native.rate} (${fx.date})` : "",
|
|
336
|
+
q.stale ? (q.ageDays !== undefined ? `price is ${q.ageDays} days old` : "the library marks this price as stale") : "",
|
|
337
|
+
q.fit === "reel" || q.fit === "reel-over" ? "a full reel" : "",
|
|
338
|
+
q.raised ? `raised from ${q.raised.from} to the price break: ${q.raised.from} would cost ${usd(q.raised.fromCost)}` : "",
|
|
339
|
+
q.covers === false ? `${offer!.supplier} showed ${offer!.stock ?? 0} in stock` : "",
|
|
340
|
+
]
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.join("; ")
|
|
343
|
+
: unfit || (line?.offers.length ? line.notes.join("; ") : "");
|
|
344
|
+
const rec = s.record;
|
|
345
|
+
out.push({
|
|
346
|
+
line: out.length + 1,
|
|
347
|
+
definition: def.id,
|
|
348
|
+
item: def.name ?? def.id,
|
|
349
|
+
manufacturer: def.manufacturer ?? "",
|
|
350
|
+
mpn,
|
|
351
|
+
value,
|
|
352
|
+
action: c.action as OrderLine["action"],
|
|
353
|
+
process: c.process,
|
|
354
|
+
qty_per_product: qty,
|
|
355
|
+
build,
|
|
356
|
+
needed,
|
|
357
|
+
spares,
|
|
358
|
+
spares_rule: rule.rule,
|
|
359
|
+
order: orderQty,
|
|
360
|
+
pack_qty: pack,
|
|
361
|
+
packs,
|
|
362
|
+
moq: offer?.minOrder ?? null,
|
|
363
|
+
supplier: offer?.supplier ?? (!included && c.action === "buy" ? (s.listing?.supplier ?? "") : ""),
|
|
364
|
+
supplier_sku: offer?.sku ?? "",
|
|
365
|
+
unit_price: q?.unitPrice ?? null,
|
|
366
|
+
currency: q ? quoted.bom.currency : "",
|
|
367
|
+
extended: q?.orderCost ?? null,
|
|
368
|
+
supplier_unit_price: q?.native?.unitPrice ?? null,
|
|
369
|
+
supplier_currency: q?.native?.currency ?? "",
|
|
370
|
+
fx_rate: q?.native?.rate ?? null,
|
|
371
|
+
fx_date: q?.native ? fx.date : "",
|
|
372
|
+
stock: offer?.stock ?? null,
|
|
373
|
+
lead_time_weeks: offer?.leadTimeWeeks ?? null,
|
|
374
|
+
offer_as_of: offer?.observedAt ?? "",
|
|
375
|
+
offer_source: offer ? (offer.origin === "library" ? `catalog offers (${offerFileLabel(offers)})` : offer.file) : s.listing && c.action === "buy" ? "model: supplier listing (no price)" : "",
|
|
376
|
+
offer_url: offer?.url ?? (c.action === "buy" ? (s.listing?.url ?? "") : ""),
|
|
377
|
+
offer_flag: flag,
|
|
378
|
+
offer_alternatives: reels.map((x) => `${supplierOf(x.offer)}: full reel ${x.buy} × ${x.unitPrice} USD = ${usd(x.orderCost!)}`).join("; "),
|
|
379
|
+
alternates: (rec?.alternates ?? []).filter((a) => a.relation !== "near-miss").map((a) => `${a.manufacturer} ${a.mpn} (${a.relation})`).join("; "),
|
|
380
|
+
lifecycle: rec?.lifecycle?.status ?? "",
|
|
381
|
+
lifecycle_source: rec?.lifecycle ? `${rec.lifecycle.source} (${rec.lifecycle.as_of})` : "",
|
|
382
|
+
...originLabel(p, def.id),
|
|
383
|
+
units: [...used].sort(byCode).join(" "),
|
|
384
|
+
note: included
|
|
385
|
+
? `included with ${includedWith(def)}`
|
|
386
|
+
: c.action === "buy" && !q
|
|
387
|
+
? flag || (s.listing ? "no dated offer recorded; listing only" : "no offer recorded (ext-sourcing supplier-compare, or protoboard parts offers)")
|
|
388
|
+
: "",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The MPN and value of one placement (a series passive's come from its overrides). */
|
|
395
|
+
function placementOf(def: ModuleDef, inst: Instance): { mpn: string; value: string } {
|
|
396
|
+
const placed = placementPart(def, inst.overrides);
|
|
397
|
+
return { mpn: placed.mpn ?? "", value: placed.value ? formatPassive(placed.value) : "" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const order = (a: Action) => ({ fabricate: 0, buy: 1, included: 2, build: 3 })[a];
|
|
401
|
+
const offerFileLabel = (f?: OffersFile) => (f ? `parts/offers.json${f.retrievedAt ? `, ${f.retrievedAt.slice(0, 10)}` : ""}` : "");
|
|
402
|
+
|
|
403
|
+
/** The headline cost and the note under it. `converted`: the supplier currencies of lines already converted to dollars. */
|
|
404
|
+
function costIn(cost: Record<string, number>, fx: FxTable, converted: string[] = []): { costText: string; costNote: string } {
|
|
405
|
+
const each = Object.entries(cost).map(([c, v]) => `${num(v)} ${c}`).join(" + ");
|
|
406
|
+
const currencies = Object.keys(cost);
|
|
407
|
+
if (!currencies.length || (currencies.length === 1 && currencies[0] === "USD"))
|
|
408
|
+
return { costText: each, costNote: converted.length ? `prices in ${[...new Set(converted)].sort().join(", ")} converted at the rates of ${fx.date} (${fx.source})` : "" };
|
|
409
|
+
const rates = currencies.map((c) => fxRate(c, "USD", fx));
|
|
410
|
+
if (rates.some((r) => r === undefined)) return { costText: each, costNote: "no rate for every currency: not totalled" };
|
|
411
|
+
const usd = currencies.reduce((s, c, i) => s + cost[c] * rates[i]!, 0);
|
|
412
|
+
return { costText: `${num(Number(usd.toFixed(2)))} USD`, costNote: `${each}, converted at the rates of ${fx.date} (${fx.source})` };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export const orderingCsv = (lines: OrderLine[]) => toCsv(ORDER_COLUMNS, lines as unknown as Record<string, unknown>[]);
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Totals for the overview: pieces, priced coverage, and the cost. The
|
|
419
|
+
* ordering list quotes every line in US dollars (PB-933), so the total is
|
|
420
|
+
* their sum; the note names the currencies that were converted and the
|
|
421
|
+
* rate table's date (`fx`: the project's parts/fx-rates.json, else core's).
|
|
422
|
+
* Lines in another currency (an ordering list written before that) are
|
|
423
|
+
* converted here, with the sums per currency kept beside the total; without
|
|
424
|
+
* a rate for one of them there is no single total, and the sums stand alone.
|
|
425
|
+
*/
|
|
426
|
+
export function orderTotals(lines: OrderLine[], fx: FxTable = DEFAULT_FX) {
|
|
427
|
+
const buy = lines.filter((l) => l.action === "buy");
|
|
428
|
+
const cost: Record<string, number> = {};
|
|
429
|
+
// summed, then rounded to cents once: rounding each running total would add up the lines' fractions of a cent
|
|
430
|
+
for (const l of buy) if (l.extended !== null && l.currency) cost[l.currency] = (cost[l.currency] ?? 0) + l.extended;
|
|
431
|
+
for (const c of Object.keys(cost)) cost[c] = Number(cost[c].toFixed(2));
|
|
432
|
+
const leads = buy.map((l) => l.lead_time_weeks).filter((x): x is number => typeof x === "number");
|
|
433
|
+
return {
|
|
434
|
+
lines: lines.length,
|
|
435
|
+
buy: buy.length,
|
|
436
|
+
fabricate: lines.filter((l) => l.action === "fabricate").length,
|
|
437
|
+
included: lines.filter((l) => l.action === "included").length,
|
|
438
|
+
pieces: lines.reduce((s, l) => s + l.needed, 0),
|
|
439
|
+
priced: buy.filter((l) => l.extended !== null).length,
|
|
440
|
+
cost,
|
|
441
|
+
...costIn(cost, fx, buy.filter((l) => l.extended !== null && l.supplier_currency).map((l) => l.supplier_currency)),
|
|
442
|
+
leadTimeWeeks: leads.length ? Math.max(...leads) : null,
|
|
443
|
+
};
|
|
444
|
+
}
|
package/lib/collect.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Each unit's manufacturing `files/`, collected from the capability providers'
|
|
3
|
+
* standard outputs (tool-integrations.md §3, §8) — never from native files:
|
|
4
|
+
*
|
|
5
|
+
* files/boards/<module>/ circuit: gerber/ (layers + drill), bom.csv, pnp.csv, board.step, board.glb
|
|
6
|
+
* files/mechanical/<module>/ cad: <module>.step and the process exports (.stl, .3mf, .dxf)
|
|
7
|
+
* files/harnesses/<module>/ cad on a cable: routed <module>.step and .glb, and cut-list.csv from the model
|
|
8
|
+
*
|
|
9
|
+
* A custom module's files go to every unit that builds it: a unit whose own
|
|
10
|
+
* module it is, or one it is folded into.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { basename, join, relative } from "node:path";
|
|
14
|
+
import type { CapabilityBinding, Project } from "@protoboardai/core";
|
|
15
|
+
import { cutList, cutListCsv, isCable } from "./cutlist.ts";
|
|
16
|
+
import type { PackageFile, Unit } from "./types.ts";
|
|
17
|
+
import { unitBom } from "./bom.ts";
|
|
18
|
+
import { byCode, posix } from "./util.ts";
|
|
19
|
+
|
|
20
|
+
const listFiles = (abs: string, base = abs): string[] =>
|
|
21
|
+
!existsSync(abs)
|
|
22
|
+
? []
|
|
23
|
+
: readdirSync(abs)
|
|
24
|
+
.sort(byCode)
|
|
25
|
+
.flatMap((f) => (statSync(join(abs, f)).isDirectory() ? listFiles(join(abs, f), base) : [posix(relative(base, join(abs, f)))]));
|
|
26
|
+
|
|
27
|
+
/** What kind of folder a bound module's files go to. */
|
|
28
|
+
export function fileGroup(p: Project, b: CapabilityBinding): "boards" | "mechanical" | "harnesses" | "firmware" {
|
|
29
|
+
if (b.capability === "circuit") return "boards";
|
|
30
|
+
if (b.capability === "firmware") return "firmware";
|
|
31
|
+
const def = p.definitions.get(b.module!)?.def;
|
|
32
|
+
return def && def.kind === "harness" ? "harnesses" : "mechanical";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Bound modules a unit builds: its own module and the modules folded into it. A purchased part researched in
|
|
37
|
+
* the project (parts/local) may have its body drawn through a cad provider; it is bought, not built, so its
|
|
38
|
+
* CAD is no fabrication file (it travels in the model bundle).
|
|
39
|
+
*/
|
|
40
|
+
export function unitModules(p: Project, unit: Unit): CapabilityBinding[] {
|
|
41
|
+
const defs = new Set([unit.kind === "root" ? "" : unit.definition, ...unit.members.filter((m) => !m.unit).map((m) => m.definition)]);
|
|
42
|
+
return p.capabilities
|
|
43
|
+
.filter((b) => !b.problem && b.module && defs.has(b.module) && p.definitions.get(b.module)?.origin.tier !== "project-researched")
|
|
44
|
+
.sort((a, b) => byCode(a.module!, b.module!));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Standard outputs → package files for one unit. Paths are package-relative. */
|
|
48
|
+
export function collectUnitFiles(p: Project, unit: Unit): PackageFile[] {
|
|
49
|
+
const files: PackageFile[] = [];
|
|
50
|
+
const add = (path: string, from: string | undefined, content: Buffer, role: string, description: string) =>
|
|
51
|
+
files.push({ path: `${unit.folder}/files/${path}`, unit: unit.id, role, ...(from ? { from } : {}), content, description });
|
|
52
|
+
for (const b of unitModules(p, unit)) {
|
|
53
|
+
const m = b.module!;
|
|
54
|
+
const group = fileGroup(p, b);
|
|
55
|
+
const dir = `${group}/${m}`;
|
|
56
|
+
const out = (rel: string) => `${b.out}/${rel}`;
|
|
57
|
+
const read = (rel: string) => readFileSync(join(p.root, b.out, rel));
|
|
58
|
+
const present = listFiles(join(p.root, b.out));
|
|
59
|
+
if (group === "boards") {
|
|
60
|
+
for (const f of present.filter((f) => f.startsWith("gerber/"))) add(`${dir}/${f}`, out(f), read(f), "fabrication", /\.(drl|xln)$/i.test(f) ? "Excellon drill" : "Gerber layer");
|
|
61
|
+
for (const [f, role, what] of [
|
|
62
|
+
["bom.csv", "assembly", "board BOM (UHD instance keys)"],
|
|
63
|
+
["pnp.csv", "assembly", "pick-and-place"],
|
|
64
|
+
["board.step", "fabrication", "board body (STEP)"],
|
|
65
|
+
["board.glb", "model", "board body (GLB)"],
|
|
66
|
+
] as const)
|
|
67
|
+
if (present.includes(f)) add(`${dir}/${f}`, out(f), read(f), role, what);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// a firmware provider's configuration (a Zephyr board definition, a sdkconfig): its whole config/ tree and manifest
|
|
71
|
+
if (group === "firmware") {
|
|
72
|
+
for (const f of present.filter((f) => f.startsWith("config/") || f === "firmware.manifest.json")) add(`${dir}/${f.replace(/^config\//, "")}`, out(f), read(f), "firmware-config", f === "firmware.manifest.json" ? "firmware configuration manifest (cites the board)" : "firmware configuration");
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const renamed = (f: string) => (basename(f).startsWith("body.") ? `${m}${basename(f).slice(4)}` : basename(f));
|
|
76
|
+
if (group === "mechanical") {
|
|
77
|
+
for (const f of present.filter((f) => !f.includes("/") && /\.(step|stl|3mf|dxf)$/i.test(f)))
|
|
78
|
+
add(`${dir}/${renamed(f)}`, out(f), read(f), "fabrication", /\.step$/i.test(f) ? "body (STEP)" : /\.dxf$/i.test(f) ? "cutting profile (DXF)" : "print file");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const f of present.filter((f) => !f.includes("/") && /\.(step|glb)$/i.test(f))) add(`${dir}/${renamed(f)}`, out(f), read(f), /\.step$/i.test(f) ? "fabrication" : "model", `routed harness (${f.split(".").pop()!.toUpperCase()})`);
|
|
82
|
+
const def = p.definitions.get(m)!.def;
|
|
83
|
+
if (isCable(def) && cutList(def).length) add(`${dir}/cut-list.csv`, undefined, Buffer.from(cutListCsv(def)), "fabrication", "cut list (from the model)");
|
|
84
|
+
}
|
|
85
|
+
// a cable made by hand, with no cad provider: its cut list still comes from the model
|
|
86
|
+
const bound = new Set(unitModules(p, unit).map((b) => b.module));
|
|
87
|
+
// (the cables the work order makes or cuts: fabricated, or supplied longer than installed)
|
|
88
|
+
const cables = [...new Set(unitBom(p, unit).filter((r) => r.kind === "cable").map((r) => r.definition))].sort(byCode);
|
|
89
|
+
for (const id of cables) {
|
|
90
|
+
const def = p.lookup(id);
|
|
91
|
+
if (!def || bound.has(id) || !isCable(def)) continue;
|
|
92
|
+
const rows = cutList(def);
|
|
93
|
+
const made = unitBom(p, unit).some((r) => r.definition === id && r.action === "fabricate");
|
|
94
|
+
const cut = rows[0]?.length_mm && rows[0].supplied_length_mm && rows[0].length_mm < rows[0].supplied_length_mm;
|
|
95
|
+
if (!rows.length || !(made || cut)) continue;
|
|
96
|
+
add(`harnesses/${id}/cut-list.csv`, undefined, Buffer.from(cutListCsv(def)), "fabrication", "cut list (from the model)");
|
|
97
|
+
}
|
|
98
|
+
return files;
|
|
99
|
+
}
|
|
100
|
+
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release configuration: `release` in protoboard.config.ts, or the
|
|
3
|
+
* default export of release.config.ts beside it (not both).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { importTsFile, type ReleaseConfig } from "./util.ts";
|
|
8
|
+
import type { SectionDiagnostic } from "./types.ts";
|
|
9
|
+
import type { Project } from "@protoboardai/core";
|
|
10
|
+
|
|
11
|
+
export const RELEASE_CONFIG_FILE = "release.config.ts";
|
|
12
|
+
|
|
13
|
+
export function defineReleaseConfig(config: ReleaseConfig): ReleaseConfig {
|
|
14
|
+
return config;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The project's release configuration, with problems that stop a release. */
|
|
18
|
+
export async function readReleaseConfig(p: Project): Promise<{ config: ReleaseConfig; source: string; diagnostics: SectionDiagnostic[] }> {
|
|
19
|
+
const diagnostics: SectionDiagnostic[] = [];
|
|
20
|
+
const bad = (message: string) => diagnostics.push({ code: "REL_CONFIG_INVALID", severity: "error", message });
|
|
21
|
+
let config: ReleaseConfig = p.config.release ?? {};
|
|
22
|
+
let source = p.config.release ? "protoboard.config.ts" : "(default: the root system only)";
|
|
23
|
+
const file = join(p.root, RELEASE_CONFIG_FILE);
|
|
24
|
+
if (existsSync(file)) {
|
|
25
|
+
if (p.config.release) bad(`both protoboard.config.ts (release) and ${RELEASE_CONFIG_FILE} configure the release; keep one`);
|
|
26
|
+
try {
|
|
27
|
+
config = ((await importTsFile(file)) as { default?: ReleaseConfig }).default ?? {};
|
|
28
|
+
source = RELEASE_CONFIG_FILE;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
bad(`${RELEASE_CONFIG_FILE} does not evaluate: ${(e as Error).message.split("\n")[0]}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (config.units !== undefined && !(Array.isArray(config.units) && config.units.every((u) => typeof u === "string"))) bad(`${source}: release.units must be a list of instance paths or module ids`);
|
|
34
|
+
if (config.build !== undefined && !(Number.isInteger(config.build) && config.build >= 1)) bad(`${source}: release.build must be a whole number ≥ 1 (products to build)`);
|
|
35
|
+
for (const [k, v] of Object.entries(config.spares ?? {})) {
|
|
36
|
+
const ok = v && typeof v === "object" && (v.percent === undefined || (typeof v.percent === "number" && v.percent >= 0)) && (v.min === undefined || (Number.isInteger(v.min) && v.min >= 0));
|
|
37
|
+
if (!ok) bad(`${source}: release.spares.${k} must be { percent?: number ≥ 0, min?: whole number ≥ 0 }`);
|
|
38
|
+
}
|
|
39
|
+
if (p.config.currency !== undefined && !(typeof p.config.currency === "string" && /^[A-Z]{3}$/.test(p.config.currency))) bad(`protoboard.config.ts: currency must be an ISO 4217 code such as "USD" (the supplier comparison quotes in it; the bill of materials and a release are in USD)`);
|
|
40
|
+
(config.checks ?? []).forEach((c, i) => {
|
|
41
|
+
const ok = c && ["check", "subject", "how", "pass", "record"].every((k) => typeof (c as unknown as Record<string, unknown>)[k] === "string" && (c as unknown as Record<string, string>)[k].trim()) && (c.group === undefined || c.group === "bringup" || c.group === "function") && (c.phase === undefined || c.phase === "power" || c.phase === "running");
|
|
42
|
+
if (!ok) bad(`${source}: release.checks[${i}] needs check, subject, how, pass and record (strings); group "bringup" | "function"; phase "power" | "running"`);
|
|
43
|
+
});
|
|
44
|
+
return { config, source, diagnostics };
|
|
45
|
+
}
|