@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.
- 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
- 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())
|