Compare commits
2 Commits
77298a471c
...
9d5cda8986
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5cda8986 | ||
|
|
6de2251e00 |
@@ -204,6 +204,17 @@ class OCIBomGenerator:
|
|||||||
def _col_count(self) -> int:
|
def _col_count(self) -> int:
|
||||||
return len(self._col_defs())
|
return len(self._col_defs())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _monthly_values(item: dict) -> tuple[float, float]:
|
||||||
|
"""Return monthly values with and without discount for one BOM line."""
|
||||||
|
price = float(item.get("list_price_usd", 0) or 0)
|
||||||
|
hours_units = float(item.get("hours_units", 0) or 0)
|
||||||
|
qty = float(item.get("qty", 0) or 0)
|
||||||
|
discount = float(item.get("discount", 0) or 0)
|
||||||
|
monthly_wo = price * hours_units * qty
|
||||||
|
monthly_w = monthly_wo * (1 - discount)
|
||||||
|
return monthly_wo, monthly_w
|
||||||
|
|
||||||
def _build_sheet(self, ws):
|
def _build_sheet(self, ws):
|
||||||
"""Build the complete BOM sheet."""
|
"""Build the complete BOM sheet."""
|
||||||
ws.title = "BOM"
|
ws.title = "BOM"
|
||||||
@@ -325,6 +336,11 @@ class OCIBomGenerator:
|
|||||||
if cat_key not in render_order:
|
if cat_key not in render_order:
|
||||||
render_order.append(cat_key)
|
render_order.append(cat_key)
|
||||||
|
|
||||||
|
grand_total_wo = 0.0
|
||||||
|
grand_total_w = 0.0
|
||||||
|
grand_total_conv_wo = 0.0
|
||||||
|
grand_total_conv_w = 0.0
|
||||||
|
|
||||||
for cat_key in render_order:
|
for cat_key in render_order:
|
||||||
if cat_key not in items_by_cat:
|
if cat_key not in items_by_cat:
|
||||||
continue
|
continue
|
||||||
@@ -344,6 +360,10 @@ class OCIBomGenerator:
|
|||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
cat_first_data = row
|
cat_first_data = row
|
||||||
|
cat_total_wo = 0.0
|
||||||
|
cat_total_w = 0.0
|
||||||
|
cat_total_conv_wo = 0.0
|
||||||
|
cat_total_conv_w = 0.0
|
||||||
|
|
||||||
for idx, item in enumerate(cat_items):
|
for idx, item in enumerate(cat_items):
|
||||||
# Static data
|
# Static data
|
||||||
@@ -356,38 +376,22 @@ class OCIBomGenerator:
|
|||||||
ws.cell(row=row, column=COL_MONTHS, value=item["months"])
|
ws.cell(row=row, column=COL_MONTHS, value=item["months"])
|
||||||
ws.cell(row=row, column=COL_DISC, value=item["discount"])
|
ws.cell(row=row, column=COL_DISC, value=item["discount"])
|
||||||
|
|
||||||
# Formula: Monthly w/o discount = Price × Hours × Qty
|
monthly_wo, monthly_w = self._monthly_values(item)
|
||||||
p = get_column_letter(COL_PRICE)
|
ws.cell(row=row, column=COL_M_WO, value=monthly_wo)
|
||||||
h = get_column_letter(COL_HOURS)
|
ws.cell(row=row, column=COL_M_W, value=monthly_w)
|
||||||
q = get_column_letter(COL_QTY)
|
cat_total_wo += monthly_wo
|
||||||
d = get_column_letter(COL_DISC)
|
cat_total_w += monthly_w
|
||||||
ws.cell(
|
|
||||||
row=row, column=COL_M_WO,
|
|
||||||
value=f"={p}{row}*{h}{row}*{q}{row}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Formula: Monthly w/ discount = Monthly_wo × (1 - Discount)
|
|
||||||
mwo = get_column_letter(COL_M_WO)
|
|
||||||
ws.cell(
|
|
||||||
row=row, column=COL_M_W,
|
|
||||||
value=f"={mwo}{row}*(1-{d}{row})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Conversion formulas
|
# Conversion formulas
|
||||||
if conv:
|
if conv:
|
||||||
xr = self.conversion.get("exchange_rate", 1)
|
xr = self.conversion.get("exchange_rate", 1)
|
||||||
tr = self.conversion.get("tax_rate", 0)
|
tr = self.conversion.get("tax_rate", 0)
|
||||||
mw = get_column_letter(COL_M_W)
|
conv_wo = monthly_w * xr
|
||||||
# Converted w/o tax
|
conv_w = conv_wo * (1 + tr)
|
||||||
ws.cell(
|
ws.cell(row=row, column=COL_CONV_WO, value=conv_wo)
|
||||||
row=row, column=COL_CONV_WO,
|
ws.cell(row=row, column=COL_CONV_W, value=conv_w)
|
||||||
value=f"={mw}{row}*{xr}",
|
cat_total_conv_wo += conv_wo
|
||||||
)
|
cat_total_conv_w += conv_w
|
||||||
# Converted w/ tax
|
|
||||||
ws.cell(
|
|
||||||
row=row, column=COL_CONV_W,
|
|
||||||
value=f"={get_column_letter(COL_CONV_WO)}{row}*(1+{tr})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Styling per row
|
# Styling per row
|
||||||
is_alt = idx % 2 == 1
|
is_alt = idx % 2 == 1
|
||||||
@@ -419,20 +423,20 @@ class OCIBomGenerator:
|
|||||||
# Category subtotal row
|
# Category subtotal row
|
||||||
cat_last_data = row - 1
|
cat_last_data = row - 1
|
||||||
ws.cell(row=row, column=COL_PRODUCT, value=f"Subtotal — {display_name}")
|
ws.cell(row=row, column=COL_PRODUCT, value=f"Subtotal — {display_name}")
|
||||||
mwo_letter = get_column_letter(COL_M_WO)
|
ws.cell(row=row, column=COL_M_WO, value=cat_total_wo)
|
||||||
mw_letter = get_column_letter(COL_M_W)
|
ws.cell(row=row, column=COL_M_W, value=cat_total_w)
|
||||||
ws.cell(row=row, column=COL_M_WO, value=f"=SUM({mwo_letter}{cat_first_data}:{mwo_letter}{cat_last_data})")
|
|
||||||
ws.cell(row=row, column=COL_M_W, value=f"=SUM({mw_letter}{cat_first_data}:{mw_letter}{cat_last_data})")
|
|
||||||
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
|
||||||
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
||||||
|
grand_total_wo += cat_total_wo
|
||||||
|
grand_total_w += cat_total_w
|
||||||
|
|
||||||
if conv:
|
if conv:
|
||||||
cwo_letter = get_column_letter(COL_CONV_WO)
|
ws.cell(row=row, column=COL_CONV_WO, value=cat_total_conv_wo)
|
||||||
cw_letter = get_column_letter(COL_CONV_W)
|
ws.cell(row=row, column=COL_CONV_W, value=cat_total_conv_w)
|
||||||
ws.cell(row=row, column=COL_CONV_WO, value=f"=SUM({cwo_letter}{cat_first_data}:{cwo_letter}{cat_last_data})")
|
|
||||||
ws.cell(row=row, column=COL_CONV_W, value=f"=SUM({cw_letter}{cat_first_data}:{cw_letter}{cat_last_data})")
|
|
||||||
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
||||||
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
||||||
|
grand_total_conv_wo += cat_total_conv_wo
|
||||||
|
grand_total_conv_w += cat_total_conv_w
|
||||||
|
|
||||||
for ci in range(1, num_cols + 1):
|
for ci in range(1, num_cols + 1):
|
||||||
cell = ws.cell(row=row, column=ci)
|
cell = ws.cell(row=row, column=ci)
|
||||||
@@ -448,19 +452,14 @@ class OCIBomGenerator:
|
|||||||
total_row = row
|
total_row = row
|
||||||
ws.cell(row=row, column=COL_PRODUCT, value="TOTAL").font = total_font
|
ws.cell(row=row, column=COL_PRODUCT, value="TOTAL").font = total_font
|
||||||
|
|
||||||
# Sum of subtotals
|
ws.cell(row=row, column=COL_M_WO, value=grand_total_wo)
|
||||||
mwo_refs = "+".join(f"{get_column_letter(COL_M_WO)}{r}" for r in cat_subtotal_rows)
|
ws.cell(row=row, column=COL_M_W, value=grand_total_w)
|
||||||
mw_refs = "+".join(f"{get_column_letter(COL_M_W)}{r}" for r in cat_subtotal_rows)
|
|
||||||
ws.cell(row=row, column=COL_M_WO, value=f"={mwo_refs}")
|
|
||||||
ws.cell(row=row, column=COL_M_W, value=f"={mw_refs}")
|
|
||||||
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
|
||||||
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
||||||
|
|
||||||
if conv:
|
if conv:
|
||||||
cwo_refs = "+".join(f"{get_column_letter(COL_CONV_WO)}{r}" for r in cat_subtotal_rows)
|
ws.cell(row=row, column=COL_CONV_WO, value=grand_total_conv_wo)
|
||||||
cw_refs = "+".join(f"{get_column_letter(COL_CONV_W)}{r}" for r in cat_subtotal_rows)
|
ws.cell(row=row, column=COL_CONV_W, value=grand_total_conv_w)
|
||||||
ws.cell(row=row, column=COL_CONV_WO, value=f"={cwo_refs}")
|
|
||||||
ws.cell(row=row, column=COL_CONV_W, value=f"={cw_refs}")
|
|
||||||
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
||||||
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
||||||
|
|
||||||
@@ -476,15 +475,13 @@ class OCIBomGenerator:
|
|||||||
ws.cell(row=row, column=COL_PRODUCT, value="Annual Run Rate (ARR)").font = Font(
|
ws.cell(row=row, column=COL_PRODUCT, value="Annual Run Rate (ARR)").font = Font(
|
||||||
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
||||||
)
|
)
|
||||||
mw_total = get_column_letter(COL_M_W)
|
ws.cell(row=row, column=COL_M_W, value=grand_total_w * 12)
|
||||||
ws.cell(row=row, column=COL_M_W, value=f"={mw_total}{total_row}*12")
|
|
||||||
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
||||||
ws.cell(row=row, column=COL_M_W).font = Font(
|
ws.cell(row=row, column=COL_M_W).font = Font(
|
||||||
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
||||||
)
|
)
|
||||||
if conv:
|
if conv:
|
||||||
cw_total = get_column_letter(COL_CONV_W)
|
ws.cell(row=row, column=COL_CONV_W, value=grand_total_conv_w * 12)
|
||||||
ws.cell(row=row, column=COL_CONV_W, value=f"={cw_total}{total_row}*12")
|
|
||||||
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
||||||
ws.cell(row=row, column=COL_CONV_W).font = Font(
|
ws.cell(row=row, column=COL_CONV_W).font = Font(
|
||||||
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
||||||
@@ -630,11 +627,9 @@ class OCIBomGenerator:
|
|||||||
ws_bom.cell(row=row, column=9, value=item["months"])
|
ws_bom.cell(row=row, column=9, value=item["months"])
|
||||||
ws_bom.cell(row=row, column=10, value=item["discount"])
|
ws_bom.cell(row=row, column=10, value=item["discount"])
|
||||||
|
|
||||||
# Formulas matching BOM.C1 style
|
monthly_wo, monthly_w = self._monthly_values(item)
|
||||||
ws_bom.cell(row=row, column=11,
|
ws_bom.cell(row=row, column=11, value=monthly_wo)
|
||||||
value="=(F{r}*G{r}*H{r})".format(r=row))
|
ws_bom.cell(row=row, column=12, value=monthly_w)
|
||||||
ws_bom.cell(row=row, column=12,
|
|
||||||
value="=IFERROR(K{r}*(1-J{r}),K{r})".format(r=row))
|
|
||||||
|
|
||||||
# Formats
|
# Formats
|
||||||
ws_bom.cell(row=row, column=6).number_format = '#,##0.0000'
|
ws_bom.cell(row=row, column=6).number_format = '#,##0.0000'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Or import and use programmatically:
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -118,6 +119,21 @@ def _default_architecture_principles() -> dict:
|
|||||||
return result if any(result.values()) else {}
|
return result if any(result.values()) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _principles_kb_index() -> dict:
|
||||||
|
"""Index every KB principle by id so spec items with only id get enriched."""
|
||||||
|
data = _load_patterns_yaml("architecture-principles.yaml")
|
||||||
|
principles = data.get("principles", {}) if isinstance(data, dict) else {}
|
||||||
|
index = {}
|
||||||
|
for cat_items in principles.values():
|
||||||
|
for p in cat_items or []:
|
||||||
|
if isinstance(p, dict) and p.get("id"):
|
||||||
|
index[str(p["id"])] = {
|
||||||
|
"name": p.get("name", ""),
|
||||||
|
"summary": p.get("principle", p.get("summary", "")),
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
def _default_operational_raci(model: str = "co_managed") -> list:
|
def _default_operational_raci(model: str = "co_managed") -> list:
|
||||||
data = _load_patterns_yaml("operational-raci.yaml")
|
data = _load_patterns_yaml("operational-raci.yaml")
|
||||||
models = data.get("models", {}) if isinstance(data, dict) else {}
|
models = data.get("models", {}) if isinstance(data, dict) else {}
|
||||||
@@ -171,6 +187,13 @@ _HA_DR_BY_TIER = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_UPTIME_BY_TIER = {
|
||||||
|
"platinum": "99.99%",
|
||||||
|
"gold": "99.95%",
|
||||||
|
"silver": "99.9%",
|
||||||
|
"bronze": "99.5%",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _derive_ha_dr_tiers(workloads: list) -> list:
|
def _derive_ha_dr_tiers(workloads: list) -> list:
|
||||||
"""Produce one HA/DR row per distinct service tier present."""
|
"""Produce one HA/DR row per distinct service tier present."""
|
||||||
@@ -236,6 +259,167 @@ def _derive_service_tiering(services: list) -> list:
|
|||||||
return workloads
|
return workloads
|
||||||
|
|
||||||
|
|
||||||
|
def _timeline_to_weeks(timeline: str) -> int | None:
|
||||||
|
"""Parse a simple duration phrase like '12 weeks' or '6 months'."""
|
||||||
|
if not timeline:
|
||||||
|
return None
|
||||||
|
text = str(timeline).lower()
|
||||||
|
match = re.search(r"(\d+)\s*(week|weeks|month|months)", text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
amount = int(match.group(1))
|
||||||
|
unit = match.group(2)
|
||||||
|
return amount * 4 if unit.startswith("month") else amount
|
||||||
|
|
||||||
|
|
||||||
|
def _proposal_default_migration(timeline: str = "") -> dict:
|
||||||
|
"""Generate a conservative Define/Design/Deliver plan from total duration."""
|
||||||
|
total_weeks = _timeline_to_weeks(timeline)
|
||||||
|
if total_weeks and total_weeks >= 6:
|
||||||
|
define = max(1, round(total_weeks * 0.2))
|
||||||
|
design = max(1, round(total_weeks * 0.3))
|
||||||
|
deliver = max(1, total_weeks - define - design)
|
||||||
|
total_duration = f"{total_weeks} weeks"
|
||||||
|
else:
|
||||||
|
define, design, deliver = 2, 4, 6
|
||||||
|
total_duration = timeline or "12 weeks (to validate)"
|
||||||
|
return {
|
||||||
|
"total_duration": total_duration,
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"name": "Define",
|
||||||
|
"duration": f"{define} weeks",
|
||||||
|
"tasks": [
|
||||||
|
"Validate scope and business drivers",
|
||||||
|
"Baseline current estate and constraints",
|
||||||
|
"Confirm success criteria and migration assumptions",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Design",
|
||||||
|
"duration": f"{design} weeks",
|
||||||
|
"tasks": [
|
||||||
|
"Finalize target architecture and landing zone",
|
||||||
|
"Confirm security, DR, and networking approach",
|
||||||
|
"Validate sizing and migration wave plan",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Deliver",
|
||||||
|
"duration": f"{deliver} weeks",
|
||||||
|
"tasks": [
|
||||||
|
"Execute migration waves and rehearsals",
|
||||||
|
"Run cutover and stabilization",
|
||||||
|
"Complete operations handover",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"downtime": "Confirm cutover window with application owners during design.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _proposal_default_risks(summary: dict) -> list:
|
||||||
|
"""Infer generic-but-grounded proposal risks from sparse summary text."""
|
||||||
|
text_parts = [
|
||||||
|
pick(summary, "why"),
|
||||||
|
pick(summary, "target_state"),
|
||||||
|
pick(summary, "timeline"),
|
||||||
|
*coerce_list(summary.get("current_state")),
|
||||||
|
]
|
||||||
|
text = " ".join(str(p) for p in text_parts if p).lower()
|
||||||
|
risks = []
|
||||||
|
|
||||||
|
if any(token in text for token in ("legacy", "on-prem", "on premises", "migration")):
|
||||||
|
risks.append({
|
||||||
|
"risk": "Current-state migration scope may be larger than initially captured.",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"mitigation": "Complete application and database discovery before locking the wave plan.",
|
||||||
|
})
|
||||||
|
if any(token in text for token in ("dr", "disaster recovery", "rto", "rpo", "resilien")):
|
||||||
|
risks.append({
|
||||||
|
"risk": "Resilience objectives need explicit validation against the proposed operating model.",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"mitigation": "Confirm RTO/RPO targets and rehearse failover before production cutover.",
|
||||||
|
})
|
||||||
|
if any(token in text for token in ("cost", "budget", "tco", "byol", "payg")):
|
||||||
|
risks.append({
|
||||||
|
"risk": "Commercial assumptions may diverge from the customer's actual utilization baseline.",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"mitigation": "Validate sizing, discount, and licensing assumptions with finance and platform owners.",
|
||||||
|
})
|
||||||
|
if any(token in text for token in ("team", "skill", "operations", "dba", "cloud")):
|
||||||
|
risks.append({
|
||||||
|
"risk": "Day-2 operating responsibilities may be unclear for the delivery team.",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"mitigation": "Review the co-managed RACI and confirm ownership before the pilot starts.",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not risks:
|
||||||
|
risks.append({
|
||||||
|
"risk": "Incomplete discovery details can force late design changes.",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"mitigation": "Run a discovery checkpoint before committing to procurement or migration dates.",
|
||||||
|
})
|
||||||
|
return risks[:4]
|
||||||
|
|
||||||
|
|
||||||
|
def _proposal_default_next_steps(summary: dict) -> list:
|
||||||
|
timeline = pick(summary, "timeline")
|
||||||
|
steps = [
|
||||||
|
"Review the proposal with technical and business stakeholders.",
|
||||||
|
"Validate current-state inventory, sizing, and dependency assumptions.",
|
||||||
|
"Confirm the target delivery model and success criteria for the first wave.",
|
||||||
|
]
|
||||||
|
if timeline:
|
||||||
|
steps.append(f"Reconcile the plan against the stated timeline ({timeline}).")
|
||||||
|
steps.append("Decide whether to proceed with a proof of concept or detailed design workshop.")
|
||||||
|
return steps[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_partial_proposal_spec(spec: dict) -> dict:
|
||||||
|
"""Add safe fallback sections so sparse proposal specs still render usable decks."""
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
return spec
|
||||||
|
|
||||||
|
summary = spec.get("summary")
|
||||||
|
if not isinstance(summary, dict):
|
||||||
|
return spec
|
||||||
|
|
||||||
|
target_state = pick(summary, "target_state")
|
||||||
|
has_context = bool(target_state or pick(summary, "why") or coerce_list(summary.get("current_state")))
|
||||||
|
if not has_context:
|
||||||
|
return spec
|
||||||
|
|
||||||
|
enriched = dict(spec)
|
||||||
|
|
||||||
|
if "architecture" not in enriched and target_state:
|
||||||
|
enriched["architecture"] = {"description": target_state}
|
||||||
|
|
||||||
|
if "architecture_principles" not in enriched:
|
||||||
|
principles = _default_architecture_principles()
|
||||||
|
if principles:
|
||||||
|
enriched["architecture_principles"] = principles
|
||||||
|
|
||||||
|
if "migration" not in enriched:
|
||||||
|
enriched["migration"] = _proposal_default_migration(pick(summary, "timeline"))
|
||||||
|
|
||||||
|
if not any(k in enriched for k in ("operational_raci", "raci", "operations_raci")):
|
||||||
|
raci_items = _default_operational_raci("co_managed")
|
||||||
|
if raci_items:
|
||||||
|
enriched["operational_raci"] = {
|
||||||
|
"model": "co_managed",
|
||||||
|
"raci_items": raci_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
if "risks" not in enriched:
|
||||||
|
enriched["risks"] = _proposal_default_risks(summary)
|
||||||
|
|
||||||
|
if "next_steps" not in enriched:
|
||||||
|
enriched["next_steps"] = {"steps": _proposal_default_next_steps(summary)}
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
def _adapt_flat_spec(spec: dict) -> dict:
|
def _adapt_flat_spec(spec: dict) -> dict:
|
||||||
"""Map the MCP flat payload to the proposal-spec structure from_spec expects."""
|
"""Map the MCP flat payload to the proposal-spec structure from_spec expects."""
|
||||||
arch = spec.get("architecture") if isinstance(spec.get("architecture"), dict) else {}
|
arch = spec.get("architecture") if isinstance(spec.get("architecture"), dict) else {}
|
||||||
@@ -836,17 +1020,22 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
row_idx = i + 1
|
row_idx = i + 1
|
||||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||||
workload_name = pick(wl, "name", "workload", "title")
|
workload_name = pick(wl, "name", "workload", "title")
|
||||||
|
tier_label = pick(wl, "tier", "service_tier", default="Silver")
|
||||||
|
tier_key = tier_label.lower()
|
||||||
|
tier_color = tier_colors.get(tier_key, Colors.SECONDARY_TEXT)
|
||||||
|
tier_defaults = _HA_DR_BY_TIER.get(tier_key, {})
|
||||||
|
uptime = wl.get("uptime") or _UPTIME_BY_TIER.get(tier_key, "")
|
||||||
|
rto = wl.get("rto") or tier_defaults.get("rto", "")
|
||||||
|
rpo = wl.get("rpo") or tier_defaults.get("rpo", "")
|
||||||
self._style_table_cell(table.cell(row_idx, 0), workload_name, font_size=10, bold=True, bg_color=bg)
|
self._style_table_cell(table.cell(row_idx, 0), workload_name, font_size=10, bold=True, bg_color=bg)
|
||||||
tier_label = pick(wl, "tier", "name", default="Silver")
|
|
||||||
tier_color = tier_colors.get(tier_label.lower(), Colors.SECONDARY_TEXT)
|
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 1), tier_label.title(),
|
table.cell(row_idx, 1), tier_label.title(),
|
||||||
font_size=10, bold=True, color=tier_color, bg_color=bg,
|
font_size=10, bold=True, color=tier_color, bg_color=bg,
|
||||||
alignment=PP_ALIGN.CENTER,
|
alignment=PP_ALIGN.CENTER,
|
||||||
)
|
)
|
||||||
self._style_table_cell(table.cell(row_idx, 2), wl.get("uptime", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
self._style_table_cell(table.cell(row_idx, 2), uptime, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||||
self._style_table_cell(table.cell(row_idx, 3), wl.get("rto", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
self._style_table_cell(table.cell(row_idx, 3), rto, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||||
self._style_table_cell(table.cell(row_idx, 4), wl.get("rpo", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
self._style_table_cell(table.cell(row_idx, 4), rpo, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||||
|
|
||||||
def add_architecture_principles_slide(self, principles: dict):
|
def add_architecture_principles_slide(self, principles: dict):
|
||||||
"""Architecture Principles slide — ECAL Design/Deployment/Service categories.
|
"""Architecture Principles slide — ECAL Design/Deployment/Service categories.
|
||||||
@@ -857,6 +1046,8 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
slide = self._add_blank_slide()
|
slide = self._add_blank_slide()
|
||||||
self._add_title_bar(slide, "Architecture Principles", margin=self.MARGIN)
|
self._add_title_bar(slide, "Architecture Principles", margin=self.MARGIN)
|
||||||
|
|
||||||
|
kb_index = _principles_kb_index()
|
||||||
|
|
||||||
y = Inches(1.2)
|
y = Inches(1.2)
|
||||||
col_x = self.MARGIN
|
col_x = self.MARGIN
|
||||||
col_width = Inches(4)
|
col_width = Inches(4)
|
||||||
@@ -877,7 +1068,11 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
for item in items:
|
for item in items:
|
||||||
pid = item.get("id", "")
|
pid = item.get("id", "")
|
||||||
name = item.get("name", "")
|
name = item.get("name", "")
|
||||||
summary = item.get("summary", "")
|
summary = item.get("summary", "") or item.get("principle", "")
|
||||||
|
if pid and (not name or not summary):
|
||||||
|
fallback = kb_index.get(str(pid), {})
|
||||||
|
name = name or fallback.get("name", "")
|
||||||
|
summary = summary or fallback.get("summary", "")
|
||||||
label = f"{pid} {name}" if pid else name
|
label = f"{pid} {name}" if pid else name
|
||||||
if summary:
|
if summary:
|
||||||
label += f" — {summary}"
|
label += f" — {summary}"
|
||||||
@@ -1588,6 +1783,8 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
# with actual content instead of only blank title/closing slides.
|
# with actual content instead of only blank title/closing slides.
|
||||||
if _is_flat_spec(spec):
|
if _is_flat_spec(spec):
|
||||||
spec = _adapt_flat_spec(spec)
|
spec = _adapt_flat_spec(spec)
|
||||||
|
else:
|
||||||
|
spec = _enrich_partial_proposal_spec(spec)
|
||||||
|
|
||||||
meta = spec.get("metadata", {})
|
meta = spec.get("metadata", {})
|
||||||
gen = cls(
|
gen = cls(
|
||||||
@@ -1622,10 +1819,43 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
# Slide 5: Architecture
|
# Slide 5: Architecture
|
||||||
if "architecture" in spec:
|
if "architecture" in spec:
|
||||||
a = spec["architecture"]
|
a = spec["architecture"]
|
||||||
|
visual = a.get("visual")
|
||||||
|
description = a.get("description", "")
|
||||||
|
primary_region = (
|
||||||
|
spec.get("primary_region")
|
||||||
|
or spec.get("region")
|
||||||
|
or a.get("primary_region")
|
||||||
|
or a.get("region")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
dr_region = (
|
||||||
|
spec.get("dr_region")
|
||||||
|
or spec.get("secondary_region")
|
||||||
|
or a.get("dr_region")
|
||||||
|
or a.get("secondary_region")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
# Auto-build a two-region visual when the spec names a DR region
|
||||||
|
# but didn't pre-render the visual structure itself.
|
||||||
|
if dr_region and not visual:
|
||||||
|
visual = {
|
||||||
|
"regions": [
|
||||||
|
{"name": primary_region or "Primary", "primary": True, "label": "PRIMARY"},
|
||||||
|
{"name": dr_region, "primary": False, "label": "DR STANDBY"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
elif dr_region and isinstance(visual, dict):
|
||||||
|
regions = visual.get("regions") or []
|
||||||
|
names = {str(r.get("name", "")).lower() for r in regions if isinstance(r, dict)}
|
||||||
|
if dr_region.lower() not in names:
|
||||||
|
regions.append({"name": dr_region, "primary": False, "label": "DR STANDBY"})
|
||||||
|
visual["regions"] = regions
|
||||||
|
if dr_region and description and dr_region.lower() not in description.lower():
|
||||||
|
description = f"{description} • DR region: {dr_region}"
|
||||||
gen.add_architecture_slide(
|
gen.add_architecture_slide(
|
||||||
diagram_path=a.get("diagram_path"),
|
diagram_path=a.get("diagram_path"),
|
||||||
description=a.get("description", ""),
|
description=description,
|
||||||
visual=a.get("visual"),
|
visual=visual,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 6: Decisions
|
# Slide 6: Decisions
|
||||||
|
|||||||
@@ -300,6 +300,8 @@ SVC_CATEGORY = {
|
|||||||
# Integration (purple #804998)
|
# Integration (purple #804998)
|
||||||
"drg": "svc_integration",
|
"drg": "svc_integration",
|
||||||
"dynamic_routing_gateway": "svc_integration",
|
"dynamic_routing_gateway": "svc_integration",
|
||||||
|
"rpc": "svc_integration",
|
||||||
|
"remote_peering_connection": "svc_integration",
|
||||||
"streaming": "svc_integration", "kafka": "svc_integration",
|
"streaming": "svc_integration", "kafka": "svc_integration",
|
||||||
"queue": "svc_integration", "oci_queue": "svc_integration",
|
"queue": "svc_integration", "oci_queue": "svc_integration",
|
||||||
"oic": "svc_integration", "integration_cloud": "svc_integration",
|
"oic": "svc_integration", "integration_cloud": "svc_integration",
|
||||||
@@ -1640,6 +1642,7 @@ class OCIDiagramGenerator:
|
|||||||
|
|
||||||
# Regions — auto-size containers from content when spec doesn't override
|
# Regions — auto-size containers from content when spec doesn't override
|
||||||
region_x_cursor = 15 # tracks right edge for auto-positioning next region
|
region_x_cursor = 15 # tracks right edge for auto-positioning next region
|
||||||
|
drg_by_region = [] # list of (region_id, drg_id) for cross-region RPC auto-wiring
|
||||||
for region in tenancy_spec.get("regions", []):
|
for region in tenancy_spec.get("regions", []):
|
||||||
is_primary = region.get("primary", False)
|
is_primary = region.get("primary", False)
|
||||||
rx = region.get("x", region_x_cursor)
|
rx = region.get("x", region_x_cursor)
|
||||||
@@ -1700,6 +1703,18 @@ class OCIDiagramGenerator:
|
|||||||
auto_rw = total_vcn_w + drg_lane_w + 50 # margins
|
auto_rw = total_vcn_w + drg_lane_w + 50 # margins
|
||||||
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
|
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
|
||||||
|
|
||||||
|
# Local DR standby (same region as primary) — adds a dormant
|
||||||
|
# node below the VCNs. Bump auto-height to make room.
|
||||||
|
local_dr = bool(region.get("local_dr") or region.get("local_dr_standby"))
|
||||||
|
if not local_dr:
|
||||||
|
for v in region_vcns:
|
||||||
|
if v.get("local_dr") or v.get("local_dr_standby"):
|
||||||
|
local_dr = True
|
||||||
|
break
|
||||||
|
local_dr_h = 90 if local_dr else 0
|
||||||
|
if local_dr:
|
||||||
|
auto_rh += local_dr_h
|
||||||
|
|
||||||
# Always use at least auto-calculated size even if spec is smaller
|
# Always use at least auto-calculated size even if spec is smaller
|
||||||
rw = max(region.get("w", auto_rw), auto_rw)
|
rw = max(region.get("w", auto_rw), auto_rw)
|
||||||
rh = max(region.get("h", auto_rh), auto_rh)
|
rh = max(region.get("h", auto_rh), auto_rh)
|
||||||
@@ -1738,6 +1753,7 @@ class OCIDiagramGenerator:
|
|||||||
drg_id, label, drg["type"],
|
drg_id, label, drg["type"],
|
||||||
region["id"], drg_x, drg_y, drg_w, drg_h,
|
region["id"], drg_x, drg_y, drg_w, drg_h,
|
||||||
)
|
)
|
||||||
|
drg_by_region.append((region["id"], drg_id))
|
||||||
|
|
||||||
# VCNs — offset right if DRG lane present
|
# VCNs — offset right if DRG lane present
|
||||||
vcn_offset_x = drg_lane_w + 15
|
vcn_offset_x = drg_lane_w + 15
|
||||||
@@ -1832,6 +1848,25 @@ class OCIDiagramGenerator:
|
|||||||
|
|
||||||
subnet_y += sh + SUBNET_GAP
|
subnet_y += sh + SUBNET_GAP
|
||||||
|
|
||||||
|
# Local DR standby — render a dormant node inside the region
|
||||||
|
# at the bottom-right corner so it's visually clear that the
|
||||||
|
# primary has a same-region failover target.
|
||||||
|
if local_dr:
|
||||||
|
standby_w = 160
|
||||||
|
standby_h = 70
|
||||||
|
standby_x = max(15, rw - standby_w - 20)
|
||||||
|
standby_y = rh - standby_h - 15
|
||||||
|
standby_label = (
|
||||||
|
region.get("local_dr_label")
|
||||||
|
or region.get("local_dr_standby_label")
|
||||||
|
or "Local DR\nStandby"
|
||||||
|
)
|
||||||
|
standby_id = region.get("local_dr_id") or gen._next_id()
|
||||||
|
gen.add_service(
|
||||||
|
standby_id, standby_label, "standby",
|
||||||
|
region["id"], standby_x, standby_y, standby_w, standby_h,
|
||||||
|
)
|
||||||
|
|
||||||
# Compartments inside region — can contain VCNs
|
# Compartments inside region — can contain VCNs
|
||||||
for comp in region.get("compartments", []):
|
for comp in region.get("compartments", []):
|
||||||
comp_id = comp.get("id", gen._next_id())
|
comp_id = comp.get("id", gen._next_id())
|
||||||
@@ -1939,9 +1974,39 @@ class OCIDiagramGenerator:
|
|||||||
)
|
)
|
||||||
opx += svc_w + 12
|
opx += svc_w + 12
|
||||||
|
|
||||||
|
# Cross-region RPC — when 2+ regions own a DRG and the spec didn't
|
||||||
|
# already wire a peering link between them, auto-add a "Remote
|
||||||
|
# Peering Connection" edge for each pair so the cross-region path
|
||||||
|
# is visible in the diagram.
|
||||||
|
connections = spec.get("connections", [])
|
||||||
|
if len(drg_by_region) >= 2:
|
||||||
|
existing_pairs = {
|
||||||
|
(c.get("from"), c.get("to")) for c in connections
|
||||||
|
if isinstance(c, dict)
|
||||||
|
}
|
||||||
|
existing_pairs |= {(b, a) for (a, b) in existing_pairs}
|
||||||
|
seen_drgs_per_region = {}
|
||||||
|
for region_id, drg_id in drg_by_region:
|
||||||
|
seen_drgs_per_region.setdefault(region_id, []).append(drg_id)
|
||||||
|
region_ids = list(seen_drgs_per_region.keys())
|
||||||
|
for i in range(len(region_ids)):
|
||||||
|
for j in range(i + 1, len(region_ids)):
|
||||||
|
src = seen_drgs_per_region[region_ids[i]][0]
|
||||||
|
tgt = seen_drgs_per_region[region_ids[j]][0]
|
||||||
|
if (src, tgt) in existing_pairs:
|
||||||
|
continue
|
||||||
|
connections = list(connections) + [{
|
||||||
|
"id": f"rpc_{src}_{tgt}",
|
||||||
|
"from": src,
|
||||||
|
"to": tgt,
|
||||||
|
"type": "network",
|
||||||
|
"label": "RPC\n(Remote Peering)",
|
||||||
|
}]
|
||||||
|
existing_pairs.add((src, tgt))
|
||||||
|
existing_pairs.add((tgt, src))
|
||||||
|
|
||||||
# Connections — merge duplicate from/to pairs (e.g., dual FastConnect)
|
# Connections — merge duplicate from/to pairs (e.g., dual FastConnect)
|
||||||
# into a single edge with combined label
|
# into a single edge with combined label
|
||||||
connections = spec.get("connections", [])
|
|
||||||
seen_pairs = {} # (from, to) -> index in deduped list
|
seen_pairs = {} # (from, to) -> index in deduped list
|
||||||
deduped = []
|
deduped = []
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
|
|||||||
Reference in New Issue
Block a user