@structupath/pi-steel 0.2.1 → 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.
Files changed (48) hide show
  1. package/DATA_PROVENANCE.json +23 -0
  2. package/DATA_PROVENANCE.md +35 -0
  3. package/PUBLIC_DATA_POLICY.md +52 -0
  4. package/README.md +114 -33
  5. package/docs/assets/pi-steel-demo.gif +0 -0
  6. package/docs/assets/pi-steel-gallery.webp +0 -0
  7. package/package.json +35 -4
  8. package/pyproject.toml +28 -0
  9. package/requirements-dev.txt +5 -0
  10. package/requirements-render.txt +1 -0
  11. package/requirements-tested.txt +9 -0
  12. package/requirements.txt +7 -0
  13. package/scripts/check-data-provenance.py +140 -0
  14. package/scripts/check-public-data.py +401 -0
  15. package/scripts/doctor.py +127 -0
  16. package/skills/_shared/bootstrap.py +32 -0
  17. package/skills/_shared/pi_steel/__init__.py +47 -0
  18. package/skills/_shared/pi_steel/cli.py +167 -0
  19. package/skills/_shared/pi_steel/contracts.py +87 -0
  20. package/skills/_shared/pi_steel/geometry_verify.py +243 -0
  21. package/skills/_shared/pi_steel/parsing.py +205 -0
  22. package/skills/_shared/pi_steel/run_manifest.py +317 -0
  23. package/skills/_shared/pi_steel/validation.py +597 -0
  24. package/skills/_shared/schemas/estimate-package.schema.json +424 -0
  25. package/skills/_shared/schemas/nest-result.schema.json +443 -0
  26. package/skills/_shared/schemas/run-manifest.schema.json +145 -0
  27. package/skills/steel-estimate/SKILL.md +119 -0
  28. package/skills/steel-estimate/references/estimate-package-example.json +91 -0
  29. package/skills/steel-estimate/references/output-contract.md +47 -0
  30. package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
  31. package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
  32. package/skills/steel-nest/SKILL.md +50 -23
  33. package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
  34. package/skills/steel-nest/references/example_job.json +21 -25
  35. package/skills/steel-nest/references/job_template.json +11 -9
  36. package/skills/steel-nest/scripts/nest.py +1276 -250
  37. package/skills/steel-rfq/SKILL.md +93 -175
  38. package/skills/steel-rfq/assets/company-profile.example.json +11 -5
  39. package/skills/steel-rfq/references/rfq-input.md +58 -0
  40. package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
  41. package/skills/steel-rfq/scripts/recalc.py +33 -10
  42. package/skills/steel-takeoff/SKILL.md +12 -8
  43. package/skills/steel-takeoff/assets/bom-template.csv +1 -1
  44. package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
  45. package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
  46. package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
  47. package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
  48. package/skills/steel-rfq/scripts/__pycache__/recalc.cpython-312.pyc +0 -0
@@ -0,0 +1,1221 @@
1
+ #!/usr/bin/env python3
2
+ """Compile a deterministic, draft-only steel RFQ workbook."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import importlib.util
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ from datetime import date
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import openpyxl
18
+ import jsonschema
19
+ from openpyxl import Workbook
20
+ from openpyxl.drawing.image import Image
21
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
22
+
23
+
24
+ SHARED_ROOT = Path(__file__).resolve().parents[2] / "_shared"
25
+ if str(SHARED_ROOT) not in sys.path:
26
+ sys.path.insert(0, str(SHARED_ROOT))
27
+ from bootstrap import bootstrap_shared # noqa: E402
28
+
29
+ bootstrap_shared(__file__)
30
+ from pi_steel import ( # noqa: E402
31
+ RunPublisher,
32
+ StageArgumentParser,
33
+ canonical_json_bytes,
34
+ outcome_exit_code,
35
+ package_version,
36
+ publish_failure_diagnostic,
37
+ sha256_bytes,
38
+ )
39
+ from pi_steel.contracts import ESTIMATE_PACKAGE_VERSION # noqa: E402
40
+ from pi_steel.validation import validate_estimate_package # noqa: E402
41
+
42
+ RECALC_PATH = Path(__file__).with_name("recalc.py")
43
+ RECALC_SPEC = importlib.util.spec_from_file_location("pi_steel_rfq_recalc", RECALC_PATH)
44
+ recalc = importlib.util.module_from_spec(RECALC_SPEC)
45
+ RECALC_SPEC.loader.exec_module(recalc)
46
+
47
+
48
+ RFQ_COMPILER_VERSION = "1.0.0"
49
+ NEST_HANDOFF_VERSION = "1.0.0"
50
+ NEST_RESULT_SCHEMA_PATH = (
51
+ SHARED_ROOT / "schemas" / "nest-result.schema.json"
52
+ )
53
+ HEADERS = [
54
+ "Item",
55
+ "Category",
56
+ "Description",
57
+ "Size / Designation",
58
+ "Grade",
59
+ "Qty",
60
+ "Stock Length / Size",
61
+ "Est. Purchase Wt (lbs)",
62
+ "Unit Price ($)",
63
+ "Total Price ($)",
64
+ "Availability",
65
+ "Lead Time (days)",
66
+ "Alternate Size",
67
+ "Notes",
68
+ ]
69
+ COLUMN_WIDTHS = {
70
+ "A": 7,
71
+ "B": 13,
72
+ "C": 38,
73
+ "D": 22,
74
+ "E": 12,
75
+ "F": 6,
76
+ "G": 20,
77
+ "H": 20,
78
+ "I": 14,
79
+ "J": 14,
80
+ "K": 14,
81
+ "L": 14,
82
+ "M": 18,
83
+ "N": 30,
84
+ }
85
+ CATEGORY_ORDER = {
86
+ "W-SHAPES": 10,
87
+ "LONG PRODUCTS": 20,
88
+ "PLATE STOCK": 30,
89
+ "PLATE PARTS": 40,
90
+ "FLAT BAR STOCK": 50,
91
+ "HARDWARE": 60,
92
+ "OTHER": 90,
93
+ }
94
+
95
+
96
+ class RfqInputError(ValueError):
97
+ """Raised when an input cannot be mapped without guessing."""
98
+
99
+
100
+ def _valid_date(value: Any) -> bool:
101
+ try:
102
+ return date.fromisoformat(str(value)).isoformat() == str(value)
103
+ except ValueError:
104
+ return False
105
+
106
+
107
+ def profile_semantic_hash(profile: dict[str, Any] | None) -> str:
108
+ if profile is None:
109
+ return sha256_bytes(b"missing-profile")
110
+ public_profile = {
111
+ key: value for key, value in profile.items() if not key.startswith("_")
112
+ }
113
+ return sha256_bytes(canonical_json_bytes(public_profile))
114
+
115
+
116
+ def _fmt(value: Any) -> str:
117
+ if value is None:
118
+ return ""
119
+ if isinstance(value, float):
120
+ return f"{value:g}"
121
+ return str(value)
122
+
123
+
124
+ def _category_for(item: dict[str, Any]) -> str:
125
+ if item.get("intent") == "hardware":
126
+ return "HARDWARE"
127
+ dimensions = item.get("dimensions") or {}
128
+ if dimensions.get("category"):
129
+ return str(dimensions["category"]).upper()
130
+ designation = str(item.get("designation") or item.get("specification") or "")
131
+ normalized = designation.upper().replace(" ", "")
132
+ if re.match(r"^W\d+(?:X|×)", normalized):
133
+ return "W-SHAPES"
134
+ if normalized.startswith(("PL", "PLATE")) or item.get("geometry"):
135
+ return "PLATE PARTS"
136
+ if normalized.startswith(("FB", "FLATBAR")):
137
+ return "FLAT BAR STOCK"
138
+ if re.match(r"^(?:HSS|PIPE)\d", normalized) or re.match(
139
+ r"^(?:C|MC|L)\d+(?:X|×)", normalized
140
+ ):
141
+ return "LONG PRODUCTS"
142
+ return "OTHER"
143
+
144
+
145
+ def _size_for(item: dict[str, Any]) -> str:
146
+ dimensions = item.get("dimensions") or {}
147
+ if dimensions.get("size"):
148
+ return str(dimensions["size"])
149
+ if item.get("designation"):
150
+ return str(item["designation"])
151
+ if item.get("specification"):
152
+ return str(item["specification"])
153
+ geometry = item.get("geometry") or {}
154
+ if geometry:
155
+ return " x ".join(
156
+ _fmt(geometry.get(field)) for field in ("width", "height", "thickness")
157
+ )
158
+ return ""
159
+
160
+
161
+ def _normalized_item(item: dict[str, Any]) -> dict[str, Any]:
162
+ dimensions = item.get("dimensions") or {}
163
+ geometry = item.get("geometry") or {}
164
+ quantity = item["quantity"]
165
+ weight = dimensions.get("purchase_weight_lbs", item.get("total_weight_lbs"))
166
+ if weight is None and item.get("length_ft") is not None and item.get("unit_weight_plf") is not None:
167
+ weight = quantity * item["length_ft"] * item["unit_weight_plf"]
168
+ stock_length = dimensions.get("stock_length")
169
+ if stock_length is None and item.get("length_ft") is not None:
170
+ stock_length = f"{quantity} pcs x {_fmt(item['length_ft'])} ft"
171
+ return {
172
+ "source_id": item["source_id"],
173
+ "item_id": item["item_id"],
174
+ "intent": item["intent"],
175
+ "item": item.get("mark") or item["source_id"],
176
+ "category": _category_for(item),
177
+ "description": item.get("description") or item.get("mark") or item["source_id"],
178
+ "size": _size_for(item),
179
+ "material": item.get("material", ""),
180
+ "grade": item.get("grade", ""),
181
+ "thickness": dimensions.get("thickness", geometry.get("thickness")),
182
+ "quantity": quantity,
183
+ "stock_length": stock_length or "",
184
+ "purchase_weight_lbs": weight,
185
+ "replaces_item_ids": tuple(dimensions.get("replaces_item_ids", [])),
186
+ }
187
+
188
+
189
+ def normalize_canonical_package(package: dict[str, Any]) -> dict[str, Any]:
190
+ """Map typed canonical scope to deterministic purchase lines."""
191
+ result = validate_estimate_package(package)
192
+ if result.blockers:
193
+ raise RfqInputError(
194
+ "canonical package is blocked: "
195
+ + "; ".join(finding["message"] for finding in result.blockers)
196
+ )
197
+ source_ids = {item["item_id"] for item in package["items"]}
198
+ items_by_id = {item["item_id"]: item for item in package["items"]}
199
+ replacements: set[str] = set()
200
+ for item in package["items"]:
201
+ if item.get("intent") not in {"purchased_stock", "hardware"}:
202
+ continue
203
+ for replaced_id in (item.get("dimensions") or {}).get(
204
+ "replaces_item_ids", []
205
+ ):
206
+ if replaced_id not in source_ids:
207
+ raise RfqInputError(
208
+ f"purchase relationship references unknown item {replaced_id!r}"
209
+ )
210
+ if items_by_id[replaced_id]["intent"] != "fabricated_part":
211
+ raise RfqInputError(
212
+ "purchase relationships may replace fabricated_part items only"
213
+ )
214
+ replacements.add(replaced_id)
215
+ included = []
216
+ for item in package["items"]:
217
+ if item["intent"] in {"allowance", "exclusion", "by_others"}:
218
+ continue
219
+ if item["item_id"] in replacements:
220
+ continue
221
+ included.append(_normalized_item(item))
222
+ included.sort(
223
+ key=lambda item: (
224
+ CATEGORY_ORDER.get(item["category"], 80),
225
+ item["material"],
226
+ item["grade"],
227
+ item["thickness"] if item["thickness"] is not None else -1,
228
+ item["size"],
229
+ item["item_id"],
230
+ )
231
+ )
232
+ return {
233
+ "input_kind": "canonical_estimate_package",
234
+ "input_version": ESTIMATE_PACKAGE_VERSION,
235
+ "input_hash": result.input_hash,
236
+ "project_id": package["project"]["project_id"],
237
+ "project_name": package["project"].get("name")
238
+ or package["project"]["project_id"],
239
+ "revision_id": package["project"]["revision"]["revision_id"],
240
+ "currency": package["commercial_basis"]["currency"],
241
+ "items": included,
242
+ "warnings": [finding["message"] for finding in result.warnings],
243
+ "review_findings": [
244
+ dict(finding)
245
+ for finding in result.active_findings
246
+ if finding["severity"] == "warning"
247
+ ],
248
+ "assumptions": [
249
+ dict(assumption) for assumption in package.get("assumptions", [])
250
+ ],
251
+ }
252
+
253
+
254
+ LEGACY_HEADERS = [
255
+ "Source_ID",
256
+ "Item_ID",
257
+ "Scope",
258
+ "Description",
259
+ "Material",
260
+ "Grade",
261
+ "Thickness",
262
+ "Size",
263
+ "Qty",
264
+ "Currency",
265
+ "Purchase Weight",
266
+ ]
267
+
268
+
269
+ def normalize_legacy_xlsx(path: str | Path) -> dict[str, Any]:
270
+ """Conservatively map one exact legacy sheet/header contract."""
271
+ workbook = openpyxl.load_workbook(path, data_only=False, read_only=True)
272
+ if "Steel Takeoff" not in workbook.sheetnames:
273
+ raise RfqInputError("legacy workbook requires a 'Steel Takeoff' sheet")
274
+ sheet = workbook["Steel Takeoff"]
275
+ headers = [sheet.cell(1, column).value for column in range(1, len(LEGACY_HEADERS) + 1)]
276
+ if headers != LEGACY_HEADERS:
277
+ raise RfqInputError(
278
+ "legacy adapter requires exact headers: " + ", ".join(LEGACY_HEADERS)
279
+ )
280
+ items = []
281
+ currencies = set()
282
+ for row_number, values in enumerate(
283
+ sheet.iter_rows(min_row=2, max_col=len(LEGACY_HEADERS), values_only=True),
284
+ start=2,
285
+ ):
286
+ row = dict(zip(LEGACY_HEADERS, values))
287
+ if not any(value is not None for value in values):
288
+ continue
289
+ if not row["Source_ID"] or not row["Item_ID"]:
290
+ raise RfqInputError(
291
+ f"legacy row {row_number} requires Source_ID and Item_ID"
292
+ )
293
+ if row["Scope"] not in {"IN SCOPE", "BY OTHERS", "EXCLUDED"}:
294
+ raise RfqInputError(
295
+ f"legacy row {row_number} has ambiguous Scope {row['Scope']!r}"
296
+ )
297
+ currency = str(row["Currency"] or "").strip().upper()
298
+ if not re.fullmatch(r"[A-Z]{3}", currency):
299
+ raise RfqInputError(
300
+ f"legacy row {row_number} requires an explicit three-letter Currency"
301
+ )
302
+ currencies.add(currency)
303
+ try:
304
+ quantity = int(row["Qty"])
305
+ except (TypeError, ValueError):
306
+ raise RfqInputError(
307
+ f"legacy row {row_number} has invalid Qty"
308
+ ) from None
309
+ if quantity != row["Qty"] or quantity < 0:
310
+ raise RfqInputError(
311
+ f"legacy row {row_number} has invalid Qty"
312
+ )
313
+ intent = (
314
+ "by_others"
315
+ if row["Scope"] == "BY OTHERS"
316
+ else ("exclusion" if row["Scope"] == "EXCLUDED" or quantity == 0 else "purchased_stock")
317
+ )
318
+ if intent in {"by_others", "exclusion"}:
319
+ continue
320
+ category = _category_for(
321
+ {
322
+ "intent": intent,
323
+ "specification": row["Size"],
324
+ "dimensions": {},
325
+ }
326
+ )
327
+ if category == "PLATE PARTS":
328
+ category = "PLATE STOCK"
329
+ item = {
330
+ "source_id": str(row["Source_ID"]),
331
+ "item_id": str(row["Item_ID"]),
332
+ "intent": intent,
333
+ "item": str(row["Source_ID"]),
334
+ "category": category,
335
+ "description": str(row["Description"] or ""),
336
+ "size": str(row["Size"] or ""),
337
+ "material": str(row["Material"] or ""),
338
+ "grade": str(row["Grade"] or ""),
339
+ "thickness": row["Thickness"],
340
+ "quantity": quantity,
341
+ "stock_length": "",
342
+ "purchase_weight_lbs": row["Purchase Weight"],
343
+ "replaces_item_ids": (),
344
+ }
345
+ items.append(item)
346
+ if len(currencies) != 1:
347
+ raise RfqInputError("legacy workbook must use one explicit currency")
348
+ items.sort(
349
+ key=lambda item: (
350
+ CATEGORY_ORDER.get(item["category"], 80),
351
+ item["material"],
352
+ item["grade"],
353
+ item["thickness"] if item["thickness"] is not None else -1,
354
+ item["size"],
355
+ item["item_id"],
356
+ )
357
+ )
358
+ input_path = Path(path)
359
+ input_hash = hashlib.sha256(input_path.read_bytes()).hexdigest()
360
+ return {
361
+ "input_kind": "legacy_xlsx",
362
+ "input_version": "exact-headers-1.0.0",
363
+ "input_hash": input_hash,
364
+ "project_id": f"legacy-workbook:{input_hash[:20]}",
365
+ "project_name": input_path.stem,
366
+ "revision_id": "LEGACY-REVISION",
367
+ "currency": next(iter(currencies)),
368
+ "items": items,
369
+ "warnings": [
370
+ "Legacy XLSX mapping used exact headers; confirm typed scope before issue."
371
+ ],
372
+ "review_findings": [],
373
+ "assumptions": [],
374
+ }
375
+
376
+
377
+ def _user_config_path() -> Path:
378
+ xdg = os.environ.get("XDG_CONFIG_HOME")
379
+ if xdg:
380
+ return Path(xdg) / "pi-steel" / "company-profile.json"
381
+ if sys.platform == "darwin":
382
+ return (
383
+ Path.home()
384
+ / "Library"
385
+ / "Application Support"
386
+ / "pi-steel"
387
+ / "company-profile.json"
388
+ )
389
+ return Path.home() / ".config" / "pi-steel" / "company-profile.json"
390
+
391
+
392
+ def resolve_company_profile(input_path: str | Path) -> tuple[dict[str, Any] | None, str]:
393
+ candidates = []
394
+ explicit = os.environ.get("PI_STEEL_CONFIG")
395
+ if explicit:
396
+ explicit_path = Path(explicit)
397
+ if not explicit_path.is_file():
398
+ raise RfqInputError(
399
+ "PI_STEEL_CONFIG names a profile file that does not exist"
400
+ )
401
+ candidates.append((explicit_path, "environment"))
402
+ candidates.append(
403
+ (Path(input_path).resolve().parent / ".pi-steel" / "company-profile.json", "project")
404
+ )
405
+ candidates.append((_user_config_path(), "user"))
406
+ for path, source in candidates:
407
+ if not path.is_file():
408
+ continue
409
+ try:
410
+ profile = json.loads(path.read_text(encoding="utf-8"))
411
+ except (OSError, json.JSONDecodeError) as exc:
412
+ raise RfqInputError(f"company profile could not be read: {exc}") from exc
413
+ profile["_profile_path"] = str(path)
414
+ return profile, source
415
+ return None, "missing"
416
+
417
+
418
+ def validate_company_profile(
419
+ profile: dict[str, Any] | None, profile_path: str | Path | None = None
420
+ ) -> list[dict[str, str]]:
421
+ findings = []
422
+
423
+ def add(code, message):
424
+ findings.append({"code": code, "severity": "error", "message": message})
425
+
426
+ if profile is None:
427
+ add("company_profile_missing", "A company profile is required.")
428
+ return findings
429
+ for field in ("company_name", "city_state"):
430
+ if not profile.get(field):
431
+ add("company_identity_incomplete", f"Profile field {field} is required.")
432
+ terms = profile.get("terms_template")
433
+ if not isinstance(terms, dict):
434
+ add("approved_terms_missing", "An approved terms_template is required.")
435
+ return findings
436
+ for field in (
437
+ "template_id",
438
+ "content",
439
+ "content_hash",
440
+ "approver",
441
+ "approval_date",
442
+ "status",
443
+ ):
444
+ if not terms.get(field):
445
+ add("approved_terms_incomplete", f"Terms field {field} is required.")
446
+ content = terms.get("content", "")
447
+ actual_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
448
+ if terms.get("content_hash") != actual_hash:
449
+ add(
450
+ "terms_content_hash_mismatch",
451
+ "Terms content changed after approval; approval is invalid.",
452
+ )
453
+ if terms.get("status") != "approved":
454
+ add("terms_not_approved", "Terms template status must be approved.")
455
+ if not _valid_date(terms.get("approval_date")):
456
+ add(
457
+ "terms_approval_date_invalid",
458
+ "Terms approval_date must be an explicit YYYY-MM-DD date.",
459
+ )
460
+ return findings
461
+
462
+
463
+ def _nest_handoff_validator() -> jsonschema.Draft202012Validator:
464
+ schema = json.loads(NEST_RESULT_SCHEMA_PATH.read_text(encoding="utf-8"))
465
+ handoff_schema = {
466
+ "$schema": schema["$schema"],
467
+ "$ref": "#/$defs/rfqHandoff",
468
+ "$defs": schema["$defs"],
469
+ }
470
+ return jsonschema.Draft202012Validator(handoff_schema)
471
+
472
+
473
+ def _json_path(parts: Any) -> str:
474
+ result = "$"
475
+ for part in parts:
476
+ result += f"[{part}]" if isinstance(part, int) else f".{part}"
477
+ return result
478
+
479
+
480
+ def validate_nest_handoff(
481
+ value: dict[str, Any] | None,
482
+ *,
483
+ expected: dict[str, Any] | None = None,
484
+ ) -> list[dict[str, str]]:
485
+ if value is None:
486
+ return []
487
+ if not isinstance(value, dict):
488
+ return [
489
+ {
490
+ "code": "invalid_nest_handoff",
491
+ "severity": "error",
492
+ "path": "$",
493
+ "message": "Nesting handoff must be a JSON object.",
494
+ }
495
+ ]
496
+ findings: list[dict[str, str]] = []
497
+ for error in sorted(
498
+ _nest_handoff_validator().iter_errors(value),
499
+ key=lambda item: tuple(str(part) for part in item.absolute_path),
500
+ ):
501
+ findings.append(
502
+ {
503
+ "code": "invalid_nest_handoff_contract",
504
+ "severity": "error",
505
+ "path": _json_path(error.absolute_path),
506
+ "message": error.message,
507
+ }
508
+ )
509
+ rows = value.get("rows")
510
+ readiness_values = [value.get("geometry_readiness")]
511
+ if isinstance(rows, list):
512
+ readiness_values.extend(
513
+ row.get("geometry_readiness")
514
+ for row in rows
515
+ if isinstance(row, dict)
516
+ )
517
+ if "diagnostic" in readiness_values:
518
+ findings.append(
519
+ {
520
+ "code": "diagnostic_nest_handoff",
521
+ "severity": "error",
522
+ "path": "$.geometry_readiness",
523
+ "message": (
524
+ "Diagnostic nesting output is not eligible for an RFQ; "
525
+ "resolve nest blockers and regenerate the handoff."
526
+ ),
527
+ }
528
+ )
529
+ if expected is not None:
530
+ for handoff_field, expected_field in (
531
+ ("project_id", "project_id"),
532
+ ("revision_id", "revision_id"),
533
+ ("estimate_input_hash", "input_hash"),
534
+ ):
535
+ if value.get(handoff_field) != expected.get(expected_field):
536
+ findings.append(
537
+ {
538
+ "code": "stale_nest_handoff",
539
+ "severity": "error",
540
+ "path": f"$.{handoff_field}",
541
+ "message": (
542
+ f"Nesting handoff {handoff_field} does not match "
543
+ "the current estimate package."
544
+ ),
545
+ }
546
+ )
547
+ return findings
548
+
549
+
550
+ def _color(value):
551
+ if value is None:
552
+ return None
553
+ def primitive(attribute):
554
+ result = getattr(value, attribute, None)
555
+ return result if isinstance(result, (str, int, float, bool)) else None
556
+ return {
557
+ "type": primitive("type"),
558
+ "rgb": primitive("rgb"),
559
+ "indexed": primitive("indexed"),
560
+ "theme": primitive("theme"),
561
+ "tint": primitive("tint"),
562
+ }
563
+
564
+
565
+ def workbook_semantic_projection(path: str | Path) -> dict[str, Any]:
566
+ workbook = openpyxl.load_workbook(path, data_only=False)
567
+ sheets = []
568
+ for sheet in workbook.worksheets:
569
+ cells = []
570
+ styles = []
571
+ style_ids = {}
572
+ for row in sheet.iter_rows():
573
+ for cell in row:
574
+ if cell.value is None and not cell.has_style:
575
+ continue
576
+ style = {
577
+ "number_format": cell.number_format,
578
+ "font": {
579
+ "name": cell.font.name,
580
+ "size": cell.font.sz,
581
+ "bold": cell.font.b,
582
+ "italic": cell.font.i,
583
+ "color": _color(cell.font.color),
584
+ },
585
+ "fill": {
586
+ "type": cell.fill.fill_type,
587
+ "fg": _color(cell.fill.fgColor),
588
+ },
589
+ "alignment": {
590
+ "horizontal": cell.alignment.horizontal,
591
+ "vertical": cell.alignment.vertical,
592
+ "wrap_text": cell.alignment.wrap_text,
593
+ },
594
+ "border": {
595
+ side: getattr(cell.border, side).style
596
+ for side in ("left", "right", "top", "bottom")
597
+ },
598
+ }
599
+ style_key = json.dumps(style, sort_keys=True, separators=(",", ":"))
600
+ if style_key not in style_ids:
601
+ style_ids[style_key] = len(styles)
602
+ styles.append(style)
603
+ cells.append([cell.coordinate, cell.value, style_ids[style_key]])
604
+ sheets.append(
605
+ {
606
+ "title": sheet.title,
607
+ "state": sheet.sheet_state,
608
+ "max_row": sheet.max_row,
609
+ "max_column": sheet.max_column,
610
+ "merges": sorted(str(value) for value in sheet.merged_cells.ranges),
611
+ "freeze_panes": str(sheet.freeze_panes) if sheet.freeze_panes else None,
612
+ "column_widths": {
613
+ key: dimension.width
614
+ for key, dimension in sorted(sheet.column_dimensions.items())
615
+ if dimension.width is not None
616
+ },
617
+ "print_title_rows": sheet.print_title_rows,
618
+ "page_setup": {
619
+ "orientation": sheet.page_setup.orientation,
620
+ "fitToWidth": sheet.page_setup.fitToWidth,
621
+ "fitToHeight": sheet.page_setup.fitToHeight,
622
+ },
623
+ "styles": styles,
624
+ "cells": cells,
625
+ }
626
+ )
627
+ return {"format_version": "1.0.0", "sheets": sheets}
628
+
629
+
630
+ def _safe_filename(project_name: str) -> str:
631
+ stem = re.sub(r"[^A-Za-z0-9._-]+", "_", project_name).strip("._")
632
+ return f"{stem or 'RFQ'}_RFQ_Material_List.xlsx"
633
+
634
+
635
+ def _escape_untrusted_formulas(
636
+ workbook: Workbook,
637
+ owned_formula_cells: set[tuple[str, str]],
638
+ ) -> None:
639
+ """Force user-controlled formula-like strings to remain literal cells."""
640
+ dangerous = re.compile(r"^\s*[=+\-@]")
641
+ for worksheet in workbook.worksheets:
642
+ for row in worksheet.iter_rows():
643
+ for cell in row:
644
+ value = cell.value
645
+ if (
646
+ isinstance(value, str)
647
+ and dangerous.match(value)
648
+ and (worksheet.title, cell.coordinate) not in owned_formula_cells
649
+ ):
650
+ cell.value = "'" + value
651
+
652
+
653
+ def compile_workbook(
654
+ normalized: dict[str, Any],
655
+ profile: dict[str, Any],
656
+ *,
657
+ nest_handoff: dict[str, Any] | None,
658
+ issued_date: str,
659
+ project_location: str,
660
+ output_directory: str | Path,
661
+ profile_source: str,
662
+ bake: bool,
663
+ ) -> dict[str, Any]:
664
+ profile_findings = validate_company_profile(
665
+ profile, profile.get("_profile_path")
666
+ )
667
+ if profile_findings:
668
+ raise RfqInputError(
669
+ "company profile blocked: "
670
+ + "; ".join(finding["message"] for finding in profile_findings)
671
+ )
672
+ nest_findings = validate_nest_handoff(nest_handoff, expected=normalized)
673
+ if nest_findings:
674
+ raise RfqInputError(
675
+ "nest handoff blocked: "
676
+ + "; ".join(finding["message"] for finding in nest_findings)
677
+ )
678
+
679
+ workbook = Workbook()
680
+ sheet = workbook.active
681
+ sheet.title = "RFQ Draft"
682
+ metadata = workbook.create_sheet("RFQ Metadata")
683
+ metadata.sheet_state = "hidden"
684
+ dark_blue = "1F3864"
685
+ medium_blue = "2E75B6"
686
+ gold = "BF8F00"
687
+ yellow = "FFF2CC"
688
+ light_blue = "D6E4F0"
689
+ green = "E2EFDA"
690
+ white = "FFFFFF"
691
+ thin = Side(style="thin", color="A6A6A6")
692
+ border = Border(left=thin, right=thin, top=thin, bottom=thin)
693
+
694
+ for column, width in COLUMN_WIDTHS.items():
695
+ sheet.column_dimensions[column].width = width
696
+ for row in (1, 2, 3, 7):
697
+ sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=14)
698
+ cell = sheet.cell(row, 1)
699
+ cell.fill = PatternFill("solid", fgColor=dark_blue)
700
+ cell.font = Font(name="Arial", color=white, bold=row != 3, size={1: 14, 2: 11}.get(row, 10))
701
+ cell.alignment = Alignment(vertical="center")
702
+ for column in range(1, 15):
703
+ sheet.cell(row, column).fill = PatternFill("solid", fgColor=dark_blue)
704
+ sheet["A1"] = "REQUEST FOR QUOTATION — Structural Steel"
705
+ sheet["A2"] = (
706
+ f"{normalized['project_name']} | {profile['company_name']} — "
707
+ f"{profile['city_state']}"
708
+ )
709
+ sheet["A3"] = (
710
+ "Document Status: DRAFT — NOT SENT OR AWARDED | "
711
+ f"Date Issued: {issued_date} | Response Requested By: _______________ | "
712
+ f"Project Location: {project_location}"
713
+ )
714
+ sheet.merge_cells("A5:B5")
715
+ sheet.merge_cells("C5:E5")
716
+ sheet.merge_cells("F5:G5")
717
+ sheet.merge_cells("H5:J5")
718
+ sheet.merge_cells("A6:B6")
719
+ sheet.merge_cells("C6:E6")
720
+ sheet.merge_cells("F6:G6")
721
+ sheet.merge_cells("H6:J6")
722
+ sheet["A5"], sheet["C5"] = "Company Name", ""
723
+ sheet["F5"], sheet["H5"] = "Contact Name", ""
724
+ sheet["A6"], sheet["C6"] = "Phone / Email", ""
725
+ sheet["F6"], sheet["H6"] = "Quote Valid Until", ""
726
+ for coordinate in ("C5", "H5", "C6", "H6"):
727
+ sheet[coordinate].fill = PatternFill("solid", fgColor=yellow)
728
+ sheet["A7"] = (
729
+ "Instructions: Complete the yellow vendor-response columns. "
730
+ "This workbook is a draft request only and is not a purchase authorization."
731
+ )
732
+ sheet["A7"].font = Font(name="Arial", size=9, italic=True, color="666666")
733
+
734
+ for column, header in enumerate(HEADERS, start=1):
735
+ cell = sheet.cell(8, column, header)
736
+ cell.fill = PatternFill(
737
+ "solid", fgColor=gold if column >= 9 else medium_blue
738
+ )
739
+ cell.font = Font(name="Arial", size=10, bold=True, color=white)
740
+ cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
741
+ cell.border = border
742
+ sheet.freeze_panes = "A9"
743
+ sheet.print_title_rows = "8:8"
744
+ sheet.page_setup.orientation = "landscape"
745
+ sheet.page_setup.fitToWidth = 1
746
+ sheet.page_setup.fitToHeight = 0
747
+ sheet.sheet_properties.pageSetUpPr.fitToPage = True
748
+
749
+ row = 9
750
+ material_rows = []
751
+ owned_formula_cells: set[tuple[str, str]] = set()
752
+ grouped: dict[tuple[Any, ...], list[dict[str, Any]]] = {}
753
+ for item in normalized["items"]:
754
+ key = (
755
+ item["category"],
756
+ item["material"],
757
+ item["grade"],
758
+ item["thickness"],
759
+ item["size"],
760
+ )
761
+ grouped.setdefault(key, []).append(item)
762
+ for key in sorted(
763
+ grouped,
764
+ key=lambda value: (
765
+ CATEGORY_ORDER.get(value[0], 80),
766
+ value[1],
767
+ value[2],
768
+ value[3] if value[3] is not None else -1,
769
+ value[4],
770
+ ),
771
+ ):
772
+ category, material, grade, thickness, size = key
773
+ sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=14)
774
+ heading = sheet.cell(
775
+ row,
776
+ 1,
777
+ f"{category} — {material} — {grade}"
778
+ + (f" — {_fmt(thickness)}" if thickness is not None else ""),
779
+ )
780
+ heading.fill = PatternFill("solid", fgColor=medium_blue)
781
+ heading.font = Font(name="Arial", bold=True, color=white)
782
+ for column in range(1, 15):
783
+ sheet.cell(row, column).fill = PatternFill("solid", fgColor=medium_blue)
784
+ row += 1
785
+ for item in grouped[key]:
786
+ material_rows.append(row)
787
+ values = [
788
+ item["item"],
789
+ item["category"],
790
+ item["description"],
791
+ item["size"],
792
+ item["grade"],
793
+ item["quantity"],
794
+ item["stock_length"],
795
+ item["purchase_weight_lbs"],
796
+ ]
797
+ for column, value in enumerate(values, start=1):
798
+ sheet.cell(row, column, value)
799
+ sheet.cell(row, 10, f'=IF(I{row}="","",F{row}*I{row})')
800
+ owned_formula_cells.add((sheet.title, f"J{row}"))
801
+ for column in range(1, 15):
802
+ cell = sheet.cell(row, column)
803
+ cell.border = border
804
+ cell.font = Font(name="Arial", size=10)
805
+ cell.alignment = Alignment(vertical="top", wrap_text=column in {3, 7, 14})
806
+ cell.fill = PatternFill(
807
+ "solid",
808
+ fgColor=yellow
809
+ if column >= 9
810
+ else (light_blue if len(material_rows) % 2 else white),
811
+ )
812
+ sheet.cell(row, 8).number_format = "#,##0"
813
+ sheet.cell(row, 9).number_format = f'"{normalized["currency"]}" #,##0.00'
814
+ sheet.cell(row, 10).number_format = f'"{normalized["currency"]}" #,##0.00'
815
+ row += 1
816
+
817
+ if not material_rows:
818
+ raise RfqInputError("no in-scope purchase items remain after typed filtering")
819
+ first_material_row = material_rows[0]
820
+ last_material_row = material_rows[-1]
821
+ total_row = row
822
+ sheet.merge_cells(start_row=total_row, start_column=1, end_row=total_row, end_column=7)
823
+ sheet.cell(total_row, 1, "TOTAL PURCHASE WEIGHT / PRICE")
824
+ sheet.cell(total_row, 1).font = Font(name="Arial", bold=True)
825
+ sheet.cell(total_row, 1).alignment = Alignment(horizontal="right")
826
+ sheet.cell(total_row, 8, f"=SUM(H{first_material_row}:H{last_material_row})")
827
+ sheet.cell(total_row, 10, f"=SUM(J{first_material_row}:J{last_material_row})")
828
+ owned_formula_cells.update(
829
+ {
830
+ (sheet.title, f"H{total_row}"),
831
+ (sheet.title, f"J{total_row}"),
832
+ }
833
+ )
834
+ for column in (8, 10):
835
+ sheet.cell(total_row, column).fill = PatternFill("solid", fgColor=green)
836
+ sheet.cell(total_row, column).font = Font(name="Arial", bold=True)
837
+ sheet.cell(total_row, column).border = border
838
+ sheet.cell(total_row, 8).number_format = "#,##0"
839
+ sheet.cell(total_row, 10).number_format = f'"{normalized["currency"]}" #,##0.00'
840
+
841
+ nest_header_row = total_row + 2
842
+ sheet.merge_cells(
843
+ start_row=nest_header_row, start_column=1, end_row=nest_header_row, end_column=14
844
+ )
845
+ sheet.cell(
846
+ nest_header_row, 1, "NESTING / REMNANT REFERENCE (For Fabricator Review)"
847
+ )
848
+ sheet.cell(nest_header_row, 1).font = Font(
849
+ name="Arial", bold=True, color=dark_blue
850
+ )
851
+ nest_columns_row = nest_header_row + 1
852
+ nest_headers = [
853
+ "Material",
854
+ "Grade",
855
+ "Thickness",
856
+ "Sheet Size",
857
+ "Nesting Plan",
858
+ "Remnant Notes",
859
+ ]
860
+ for column, header in enumerate(nest_headers, start=1):
861
+ cell = sheet.cell(nest_columns_row, column, header)
862
+ cell.fill = PatternFill("solid", fgColor=medium_blue)
863
+ cell.font = Font(name="Arial", bold=True, color=white)
864
+ cell.border = border
865
+ nest_row = nest_columns_row + 1
866
+ reference_warning = (
867
+ (nest_handoff or {}).get("geometry_readiness") == "reference_only"
868
+ )
869
+ for entry in (nest_handoff or {}).get("rows", []):
870
+ readiness = entry.get(
871
+ "geometry_readiness",
872
+ (nest_handoff or {}).get("geometry_readiness"),
873
+ )
874
+ reference_warning = reference_warning or readiness == "reference_only"
875
+ values = [
876
+ entry.get("material"),
877
+ entry.get("grade"),
878
+ entry.get("thickness"),
879
+ entry.get("sheet_size"),
880
+ entry.get("nesting_plan"),
881
+ entry.get("drop_notes")
882
+ + (" — REFERENCE ONLY" if readiness == "reference_only" else ""),
883
+ ]
884
+ for column, value in enumerate(values, start=1):
885
+ cell = sheet.cell(nest_row, column, value)
886
+ cell.border = border
887
+ cell.alignment = Alignment(vertical="top", wrap_text=True)
888
+ nest_row += 1
889
+
890
+ review_header_row = nest_row + 1
891
+ sheet.merge_cells(
892
+ start_row=review_header_row,
893
+ start_column=1,
894
+ end_row=review_header_row,
895
+ end_column=14,
896
+ )
897
+ sheet.cell(review_header_row, 1, "REVIEW NOTES / ASSUMPTIONS")
898
+ sheet.cell(review_header_row, 1).font = Font(
899
+ name="Arial", bold=True, color=dark_blue
900
+ )
901
+ review_row = review_header_row + 1
902
+ review_notes = [
903
+ f"{finding.get('finding_id', finding.get('code', 'review'))}: "
904
+ f"{finding.get('message', '')}"
905
+ for finding in normalized.get("review_findings", [])
906
+ ]
907
+ known_messages = {
908
+ finding.get("message")
909
+ for finding in normalized.get("review_findings", [])
910
+ }
911
+ review_notes.extend(
912
+ warning
913
+ for warning in normalized.get("warnings", [])
914
+ if warning not in known_messages
915
+ )
916
+ review_notes.extend(
917
+ (
918
+ f"Assumption {assumption.get('assumption_id', 'unidentified')} "
919
+ f"[{assumption.get('status', 'unknown')}]: {assumption.get('text', '')}"
920
+ )
921
+ for assumption in normalized.get("assumptions", [])
922
+ )
923
+ if not review_notes:
924
+ review_notes.append("No active review warnings or assumptions.")
925
+ for note in review_notes:
926
+ sheet.merge_cells(
927
+ start_row=review_row,
928
+ start_column=1,
929
+ end_row=review_row,
930
+ end_column=14,
931
+ )
932
+ sheet.cell(review_row, 1, note)
933
+ sheet.cell(review_row, 1).alignment = Alignment(
934
+ wrap_text=True, vertical="top"
935
+ )
936
+ review_row += 1
937
+
938
+ terms_header_row = review_row + 1
939
+ sheet.merge_cells(
940
+ start_row=terms_header_row, start_column=1, end_row=terms_header_row, end_column=14
941
+ )
942
+ sheet.cell(terms_header_row, 1, "TERMS & CONDITIONS — APPROVED TEMPLATE")
943
+ sheet.cell(terms_header_row, 1).font = Font(
944
+ name="Arial", bold=True, color=dark_blue
945
+ )
946
+ terms_row = terms_header_row + 1
947
+ sheet.merge_cells(
948
+ start_row=terms_row, start_column=1, end_row=terms_row, end_column=14
949
+ )
950
+ sheet.cell(terms_row, 1, profile["terms_template"]["content"])
951
+ sheet.cell(terms_row, 1).alignment = Alignment(wrap_text=True, vertical="top")
952
+
953
+ metadata["A1"], metadata["B1"] = "Field", "Value"
954
+ metadata["A2"], metadata["B2"] = "Document Status", "DRAFT — NOT SENT OR AWARDED"
955
+ metadata["A3"], metadata["B3"] = "Compiler Version", RFQ_COMPILER_VERSION
956
+ metadata["A4"], metadata["B4"] = "Input Hash", normalized["input_hash"]
957
+ metadata["A5"], metadata["B5"] = "Profile Source", profile_source
958
+ metadata["A6"], metadata["B6"] = "Terms Template ID", profile["terms_template"]["template_id"]
959
+ metadata["A7"], metadata["B7"] = "Terms Content Hash", profile["terms_template"]["content_hash"]
960
+ metadata["A8"], metadata["B8"] = "Terms Approver", profile["terms_template"]["approver"]
961
+ metadata["A9"], metadata["B9"] = "Terms Approval Date", profile["terms_template"]["approval_date"]
962
+ metadata["A10"], metadata["B10"] = "Issued Date", issued_date
963
+ metadata["A11"], metadata["B11"] = (
964
+ "Formula Cache",
965
+ "Recalculate on open requested; QA report records cache status",
966
+ )
967
+ workbook.calculation.fullCalcOnLoad = True
968
+ workbook.calculation.forceFullCalc = True
969
+ workbook.calculation.calcMode = "auto"
970
+
971
+ logo_status = "not_configured"
972
+ logo = profile.get("logo")
973
+ if logo:
974
+ profile_path = Path(profile.get("_profile_path", "."))
975
+ logo_path = Path(logo)
976
+ if not logo_path.is_absolute():
977
+ logo_path = profile_path.parent / logo_path
978
+ if logo_path.is_file():
979
+ try:
980
+ image = Image(logo_path)
981
+ image.width, image.height = 90, 36
982
+ sheet.add_image(image, "A1")
983
+ logo_status = "embedded"
984
+ except Exception:
985
+ logo_status = "text_fallback"
986
+ else:
987
+ logo_status = "text_fallback"
988
+
989
+ _escape_untrusted_formulas(workbook, owned_formula_cells)
990
+ output_directory = Path(output_directory)
991
+ output_directory.mkdir(parents=True, exist_ok=True)
992
+ workbook_path = output_directory / _safe_filename(normalized["project_name"])
993
+ workbook.save(workbook_path)
994
+ recalculation_status = recalc.recalculate(workbook_path, bake=bake)
995
+ return {
996
+ "workbook_path": workbook_path,
997
+ "recalculation_status": recalculation_status,
998
+ "logo_status": logo_status,
999
+ "reference_warning": reference_warning,
1000
+ "contract": {
1001
+ "first_material_row": first_material_row,
1002
+ "last_material_row": last_material_row,
1003
+ "total_row": total_row,
1004
+ "nest_header_row": nest_header_row,
1005
+ "review_header_row": review_header_row,
1006
+ "terms_header_row": terms_header_row,
1007
+ },
1008
+ }
1009
+
1010
+
1011
+ def _load_normalized(input_path: Path) -> dict[str, Any]:
1012
+ if input_path.suffix.lower() == ".json":
1013
+ value = json.loads(input_path.read_text(encoding="utf-8"))
1014
+ return normalize_canonical_package(value)
1015
+ if input_path.suffix.lower() == ".xlsx":
1016
+ return normalize_legacy_xlsx(input_path)
1017
+ raise RfqInputError("input must be a canonical .json package or exact legacy .xlsx")
1018
+
1019
+
1020
+ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]:
1021
+ input_path = Path(args.input)
1022
+ input_findings = []
1023
+ if not _valid_date(args.issued_date):
1024
+ input_findings.append(
1025
+ {
1026
+ "code": "invalid_issued_date",
1027
+ "severity": "error",
1028
+ "message": "issued-date must be an explicit YYYY-MM-DD date",
1029
+ }
1030
+ )
1031
+ try:
1032
+ normalized = _load_normalized(input_path)
1033
+ except RfqInputError as exc:
1034
+ normalized = {
1035
+ "input_kind": "invalid",
1036
+ "input_version": "unknown",
1037
+ "input_hash": hashlib.sha256(input_path.read_bytes()).hexdigest(),
1038
+ "project_id": input_path.stem,
1039
+ "project_name": input_path.stem,
1040
+ "revision_id": "unknown",
1041
+ "currency": "USD",
1042
+ "items": [],
1043
+ "warnings": [],
1044
+ "review_findings": [],
1045
+ "assumptions": [],
1046
+ }
1047
+ input_findings.append(
1048
+ {
1049
+ "code": "invalid_rfq_input",
1050
+ "severity": "error",
1051
+ "message": str(exc),
1052
+ }
1053
+ )
1054
+ try:
1055
+ profile, profile_source = resolve_company_profile(input_path)
1056
+ except RfqInputError as exc:
1057
+ profile, profile_source = None, "invalid"
1058
+ input_findings.append(
1059
+ {
1060
+ "code": "invalid_company_profile",
1061
+ "severity": "error",
1062
+ "message": str(exc),
1063
+ }
1064
+ )
1065
+ profile_findings = validate_company_profile(
1066
+ profile, profile.get("_profile_path") if profile else None
1067
+ )
1068
+ try:
1069
+ nest_handoff = (
1070
+ json.loads(Path(args.nest).read_text(encoding="utf-8"))
1071
+ if args.nest
1072
+ else None
1073
+ )
1074
+ nest_findings = validate_nest_handoff(
1075
+ nest_handoff,
1076
+ expected=normalized,
1077
+ )
1078
+ except (OSError, json.JSONDecodeError) as exc:
1079
+ nest_handoff = None
1080
+ nest_findings = [
1081
+ {
1082
+ "code": "invalid_nest_handoff",
1083
+ "severity": "error",
1084
+ "message": str(exc),
1085
+ }
1086
+ ]
1087
+ findings = input_findings + profile_findings + nest_findings
1088
+ blockers = [finding for finding in findings if finding["severity"] == "error"]
1089
+ review_reasons = list(normalized["warnings"])
1090
+ if any(
1091
+ readiness == "reference_only"
1092
+ for readiness in [
1093
+ (nest_handoff or {}).get("geometry_readiness"),
1094
+ *[
1095
+ row.get("geometry_readiness")
1096
+ for row in (nest_handoff or {}).get("rows", [])
1097
+ ],
1098
+ ]
1099
+ ):
1100
+ review_reasons.append("Nesting handoff contains reference-only geometry.")
1101
+ if blockers:
1102
+ outcome, package_status = "blocked", "draft"
1103
+ elif review_reasons:
1104
+ outcome, package_status = "review_required", "rfq_draft_review_required"
1105
+ else:
1106
+ outcome, package_status = "ready", "rfq_ready_for_review"
1107
+ configuration_hash = sha256_bytes(
1108
+ canonical_json_bytes(
1109
+ {
1110
+ "compiler_version": RFQ_COMPILER_VERSION,
1111
+ "issued_date": args.issued_date,
1112
+ "project_location": args.project_location,
1113
+ "profile_hash": profile_semantic_hash(profile),
1114
+ "nest_handoff": nest_handoff,
1115
+ "bake_requested": not args.no_bake,
1116
+ }
1117
+ )
1118
+ )
1119
+ qa_report = {
1120
+ "schema_version": "1.0.0",
1121
+ "stage": "steel-rfq",
1122
+ "run_outcome": outcome,
1123
+ "package_status": package_status,
1124
+ "document_status": "DRAFT — NOT SENT OR AWARDED",
1125
+ "profile_source": profile_source,
1126
+ "findings": findings,
1127
+ "warnings": review_reasons,
1128
+ }
1129
+ with RunPublisher(
1130
+ args.out,
1131
+ stage="steel-rfq",
1132
+ run_outcome=outcome,
1133
+ package_status=package_status,
1134
+ input_hash=normalized["input_hash"],
1135
+ configuration_hash=configuration_hash,
1136
+ schema_versions={
1137
+ "run_manifest": "1.0.0",
1138
+ "estimate_package": normalized["input_version"],
1139
+ "rfq_workbook": RFQ_COMPILER_VERSION,
1140
+ "rfq_nesting": NEST_HANDOFF_VERSION,
1141
+ },
1142
+ tool_versions={
1143
+ "pi_steel": package_version(__file__),
1144
+ "rfq_compiler": RFQ_COMPILER_VERSION,
1145
+ },
1146
+ explicit_dates={"issued_date": args.issued_date},
1147
+ warnings=review_reasons
1148
+ + [finding["message"] for finding in findings],
1149
+ approximations=[],
1150
+ run_id=args.run_id,
1151
+ ) as publisher:
1152
+ if not blockers:
1153
+ compile_result = compile_workbook(
1154
+ normalized,
1155
+ profile,
1156
+ nest_handoff=nest_handoff,
1157
+ issued_date=args.issued_date,
1158
+ project_location=args.project_location,
1159
+ output_directory=publisher.staging_path,
1160
+ profile_source=profile_source,
1161
+ bake=not args.no_bake,
1162
+ )
1163
+ qa_report["recalculation_status"] = compile_result["recalculation_status"]
1164
+ qa_report["logo_status"] = compile_result["logo_status"]
1165
+ workbook_name = compile_result["workbook_path"].name
1166
+ publisher.register_artifact(
1167
+ workbook_name,
1168
+ readiness="draft",
1169
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1170
+ )
1171
+ publisher.write_json(
1172
+ "workbook-semantic.json",
1173
+ workbook_semantic_projection(compile_result["workbook_path"]),
1174
+ readiness="diagnostic",
1175
+ )
1176
+ publisher.write_qa_report(qa_report)
1177
+ final_path = publisher.publish()
1178
+ return qa_report, final_path
1179
+
1180
+
1181
+ def main(argv=None) -> int:
1182
+ parser = StageArgumentParser(description=__doc__)
1183
+ parser.configure_failure_diagnostics(
1184
+ stage="steel-rfq",
1185
+ entry_file=__file__,
1186
+ input_option="--input",
1187
+ date_options={"--issued-date": "issued_date"},
1188
+ )
1189
+ parser.add_argument("--input", required=True)
1190
+ parser.add_argument("--nest")
1191
+ parser.add_argument("--out", default="outputs")
1192
+ parser.add_argument("--issued-date", required=True)
1193
+ parser.add_argument("--project-location", default="")
1194
+ parser.add_argument("--no-bake", action="store_true")
1195
+ parser.add_argument("--run-id", help=argparse.SUPPRESS)
1196
+ args = parser.parse_args(argv)
1197
+ try:
1198
+ qa_report, final_path = publish_rfq_run(args)
1199
+ except (OSError, json.JSONDecodeError, RfqInputError) as exc:
1200
+ diagnostic_path = publish_failure_diagnostic(
1201
+ args.out,
1202
+ stage="steel-rfq",
1203
+ input_path=args.input,
1204
+ error=exc,
1205
+ tool_version=package_version(__file__),
1206
+ run_id=args.run_id,
1207
+ explicit_dates={"issued_date": args.issued_date},
1208
+ )
1209
+ suffix = (
1210
+ f"; diagnostic published: {diagnostic_path}"
1211
+ if diagnostic_path is not None
1212
+ else "; diagnostic publication unavailable"
1213
+ )
1214
+ print(f"RFQ generation failed: {exc}{suffix}", file=sys.stderr)
1215
+ return 1
1216
+ print(f"Published {qa_report['run_outcome']} draft RFQ run: {final_path}")
1217
+ return outcome_exit_code(qa_report["run_outcome"])
1218
+
1219
+
1220
+ if __name__ == "__main__":
1221
+ raise SystemExit(main())