@structupath/pi-steel 0.2.2 → 0.2.3
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/DATA_PROVENANCE.json +23 -0
- package/DATA_PROVENANCE.md +35 -0
- package/PUBLIC_DATA_POLICY.md +52 -0
- package/README.md +114 -33
- package/docs/assets/pi-steel-demo.gif +0 -0
- package/docs/assets/pi-steel-gallery.webp +0 -0
- package/package.json +35 -4
- package/pyproject.toml +28 -0
- package/requirements-dev.txt +5 -0
- package/requirements-render.txt +1 -0
- package/requirements-tested.txt +9 -0
- package/requirements.txt +7 -0
- package/scripts/check-data-provenance.py +140 -0
- package/scripts/check-public-data.py +401 -0
- package/scripts/doctor.py +127 -0
- package/skills/_shared/bootstrap.py +32 -0
- package/skills/_shared/pi_steel/__init__.py +47 -0
- package/skills/_shared/pi_steel/cli.py +167 -0
- package/skills/_shared/pi_steel/contracts.py +87 -0
- package/skills/_shared/pi_steel/geometry_verify.py +243 -0
- package/skills/_shared/pi_steel/parsing.py +205 -0
- package/skills/_shared/pi_steel/run_manifest.py +317 -0
- package/skills/_shared/pi_steel/validation.py +597 -0
- package/skills/_shared/schemas/estimate-package.schema.json +424 -0
- package/skills/_shared/schemas/nest-result.schema.json +443 -0
- package/skills/_shared/schemas/run-manifest.schema.json +145 -0
- package/skills/steel-estimate/SKILL.md +119 -0
- package/skills/steel-estimate/references/estimate-package-example.json +91 -0
- package/skills/steel-estimate/references/output-contract.md +47 -0
- package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
- package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
- package/skills/steel-nest/SKILL.md +50 -23
- package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
- package/skills/steel-nest/references/example_job.json +21 -25
- package/skills/steel-nest/references/job_template.json +11 -9
- package/skills/steel-nest/scripts/nest.py +1276 -250
- package/skills/steel-rfq/SKILL.md +93 -175
- package/skills/steel-rfq/assets/company-profile.example.json +11 -5
- package/skills/steel-rfq/references/rfq-input.md +58 -0
- package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
- package/skills/steel-rfq/scripts/recalc.py +33 -10
- package/skills/steel-takeoff/SKILL.md +12 -8
- package/skills/steel-takeoff/assets/bom-template.csv +1 -1
- package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
- package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
- package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
- package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Schema and domain validation for canonical estimate packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import jsonschema
|
|
13
|
+
|
|
14
|
+
from .contracts import (
|
|
15
|
+
ESTIMATE_PACKAGE_VERSION,
|
|
16
|
+
content_hash,
|
|
17
|
+
estimate_input_hash,
|
|
18
|
+
finding_id_for,
|
|
19
|
+
)
|
|
20
|
+
from .geometry_verify import (
|
|
21
|
+
SUPPORTED_SHAPES,
|
|
22
|
+
finite_positive,
|
|
23
|
+
hole_within_bounds,
|
|
24
|
+
net_area,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_SCHEMA_PATH = (
|
|
29
|
+
Path(__file__).resolve().parents[1] / "schemas" / "estimate-package.schema.json"
|
|
30
|
+
)
|
|
31
|
+
_REVIEWABLE_BLOCKERS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"missing_material_basis",
|
|
34
|
+
"unconfirmed_on_hand_stock",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ValidationResult:
|
|
41
|
+
status: str
|
|
42
|
+
input_hash: str
|
|
43
|
+
findings: list[dict[str, Any]]
|
|
44
|
+
active_findings: list[dict[str, Any]]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def blockers(self) -> list[dict[str, Any]]:
|
|
48
|
+
return [
|
|
49
|
+
finding
|
|
50
|
+
for finding in self.active_findings
|
|
51
|
+
if finding["severity"] == "blocker"
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def warnings(self) -> list[dict[str, Any]]:
|
|
56
|
+
return [
|
|
57
|
+
finding
|
|
58
|
+
for finding in self.active_findings
|
|
59
|
+
if finding["severity"] == "warning"
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _path(parts: Any) -> str:
|
|
64
|
+
result = "$"
|
|
65
|
+
for part in parts:
|
|
66
|
+
result += f"[{part}]" if isinstance(part, int) else f".{part}"
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _finding(
|
|
71
|
+
*,
|
|
72
|
+
code: str,
|
|
73
|
+
severity: str,
|
|
74
|
+
path: str,
|
|
75
|
+
message: str,
|
|
76
|
+
relevant_hash: str,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
return {
|
|
79
|
+
"finding_id": finding_id_for(code, path),
|
|
80
|
+
"code": code,
|
|
81
|
+
"severity": severity,
|
|
82
|
+
"path": path,
|
|
83
|
+
"message": message,
|
|
84
|
+
"relevant_hash": relevant_hash,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _add(
|
|
89
|
+
findings: list[dict[str, Any]],
|
|
90
|
+
input_hash: str,
|
|
91
|
+
code: str,
|
|
92
|
+
severity: str,
|
|
93
|
+
path: str,
|
|
94
|
+
message: str,
|
|
95
|
+
) -> None:
|
|
96
|
+
findings.append(
|
|
97
|
+
_finding(
|
|
98
|
+
code=code,
|
|
99
|
+
severity=severity,
|
|
100
|
+
path=path,
|
|
101
|
+
message=message,
|
|
102
|
+
relevant_hash=input_hash,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@lru_cache(maxsize=1)
|
|
108
|
+
def _schema_validator():
|
|
109
|
+
schema = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
110
|
+
return jsonschema.Draft202012Validator(
|
|
111
|
+
schema, format_checker=jsonschema.FormatChecker()
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _schema_findings(
|
|
116
|
+
package: dict[str, Any], input_hash: str
|
|
117
|
+
) -> list[dict[str, Any]]:
|
|
118
|
+
findings = []
|
|
119
|
+
for error in sorted(
|
|
120
|
+
_schema_validator().iter_errors(package),
|
|
121
|
+
key=lambda item: list(item.path),
|
|
122
|
+
):
|
|
123
|
+
_add(
|
|
124
|
+
findings,
|
|
125
|
+
input_hash,
|
|
126
|
+
"schema_validation",
|
|
127
|
+
"blocker",
|
|
128
|
+
_path(error.absolute_path),
|
|
129
|
+
error.message,
|
|
130
|
+
)
|
|
131
|
+
return findings
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _nonfinite_number_findings(
|
|
135
|
+
value: Any,
|
|
136
|
+
input_hash: str,
|
|
137
|
+
*,
|
|
138
|
+
path: str = "$",
|
|
139
|
+
) -> list[dict[str, Any]]:
|
|
140
|
+
"""Reject non-JSON numeric values throughout the canonical package.
|
|
141
|
+
|
|
142
|
+
Python's JSON and JSON Schema implementations can accept NaN and infinity
|
|
143
|
+
even though they are not interoperable JSON values. Keep this domain guard
|
|
144
|
+
recursive so newly added numeric contract fields are protected by default.
|
|
145
|
+
"""
|
|
146
|
+
findings: list[dict[str, Any]] = []
|
|
147
|
+
if isinstance(value, dict):
|
|
148
|
+
for key, child in value.items():
|
|
149
|
+
findings.extend(
|
|
150
|
+
_nonfinite_number_findings(
|
|
151
|
+
child,
|
|
152
|
+
input_hash,
|
|
153
|
+
path=f"{path}.{key}",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
elif isinstance(value, list):
|
|
157
|
+
for index, child in enumerate(value):
|
|
158
|
+
findings.extend(
|
|
159
|
+
_nonfinite_number_findings(
|
|
160
|
+
child,
|
|
161
|
+
input_hash,
|
|
162
|
+
path=f"{path}[{index}]",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
elif isinstance(value, float) and not math.isfinite(value):
|
|
166
|
+
_add(
|
|
167
|
+
findings,
|
|
168
|
+
input_hash,
|
|
169
|
+
"nonfinite_number",
|
|
170
|
+
"blocker",
|
|
171
|
+
path,
|
|
172
|
+
"Numeric values must be finite JSON numbers.",
|
|
173
|
+
)
|
|
174
|
+
return findings
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _merge_supplied_review_findings(
|
|
178
|
+
package: dict[str, Any],
|
|
179
|
+
input_hash: str,
|
|
180
|
+
findings: list[dict[str, Any]],
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Carry canonical review findings forward and expose stale review state."""
|
|
183
|
+
existing = {
|
|
184
|
+
(finding["finding_id"], finding["relevant_hash"])
|
|
185
|
+
for finding in findings
|
|
186
|
+
}
|
|
187
|
+
for index, supplied in enumerate(
|
|
188
|
+
package.get("review", {}).get("findings", [])
|
|
189
|
+
):
|
|
190
|
+
key = (supplied["finding_id"], supplied["relevant_hash"])
|
|
191
|
+
if key not in existing:
|
|
192
|
+
findings.append(dict(supplied))
|
|
193
|
+
existing.add(key)
|
|
194
|
+
if supplied["relevant_hash"] != input_hash:
|
|
195
|
+
stale = _finding(
|
|
196
|
+
code="stale_review_finding",
|
|
197
|
+
severity="blocker",
|
|
198
|
+
path=f"$.review.findings[{index}].relevant_hash",
|
|
199
|
+
message=(
|
|
200
|
+
f"Review finding {supplied['finding_id']!r} was produced for "
|
|
201
|
+
"a different estimate input and must be regenerated."
|
|
202
|
+
),
|
|
203
|
+
relevant_hash=input_hash,
|
|
204
|
+
)
|
|
205
|
+
stale_key = (stale["finding_id"], stale["relevant_hash"])
|
|
206
|
+
if stale_key not in existing:
|
|
207
|
+
findings.append(stale)
|
|
208
|
+
existing.add(stale_key)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _geometry_findings(
|
|
212
|
+
item: dict[str, Any],
|
|
213
|
+
index: int,
|
|
214
|
+
input_hash: str,
|
|
215
|
+
findings: list[dict[str, Any]],
|
|
216
|
+
) -> None:
|
|
217
|
+
geometry = item.get("geometry")
|
|
218
|
+
if not isinstance(geometry, dict):
|
|
219
|
+
return
|
|
220
|
+
base = f"$.items[{index}].geometry"
|
|
221
|
+
shape = geometry.get("shape")
|
|
222
|
+
if shape not in SUPPORTED_SHAPES:
|
|
223
|
+
_add(
|
|
224
|
+
findings,
|
|
225
|
+
input_hash,
|
|
226
|
+
"unsupported_shape",
|
|
227
|
+
"blocker",
|
|
228
|
+
f"{base}.shape",
|
|
229
|
+
f"Unsupported shape {shape!r}; expected rect or irregular.",
|
|
230
|
+
)
|
|
231
|
+
dimensions = ("width", "height", "thickness")
|
|
232
|
+
for field in dimensions:
|
|
233
|
+
value = geometry.get(field)
|
|
234
|
+
if isinstance(value, (int, float)) and not math.isfinite(value):
|
|
235
|
+
_add(
|
|
236
|
+
findings,
|
|
237
|
+
input_hash,
|
|
238
|
+
"nonfinite_dimension",
|
|
239
|
+
"blocker",
|
|
240
|
+
f"{base}.{field}",
|
|
241
|
+
f"{field} must be finite.",
|
|
242
|
+
)
|
|
243
|
+
elif not finite_positive(value):
|
|
244
|
+
_add(
|
|
245
|
+
findings,
|
|
246
|
+
input_hash,
|
|
247
|
+
"nonpositive_dimension",
|
|
248
|
+
"blocker",
|
|
249
|
+
f"{base}.{field}",
|
|
250
|
+
f"{field} must be greater than zero.",
|
|
251
|
+
)
|
|
252
|
+
width, height = geometry.get("width"), geometry.get("height")
|
|
253
|
+
if finite_positive(width) and finite_positive(height):
|
|
254
|
+
for hole_index, hole in enumerate(geometry.get("holes", [])):
|
|
255
|
+
if not hole_within_bounds(hole, width, height):
|
|
256
|
+
_add(
|
|
257
|
+
findings,
|
|
258
|
+
input_hash,
|
|
259
|
+
"hole_out_of_bounds",
|
|
260
|
+
"blocker",
|
|
261
|
+
f"{base}.holes[{hole_index}]",
|
|
262
|
+
"Hole geometry must be positive and contained by the part.",
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
area = net_area(geometry)
|
|
266
|
+
except (TypeError, ValueError, OverflowError):
|
|
267
|
+
area = math.nan
|
|
268
|
+
if not math.isfinite(area) or area <= 0:
|
|
269
|
+
_add(
|
|
270
|
+
findings,
|
|
271
|
+
input_hash,
|
|
272
|
+
"nonpositive_net_area",
|
|
273
|
+
"blocker",
|
|
274
|
+
base,
|
|
275
|
+
"Part net area after holes must be greater than zero.",
|
|
276
|
+
)
|
|
277
|
+
if shape == "irregular" and not finite_positive(geometry.get("area")):
|
|
278
|
+
_add(
|
|
279
|
+
findings,
|
|
280
|
+
input_hash,
|
|
281
|
+
"invalid_irregular_area",
|
|
282
|
+
"blocker",
|
|
283
|
+
f"{base}.area",
|
|
284
|
+
"Irregular parts require a positive true-cut area.",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def validate_estimate_package(package: dict[str, Any]) -> ValidationResult:
|
|
289
|
+
if not isinstance(package, dict):
|
|
290
|
+
input_hash = content_hash(package)
|
|
291
|
+
finding = _finding(
|
|
292
|
+
code="schema_validation",
|
|
293
|
+
severity="blocker",
|
|
294
|
+
path="$",
|
|
295
|
+
message="Estimate package must be a JSON object.",
|
|
296
|
+
relevant_hash=input_hash,
|
|
297
|
+
)
|
|
298
|
+
return ValidationResult("invalid", input_hash, [finding], [finding])
|
|
299
|
+
input_hash = estimate_input_hash(package)
|
|
300
|
+
version = package.get("schema_version")
|
|
301
|
+
if version != ESTIMATE_PACKAGE_VERSION:
|
|
302
|
+
finding = _finding(
|
|
303
|
+
code="unsupported_contract_version",
|
|
304
|
+
severity="blocker",
|
|
305
|
+
path="$.schema_version",
|
|
306
|
+
message=(
|
|
307
|
+
f"Unsupported estimate package version {version!r}; "
|
|
308
|
+
f"migrate to {ESTIMATE_PACKAGE_VERSION} before processing."
|
|
309
|
+
),
|
|
310
|
+
relevant_hash=input_hash,
|
|
311
|
+
)
|
|
312
|
+
return ValidationResult("invalid", input_hash, [finding], [finding])
|
|
313
|
+
|
|
314
|
+
findings = _nonfinite_number_findings(package, input_hash)
|
|
315
|
+
schema_findings = _schema_findings(package, input_hash)
|
|
316
|
+
if schema_findings:
|
|
317
|
+
findings.extend(schema_findings)
|
|
318
|
+
# Retain the pre-existing field-specific diagnostics for the common
|
|
319
|
+
# scalar errors that are now also constrained directly by the schema.
|
|
320
|
+
items = package.get("items", [])
|
|
321
|
+
if isinstance(items, list):
|
|
322
|
+
for index, item in enumerate(items):
|
|
323
|
+
if not isinstance(item, dict):
|
|
324
|
+
continue
|
|
325
|
+
quantity = item.get("quantity")
|
|
326
|
+
if (
|
|
327
|
+
not isinstance(quantity, int)
|
|
328
|
+
or isinstance(quantity, bool)
|
|
329
|
+
or quantity <= 0
|
|
330
|
+
):
|
|
331
|
+
_add(
|
|
332
|
+
findings,
|
|
333
|
+
input_hash,
|
|
334
|
+
"invalid_quantity",
|
|
335
|
+
"blocker",
|
|
336
|
+
f"$.items[{index}].quantity",
|
|
337
|
+
"Quantity must be a positive integer.",
|
|
338
|
+
)
|
|
339
|
+
if item.get("intent") == "fabricated_part":
|
|
340
|
+
_geometry_findings(item, index, input_hash, findings)
|
|
341
|
+
return ValidationResult("invalid", input_hash, findings, findings)
|
|
342
|
+
source_ids: dict[str, int] = {}
|
|
343
|
+
item_ids: dict[str, int] = {}
|
|
344
|
+
for index, item in enumerate(package.get("items", [])):
|
|
345
|
+
base = f"$.items[{index}]"
|
|
346
|
+
quantity = item.get("quantity")
|
|
347
|
+
if (
|
|
348
|
+
not isinstance(quantity, int)
|
|
349
|
+
or isinstance(quantity, bool)
|
|
350
|
+
or quantity <= 0
|
|
351
|
+
):
|
|
352
|
+
_add(
|
|
353
|
+
findings,
|
|
354
|
+
input_hash,
|
|
355
|
+
"invalid_quantity",
|
|
356
|
+
"blocker",
|
|
357
|
+
f"{base}.quantity",
|
|
358
|
+
"Quantity must be a positive integer.",
|
|
359
|
+
)
|
|
360
|
+
for field, seen, code in (
|
|
361
|
+
("source_id", source_ids, "duplicate_source_id"),
|
|
362
|
+
("item_id", item_ids, "duplicate_item_id"),
|
|
363
|
+
):
|
|
364
|
+
identity = item.get(field)
|
|
365
|
+
if identity in seen:
|
|
366
|
+
_add(
|
|
367
|
+
findings,
|
|
368
|
+
input_hash,
|
|
369
|
+
code,
|
|
370
|
+
"blocker",
|
|
371
|
+
f"{base}.{field}",
|
|
372
|
+
f"{field} duplicates item {seen[identity]}; rows were not merged.",
|
|
373
|
+
)
|
|
374
|
+
elif identity is not None:
|
|
375
|
+
seen[identity] = index
|
|
376
|
+
|
|
377
|
+
if item.get("identity_warning"):
|
|
378
|
+
_add(
|
|
379
|
+
findings,
|
|
380
|
+
input_hash,
|
|
381
|
+
"unstable_source_identity",
|
|
382
|
+
"warning",
|
|
383
|
+
f"{base}.source_id",
|
|
384
|
+
"No explicit source ID or stable mark was supplied; identity is revision-scoped.",
|
|
385
|
+
)
|
|
386
|
+
if not item.get("source_evidence"):
|
|
387
|
+
_add(
|
|
388
|
+
findings,
|
|
389
|
+
input_hash,
|
|
390
|
+
"missing_source_evidence",
|
|
391
|
+
"warning",
|
|
392
|
+
f"{base}.source_evidence",
|
|
393
|
+
"No drawing or structured-source locator was supplied; none was invented.",
|
|
394
|
+
)
|
|
395
|
+
if item.get("intent") == "fabricated_part":
|
|
396
|
+
_geometry_findings(item, index, input_hash, findings)
|
|
397
|
+
if item.get("geometry") and (
|
|
398
|
+
not item.get("material")
|
|
399
|
+
or not item.get("grade")
|
|
400
|
+
or not finite_positive(item.get("geometry", {}).get("thickness"))
|
|
401
|
+
):
|
|
402
|
+
_add(
|
|
403
|
+
findings,
|
|
404
|
+
input_hash,
|
|
405
|
+
"missing_material_basis",
|
|
406
|
+
"blocker",
|
|
407
|
+
base,
|
|
408
|
+
"Plate material, grade, and thickness must be explicit before nesting, RFQ, or burn.",
|
|
409
|
+
)
|
|
410
|
+
if not item.get("geometry"):
|
|
411
|
+
if not item.get("designation"):
|
|
412
|
+
_add(
|
|
413
|
+
findings,
|
|
414
|
+
input_hash,
|
|
415
|
+
"missing_designation",
|
|
416
|
+
"blocker",
|
|
417
|
+
f"{base}.designation",
|
|
418
|
+
"A legacy member requires a section designation.",
|
|
419
|
+
)
|
|
420
|
+
for field, code, label in (
|
|
421
|
+
("length_ft", "invalid_member_length", "Member length"),
|
|
422
|
+
(
|
|
423
|
+
"unit_weight_plf",
|
|
424
|
+
"invalid_unit_weight",
|
|
425
|
+
"Member unit weight",
|
|
426
|
+
),
|
|
427
|
+
):
|
|
428
|
+
if not finite_positive(item.get(field)):
|
|
429
|
+
_add(
|
|
430
|
+
findings,
|
|
431
|
+
input_hash,
|
|
432
|
+
code,
|
|
433
|
+
"blocker",
|
|
434
|
+
f"{base}.{field}",
|
|
435
|
+
f"{label} must be finite and greater than zero.",
|
|
436
|
+
)
|
|
437
|
+
if not item.get("grade"):
|
|
438
|
+
_add(
|
|
439
|
+
findings,
|
|
440
|
+
input_hash,
|
|
441
|
+
"missing_grade",
|
|
442
|
+
"warning",
|
|
443
|
+
f"{base}.grade",
|
|
444
|
+
"No material grade was supplied; none was inferred.",
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
for index, stock in enumerate(package.get("stock", [])):
|
|
448
|
+
if stock.get("stock_kind") != "on_hand":
|
|
449
|
+
continue
|
|
450
|
+
required = (
|
|
451
|
+
stock.get("inventory_id"),
|
|
452
|
+
stock.get("measured_at"),
|
|
453
|
+
stock.get("source"),
|
|
454
|
+
stock.get("status") in {"available", "reserved"},
|
|
455
|
+
)
|
|
456
|
+
confirmation = stock.get("reviewer_confirmation", {})
|
|
457
|
+
confirmed = (
|
|
458
|
+
all(required)
|
|
459
|
+
and confirmation.get("estimate_hash") == input_hash
|
|
460
|
+
and confirmation.get("actor")
|
|
461
|
+
and confirmation.get("timestamp")
|
|
462
|
+
)
|
|
463
|
+
if not confirmed:
|
|
464
|
+
_add(
|
|
465
|
+
findings,
|
|
466
|
+
input_hash,
|
|
467
|
+
"unconfirmed_on_hand_stock",
|
|
468
|
+
"blocker",
|
|
469
|
+
f"$.stock[{index}]",
|
|
470
|
+
"On-hand stock cannot reduce purchasing without traceable measurements and hash-bound reviewer confirmation.",
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
basis = package.get("commercial_basis", {})
|
|
474
|
+
for index, cost in enumerate(basis.get("costs", [])):
|
|
475
|
+
if cost.get("currency") != basis.get("currency") or not all(
|
|
476
|
+
cost.get(field)
|
|
477
|
+
for field in ("unit_basis", "effective_date", "source")
|
|
478
|
+
):
|
|
479
|
+
_add(
|
|
480
|
+
findings,
|
|
481
|
+
input_hash,
|
|
482
|
+
"invalid_cost_basis",
|
|
483
|
+
"blocker",
|
|
484
|
+
f"$.commercial_basis.costs[{index}]",
|
|
485
|
+
"Cost currency, unit basis, effective date, and source must be preserved.",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
for index, assumption in enumerate(package.get("assumptions", [])):
|
|
489
|
+
if assumption.get("status") == "unresolved":
|
|
490
|
+
_add(
|
|
491
|
+
findings,
|
|
492
|
+
input_hash,
|
|
493
|
+
"unresolved_assumption",
|
|
494
|
+
"warning",
|
|
495
|
+
f"$.assumptions[{index}]",
|
|
496
|
+
(
|
|
497
|
+
f"Assumption {assumption['assumption_id']!r} remains "
|
|
498
|
+
"unresolved and requires estimator review."
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
_merge_supplied_review_findings(package, input_hash, findings)
|
|
503
|
+
|
|
504
|
+
acknowledgements = package.get("review", {}).get("acknowledgements", [])
|
|
505
|
+
accepted_findings = {
|
|
506
|
+
(acknowledgement.get("finding_id"), acknowledgement.get("input_hash"))
|
|
507
|
+
for acknowledgement in acknowledgements
|
|
508
|
+
if acknowledgement.get("disposition") == "accepted"
|
|
509
|
+
}
|
|
510
|
+
active = []
|
|
511
|
+
for finding in findings:
|
|
512
|
+
acknowledged = (
|
|
513
|
+
finding["severity"] != "blocker"
|
|
514
|
+
and (finding["finding_id"], finding["relevant_hash"])
|
|
515
|
+
in accepted_findings
|
|
516
|
+
)
|
|
517
|
+
if not acknowledged:
|
|
518
|
+
active.append(finding)
|
|
519
|
+
|
|
520
|
+
blockers = [f for f in active if f["severity"] == "blocker"]
|
|
521
|
+
if blockers:
|
|
522
|
+
status = (
|
|
523
|
+
"review_required"
|
|
524
|
+
if all(f["code"] in _REVIEWABLE_BLOCKERS for f in blockers)
|
|
525
|
+
else "invalid"
|
|
526
|
+
)
|
|
527
|
+
elif any(f["severity"] == "warning" for f in active):
|
|
528
|
+
status = "review_required"
|
|
529
|
+
else:
|
|
530
|
+
status = "validated"
|
|
531
|
+
return ValidationResult(status, input_hash, findings, active)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def acknowledge_finding(
|
|
535
|
+
package: dict[str, Any],
|
|
536
|
+
finding: dict[str, Any],
|
|
537
|
+
*,
|
|
538
|
+
actor: str,
|
|
539
|
+
timestamp: str,
|
|
540
|
+
disposition: str,
|
|
541
|
+
) -> dict[str, Any]:
|
|
542
|
+
if disposition not in {"accepted", "rejected", "deferred"}:
|
|
543
|
+
raise ValueError("unsupported acknowledgement disposition")
|
|
544
|
+
acknowledgement = {
|
|
545
|
+
"finding_id": finding["finding_id"],
|
|
546
|
+
"actor": actor,
|
|
547
|
+
"timestamp": timestamp,
|
|
548
|
+
"disposition": disposition,
|
|
549
|
+
"input_hash": finding["relevant_hash"],
|
|
550
|
+
}
|
|
551
|
+
package.setdefault("review", {}).setdefault("acknowledgements", []).append(
|
|
552
|
+
acknowledgement
|
|
553
|
+
)
|
|
554
|
+
return acknowledgement
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def eligible_on_hand_stock(package: dict[str, Any]) -> list[dict[str, Any]]:
|
|
558
|
+
input_hash = estimate_input_hash(package)
|
|
559
|
+
eligible = []
|
|
560
|
+
for stock in package.get("stock", []):
|
|
561
|
+
confirmation = stock.get("reviewer_confirmation", {})
|
|
562
|
+
if (
|
|
563
|
+
stock.get("stock_kind") == "on_hand"
|
|
564
|
+
and stock.get("inventory_id")
|
|
565
|
+
and finite_positive(stock.get("width"))
|
|
566
|
+
and finite_positive(stock.get("height"))
|
|
567
|
+
and finite_positive(stock.get("thickness"))
|
|
568
|
+
and stock.get("measured_at")
|
|
569
|
+
and stock.get("source")
|
|
570
|
+
and stock.get("status") == "available"
|
|
571
|
+
and confirmation.get("actor")
|
|
572
|
+
and confirmation.get("timestamp")
|
|
573
|
+
and confirmation.get("estimate_hash") == input_hash
|
|
574
|
+
):
|
|
575
|
+
eligible.append(stock)
|
|
576
|
+
return eligible
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def purchasable_items(package: dict[str, Any]) -> list[dict[str, Any]]:
|
|
580
|
+
return [
|
|
581
|
+
item
|
|
582
|
+
for item in package.get("items", [])
|
|
583
|
+
if item.get("intent") in {"fabricated_part", "purchased_stock", "hardware"}
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def stock_requiring_purchase(package: dict[str, Any]) -> list[dict[str, Any]]:
|
|
588
|
+
"""Return only stock explicitly modeled as purchasable.
|
|
589
|
+
|
|
590
|
+
On-hand entries that are unavailable or not confirmed remain validation
|
|
591
|
+
findings; they never silently become vendor demand.
|
|
592
|
+
"""
|
|
593
|
+
return [
|
|
594
|
+
stock
|
|
595
|
+
for stock in package.get("stock", [])
|
|
596
|
+
if stock.get("stock_kind") == "purchasable"
|
|
597
|
+
]
|