napkin2factory 0.1.0__py3-none-any.whl
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.
- napkin2factory/__init__.py +3 -0
- napkin2factory/bom/__init__.py +1 -0
- napkin2factory/bom/files.py +313 -0
- napkin2factory/bom/rollups.py +297 -0
- napkin2factory/bom/validation.py +136 -0
- napkin2factory/catalog/__init__.py +5 -0
- napkin2factory/catalog/common.yaml +76 -0
- napkin2factory/cli/__init__.py +1 -0
- napkin2factory/cli/__main__.py +5 -0
- napkin2factory/cli/app.py +62 -0
- napkin2factory/cli/build.py +115 -0
- napkin2factory/cli/check.py +260 -0
- napkin2factory/cli/common.py +76 -0
- napkin2factory/cli/doctor.py +124 -0
- napkin2factory/cli/ecr.py +195 -0
- napkin2factory/cli/graph.py +62 -0
- napkin2factory/cli/init.py +114 -0
- napkin2factory/cli/release.py +97 -0
- napkin2factory/cli/serve.py +36 -0
- napkin2factory/cli/snapshot.py +63 -0
- napkin2factory/cli/validate.py +88 -0
- napkin2factory/core/__init__.py +1 -0
- napkin2factory/core/checks/__init__.py +1 -0
- napkin2factory/core/checks/calcs.py +139 -0
- napkin2factory/core/checks/engine.py +358 -0
- napkin2factory/core/checks/evaluators.py +268 -0
- napkin2factory/core/checks/evidence.py +103 -0
- napkin2factory/core/checks/interface.py +160 -0
- napkin2factory/core/checks/interference.py +153 -0
- napkin2factory/core/checks/results.py +100 -0
- napkin2factory/core/checks/stage.py +212 -0
- napkin2factory/core/errors.py +118 -0
- napkin2factory/core/expr.py +183 -0
- napkin2factory/core/graph/__init__.py +1 -0
- napkin2factory/core/graph/export.py +98 -0
- napkin2factory/core/graph/hashing.py +53 -0
- napkin2factory/core/graph/impact.py +546 -0
- napkin2factory/core/graph/manifest.py +84 -0
- napkin2factory/core/graph/rules.py +263 -0
- napkin2factory/core/graph/runner.py +317 -0
- napkin2factory/core/graph/runs.py +249 -0
- napkin2factory/core/graph/values.py +152 -0
- napkin2factory/core/lint.py +255 -0
- napkin2factory/core/loader.py +836 -0
- napkin2factory/core/model/__init__.py +65 -0
- napkin2factory/core/model/assemblies.py +66 -0
- napkin2factory/core/model/base.py +149 -0
- napkin2factory/core/model/decisions.py +32 -0
- napkin2factory/core/model/docs.py +23 -0
- napkin2factory/core/model/ecr.py +92 -0
- napkin2factory/core/model/interfaces.py +129 -0
- napkin2factory/core/model/manifest.py +57 -0
- napkin2factory/core/model/parameters.py +37 -0
- napkin2factory/core/model/parts.py +131 -0
- napkin2factory/core/model/requirements.py +101 -0
- napkin2factory/core/model/signoff.py +23 -0
- napkin2factory/core/units.py +158 -0
- napkin2factory/core/uris.py +142 -0
- napkin2factory/core/vcs.py +283 -0
- napkin2factory/core/yamlio.py +60 -0
- napkin2factory/docs/__init__.py +1 -0
- napkin2factory/docs/engine.py +199 -0
- napkin2factory/docs/html.py +138 -0
- napkin2factory/docs/outputs.py +213 -0
- napkin2factory/docs/typst.py +418 -0
- napkin2factory/drawings/__init__.py +1 -0
- napkin2factory/drawings/dxf.py +99 -0
- napkin2factory/drawings/engine.py +173 -0
- napkin2factory/drawings/sheet.py +747 -0
- napkin2factory/drawings/svg.py +456 -0
- napkin2factory/drawings/views.py +174 -0
- napkin2factory/ecr/__init__.py +1 -0
- napkin2factory/ecr/diff.py +372 -0
- napkin2factory/ecr/lifecycle.py +436 -0
- napkin2factory/ecr/release.py +338 -0
- napkin2factory/geometry/__init__.py +1 -0
- napkin2factory/geometry/accessors.py +146 -0
- napkin2factory/geometry/assembly.py +282 -0
- napkin2factory/geometry/build.py +613 -0
- napkin2factory/geometry/exporters.py +162 -0
- napkin2factory/geometry/features.py +215 -0
- napkin2factory/geometry/glb.py +230 -0
- napkin2factory/geometry/kit/__init__.py +102 -0
- napkin2factory/geometry/kit/_common.py +63 -0
- napkin2factory/geometry/kit/_sheet.py +254 -0
- napkin2factory/geometry/kit/block.py +83 -0
- napkin2factory/geometry/kit/bracket_l.py +242 -0
- napkin2factory/geometry/kit/enclosure.py +369 -0
- napkin2factory/geometry/kit/frame.py +259 -0
- napkin2factory/geometry/kit/hole_pattern.py +396 -0
- napkin2factory/geometry/kit/panel.py +284 -0
- napkin2factory/geometry/kit/plate.py +126 -0
- napkin2factory/geometry/kit/tube.py +135 -0
- napkin2factory/geometry/materials.py +51 -0
- napkin2factory/geometry/render.py +214 -0
- napkin2factory/report/__init__.py +1 -0
- napkin2factory/report/builder.py +524 -0
- napkin2factory/report/inline.py +89 -0
- napkin2factory/report/site.py +22 -0
- napkin2factory/report/templates/diff.html.j2 +78 -0
- napkin2factory/report/templates/report.html.j2 +241 -0
- napkin2factory/report/vendor/three/BufferGeometryUtils.js +1373 -0
- napkin2factory/report/vendor/three/GLTFLoader.js +4727 -0
- napkin2factory/report/vendor/three/OrbitControls.js +1523 -0
- napkin2factory/report/vendor/three/three.module.js +54571 -0
- napkin2factory/report/viewer.js +236 -0
- napkin2factory/rulepacks/__init__.py +5 -0
- napkin2factory/rulepacks/checks.py +201 -0
- napkin2factory/rulepacks/general-v0.yaml +29 -0
- napkin2factory/rulepacks/runner.py +136 -0
- napkin2factory/rulepacks/sheet-metal-v0.yaml +44 -0
- napkin2factory/templates/__init__.py +11 -0
- napkin2factory/templates/_common/.claude/agents/n2f-impact-analyst.md +17 -0
- napkin2factory/templates/_common/.claude/agents/n2f-verifier.md +27 -0
- napkin2factory/templates/_common/.claude/skills/n2f-design/SKILL.md +59 -0
- napkin2factory/templates/_common/.claude/skills/n2f-pm/SKILL.md +77 -0
- napkin2factory/templates/_common/.github/workflows/build.yml +18 -0
- napkin2factory/templates/_common/.github/workflows/release.yml +26 -0
- napkin2factory/templates/_common/.gitignore +4 -0
- napkin2factory/templates/_common/AGENTS.md +55 -0
- napkin2factory/templates/_common/CLAUDE.md +55 -0
- napkin2factory/templates/blank/CHANGELOG.md +3 -0
- napkin2factory/templates/blank/changes/.gitkeep +0 -0
- napkin2factory/templates/blank/model/assemblies/.gitkeep +0 -0
- napkin2factory/templates/blank/model/calcs/.gitkeep +0 -0
- napkin2factory/templates/blank/model/decisions.yaml +2 -0
- napkin2factory/templates/blank/model/docs/safety.md +23 -0
- napkin2factory/templates/blank/model/geometry/.gitkeep +0 -0
- napkin2factory/templates/blank/model/intake.md +5 -0
- napkin2factory/templates/blank/model/interfaces/.gitkeep +0 -0
- napkin2factory/templates/blank/model/manufacturers/.gitkeep +0 -0
- napkin2factory/templates/blank/model/parameters.yaml +2 -0
- napkin2factory/templates/blank/model/parts/.gitkeep +0 -0
- napkin2factory/templates/blank/model/requirements.yaml +3 -0
- napkin2factory/templates/blank/model/signoffs/.gitkeep +0 -0
- napkin2factory/templates/blank/n2f.yaml +7 -0
- napkin2factory/templates/blank/operator.yaml +5 -0
- napkin2factory/templates/blank/reports/.gitkeep +0 -0
- napkin2factory/templates/bracket/.claude/agents/n2f-impact-analyst.md +17 -0
- napkin2factory/templates/bracket/.claude/agents/n2f-verifier.md +27 -0
- napkin2factory/templates/bracket/.claude/skills/n2f-design/SKILL.md +59 -0
- napkin2factory/templates/bracket/.claude/skills/n2f-pm/SKILL.md +77 -0
- napkin2factory/templates/bracket/.github/workflows/build.yml +18 -0
- napkin2factory/templates/bracket/.github/workflows/release.yml +26 -0
- napkin2factory/templates/bracket/.gitignore +4 -0
- napkin2factory/templates/bracket/AGENTS.md +55 -0
- napkin2factory/templates/bracket/CHANGELOG.md +3 -0
- napkin2factory/templates/bracket/CLAUDE.md +55 -0
- napkin2factory/templates/bracket/changes/.gitkeep +0 -0
- napkin2factory/templates/bracket/model/assemblies/bracket.yaml +7 -0
- napkin2factory/templates/bracket/model/calcs/.gitkeep +0 -0
- napkin2factory/templates/bracket/model/decisions.yaml +13 -0
- napkin2factory/templates/bracket/model/docs/installation.md +22 -0
- napkin2factory/templates/bracket/model/docs/safety.md +20 -0
- napkin2factory/templates/bracket/model/geometry/bracket.py +14 -0
- napkin2factory/templates/bracket/model/intake.md +7 -0
- napkin2factory/templates/bracket/model/interfaces/.gitkeep +0 -0
- napkin2factory/templates/bracket/model/manufacturers/.gitkeep +0 -0
- napkin2factory/templates/bracket/model/parameters.yaml +12 -0
- napkin2factory/templates/bracket/model/parts/l-bracket.yaml +26 -0
- napkin2factory/templates/bracket/model/requirements.yaml +27 -0
- napkin2factory/templates/bracket/model/signoffs/.gitkeep +0 -0
- napkin2factory/templates/bracket/n2f.yaml +8 -0
- napkin2factory/templates/bracket/operator.yaml +5 -0
- napkin2factory/templates/bracket/reports/.gitkeep +0 -0
- napkin2factory/templates/enclosure/.claude/agents/n2f-impact-analyst.md +17 -0
- napkin2factory/templates/enclosure/.claude/agents/n2f-verifier.md +27 -0
- napkin2factory/templates/enclosure/.claude/skills/n2f-design/SKILL.md +59 -0
- napkin2factory/templates/enclosure/.claude/skills/n2f-pm/SKILL.md +77 -0
- napkin2factory/templates/enclosure/.github/workflows/build.yml +18 -0
- napkin2factory/templates/enclosure/.github/workflows/release.yml +26 -0
- napkin2factory/templates/enclosure/.gitignore +4 -0
- napkin2factory/templates/enclosure/AGENTS.md +55 -0
- napkin2factory/templates/enclosure/CHANGELOG.md +3 -0
- napkin2factory/templates/enclosure/CLAUDE.md +55 -0
- napkin2factory/templates/enclosure/changes/.gitkeep +0 -0
- napkin2factory/templates/enclosure/model/assemblies/enclosure.yaml +11 -0
- napkin2factory/templates/enclosure/model/calcs/.gitkeep +0 -0
- napkin2factory/templates/enclosure/model/decisions.yaml +19 -0
- napkin2factory/templates/enclosure/model/docs/assembly.md +17 -0
- napkin2factory/templates/enclosure/model/docs/safety.md +20 -0
- napkin2factory/templates/enclosure/model/geometry/enclosure.py +32 -0
- napkin2factory/templates/enclosure/model/intake.md +6 -0
- napkin2factory/templates/enclosure/model/interfaces/lid-base-mount.yaml +10 -0
- napkin2factory/templates/enclosure/model/manufacturers/.gitkeep +0 -0
- napkin2factory/templates/enclosure/model/parameters.yaml +22 -0
- napkin2factory/templates/enclosure/model/parts/base.yaml +24 -0
- napkin2factory/templates/enclosure/model/parts/bracket.yaml +21 -0
- napkin2factory/templates/enclosure/model/parts/lid.yaml +19 -0
- napkin2factory/templates/enclosure/model/parts/m4x10-screw.yaml +11 -0
- napkin2factory/templates/enclosure/model/requirements.yaml +59 -0
- napkin2factory/templates/enclosure/model/signoffs/.gitkeep +0 -0
- napkin2factory/templates/enclosure/n2f.yaml +8 -0
- napkin2factory/templates/enclosure/operator.yaml +5 -0
- napkin2factory/templates/enclosure/reports/.gitkeep +0 -0
- napkin2factory/templates/sync.py +80 -0
- napkin2factory-0.1.0.dist-info/METADATA +162 -0
- napkin2factory-0.1.0.dist-info/RECORD +201 -0
- napkin2factory-0.1.0.dist-info/WHEEL +4 -0
- napkin2factory-0.1.0.dist-info/entry_points.txt +2 -0
- napkin2factory-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bill of materials: rollups, validation tiers, CSV/XLSX, curated catalog."""
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""BOM files: validation (D15), flat CSV and a formatted XLSX workbook (D18, D17).
|
|
2
|
+
|
|
3
|
+
The rollup numbers are written as values, never formulas, so readers that do not
|
|
4
|
+
calculate (CSV importers, ``openpyxl`` with ``data_only``) see the same totals as
|
|
5
|
+
``rollups.json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from napkin2factory.core.graph.rules import RunContext
|
|
18
|
+
|
|
19
|
+
COLUMNS = [
|
|
20
|
+
"level",
|
|
21
|
+
"uri",
|
|
22
|
+
"id",
|
|
23
|
+
"rev",
|
|
24
|
+
"name",
|
|
25
|
+
"kind",
|
|
26
|
+
"qty",
|
|
27
|
+
"total_qty",
|
|
28
|
+
"process",
|
|
29
|
+
"material",
|
|
30
|
+
"finish",
|
|
31
|
+
"tolerance_class",
|
|
32
|
+
"unit_cost_usd",
|
|
33
|
+
"ext_cost_usd",
|
|
34
|
+
"lead_time_days",
|
|
35
|
+
"mass_kg",
|
|
36
|
+
"sourcing_status",
|
|
37
|
+
"source",
|
|
38
|
+
"manufacturer",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
TOTALS = [
|
|
42
|
+
("total_cost_usd", "total_cost"),
|
|
43
|
+
("total_mass_kg", "total_mass"),
|
|
44
|
+
("part_count", "part_count"),
|
|
45
|
+
("custom_count", "custom_count"),
|
|
46
|
+
("custom_ratio", "custom_ratio"),
|
|
47
|
+
("max_lead_time_days", "max_lead_time"),
|
|
48
|
+
("cost_complete", "cost_complete"),
|
|
49
|
+
("mass_complete", "mass_complete"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
SOURCING_NOTE = (
|
|
53
|
+
"Unverified sources are marked in sourcing_status; costs are declared estimates unless "
|
|
54
|
+
"basis says quote"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_WIDTHS = {
|
|
58
|
+
"level": 6,
|
|
59
|
+
"uri": 24,
|
|
60
|
+
"id": 16,
|
|
61
|
+
"rev": 6,
|
|
62
|
+
"name": 32,
|
|
63
|
+
"kind": 10,
|
|
64
|
+
"qty": 6,
|
|
65
|
+
"total_qty": 9,
|
|
66
|
+
"process": 16,
|
|
67
|
+
"material": 22,
|
|
68
|
+
"finish": 18,
|
|
69
|
+
"tolerance_class": 14,
|
|
70
|
+
"unit_cost_usd": 13,
|
|
71
|
+
"ext_cost_usd": 13,
|
|
72
|
+
"lead_time_days": 14,
|
|
73
|
+
"mass_kg": 10,
|
|
74
|
+
"sourcing_status": 15,
|
|
75
|
+
"source": 28,
|
|
76
|
+
"manufacturer": 60,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def iter_lines(rollups: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
|
81
|
+
"""Every BOM line, depth-first through sub-assemblies, for each top assembly.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
rollups: The ``rollups.json`` payload.
|
|
85
|
+
|
|
86
|
+
Yields:
|
|
87
|
+
Line dicts (as in ``rollups["structure"]``) in display order.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def walk(lines: list[dict[str, Any]]) -> Iterator[dict[str, Any]]:
|
|
91
|
+
for ln in lines:
|
|
92
|
+
yield ln
|
|
93
|
+
yield from walk(ln.get("children") or [])
|
|
94
|
+
|
|
95
|
+
for top in rollups.get("top_assemblies", []):
|
|
96
|
+
yield from walk(rollups.get("structure", {}).get(top, []))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def line_row(ln: dict[str, Any]) -> dict[str, Any]:
|
|
100
|
+
"""Map a rollup line dict onto the :data:`COLUMNS` (``None`` for unknown numbers).
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
ln: One line dict from ``rollups["structure"]``.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A dict keyed by the BOM column names.
|
|
107
|
+
"""
|
|
108
|
+
return {
|
|
109
|
+
"level": ln.get("level", 0),
|
|
110
|
+
"uri": ln.get("uri", ""),
|
|
111
|
+
"id": ln.get("id", ""),
|
|
112
|
+
"rev": ln.get("rev", ""),
|
|
113
|
+
"name": ln.get("name", ""),
|
|
114
|
+
"kind": ln.get("kind", ""),
|
|
115
|
+
"qty": ln.get("qty"),
|
|
116
|
+
"total_qty": ln.get("total_qty"),
|
|
117
|
+
"process": ln.get("process", ""),
|
|
118
|
+
"material": ln.get("material", ""),
|
|
119
|
+
"finish": ln.get("finish", ""),
|
|
120
|
+
"tolerance_class": ln.get("tolerance_class", ""),
|
|
121
|
+
"unit_cost_usd": ln.get("unit_cost"),
|
|
122
|
+
"ext_cost_usd": None if ln.get("ext_cost") is None else round(ln["ext_cost"], 6),
|
|
123
|
+
"lead_time_days": ln.get("lead_time_days"),
|
|
124
|
+
"mass_kg": ln.get("mass_kg"),
|
|
125
|
+
"sourcing_status": ln.get("sourcing_status", ""),
|
|
126
|
+
"source": ln.get("source", ""),
|
|
127
|
+
"manufacturer": ln.get("manufacturer", ""),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _csv_value(value: Any) -> Any:
|
|
132
|
+
if value is None:
|
|
133
|
+
return ""
|
|
134
|
+
if isinstance(value, bool):
|
|
135
|
+
return "true" if value else "false"
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_csv(path: Path, rollups: dict[str, Any]) -> Path:
|
|
140
|
+
"""Write the flat ``bom.csv`` (one row per line, then a blank row and the totals).
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
path: Destination file.
|
|
144
|
+
rollups: The ``rollups.json`` payload.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
``path``.
|
|
148
|
+
"""
|
|
149
|
+
with path.open("w", encoding="utf-8", newline="") as fh:
|
|
150
|
+
writer = csv.writer(fh, lineterminator="\n")
|
|
151
|
+
writer.writerow(COLUMNS)
|
|
152
|
+
for ln in iter_lines(rollups):
|
|
153
|
+
row = line_row(ln)
|
|
154
|
+
writer.writerow([_csv_value(row[c]) for c in COLUMNS])
|
|
155
|
+
writer.writerow([])
|
|
156
|
+
for label, key in TOTALS:
|
|
157
|
+
writer.writerow([label, _csv_value(rollups.get(key))])
|
|
158
|
+
writer.writerow(
|
|
159
|
+
[
|
|
160
|
+
"note",
|
|
161
|
+
"This design was produced with an AI system and has not been reviewed "
|
|
162
|
+
"by a licensed engineer.",
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def write_xlsx(path: Path, rollups: dict[str, Any]) -> Path:
|
|
169
|
+
"""Write ``bom.xlsx`` with the BOM, Rollups, Parts and Manufacturer sheets.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
path: Destination file.
|
|
173
|
+
rollups: The ``rollups.json`` payload.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
``path``.
|
|
177
|
+
"""
|
|
178
|
+
from openpyxl import Workbook
|
|
179
|
+
from openpyxl.styles import Alignment, Font
|
|
180
|
+
from openpyxl.utils import get_column_letter
|
|
181
|
+
|
|
182
|
+
bold = Font(bold=True)
|
|
183
|
+
wb = Workbook()
|
|
184
|
+
ws = wb.active
|
|
185
|
+
ws.title = "BOM"
|
|
186
|
+
ws.append(COLUMNS)
|
|
187
|
+
for cell in ws[1]:
|
|
188
|
+
cell.font = bold
|
|
189
|
+
money = {COLUMNS.index(c) + 1 for c in ("unit_cost_usd", "ext_cost_usd")}
|
|
190
|
+
mass_col = COLUMNS.index("mass_kg") + 1
|
|
191
|
+
name_col = COLUMNS.index("name") + 1
|
|
192
|
+
for ln in iter_lines(rollups):
|
|
193
|
+
row = line_row(ln)
|
|
194
|
+
row["name"] = " " * int(row["level"] or 0) + str(row["name"])
|
|
195
|
+
ws.append([row[c] for c in COLUMNS])
|
|
196
|
+
r = ws.max_row
|
|
197
|
+
for col in money:
|
|
198
|
+
ws.cell(r, col).number_format = "0.00"
|
|
199
|
+
ws.cell(r, mass_col).number_format = "0.000"
|
|
200
|
+
ws.cell(r, name_col).alignment = Alignment(indent=int(row["level"] or 0))
|
|
201
|
+
ws.freeze_panes = "A2"
|
|
202
|
+
for i, col in enumerate(COLUMNS, start=1):
|
|
203
|
+
ws.column_dimensions[get_column_letter(i)].width = _WIDTHS.get(col, 12)
|
|
204
|
+
|
|
205
|
+
ws.append([])
|
|
206
|
+
ws.append(["Totals"])
|
|
207
|
+
ws.cell(ws.max_row, 1).font = bold
|
|
208
|
+
for label, key in TOTALS:
|
|
209
|
+
ws.append([label, rollups.get(key)])
|
|
210
|
+
r = ws.max_row
|
|
211
|
+
ws.cell(r, 1).font = bold
|
|
212
|
+
if key == "total_cost":
|
|
213
|
+
ws.cell(r, 2).number_format = "0.00"
|
|
214
|
+
elif key == "total_mass":
|
|
215
|
+
ws.cell(r, 2).number_format = "0.000"
|
|
216
|
+
ws.append([])
|
|
217
|
+
ws.append([SOURCING_NOTE])
|
|
218
|
+
ws.cell(ws.max_row, 1).font = Font(italic=True)
|
|
219
|
+
|
|
220
|
+
rs = wb.create_sheet("Rollups")
|
|
221
|
+
rs.append(["key", "value"])
|
|
222
|
+
for cell in rs[1]:
|
|
223
|
+
cell.font = bold
|
|
224
|
+
for key in (
|
|
225
|
+
"total_cost",
|
|
226
|
+
"total_cost_unit",
|
|
227
|
+
"cost_complete",
|
|
228
|
+
"total_mass",
|
|
229
|
+
"total_mass_unit",
|
|
230
|
+
"mass_complete",
|
|
231
|
+
"part_count",
|
|
232
|
+
"distinct_parts",
|
|
233
|
+
"custom_count",
|
|
234
|
+
"custom_ratio",
|
|
235
|
+
"max_lead_time",
|
|
236
|
+
"max_lead_time_unit",
|
|
237
|
+
):
|
|
238
|
+
if key in rollups:
|
|
239
|
+
rs.append([key, rollups[key]])
|
|
240
|
+
rs.append(["top_assemblies", ", ".join(rollups.get("top_assemblies", []))])
|
|
241
|
+
rs.append(["orphans", ", ".join(rollups.get("orphans", []))])
|
|
242
|
+
rs.column_dimensions["A"].width = 20
|
|
243
|
+
rs.column_dimensions["B"].width = 30
|
|
244
|
+
|
|
245
|
+
ps = wb.create_sheet("Parts")
|
|
246
|
+
part_cols = [
|
|
247
|
+
"uri",
|
|
248
|
+
"name",
|
|
249
|
+
"kind",
|
|
250
|
+
"total_qty",
|
|
251
|
+
"unit_cost",
|
|
252
|
+
"lead_time_days",
|
|
253
|
+
"mass_kg",
|
|
254
|
+
"sourcing_status",
|
|
255
|
+
]
|
|
256
|
+
ps.append(part_cols)
|
|
257
|
+
for cell in ps[1]:
|
|
258
|
+
cell.font = bold
|
|
259
|
+
for part in rollups.get("parts", {}).values():
|
|
260
|
+
ps.append([part.get(c) for c in part_cols])
|
|
261
|
+
r = ps.max_row
|
|
262
|
+
ps.cell(r, part_cols.index("unit_cost") + 1).number_format = "0.00"
|
|
263
|
+
ps.cell(r, part_cols.index("mass_kg") + 1).number_format = "0.000"
|
|
264
|
+
for i, w in enumerate((24, 32, 10, 9, 10, 14, 10, 15), start=1):
|
|
265
|
+
ps.column_dimensions[get_column_letter(i)].width = w
|
|
266
|
+
ps.freeze_panes = "A2"
|
|
267
|
+
|
|
268
|
+
ms = wb.create_sheet("Manufacturer")
|
|
269
|
+
ms.append(["uri", "name", "kind", "total_qty", "instruction"])
|
|
270
|
+
for cell in ms[1]:
|
|
271
|
+
cell.font = bold
|
|
272
|
+
seen: set[str] = set()
|
|
273
|
+
for ln in iter_lines(rollups):
|
|
274
|
+
if ln.get("kind") == "assembly" or ln.get("uri") in seen:
|
|
275
|
+
continue
|
|
276
|
+
seen.add(str(ln.get("uri")))
|
|
277
|
+
total = (
|
|
278
|
+
rollups.get("parts", {}).get(ln.get("uri"), {}).get("total_qty", ln.get("total_qty"))
|
|
279
|
+
)
|
|
280
|
+
ms.append(
|
|
281
|
+
[ln.get("uri"), ln.get("name"), ln.get("kind"), total, ln.get("manufacturer", "")]
|
|
282
|
+
)
|
|
283
|
+
ms.cell(ms.max_row, 5).alignment = Alignment(wrap_text=True, vertical="top")
|
|
284
|
+
for i, w in enumerate((24, 32, 10, 9, 80), start=1):
|
|
285
|
+
ms.column_dimensions[get_column_letter(i)].width = w
|
|
286
|
+
ms.freeze_panes = "A2"
|
|
287
|
+
|
|
288
|
+
wb.save(path)
|
|
289
|
+
return path
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def write_bom_files(ctx: RunContext, rollups: dict[str, Any]) -> list[Path]:
|
|
293
|
+
"""Write ``validation.json``, ``bom.csv`` and ``bom.xlsx``; return the files written.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
ctx: The build context.
|
|
297
|
+
rollups: The ``rollups.json`` payload from ``compute_rollups``.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
``[validation.json, bom.csv, bom.xlsx]``.
|
|
301
|
+
"""
|
|
302
|
+
from napkin2factory.bom.validation import validate_bom
|
|
303
|
+
|
|
304
|
+
out_dir = ctx.build_dir / "bom"
|
|
305
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
validation = validate_bom(ctx.product, ctx.build_dir)
|
|
307
|
+
path = out_dir / "validation.json"
|
|
308
|
+
path.write_text(json.dumps(validation, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
309
|
+
return [
|
|
310
|
+
path,
|
|
311
|
+
write_csv(out_dir / "bom.csv", rollups),
|
|
312
|
+
write_xlsx(out_dir / "bom.xlsx", rollups),
|
|
313
|
+
]
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""BOM structure and rollups (D18): quantities through sub-assemblies, cost, mass, counts.
|
|
2
|
+
|
|
3
|
+
Every declared value is read through recording selectors so the ``bom`` rule's
|
|
4
|
+
dependencies are exact (``part://x#cost.unit``, ``asm://y#children``, ``part://x#props.mass``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from napkin2factory.core.loader import Product
|
|
14
|
+
from napkin2factory.core.model import Part
|
|
15
|
+
from napkin2factory.core.units import format_quantity, parse_quantity
|
|
16
|
+
from napkin2factory.geometry.accessors import ReadLog
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BomLine:
|
|
21
|
+
"""One indented BOM row."""
|
|
22
|
+
|
|
23
|
+
level: int
|
|
24
|
+
uri: str
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
kind: str
|
|
28
|
+
qty: int # quantity at this position (per parent)
|
|
29
|
+
total_qty: int # rolled-up quantity in the top assembly
|
|
30
|
+
process: str = ""
|
|
31
|
+
material: str = ""
|
|
32
|
+
finish: str = ""
|
|
33
|
+
tolerance_class: str = ""
|
|
34
|
+
unit_cost: float | None = None
|
|
35
|
+
cost_basis: str = ""
|
|
36
|
+
lead_time_days: float | None = None
|
|
37
|
+
mass_kg: float | None = None
|
|
38
|
+
mass_source: str = ""
|
|
39
|
+
source: str = ""
|
|
40
|
+
sourcing_status: str = ""
|
|
41
|
+
manufacturer: str = ""
|
|
42
|
+
rev: str = ""
|
|
43
|
+
children: list[BomLine] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ext_cost(self) -> float | None:
|
|
47
|
+
"""Extended cost (unit × total quantity)."""
|
|
48
|
+
return None if self.unit_cost is None else self.unit_cost * self.total_qty
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _props_mass(build_dir: Path | None, part: Part) -> tuple[float | None, str]:
|
|
52
|
+
if part.mass is not None:
|
|
53
|
+
return float(part.mass.to("kg").magnitude), "declared"
|
|
54
|
+
if build_dir is None:
|
|
55
|
+
return None, "not built"
|
|
56
|
+
p = build_dir / "parts" / part.id / "props.json"
|
|
57
|
+
if not p.is_file():
|
|
58
|
+
return None, "not built"
|
|
59
|
+
import json
|
|
60
|
+
|
|
61
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
62
|
+
return float(data["mass"]), str(data.get("mass_source", "computed"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def manufacturer_column(part: Part, qty: int) -> str:
|
|
66
|
+
"""The "what to tell the manufacturer" text for a row."""
|
|
67
|
+
if part.kind == "reference":
|
|
68
|
+
return f"supplied by the owner / already exists: {part.name} ({qty}×); not quoted"
|
|
69
|
+
if part.kind == "cots":
|
|
70
|
+
src = part.source
|
|
71
|
+
return f"buy {qty}× {part.name}" + (f" ({src.vendor} {src.mpn})" if src else "")
|
|
72
|
+
bits = [part.process]
|
|
73
|
+
if part.material:
|
|
74
|
+
bits.append(part.material.spec or part.material.name)
|
|
75
|
+
if part.finish:
|
|
76
|
+
bits.append(part.finish)
|
|
77
|
+
if part.tolerance_class:
|
|
78
|
+
bits.append(part.tolerance_class)
|
|
79
|
+
bits.append(f"qty {qty}")
|
|
80
|
+
if part.manufacturer_notes:
|
|
81
|
+
bits.append(part.manufacturer_notes)
|
|
82
|
+
return "; ".join(b for b in bits if b)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sourcing_status(part: Part) -> str:
|
|
86
|
+
"""Complete | unverified | missing — before the URL/catalog tiers run (M4 refines)."""
|
|
87
|
+
if part.kind == "reference":
|
|
88
|
+
return "reference"
|
|
89
|
+
if part.kind != "cots":
|
|
90
|
+
return "custom"
|
|
91
|
+
if part.source is None:
|
|
92
|
+
return "missing"
|
|
93
|
+
if not part.source.url:
|
|
94
|
+
return "no-url"
|
|
95
|
+
return "verified" if part.source.verified else "unverified"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_lines(
|
|
99
|
+
product: Product,
|
|
100
|
+
asm_id: str,
|
|
101
|
+
log: ReadLog,
|
|
102
|
+
build_dir: Path | None,
|
|
103
|
+
level: int = 0,
|
|
104
|
+
multiplier: int = 1,
|
|
105
|
+
) -> list[BomLine]:
|
|
106
|
+
"""Indented lines for one assembly, recursing into sub-assemblies."""
|
|
107
|
+
asm = product.assemblies[asm_id]
|
|
108
|
+
log.add(f"asm://{asm_id}#children")
|
|
109
|
+
lines: list[BomLine] = []
|
|
110
|
+
for child in asm.children:
|
|
111
|
+
if child.part is not None:
|
|
112
|
+
part = product.parts[child.part.id]
|
|
113
|
+
for sel in (
|
|
114
|
+
"name",
|
|
115
|
+
"kind",
|
|
116
|
+
"process",
|
|
117
|
+
"material",
|
|
118
|
+
"finish",
|
|
119
|
+
"tolerance_class",
|
|
120
|
+
"cost",
|
|
121
|
+
"lead_time",
|
|
122
|
+
"source",
|
|
123
|
+
"rev",
|
|
124
|
+
"mass",
|
|
125
|
+
"manufacturer_notes",
|
|
126
|
+
):
|
|
127
|
+
log.add(f"{part.uri}#{sel}")
|
|
128
|
+
mass, mass_src = _props_mass(build_dir, part)
|
|
129
|
+
log.add(f"{part.uri}#geometry")
|
|
130
|
+
if part.mass is None and part.has_geometry:
|
|
131
|
+
log.add(f"{part.uri}#props.mass")
|
|
132
|
+
lines.append(
|
|
133
|
+
BomLine(
|
|
134
|
+
level=level,
|
|
135
|
+
uri=part.uri,
|
|
136
|
+
id=part.id,
|
|
137
|
+
name=part.name,
|
|
138
|
+
kind=part.kind,
|
|
139
|
+
qty=child.qty,
|
|
140
|
+
total_qty=child.qty * multiplier,
|
|
141
|
+
process=part.process,
|
|
142
|
+
material=(part.material.spec or part.material.name) if part.material else "",
|
|
143
|
+
finish=part.finish or "",
|
|
144
|
+
tolerance_class=part.tolerance_class or "",
|
|
145
|
+
unit_cost=float(part.cost.unit.to("USD").magnitude) if part.cost else None,
|
|
146
|
+
cost_basis=part.cost.basis if part.cost else "",
|
|
147
|
+
lead_time_days=float(part.lead_time.to("day").magnitude)
|
|
148
|
+
if part.lead_time
|
|
149
|
+
else None,
|
|
150
|
+
mass_kg=mass,
|
|
151
|
+
mass_source=mass_src,
|
|
152
|
+
source=f"{part.source.vendor} {part.source.mpn}" if part.source else "",
|
|
153
|
+
sourcing_status=sourcing_status(part),
|
|
154
|
+
manufacturer=manufacturer_column(part, child.qty * multiplier),
|
|
155
|
+
rev=part.rev,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
elif child.asm is not None:
|
|
159
|
+
sub = product.assemblies[child.asm.id]
|
|
160
|
+
log.add(f"{sub.uri}#name")
|
|
161
|
+
log.add(f"{sub.uri}#rev")
|
|
162
|
+
line = BomLine(
|
|
163
|
+
level=level,
|
|
164
|
+
uri=sub.uri,
|
|
165
|
+
id=sub.id,
|
|
166
|
+
name=sub.name,
|
|
167
|
+
kind="assembly",
|
|
168
|
+
qty=child.qty,
|
|
169
|
+
total_qty=child.qty * multiplier,
|
|
170
|
+
rev=sub.rev,
|
|
171
|
+
)
|
|
172
|
+
line.children = build_lines(
|
|
173
|
+
product, sub.id, log, build_dir, level + 1, multiplier * child.qty
|
|
174
|
+
)
|
|
175
|
+
lines.append(line)
|
|
176
|
+
return lines
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def flatten_lines(lines: list[BomLine]) -> list[BomLine]:
|
|
180
|
+
"""Depth-first list including nested children."""
|
|
181
|
+
out: list[BomLine] = []
|
|
182
|
+
for ln in lines:
|
|
183
|
+
out.append(ln)
|
|
184
|
+
out.extend(flatten_lines(ln.children))
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def top_assemblies(product: Product) -> list[str]:
|
|
189
|
+
"""Assemblies no other assembly contains."""
|
|
190
|
+
used = {c.asm.id for a in product.assemblies.values() for c in a.children if c.asm is not None}
|
|
191
|
+
return sorted(a for a in product.assemblies if a not in used)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def compute_rollups(
|
|
195
|
+
product: Product, build_dir: Path | None = None, log: ReadLog | None = None
|
|
196
|
+
) -> dict[str, Any]:
|
|
197
|
+
"""The ``rollups.json`` payload: lines per top assembly plus totals."""
|
|
198
|
+
log = log if log is not None else ReadLog()
|
|
199
|
+
log.add("set://parts")
|
|
200
|
+
log.add("set://asms")
|
|
201
|
+
tops = top_assemblies(product)
|
|
202
|
+
per_part: dict[str, dict[str, Any]] = {}
|
|
203
|
+
structure: dict[str, list[dict[str, Any]]] = {}
|
|
204
|
+
for top in tops:
|
|
205
|
+
lines = build_lines(product, top, log, build_dir)
|
|
206
|
+
structure[f"asm://{top}"] = [line_to_dict(ln) for ln in lines]
|
|
207
|
+
for ln in flatten_lines(lines):
|
|
208
|
+
if ln.kind == "assembly":
|
|
209
|
+
continue
|
|
210
|
+
agg = per_part.setdefault(
|
|
211
|
+
ln.uri,
|
|
212
|
+
{
|
|
213
|
+
"uri": ln.uri,
|
|
214
|
+
"name": ln.name,
|
|
215
|
+
"kind": ln.kind,
|
|
216
|
+
"total_qty": 0,
|
|
217
|
+
"unit_cost": ln.unit_cost,
|
|
218
|
+
"lead_time_days": ln.lead_time_days,
|
|
219
|
+
"mass_kg": ln.mass_kg,
|
|
220
|
+
"sourcing_status": ln.sourcing_status,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
agg["total_qty"] += ln.total_qty
|
|
224
|
+
total_cost = 0.0
|
|
225
|
+
cost_known = True
|
|
226
|
+
total_mass = 0.0
|
|
227
|
+
mass_known = True
|
|
228
|
+
max_lead = 0.0
|
|
229
|
+
custom = 0
|
|
230
|
+
part_count = 0
|
|
231
|
+
for agg in per_part.values():
|
|
232
|
+
part_count += agg["total_qty"]
|
|
233
|
+
if agg["kind"] == "custom":
|
|
234
|
+
custom += agg["total_qty"]
|
|
235
|
+
if agg["kind"] == "reference":
|
|
236
|
+
pass # not bought, not made: contributes mass but no cost
|
|
237
|
+
elif agg["unit_cost"] is None:
|
|
238
|
+
cost_known = False
|
|
239
|
+
else:
|
|
240
|
+
total_cost += agg["unit_cost"] * agg["total_qty"]
|
|
241
|
+
if agg["mass_kg"] is None:
|
|
242
|
+
mass_known = False
|
|
243
|
+
else:
|
|
244
|
+
total_mass += agg["mass_kg"] * agg["total_qty"]
|
|
245
|
+
if agg["lead_time_days"] is not None:
|
|
246
|
+
max_lead = max(max_lead, agg["lead_time_days"])
|
|
247
|
+
# Parts in no assembly still count for sourcing but not for totals.
|
|
248
|
+
orphans = sorted(p.uri for p in product.parts.values() if p.uri not in per_part)
|
|
249
|
+
return {
|
|
250
|
+
"top_assemblies": [f"asm://{t}" for t in tops],
|
|
251
|
+
"structure": structure,
|
|
252
|
+
"parts": dict(sorted(per_part.items())),
|
|
253
|
+
"orphans": orphans,
|
|
254
|
+
"total_cost": round(total_cost, 2),
|
|
255
|
+
"total_cost_unit": "USD",
|
|
256
|
+
"cost_complete": cost_known,
|
|
257
|
+
"total_mass": round(total_mass, 4),
|
|
258
|
+
"total_mass_unit": "kg",
|
|
259
|
+
"mass_complete": mass_known,
|
|
260
|
+
"part_count": part_count,
|
|
261
|
+
"distinct_parts": len(per_part),
|
|
262
|
+
"custom_count": custom,
|
|
263
|
+
"custom_ratio": round(custom / part_count, 4) if part_count else 0.0,
|
|
264
|
+
"max_lead_time": max_lead,
|
|
265
|
+
"max_lead_time_unit": "d",
|
|
266
|
+
"reads": log.keys,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def line_to_dict(ln: BomLine) -> dict[str, Any]:
|
|
271
|
+
"""JSON form of a line (recursive)."""
|
|
272
|
+
d = {k: v for k, v in ln.__dict__.items() if k != "children"}
|
|
273
|
+
d["ext_cost"] = ln.ext_cost
|
|
274
|
+
d["children"] = [line_to_dict(c) for c in ln.children]
|
|
275
|
+
return d
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def rollup_value(rollups: dict[str, Any], selector: str) -> Any:
|
|
279
|
+
"""``bom://rollups#total_cost`` → a pint quantity (or plain number) for the checks."""
|
|
280
|
+
units = {"total_cost": "USD", "total_mass": "kg", "max_lead_time": "d"}
|
|
281
|
+
if selector not in rollups:
|
|
282
|
+
raise KeyError(
|
|
283
|
+
f"bom://rollups has no {selector!r}; available: total_cost, total_mass, "
|
|
284
|
+
"part_count, custom_ratio, max_lead_time"
|
|
285
|
+
)
|
|
286
|
+
val = rollups[selector]
|
|
287
|
+
return parse_quantity(val, units.get(selector)) if isinstance(val, (int, float)) else val
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
__all__ = [
|
|
291
|
+
"BomLine",
|
|
292
|
+
"compute_rollups",
|
|
293
|
+
"flatten_lines",
|
|
294
|
+
"format_quantity",
|
|
295
|
+
"rollup_value",
|
|
296
|
+
"top_assemblies",
|
|
297
|
+
]
|