#!/usr/bin/env python3 """ OCI Deal Accelerator — Bill of Materials (BOM) Generator Generates a professional .xlsx BOM from a YAML spec, resolving SKUs against the OCI SKU catalog. Only requested services appear in the output. Usage: python oci_bom_gen.py --spec bom-spec.yaml --output customer-bom.xlsx python oci_bom_gen.py --spec bom-spec.yaml --output bom.xlsx --catalog kb/pricing/oci-sku-catalog.yaml """ import argparse import os import sys from datetime import date from pathlib import Path from typing import Dict, List, Optional, Tuple import yaml from openpyxl import Workbook from openpyxl.styles import ( Alignment, Border, Font, NamedStyle, PatternFill, Side, numbers, ) from openpyxl.utils import get_column_letter # ── Oracle Redwood palette ──────────────────────────────────────── class Colors: TEAL = "2C5967" TEAL_LIGHT = "E8F0F2" DARK_BG = "312D2A" COPPER = "AA643B" WHITE = "FFFFFF" GRAY_100 = "F5F4F2" GRAY_300 = "D4D1CC" GRAY_500 = "6F6964" GRAY_700 = "3A3631" BLACK = "000000" # ── Column definitions ──────────────────────────────────────────── # Base columns (always present) BASE_COLS = [ ("Part (SKU)", 14), ("Product / Description", 55), ("Metric", 32), ("Unit Price (USD)", 16), ("Hours/Units", 12), ("Qty", 10), ("Months", 10), ("Discount", 10), ("Monthly USD\n(w/o discount)", 18), ("Monthly USD\n(w/ discount)", 18), ] # Conversion columns (added when conversion is enabled) # Placeholders — header text is set dynamically with target currency CONV_COLS = [ ("Monthly {cur}\n(w/o tax)", 18), ("Monthly {cur}\n(w/ tax)", 18), ] # Always-last column TAIL_COLS = [ ("Cost %", 10), ] def pick(mapping: dict, *keys, default=""): """Return the first non-empty value for any of the provided keys.""" if not isinstance(mapping, dict): return default for key in keys: if key in mapping: value = mapping.get(key) if value is not None: return value return default class OCIBomGenerator: """Generate OCI Bill of Materials (.xlsx) with Oracle Redwood styling.""" def __init__( self, customer: str = "", project: str = "", prepared_by: str = "", bom_date: str = "", reference_label: str = "", currency: str = "USD", realm: str = "", service_type: str = "", conversion: Optional[dict] = None, notes: Optional[list] = None, ): self.customer = customer self.project = project self.prepared_by = prepared_by self.bom_date = bom_date or date.today().isoformat() self.reference_label = reference_label self.currency = currency self.realm = realm self.service_type = service_type self.conversion = conversion or {"enabled": False} self.notes = notes or [] self.catalog: dict = {} # sku_code -> {product, metric, ...} self.catalog_meta: dict = {} # top-level catalog metadata self.category_order: list = [] # ordered category keys self.category_names: dict = {} # cat_key -> display_name self.line_items: list = [] # resolved line items self.wb = Workbook() # ── Catalog loading ─────────────────────────────────────────── def load_catalog(self, catalog_path: Optional[str] = None): """Load SKU catalog from YAML. Auto-discovers path if not given.""" if not catalog_path: # Auto-discover relative to this script tools_dir = Path(__file__).resolve().parent catalog_path = str(tools_dir.parent / "kb" / "pricing" / "oci-sku-catalog.yaml") with open(catalog_path, "r") as f: raw = yaml.safe_load(f) self.catalog_meta = { k: v for k, v in raw.items() if k != "categories" } categories = raw.get("categories", {}) for cat_key, cat_data in categories.items(): self.category_order.append(cat_key) self.category_names[cat_key] = cat_data.get("display_name", cat_key) for sku_entry in cat_data.get("skus", []): sku_code = str(sku_entry["sku"]) self.catalog[sku_code] = { **sku_entry, "sku": sku_code, "category": cat_key, } # ── Line items ──────────────────────────────────────────────── def add_line_item( self, sku: str, qty: float = 0, hours_units: Optional[float] = None, months: int = 12, discount: float = 0.0, custom_label: str = "", custom_note: str = "", ): """Add a line item, resolving SKU details from catalog.""" sku = str(sku) cat_entry = self.catalog.get(sku, {}) note = custom_note if not cat_entry: warn = f"[WARN] SKU {sku!r} not in catalog — included as line item with $0 price and warning note" print(warn, file=sys.stderr) flag = "⚠ SKU not in catalog — estimated $0, must be confirmed before quoting" note = f"{note}. {flag}" if note else flag elif cat_entry.get("estimate"): flag = "⚠ Estimate only — billed via underlying compute/storage; confirm actual shape + hours" note = f"{note}. {flag}" if note else flag item = { "sku": sku, "product": cat_entry.get("product", f"Unknown SKU ({sku})"), "metric": cat_entry.get("metric", ""), "list_price_usd": cat_entry.get("list_price_usd", 0), "hours_units": hours_units if hours_units is not None else cat_entry.get("default_hours_units", 730), "qty": qty, "months": months, "discount": discount, "category": cat_entry.get("category", "other"), "discountable": cat_entry.get("discountable", True), "billing_type": cat_entry.get("billing_type", "hourly"), "custom_label": custom_label, "custom_note": note, } self.line_items.append(item) # ── Excel generation ────────────────────────────────────────── def _col_defs(self) -> List[Tuple[str, int]]: """Return full column definitions based on conversion setting.""" cols = list(BASE_COLS) if self.conversion.get("enabled"): cur = self.conversion.get("target_currency", "BRL") cols += [(h.format(cur=cur), w) for h, w in CONV_COLS] cols += list(TAIL_COLS) return cols def _col_count(self) -> int: 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): """Build the complete BOM sheet.""" ws.title = "BOM" col_defs = self._col_defs() num_cols = len(col_defs) conv = self.conversion.get("enabled", False) # ── Styles ──────────────────────────────────────────────── thin_border = Border( left=Side(style="thin", color=Colors.GRAY_300), right=Side(style="thin", color=Colors.GRAY_300), top=Side(style="thin", color=Colors.GRAY_300), bottom=Side(style="thin", color=Colors.GRAY_300), ) header_font = Font(name="Segoe UI", size=11, bold=True, color=Colors.WHITE) header_fill = PatternFill(start_color=Colors.TEAL, end_color=Colors.TEAL, fill_type="solid") header_align = Alignment(horizontal="center", vertical="center", wrap_text=True) cat_font = Font(name="Segoe UI", size=10, bold=True, color=Colors.TEAL) cat_fill = PatternFill(start_color=Colors.TEAL_LIGHT, end_color=Colors.TEAL_LIGHT, fill_type="solid") data_font = Font(name="Segoe UI", size=10, color=Colors.GRAY_700) alt_fill = PatternFill(start_color=Colors.GRAY_100, end_color=Colors.GRAY_100, fill_type="solid") total_font = Font(name="Segoe UI", size=11, bold=True, color=Colors.DARK_BG) total_fill = PatternFill(start_color=Colors.GRAY_300, end_color=Colors.GRAY_300, fill_type="solid") subtotal_font = Font(name="Segoe UI", size=10, bold=True, color=Colors.GRAY_700) # ── Header area ─────────────────────────────────────────── row = 1 title_font = Font(name="Segoe UI", size=16, bold=True, color=Colors.TEAL) ws.cell(row=row, column=1, value="Oracle Investment Proposal").font = title_font ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=num_cols) row += 1 subtitle_font = Font(name="Segoe UI", size=10, color=Colors.GRAY_500) ws.cell(row=row, column=1, value=f"as of {self.bom_date}").font = subtitle_font row += 1 meta_font = Font(name="Segoe UI", size=10, color=Colors.GRAY_700) meta_items = [ ("Customer", self.customer), ("Project", self.project), ("Prepared by", self.prepared_by), ("Reference", self.reference_label), ("Currency", self.currency), ("Realm", self.realm), ("Service Type", self.service_type), ] for label, value in meta_items: if value: ws.cell(row=row, column=1, value=f"{label}:").font = Font( name="Segoe UI", size=10, bold=True, color=Colors.GRAY_700 ) ws.cell(row=row, column=2, value=value).font = meta_font row += 1 if conv: xr = self.conversion.get("exchange_rate", 1) tc = self.conversion.get("target_currency", "BRL") tr = self.conversion.get("tax_rate", 0) ws.cell(row=row, column=1, value="Exchange Rate:").font = Font( name="Segoe UI", size=10, bold=True, color=Colors.GRAY_700 ) ws.cell(row=row, column=2, value=f"1 USD = {xr} {tc}").font = meta_font row += 1 if tr > 0: ws.cell(row=row, column=1, value="Tax Rate:").font = Font( name="Segoe UI", size=10, bold=True, color=Colors.GRAY_700 ) ws.cell(row=row, column=2, value=f"{tr*100:.2f}%").font = meta_font row += 1 row += 1 # blank row before table # ── Column headers ──────────────────────────────────────── header_row = row for ci, (col_name, col_width) in enumerate(col_defs, 1): cell = ws.cell(row=row, column=ci, value=col_name) cell.font = header_font cell.fill = header_fill cell.alignment = header_align cell.border = thin_border ws.column_dimensions[get_column_letter(ci)].width = col_width row += 1 # ── Group items by category ─────────────────────────────── items_by_cat: dict[str, list] = {} for item in self.line_items: cat = item["category"] items_by_cat.setdefault(cat, []).append(item) # Column index references (1-based) COL_SKU = 1 COL_PRODUCT = 2 COL_METRIC = 3 COL_PRICE = 4 COL_HOURS = 5 COL_QTY = 6 COL_MONTHS = 7 COL_DISC = 8 COL_M_WO = 9 # monthly without discount COL_M_W = 10 # monthly with discount if conv: COL_CONV_WO = 11 # monthly converted without tax COL_CONV_W = 12 # monthly converted with tax COL_PCT = 13 # cost proportion else: COL_PCT = 11 data_start_row = row cat_subtotal_rows = [] data_item_rows = [] # line-item rows (excludes category headers/subtotals/totals) # Ensure categories present in items but missing from catalog order # (e.g., "other" from unknown SKUs) still render at the end. render_order = list(self.category_order) for cat_key in items_by_cat: if cat_key not in render_order: 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: if cat_key not in items_by_cat: continue cat_items = items_by_cat[cat_key] display_name = self.category_names.get( cat_key, "Uncategorized — confirm SKUs" if cat_key == "other" else cat_key, ) # Category header row ws.cell(row=row, column=COL_PRODUCT, value=display_name).font = cat_font for ci in range(1, num_cols + 1): ws.cell(row=row, column=ci).fill = cat_fill ws.cell(row=row, column=ci).border = thin_border cat_header_row = row row += 1 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): # Static data ws.cell(row=row, column=COL_SKU, value=item["sku"]) ws.cell(row=row, column=COL_PRODUCT, value=item["product"]) ws.cell(row=row, column=COL_METRIC, value=item["metric"]) ws.cell(row=row, column=COL_PRICE, value=item["list_price_usd"]) ws.cell(row=row, column=COL_HOURS, value=item["hours_units"]) ws.cell(row=row, column=COL_QTY, value=item["qty"]) ws.cell(row=row, column=COL_MONTHS, value=item["months"]) ws.cell(row=row, column=COL_DISC, value=item["discount"]) monthly_wo, monthly_w = self._monthly_values(item) ws.cell(row=row, column=COL_M_WO, value=monthly_wo) ws.cell(row=row, column=COL_M_W, value=monthly_w) cat_total_wo += monthly_wo cat_total_w += monthly_w # Conversion formulas if conv: xr = self.conversion.get("exchange_rate", 1) tr = self.conversion.get("tax_rate", 0) conv_wo = monthly_w * xr conv_w = conv_wo * (1 + tr) ws.cell(row=row, column=COL_CONV_WO, value=conv_wo) ws.cell(row=row, column=COL_CONV_W, value=conv_w) cat_total_conv_wo += conv_wo cat_total_conv_w += conv_w # Styling per row is_alt = idx % 2 == 1 for ci in range(1, num_cols + 1): cell = ws.cell(row=row, column=ci) cell.font = data_font cell.border = thin_border if is_alt: cell.fill = alt_fill cell.alignment = Alignment( vertical="center", wrap_text=(ci == COL_PRODUCT), ) # Number formats ws.cell(row=row, column=COL_PRICE).number_format = '$#,##0.0000' ws.cell(row=row, column=COL_HOURS).number_format = '#,##0' ws.cell(row=row, column=COL_QTY).number_format = '#,##0.##' ws.cell(row=row, column=COL_MONTHS).number_format = '#,##0' ws.cell(row=row, column=COL_DISC).number_format = '0%' ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00' ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00' if conv: ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00' ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00' data_item_rows.append(row) row += 1 # Category subtotal row cat_last_data = row - 1 ws.cell(row=row, column=COL_PRODUCT, value=f"Subtotal — {display_name}") ws.cell(row=row, column=COL_M_WO, value=cat_total_wo) ws.cell(row=row, column=COL_M_W, value=cat_total_w) ws.cell(row=row, column=COL_M_WO).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: ws.cell(row=row, column=COL_CONV_WO, value=cat_total_conv_wo) ws.cell(row=row, column=COL_CONV_W, value=cat_total_conv_w) ws.cell(row=row, column=COL_CONV_WO).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): cell = ws.cell(row=row, column=ci) cell.font = subtotal_font cell.border = thin_border cell.fill = PatternFill(start_color="EDE9E5", end_color="EDE9E5", fill_type="solid") cat_subtotal_rows.append(row) row += 1 # blank row between categories # ── Grand total ─────────────────────────────────────────── total_row = row ws.cell(row=row, column=COL_PRODUCT, value="TOTAL").font = total_font ws.cell(row=row, column=COL_M_WO, value=grand_total_wo) ws.cell(row=row, column=COL_M_W, value=grand_total_w) ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00' ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00' if conv: ws.cell(row=row, column=COL_CONV_WO, value=grand_total_conv_wo) ws.cell(row=row, column=COL_CONV_W, value=grand_total_conv_w) ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00' ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00' for ci in range(1, num_cols + 1): cell = ws.cell(row=row, column=ci) cell.font = total_font cell.fill = total_fill cell.border = thin_border row += 1 # ── ARR (Annual Run Rate) ──────────────────────────────── arr_row = row ws.cell(row=row, column=COL_PRODUCT, value="Annual Run Rate (ARR)").font = Font( name="Segoe UI", size=11, bold=True, color=Colors.COPPER ) ws.cell(row=row, column=COL_M_W, value=grand_total_w * 12) ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00' ws.cell(row=row, column=COL_M_W).font = Font( name="Segoe UI", size=11, bold=True, color=Colors.COPPER ) if conv: ws.cell(row=row, column=COL_CONV_W, value=grand_total_conv_w * 12) ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00' ws.cell(row=row, column=COL_CONV_W).font = Font( name="Segoe UI", size=11, bold=True, color=Colors.COPPER ) for ci in range(1, num_cols + 1): ws.cell(row=row, column=ci).border = thin_border row += 2 # ── Cost proportion formulas (back-fill) ───────────────── # Cost % = line monthly w/ discount / total monthly w/ discount mw_l = get_column_letter(COL_M_W) for item_row in data_item_rows: ws.cell( row=item_row, column=COL_PCT, value=f"=IF({mw_l}{total_row}=0,0,{mw_l}{item_row}/{mw_l}{total_row})", ) ws.cell(row=item_row, column=COL_PCT).number_format = '0.0%' ws.cell(row=item_row, column=COL_PCT).font = data_font ws.cell(row=item_row, column=COL_PCT).border = thin_border # ── Notes ───────────────────────────────────────────────── if self.notes: ws.cell(row=row, column=1, value="Notes:").font = Font( name="Segoe UI", size=10, bold=True, color=Colors.GRAY_500 ) row += 1 for note in self.notes: ws.cell(row=row, column=1, value=f" - {note}").font = Font( name="Segoe UI", size=9, color=Colors.GRAY_500 ) row += 1 row += 1 # ── Disclaimer ──────────────────────────────────────────── disclaimer = self.catalog_meta.get("disclaimer", "") if disclaimer: ws.cell(row=row, column=1, value="Disclaimer:").font = Font( name="Segoe UI", size=8, bold=True, color=Colors.GRAY_500 ) row += 1 ws.merge_cells( start_row=row, start_column=1, end_row=row, end_column=num_cols, ) cell = ws.cell(row=row, column=1, value=disclaimer.strip()) cell.font = Font(name="Segoe UI", size=8, color=Colors.GRAY_500) cell.alignment = Alignment(wrap_text=True, vertical="top") ws.row_dimensions[row].height = 60 # ── Print setup ─────────────────────────────────────────── ws.sheet_properties.pageSetUpPr = None ws.page_setup.orientation = "landscape" ws.page_setup.fitToWidth = 1 ws.page_setup.fitToHeight = 0 ws.print_title_rows = f"{header_row}:{header_row}" # ── AppCA Export ────────────────────────────────────────────── def _build_appca_sheets(self): """Build the two sheets needed for AppCA import: 'Export to AppCA' and 'BOM.C1'.""" # ── Styles ──────────────────────────────────────────────── thin_border = Border( left=Side(style="thin", color=Colors.GRAY_300), right=Side(style="thin", color=Colors.GRAY_300), top=Side(style="thin", color=Colors.GRAY_300), bottom=Side(style="thin", color=Colors.GRAY_300), ) header_font = Font(name="Segoe UI", size=11, bold=True, color=Colors.WHITE) header_fill = PatternFill(start_color=Colors.TEAL, end_color=Colors.TEAL, fill_type="solid") header_align = Alignment(horizontal="center", vertical="center", wrap_text=True) data_font = Font(name="Segoe UI", size=10, color=Colors.GRAY_700) cat_font = Font(name="Segoe UI", size=10, bold=True, color=Colors.TEAL) cat_fill = PatternFill(start_color=Colors.TEAL_LIGHT, end_color=Colors.TEAL_LIGHT, fill_type="solid") # ── Sheet: BOM.C1 ───────────────────────────────────────── ws_bom = self.wb.create_sheet("BOM.C1") # Row 1: global parameters ws_bom.cell(row=1, column=6, value="Hours: ").font = Font(name="Segoe UI", size=10, bold=True) ws_bom.cell(row=1, column=7, value=730) ws_bom.cell(row=1, column=8, value="Months: ").font = Font(name="Segoe UI", size=10, bold=True) months_val = self.line_items[0]["months"] if self.line_items else 12 ws_bom.cell(row=1, column=9, value=months_val) # Row 4: title ws_bom.cell(row=4, column=4, value="Oracle Cloud Infrastructure - Bill of Material (BOM)").font = Font( name="Segoe UI", size=12, bold=True, color=Colors.TEAL ) # Row 5: column headers (matching original BOM.C1 layout: C=SKU, D=Product, etc.) bom_headers = [ (3, "SKU", 14), (4, "Product", 50), (5, "Metric", 30), (6, "Price List US$", 14), (7, "Hours/Units", 12), (8, "Qty", 10), (9, "Months", 10), (10, "Discount", 10), (11, "US$\n(w/o discount)", 16), (12, "US$\n(w/ discount)", 16), ] for col, name, width in bom_headers: cell = ws_bom.cell(row=5, column=col, value=name) cell.font = header_font cell.fill = header_fill cell.alignment = header_align cell.border = thin_border ws_bom.column_dimensions[get_column_letter(col)].width = width # Row 6+: data rows grouped by category row = 6 items_by_cat = {} for item in self.line_items: cat = item["category"] items_by_cat.setdefault(cat, []).append(item) render_order = list(self.category_order) for cat_key in items_by_cat: if cat_key not in render_order: render_order.append(cat_key) for cat_key in render_order: if cat_key not in items_by_cat: continue display_name = self.category_names.get( cat_key, "Uncategorized — confirm SKUs" if cat_key == "other" else cat_key, ) # Category header ws_bom.cell(row=row, column=4, value=display_name).font = cat_font for ci in range(3, 13): ws_bom.cell(row=row, column=ci).fill = cat_fill ws_bom.cell(row=row, column=ci).border = thin_border row += 1 for item in items_by_cat[cat_key]: ws_bom.cell(row=row, column=3, value=item["sku"]) ws_bom.cell(row=row, column=4, value=item["product"]) ws_bom.cell(row=row, column=5, value=item["metric"]) ws_bom.cell(row=row, column=6, value=item["list_price_usd"]) ws_bom.cell(row=row, column=7, value=item["hours_units"]) ws_bom.cell(row=row, column=8, value=item["qty"]) ws_bom.cell(row=row, column=9, value=item["months"]) ws_bom.cell(row=row, column=10, value=item["discount"]) monthly_wo, monthly_w = self._monthly_values(item) ws_bom.cell(row=row, column=11, value=monthly_wo) ws_bom.cell(row=row, column=12, value=monthly_w) # Formats ws_bom.cell(row=row, column=6).number_format = '#,##0.0000' ws_bom.cell(row=row, column=10).number_format = '0.0%' ws_bom.cell(row=row, column=11).number_format = '$#,##0.00' ws_bom.cell(row=row, column=12).number_format = '$#,##0.00' for ci in range(3, 13): cell = ws_bom.cell(row=row, column=ci) cell.font = data_font cell.border = thin_border row += 1 # ── Sheet: Export to AppCA ───────────────────────────────── ws_appca = self.wb.create_sheet("Export to AppCA", 0) # first sheet # Title ws_appca.cell(row=1, column=1, value="TEMPLATE BOM - APPCA").font = Font( name="Segoe UI", size=14, bold=True, color=Colors.TEAL ) ws_appca.merge_cells("A1:G1") # Headers — AppCA expects: SKU, QTY, HOURS, MONTHS, DISCOUNT, BURSTABLE, STATUS appca_headers = ["SKU", "QTY", "HOURS", "MONTHS", "DISCOUNT", "BURSTABLE", "STATUS"] appca_widths = [14, 10, 12, 10, 12, 10, 12] for ci, (name, width) in enumerate(zip(appca_headers, appca_widths), 1): cell = ws_appca.cell(row=2, column=ci, value=name) cell.font = header_font cell.fill = header_fill cell.alignment = header_align cell.border = thin_border ws_appca.column_dimensions[get_column_letter(ci)].width = width # Data rows: only items with qty > 0 appca_row = 3 for item in self.line_items: if item["qty"] > 0: ws_appca.cell(row=appca_row, column=1, value=item["sku"]) ws_appca.cell(row=appca_row, column=2, value=item["qty"]) ws_appca.cell(row=appca_row, column=3, value=item["hours_units"]) ws_appca.cell(row=appca_row, column=4, value=item["months"]) ws_appca.cell(row=appca_row, column=5, value=item["discount"]) ws_appca.cell(row=appca_row, column=6, value="No") # BURSTABLE: No unless specified ws_appca.cell(row=appca_row, column=7, value="Yes") # STATUS: required by AppCA ws_appca.cell(row=appca_row, column=5).number_format = '0.0%' for ci in range(1, 8): cell = ws_appca.cell(row=appca_row, column=ci) cell.font = data_font cell.border = thin_border appca_row += 1 # ── Public API ──────────────────────────────────────────────── def save(self, filepath: str, appca: bool = False): """Build and save the workbook to an .xlsx file.""" if appca: # Remove default empty sheet, build AppCA sheets default_sheet = self.wb.active self._build_appca_sheets() self.wb.remove(default_sheet) else: ws = self.wb.active self._build_sheet(ws) self.wb.save(filepath) print(" BOM saved: {}".format(filepath)) @classmethod def from_spec(cls, spec: dict, catalog_path: Optional[str] = None) -> "OCIBomGenerator": """Build BOM from a YAML specification dict.""" bom = spec.get("bom", spec) conversion = bom.get("conversion") if isinstance(conversion, dict): conversion = dict(conversion) if "target_currency" not in conversion and "currency" in conversion: conversion["target_currency"] = conversion.get("currency") gen = cls( customer=pick(bom, "customer_name", "customer"), project=pick(bom, "project_name", "project"), prepared_by=pick(bom, "prepared_by", "author"), bom_date=bom.get("date", ""), reference_label=pick(bom, "reference_label", "reference"), currency=bom.get("currency", "USD"), realm=bom.get("realm", ""), service_type=bom.get("service_type", ""), conversion=conversion, notes=bom.get("notes"), ) gen.load_catalog(catalog_path) # Global discount from metadata — used as fallback for line items without explicit discount bom_meta = bom.get("metadata", {}) global_discount = bom_meta.get("discount_pct", bom.get("discount_pct", 0.0)) for item in bom.get("line_items", []): sku = pick(item, "sku", "part_number") if sku in ("", None): raise ValueError("BOM line item requires 'sku' or 'part_number'") gen.add_line_item( sku=str(sku), qty=pick(item, "qty", "quantity", default=0), hours_units=pick(item, "hours_units", "units", "hours", default=None), months=item.get("months", 12), discount=pick(item, "discount", "discount_pct", default=global_discount), custom_label=pick(item, "custom_label", "label"), custom_note=pick(item, "custom_note", "note", "notes"), ) return gen def main(): parser = argparse.ArgumentParser( description="OCI Deal Accelerator — BOM Generator (.xlsx)" ) parser.add_argument( "--spec", required=True, help="Path to BOM spec YAML file", ) parser.add_argument( "--output", default="bom.xlsx", help="Output .xlsx file path (default: bom.xlsx)", ) parser.add_argument( "--catalog", default=None, help="Override path to SKU catalog YAML (default: auto-discover)", ) parser.add_argument( "--appca", action="store_true", help="Generate AppCA import format (2 sheets: 'Export to AppCA' + 'BOM.C1')", ) args = parser.parse_args() with open(args.spec, "r") as f: spec = yaml.safe_load(f) gen = OCIBomGenerator.from_spec(spec, catalog_path=args.catalog) gen.save(args.output, appca=args.appca) if __name__ == "__main__": main()