Compare commits
2 Commits
9d269e902b
...
fabd7254dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fabd7254dd | ||
|
|
2b923f1aed |
@@ -59,6 +59,11 @@ Present these options as a compact numbered list. Each option has a bold title f
|
|||||||
─────────────────
|
─────────────────
|
||||||
12. 📊 ECAL readiness score — *60-artefact gap analysis*
|
12. 📊 ECAL readiness score — *60-artefact gap analysis*
|
||||||
|
|
||||||
|
SA TOOLS
|
||||||
|
─────────────────
|
||||||
|
13. 📦 BOM generator — *services + quantities → .xlsx Bill of Materials*
|
||||||
|
14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA*
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Pick a number, or just describe what you need.
|
Pick a number, or just describe what you need.
|
||||||
```
|
```
|
||||||
@@ -247,6 +252,29 @@ Pick a number, or just describe what you need.
|
|||||||
→ [D] Re-score after updates
|
→ [D] Re-score after updates
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- If the user picks **13**, ask: "What services does the customer need? (e.g., 'ExaCS X11M BYOL 2 DB servers + 4 storage + 128 ECPUs + ADB-S 8 ECPU + 2TB Block Storage + FastConnect 1Gbps'). I'll generate the BOM with only those SKUs."
|
||||||
|
Then follow the BOM generation flow:
|
||||||
|
1. Parse the customer request to identify needed OCI services and quantities
|
||||||
|
2. Match services against `kb/pricing/oci-sku-catalog.yaml` to select exact SKUs
|
||||||
|
3. Ask for discount % and contract duration if not specified (default: 0%, 12 months)
|
||||||
|
4. Ask if currency conversion is needed (e.g., USD→BRL with exchange rate and tax)
|
||||||
|
5. Generate the BOM spec YAML and save to the output folder
|
||||||
|
6. Run `tools/oci_bom_gen.py` to produce the .xlsx
|
||||||
|
7. Present a summary table in the terminal showing key totals (monthly, ARR)
|
||||||
|
8. List the files generated
|
||||||
|
|
||||||
|
**BOM Output Rules:**
|
||||||
|
- NEVER include "Confidential: Internal ONLY" or any confidentiality marking
|
||||||
|
- ALWAYS include the Oracle Cost Estimator disclaimer at the bottom
|
||||||
|
- Only include SKUs the customer actually requested — never dump the full catalog
|
||||||
|
- Show cost proportions so the customer can see where their spend concentrates
|
||||||
|
- Use Excel formulas (not static values) so the customer can adjust quantities
|
||||||
|
|
||||||
|
- If the user picks **14**, follow the same flow as option 13 (BOM generator) but generate the output in AppCA import format. AppCA is Oracle's internal deal approval tool. The generated .xlsx has two sheets:
|
||||||
|
- **"Export to AppCA"**: Flat table with columns SKU, QTY, HOURS, MONTHS, DISCOUNT, BURSTABLE — ready to paste/import into AppCA
|
||||||
|
- **"BOM.C1"**: Full BOM detail with product names, metrics, prices, and formulas for cost calculations
|
||||||
|
Run with: `python tools/oci_bom_gen.py --spec <spec>.yaml --output <name>.xlsx --appca`
|
||||||
|
|
||||||
- If the user sends discovery notes directly (without picking a number), detect this and go straight to option 1 (full proposal flow).
|
- If the user sends discovery notes directly (without picking a number), detect this and go straight to option 1 (full proposal flow).
|
||||||
- If the user asks a specific question (e.g., "does ADB-S support vector search?"), detect this and go straight to the relevant capability without showing the menu.
|
- If the user asks a specific question (e.g., "does ADB-S support vector search?"), detect this and go straight to the relevant capability without showing the menu.
|
||||||
- Only show the welcome menu on the FIRST message if it's a greeting or empty context. Don't re-show it on every turn.
|
- Only show the welcome menu on the FIRST message if it's a greeting or empty context. Don't re-show it on every turn.
|
||||||
|
|||||||
@@ -60,7 +60,21 @@
|
|||||||
"Bash(PYTHONPATH=/tmp/pptx_ok python3 -c \"from pptx import Presentation; print\\(''''OK''''\\)\")",
|
"Bash(PYTHONPATH=/tmp/pptx_ok python3 -c \"from pptx import Presentation; print\\(''''OK''''\\)\")",
|
||||||
"Bash(PYTHONPATH=/tmp/pptx_ok python3 /tmp/migrate_to_oracle.py)",
|
"Bash(PYTHONPATH=/tmp/pptx_ok python3 /tmp/migrate_to_oracle.py)",
|
||||||
"Bash(PYTHONPATH=/tmp/pptx_ok python3 -c \"\nfrom pptx import Presentation\nprs = Presentation\\('/mnt/c/Users/Diego/Documents/oci-deal-accelerator/oci-deal-accelerator/templates/Oracle_PPT-template_FY26.pptx'\\)\nprint\\('Template has {} existing slides'.format\\(len\\(prs.slides\\)\\)\\)\n\")",
|
"Bash(PYTHONPATH=/tmp/pptx_ok python3 -c \"\nfrom pptx import Presentation\nprs = Presentation\\('/mnt/c/Users/Diego/Documents/oci-deal-accelerator/oci-deal-accelerator/templates/Oracle_PPT-template_FY26.pptx'\\)\nprint\\('Template has {} existing slides'.format\\(len\\(prs.slides\\)\\)\\)\n\")",
|
||||||
"Bash(grep:*)"
|
"Bash(grep:*)",
|
||||||
|
"Bash(ls \"/mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/templates/Oracle_PPT\"*)",
|
||||||
|
"Bash(ls \"/mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/templates/\"*.pptx)",
|
||||||
|
"Bash(ls \"/mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/templates/\"*.potx)",
|
||||||
|
"Bash(python3.12 -c \"import drawpyo\")",
|
||||||
|
"Bash(python3.12 tools/oci_diagram_gen.py --spec examples/output-meli-kvs/diagram-spec.yaml --output examples/output-meli-kvs/architecture.drawio)",
|
||||||
|
"Bash(python3.12 tools/oci_pdf_gen.py --spec examples/output-meli-kvs/proposal-spec.yaml --output examples/output-meli-kvs/customer-proposal.pdf)",
|
||||||
|
"Bash(python3.12 -c \":*)",
|
||||||
|
"Bash(python3.12 tools/oci_deck_gen.py --spec examples/output-meli-kvs/proposal-spec.yaml --output examples/output-meli-kvs/architecture-proposal.pptx)",
|
||||||
|
"Bash(python3.12 tools/oci_bizcase_gen.py --spec examples/output-meli-kvs/business-case.yaml --output examples/output-meli-kvs/business-case.pptx)",
|
||||||
|
"Bash(python3 -c \":*)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"Bash(curl -s --max-time 15 -H \"X-Oracle-Accept-CurrencyCode: USD\" \"https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=2&partNumber=B110627\")",
|
||||||
|
"Bash(curl -v --max-time 10 -H \"X-Oracle-Accept-CurrencyCode: USD\" \"https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=1\")",
|
||||||
|
"Bash(curl -s --max-time 15 \"https://apexapps.oracle.com/pls/apex/cetools/api/v1/products/?partNumber=B110627¤cyCode=USD\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,3 +31,4 @@ examples/*.yaml
|
|||||||
|
|
||||||
# Exported PDFfiles
|
# Exported PDFfiles
|
||||||
examples/*.pdf
|
examples/*.pdf
|
||||||
|
examples/*.xlsx
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -38,6 +38,7 @@ AI skill aligned with Oracle ECAL framework (Define → Design → Deliver) that
|
|||||||
│ │ └── (dirs: database-ha/, database-dr/, networking-hub-spoke/, etc.)
|
│ │ └── (dirs: database-ha/, database-dr/, networking-hub-spoke/, etc.)
|
||||||
│ ├── sizing/ # CPU conversion ratios, IOPS, scaling rules
|
│ ├── sizing/ # CPU conversion ratios, IOPS, scaling rules
|
||||||
│ ├── pricing/ # Simplified pricing for estimation
|
│ ├── pricing/ # Simplified pricing for estimation
|
||||||
|
│ │ └── oci-sku-catalog.yaml # ~160 OCI SKUs for BOM generation
|
||||||
│ ├── competitive/ # AWS/Azure/GCP service mapping
|
│ ├── competitive/ # AWS/Azure/GCP service mapping
|
||||||
│ ├── well-architected/ # 5-pillar WA Framework checklists
|
│ ├── well-architected/ # 5-pillar WA Framework checklists
|
||||||
│ ├── compatibility/ # Feature matrices (ADB, etc.)
|
│ ├── compatibility/ # Feature matrices (ADB, etc.)
|
||||||
@@ -47,7 +48,9 @@ AI skill aligned with Oracle ECAL framework (Define → Design → Deliver) that
|
|||||||
│ ├── oci_deck_gen.py # .pptx slide deck generator (DEFAULT output)
|
│ ├── oci_deck_gen.py # .pptx slide deck generator (DEFAULT output)
|
||||||
│ ├── oci_pdf_gen.py # .pdf customer-facing document (branded, no internal refs)
|
│ ├── oci_pdf_gen.py # .pdf customer-facing document (branded, no internal refs)
|
||||||
│ ├── oci_diagram_gen.py # .drawio diagram generator
|
│ ├── oci_diagram_gen.py # .drawio diagram generator
|
||||||
|
│ ├── oci_bom_gen.py # .xlsx Bill of Materials generator (SA tool)
|
||||||
│ ├── oci_output.py # Output orchestrator
|
│ ├── oci_output.py # Output orchestrator
|
||||||
|
│ ├── refresh_sku_catalog.py # SKU catalog refresh from Oracle pricing API
|
||||||
│ └── refresh_arch_catalog.py # Architecture Center catalog refresh tool
|
│ └── refresh_arch_catalog.py # Architecture Center catalog refresh tool
|
||||||
├── scripts/ # Validation and utilities
|
├── scripts/ # Validation and utilities
|
||||||
│ └── validate-architecture.py # WA validation engine
|
│ └── validate-architecture.py # WA validation engine
|
||||||
@@ -72,7 +75,8 @@ AI skill aligned with Oracle ECAL framework (Define → Design → Deliver) that
|
|||||||
│ ├── handover-document.yaml # DELIVER: Implementation handover
|
│ ├── handover-document.yaml # DELIVER: Implementation handover
|
||||||
│ ├── go-live-checklist.yaml # DELIVER: Pre-cutover verification
|
│ ├── go-live-checklist.yaml # DELIVER: Pre-cutover verification
|
||||||
│ ├── success-criteria.yaml # DELIVER: Post go-live metrics
|
│ ├── success-criteria.yaml # DELIVER: Post go-live metrics
|
||||||
│ └── lessons-learned.yaml # DELIVER: Engagement retrospective
|
│ ├── lessons-learned.yaml # DELIVER: Engagement retrospective
|
||||||
|
│ └── bom-spec.yaml # SA TOOL: BOM input spec template
|
||||||
├── codex/ # Codex setup guide (README only)
|
├── codex/ # Codex setup guide (README only)
|
||||||
└── examples/ # Example specs and generated outputs
|
└── examples/ # Example specs and generated outputs
|
||||||
```
|
```
|
||||||
@@ -115,6 +119,9 @@ python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposa
|
|||||||
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf
|
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf
|
||||||
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf --diagram arch.png
|
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf --diagram arch.png
|
||||||
|
|
||||||
|
# Generate BOM (.xlsx Bill of Materials)
|
||||||
|
python tools/oci_bom_gen.py --spec examples/bom-spec.yaml --output customer-bom.xlsx
|
||||||
|
|
||||||
# Generate diagram
|
# Generate diagram
|
||||||
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
||||||
|
|
||||||
@@ -127,6 +134,12 @@ python scripts/validate-architecture.py \
|
|||||||
# Output orchestrator (multiple formats at once)
|
# Output orchestrator (multiple formats at once)
|
||||||
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
||||||
|
|
||||||
|
# Refresh SKU catalog from Oracle pricing API
|
||||||
|
python tools/refresh_sku_catalog.py --refresh # update all prices
|
||||||
|
python tools/refresh_sku_catalog.py --refresh --diff # update + show changes
|
||||||
|
python tools/refresh_sku_catalog.py --validate # check for stale prices
|
||||||
|
python tools/refresh_sku_catalog.py --sku B110627 # inspect single SKU
|
||||||
|
|
||||||
# Refresh Architecture Center catalog
|
# Refresh Architecture Center catalog
|
||||||
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
|
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
|
||||||
python tools/refresh_arch_catalog.py --url <url> # add single entry
|
python tools/refresh_arch_catalog.py --url <url> # add single entry
|
||||||
|
|||||||
28
SKILL.md
28
SKILL.md
@@ -59,6 +59,11 @@ Present these options as a compact numbered list. Each option has a bold title f
|
|||||||
─────────────────
|
─────────────────
|
||||||
12. 📊 ECAL readiness score — *60-artefact gap analysis*
|
12. 📊 ECAL readiness score — *60-artefact gap analysis*
|
||||||
|
|
||||||
|
SA TOOLS
|
||||||
|
─────────────────
|
||||||
|
13. 📦 BOM generator — *services + quantities → .xlsx Bill of Materials*
|
||||||
|
14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA*
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Pick a number, or just describe what you need.
|
Pick a number, or just describe what you need.
|
||||||
```
|
```
|
||||||
@@ -264,6 +269,29 @@ Pick a number, or just describe what you need.
|
|||||||
→ [D] Re-score after updates
|
→ [D] Re-score after updates
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- If the user picks **13**, ask: "What services does the customer need? (e.g., 'ExaCS X11M BYOL 2 DB servers + 4 storage + 128 ECPUs + ADB-S 8 ECPU + 2TB Block Storage + FastConnect 1Gbps'). I'll generate the BOM with only those SKUs."
|
||||||
|
Then follow the BOM generation flow:
|
||||||
|
1. Parse the customer request to identify needed OCI services and quantities
|
||||||
|
2. Match services against `kb/pricing/oci-sku-catalog.yaml` to select exact SKUs
|
||||||
|
3. Ask for discount % and contract duration if not specified (default: 0%, 12 months)
|
||||||
|
4. Ask if currency conversion is needed (e.g., USD→BRL with exchange rate and tax)
|
||||||
|
5. Generate the BOM spec YAML and save to the output folder
|
||||||
|
6. Run `tools/oci_bom_gen.py` to produce the .xlsx
|
||||||
|
7. Present a summary table in the terminal showing key totals (monthly, ARR)
|
||||||
|
8. List the files generated
|
||||||
|
|
||||||
|
**BOM Output Rules:**
|
||||||
|
- NEVER include "Confidential: Internal ONLY" or any confidentiality marking
|
||||||
|
- ALWAYS include the Oracle Cost Estimator disclaimer at the bottom
|
||||||
|
- Only include SKUs the customer actually requested — never dump the full catalog
|
||||||
|
- Show cost proportions so the customer can see where their spend concentrates
|
||||||
|
- Use Excel formulas (not static values) so the customer can adjust quantities
|
||||||
|
|
||||||
|
- If the user picks **14**, follow the same flow as option 13 (BOM generator) but generate the output in AppCA import format. AppCA is Oracle's internal deal approval tool. The generated .xlsx has two sheets:
|
||||||
|
- **"Export to AppCA"**: Flat table with columns SKU, QTY, HOURS, MONTHS, DISCOUNT, BURSTABLE — ready to paste/import into AppCA
|
||||||
|
- **"BOM.C1"**: Full BOM detail with product names, metrics, prices, and formulas for cost calculations
|
||||||
|
Run with: `python tools/oci_bom_gen.py --spec <spec>.yaml --output <name>.xlsx --appca`
|
||||||
|
|
||||||
- If the user sends discovery notes directly (without picking a number), detect this and go straight to option 1 (full proposal flow).
|
- If the user sends discovery notes directly (without picking a number), detect this and go straight to option 1 (full proposal flow).
|
||||||
- If the user asks a specific question (e.g., "does ADB-S support vector search?"), detect this and go straight to the relevant capability without showing the menu.
|
- If the user asks a specific question (e.g., "does ADB-S support vector search?"), detect this and go straight to the relevant capability without showing the menu.
|
||||||
- Only show the welcome menu on the FIRST message if it's a greeting or empty context. Don't re-show it on every turn.
|
- Only show the welcome menu on the FIRST message if it's a greeting or empty context. Don't re-show it on every turn.
|
||||||
|
|||||||
1327
kb/pricing/oci-sku-catalog.yaml
Normal file
1327
kb/pricing/oci-sku-catalog.yaml
Normal file
File diff suppressed because it is too large
Load Diff
50
templates/bom-spec.yaml
Normal file
50
templates/bom-spec.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# OCI Bill of Materials (BOM) — Spec Template
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# The AI generates this YAML based on the customer's request,
|
||||||
|
# selecting only the relevant SKUs from kb/pricing/oci-sku-catalog.yaml.
|
||||||
|
# The BOM generator tool (tools/oci_bom_gen.py) renders it to .xlsx.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# python tools/oci_bom_gen.py --spec this-file.yaml --output bom.xlsx
|
||||||
|
|
||||||
|
bom:
|
||||||
|
# ── Header ──────────────────────────────────────────────────────
|
||||||
|
customer_name: ""
|
||||||
|
project_name: ""
|
||||||
|
prepared_by: ""
|
||||||
|
date: "" # ISO format: 2026-03-27
|
||||||
|
reference_label: "" # e.g., "ACME-BOM-001"
|
||||||
|
currency: USD # Primary currency
|
||||||
|
# realm: "OC1 - Commercial" # Optional: OC1 / OC2 / OC3 / OC9
|
||||||
|
# service_type: "Pay as You Go" # Optional: PAYG / UCM / Annual Flex
|
||||||
|
|
||||||
|
# ── Optional currency conversion ────────────────────────────────
|
||||||
|
# Enable to add converted columns (e.g., USD → BRL with tax)
|
||||||
|
conversion:
|
||||||
|
enabled: false
|
||||||
|
target_currency: BRL
|
||||||
|
exchange_rate: 5.75 # 1 USD = X target currency
|
||||||
|
tax_rate: 0.0 # e.g., 0.1385 for 13.85% ISS+PIS+COFINS
|
||||||
|
|
||||||
|
# ── Line items (only requested services) ────────────────────────
|
||||||
|
# Each item must reference a SKU from oci-sku-catalog.yaml.
|
||||||
|
# Only include services the customer actually needs.
|
||||||
|
line_items:
|
||||||
|
- sku: "B97384" # Must match oci-sku-catalog.yaml
|
||||||
|
qty: 4 # Number of instances/units
|
||||||
|
# hours_units: 730 # Override catalog default if needed
|
||||||
|
months: 12 # Contract duration
|
||||||
|
discount: 0.0 # 0.0 to 1.0 (e.g., 0.30 = 30%)
|
||||||
|
# custom_label: "" # Optional label
|
||||||
|
# custom_note: "" # Optional note
|
||||||
|
|
||||||
|
- sku: "B97385"
|
||||||
|
qty: 64 # 64 GB memory
|
||||||
|
months: 12
|
||||||
|
discount: 0.0
|
||||||
|
|
||||||
|
# ── Notes (optional) ────────────────────────────────────────────
|
||||||
|
notes:
|
||||||
|
- "Pricing based on Ashburn region (OC1)"
|
||||||
|
- "Discounts subject to Oracle approval"
|
||||||
730
tools/oci_bom_gen.py
Normal file
730
tools/oci_bom_gen.py
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
#!/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),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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, {})
|
||||||
|
|
||||||
|
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": custom_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())
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
|
||||||
|
for cat_key in self.category_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, 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
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
# Formula: Monthly w/o discount = Price × Hours × Qty
|
||||||
|
p = get_column_letter(COL_PRICE)
|
||||||
|
h = get_column_letter(COL_HOURS)
|
||||||
|
q = get_column_letter(COL_QTY)
|
||||||
|
d = get_column_letter(COL_DISC)
|
||||||
|
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
|
||||||
|
if conv:
|
||||||
|
xr = self.conversion.get("exchange_rate", 1)
|
||||||
|
tr = self.conversion.get("tax_rate", 0)
|
||||||
|
mw = get_column_letter(COL_M_W)
|
||||||
|
# Converted w/o tax
|
||||||
|
ws.cell(
|
||||||
|
row=row, column=COL_CONV_WO,
|
||||||
|
value=f"={mw}{row}*{xr}",
|
||||||
|
)
|
||||||
|
# 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
|
||||||
|
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'
|
||||||
|
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Category subtotal row
|
||||||
|
cat_last_data = row - 1
|
||||||
|
ws.cell(row=row, column=COL_PRODUCT, value=f"Subtotal — {display_name}")
|
||||||
|
mwo_letter = get_column_letter(COL_M_WO)
|
||||||
|
mw_letter = get_column_letter(COL_M_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_W).number_format = '$#,##0.00'
|
||||||
|
|
||||||
|
if conv:
|
||||||
|
cwo_letter = get_column_letter(COL_CONV_WO)
|
||||||
|
cw_letter = get_column_letter(COL_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_W).number_format = '#,##0.00'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Sum of subtotals
|
||||||
|
mwo_refs = "+".join(f"{get_column_letter(COL_M_WO)}{r}" for r in cat_subtotal_rows)
|
||||||
|
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_W).number_format = '$#,##0.00'
|
||||||
|
|
||||||
|
if conv:
|
||||||
|
cwo_refs = "+".join(f"{get_column_letter(COL_CONV_WO)}{r}" for r in cat_subtotal_rows)
|
||||||
|
cw_refs = "+".join(f"{get_column_letter(COL_CONV_W)}{r}" for r in cat_subtotal_rows)
|
||||||
|
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_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
|
||||||
|
)
|
||||||
|
mw_total = get_column_letter(COL_M_W)
|
||||||
|
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).font = Font(
|
||||||
|
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
||||||
|
)
|
||||||
|
if conv:
|
||||||
|
cw_total = get_column_letter(COL_CONV_W)
|
||||||
|
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).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
|
||||||
|
for item_row in range(data_start_row, total_row):
|
||||||
|
cell_val = ws.cell(row=item_row, column=COL_M_W).value
|
||||||
|
if cell_val and str(cell_val).startswith("=") and "SUM" not in str(cell_val):
|
||||||
|
mw_l = get_column_letter(COL_M_W)
|
||||||
|
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)
|
||||||
|
|
||||||
|
for cat_key in self.category_order:
|
||||||
|
if cat_key not in items_by_cat:
|
||||||
|
continue
|
||||||
|
|
||||||
|
display_name = self.category_names.get(cat_key, 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"])
|
||||||
|
|
||||||
|
# Formulas matching BOM.C1 style
|
||||||
|
ws_bom.cell(row=row, column=11,
|
||||||
|
value="=(F{r}*G{r}*H{r})".format(r=row))
|
||||||
|
ws_bom.cell(row=row, column=12,
|
||||||
|
value="=IFERROR(K{r}*(1-J{r}),K{r})".format(r=row))
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
gen = cls(
|
||||||
|
customer=bom.get("customer_name", ""),
|
||||||
|
project=bom.get("project_name", ""),
|
||||||
|
prepared_by=bom.get("prepared_by", ""),
|
||||||
|
bom_date=bom.get("date", ""),
|
||||||
|
reference_label=bom.get("reference_label", ""),
|
||||||
|
currency=bom.get("currency", "USD"),
|
||||||
|
realm=bom.get("realm", ""),
|
||||||
|
service_type=bom.get("service_type", ""),
|
||||||
|
conversion=bom.get("conversion"),
|
||||||
|
notes=bom.get("notes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
gen.load_catalog(catalog_path)
|
||||||
|
|
||||||
|
for item in bom.get("line_items", []):
|
||||||
|
gen.add_line_item(
|
||||||
|
sku=str(item["sku"]),
|
||||||
|
qty=item.get("qty", 0),
|
||||||
|
hours_units=item.get("hours_units"),
|
||||||
|
months=item.get("months", 12),
|
||||||
|
discount=item.get("discount", 0.0),
|
||||||
|
custom_label=item.get("custom_label", ""),
|
||||||
|
custom_note=item.get("custom_note", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -27,6 +27,7 @@ FORMATS = {
|
|||||||
"full": ["pptx", "drawio", "docx", "xlsx", "pdf"],
|
"full": ["pptx", "drawio", "docx", "xlsx", "pdf"],
|
||||||
"doc": ["docx"],
|
"doc": ["docx"],
|
||||||
"pdf": ["pdf"],
|
"pdf": ["pdf"],
|
||||||
|
"bom": ["bom"],
|
||||||
}
|
}
|
||||||
|
|
||||||
GENERATORS = {
|
GENERATORS = {
|
||||||
@@ -62,6 +63,13 @@ GENERATORS = {
|
|||||||
"method": "from_spec",
|
"method": "from_spec",
|
||||||
"description": "Customer-facing PDF (.pdf)",
|
"description": "Customer-facing PDF (.pdf)",
|
||||||
},
|
},
|
||||||
|
"bom": {
|
||||||
|
"module": "oci_bom_gen",
|
||||||
|
"class": "OCIBomGenerator",
|
||||||
|
"method": "from_spec",
|
||||||
|
"description": "Bill of Materials (.xlsx)",
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
335
tools/refresh_sku_catalog.py
Normal file
335
tools/refresh_sku_catalog.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
OCI Deal Accelerator — SKU Catalog Refresh Tool
|
||||||
|
|
||||||
|
Fetches current OCI SKU pricing from Oracle's public pricing API and
|
||||||
|
updates kb/pricing/oci-sku-catalog.yaml with the latest prices.
|
||||||
|
|
||||||
|
API endpoint (anonymous, no auth required):
|
||||||
|
https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Refresh full catalog from API
|
||||||
|
python tools/refresh_sku_catalog.py --refresh
|
||||||
|
|
||||||
|
# Refresh and show what changed
|
||||||
|
python tools/refresh_sku_catalog.py --refresh --diff
|
||||||
|
|
||||||
|
# Fetch a specific SKU to inspect
|
||||||
|
python tools/refresh_sku_catalog.py --sku B110627
|
||||||
|
|
||||||
|
# Export raw API dump to JSON (for debugging)
|
||||||
|
python tools/refresh_sku_catalog.py --dump oci-skus-raw.json
|
||||||
|
|
||||||
|
# Validate current catalog against API (report stale prices)
|
||||||
|
python tools/refresh_sku_catalog.py --validate
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("Error: 'requests' package required. Install with: pip install requests")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# ── API Configuration ─────────────────────────────────────────────
|
||||||
|
# Public Oracle pricing API (no authentication required)
|
||||||
|
# Documented: https://docs.oracle.com/en-us/iaas/Content/GSG/Tasks/signingup_topic-Estimating_Costs.htm
|
||||||
|
API_BASE = "https://apexapps.oracle.com/pls/apex/cetools/api/v1/products/"
|
||||||
|
DEFAULT_CURRENCY = "USD"
|
||||||
|
|
||||||
|
# ── Paths ─────────────────────────────────────────────────────────
|
||||||
|
TOOLS_DIR = Path(__file__).resolve().parent
|
||||||
|
CATALOG_PATH = TOOLS_DIR.parent / "kb" / "pricing" / "oci-sku-catalog.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_products(currency="USD", verbose=False):
|
||||||
|
"""Fetch all OCI products from the APEX pricing API."""
|
||||||
|
url = "{}?currencyCode={}".format(API_BASE, currency)
|
||||||
|
if verbose:
|
||||||
|
print(" Fetching: {}".format(url))
|
||||||
|
|
||||||
|
resp = requests.get(url, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
items = data.get("items", [])
|
||||||
|
if verbose:
|
||||||
|
print(" Fetched {} products".format(len(items)))
|
||||||
|
last_updated = data.get("lastUpdated", "unknown")
|
||||||
|
print(" API last updated: {}".format(last_updated))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_single_sku(part_number, currency="USD"):
|
||||||
|
"""Fetch a single SKU by part number."""
|
||||||
|
url = "{}?partNumber={}¤cyCode={}".format(API_BASE, part_number, currency)
|
||||||
|
resp = requests.get(url, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
def extract_payg_price(product, currency="USD"):
|
||||||
|
"""Extract the Pay As You Go price from a product's prices structure.
|
||||||
|
|
||||||
|
APEX API format:
|
||||||
|
currencyCodeLocalizations: [
|
||||||
|
{ currencyCode: "USD", prices: [ {model: "PAY_AS_YOU_GO", value: 2.9032} ] }
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
localizations = product.get("currencyCodeLocalizations", [])
|
||||||
|
for loc in localizations:
|
||||||
|
if loc.get("currencyCode") == currency:
|
||||||
|
for price in loc.get("prices", []):
|
||||||
|
if price.get("model") == "PAY_AS_YOU_GO":
|
||||||
|
return price.get("value", 0)
|
||||||
|
# Fallback: take first price in this currency
|
||||||
|
prices = loc.get("prices", [])
|
||||||
|
if prices:
|
||||||
|
return prices[0].get("value", 0)
|
||||||
|
|
||||||
|
# Legacy format fallback (itra API)
|
||||||
|
for price in product.get("prices", []):
|
||||||
|
if price.get("model") == "PAY_AS_YOU_GO":
|
||||||
|
return price.get("value", 0)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def load_current_catalog():
|
||||||
|
"""Load the current YAML catalog and build a SKU lookup."""
|
||||||
|
if not CATALOG_PATH.exists():
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
with open(str(CATALOG_PATH), "r") as f:
|
||||||
|
raw = yaml.safe_load(f)
|
||||||
|
|
||||||
|
sku_map = {} # sku -> {product, category, ...}
|
||||||
|
categories = raw.get("categories", {})
|
||||||
|
for cat_key, cat_data in categories.items():
|
||||||
|
for entry in cat_data.get("skus", []):
|
||||||
|
sku_map[str(entry["sku"])] = {
|
||||||
|
"category": cat_key,
|
||||||
|
"product": entry.get("product", ""),
|
||||||
|
"list_price_usd": entry.get("list_price_usd", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw, sku_map
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_catalog(verbose=False):
|
||||||
|
"""Refresh the catalog YAML with latest API prices."""
|
||||||
|
print("Fetching current prices from Oracle API...")
|
||||||
|
products = fetch_all_products(currency=DEFAULT_CURRENCY, verbose=verbose)
|
||||||
|
|
||||||
|
# Build API lookup: partNumber -> product data
|
||||||
|
api_map = {}
|
||||||
|
for p in products:
|
||||||
|
pn = p.get("partNumber", "")
|
||||||
|
if pn:
|
||||||
|
api_map[pn] = p
|
||||||
|
|
||||||
|
print(" {} products from API".format(len(api_map)))
|
||||||
|
|
||||||
|
# Load current catalog
|
||||||
|
raw, sku_map = load_current_catalog()
|
||||||
|
if not raw:
|
||||||
|
print("Error: Could not load catalog at {}".format(CATALOG_PATH))
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
not_found = 0
|
||||||
|
unchanged = 0
|
||||||
|
|
||||||
|
categories = raw.get("categories", {})
|
||||||
|
for cat_key, cat_data in categories.items():
|
||||||
|
for entry in cat_data.get("skus", []):
|
||||||
|
sku = str(entry["sku"])
|
||||||
|
if sku in api_map:
|
||||||
|
api_price = extract_payg_price(api_map[sku])
|
||||||
|
old_price = entry.get("list_price_usd", 0)
|
||||||
|
|
||||||
|
# Skip $0 API prices when catalog has a real price
|
||||||
|
# (API returns $0 for tiered/free-tier SKUs where
|
||||||
|
# the base tier is free but usage above free tier costs)
|
||||||
|
if api_price == 0 and old_price > 0:
|
||||||
|
if verbose:
|
||||||
|
print(" {} SKIP: API=$0 but catalog=${} (tiered/free-tier SKU)".format(
|
||||||
|
sku, old_price
|
||||||
|
))
|
||||||
|
unchanged += 1
|
||||||
|
elif abs(api_price - old_price) > 0.0001:
|
||||||
|
if verbose:
|
||||||
|
print(" {} price: {} -> {} ({})".format(
|
||||||
|
sku, old_price, api_price, entry.get("product", "")[:50]
|
||||||
|
))
|
||||||
|
entry["list_price_usd"] = api_price
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
unchanged += 1
|
||||||
|
|
||||||
|
# Also update product name if different
|
||||||
|
api_name = api_map[sku].get("displayName", "")
|
||||||
|
if api_name and api_name != entry.get("product", ""):
|
||||||
|
# Keep our shorter names, but log the difference
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Update metric from API
|
||||||
|
api_metric = api_map[sku].get("metricDisplayName", "")
|
||||||
|
if api_metric and api_metric != entry.get("metric", ""):
|
||||||
|
pass # Keep our curated metrics
|
||||||
|
else:
|
||||||
|
not_found += 1
|
||||||
|
if verbose:
|
||||||
|
print(" {} NOT in API (may be retired): {}".format(
|
||||||
|
sku, entry.get("product", "")[:50]
|
||||||
|
))
|
||||||
|
|
||||||
|
# Update last_verified date
|
||||||
|
raw["last_verified"] = date.today().isoformat()
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
with open(str(CATALOG_PATH), "w") as f:
|
||||||
|
yaml.dump(raw, f, default_flow_style=False, allow_unicode=True, sort_keys=False, width=120)
|
||||||
|
|
||||||
|
print("\nRefresh complete:")
|
||||||
|
print(" Updated: {}".format(updated))
|
||||||
|
print(" Unchanged: {}".format(unchanged))
|
||||||
|
print(" Not in API (possibly retired): {}".format(not_found))
|
||||||
|
print(" Catalog saved: {}".format(CATALOG_PATH))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_catalog(verbose=False):
|
||||||
|
"""Compare current catalog prices against API and report differences."""
|
||||||
|
print("Validating catalog against Oracle API...")
|
||||||
|
products = fetch_all_products(currency=DEFAULT_CURRENCY, verbose=verbose)
|
||||||
|
|
||||||
|
api_map = {}
|
||||||
|
for p in products:
|
||||||
|
pn = p.get("partNumber", "")
|
||||||
|
if pn:
|
||||||
|
api_map[pn] = p
|
||||||
|
|
||||||
|
raw, sku_map = load_current_catalog()
|
||||||
|
if not raw:
|
||||||
|
print("Error: Could not load catalog")
|
||||||
|
return
|
||||||
|
|
||||||
|
diffs = []
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
categories = raw.get("categories", {})
|
||||||
|
for cat_key, cat_data in categories.items():
|
||||||
|
for entry in cat_data.get("skus", []):
|
||||||
|
sku = str(entry["sku"])
|
||||||
|
if sku in api_map:
|
||||||
|
api_price = extract_payg_price(api_map[sku])
|
||||||
|
cat_price = entry.get("list_price_usd", 0)
|
||||||
|
# Skip $0 API prices for tiered/free-tier SKUs
|
||||||
|
if api_price == 0 and cat_price > 0:
|
||||||
|
continue
|
||||||
|
if abs(api_price - cat_price) > 0.0001:
|
||||||
|
diffs.append({
|
||||||
|
"sku": sku,
|
||||||
|
"product": entry.get("product", "")[:60],
|
||||||
|
"catalog_price": cat_price,
|
||||||
|
"api_price": api_price,
|
||||||
|
"diff_pct": ((api_price - cat_price) / cat_price * 100) if cat_price else 0,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
missing.append(sku)
|
||||||
|
|
||||||
|
if diffs:
|
||||||
|
print("\nPrice differences found ({}):\n".format(len(diffs)))
|
||||||
|
print("{:<10} {:<60} {:>12} {:>12} {:>8}".format(
|
||||||
|
"SKU", "Product", "Catalog", "API", "Diff %"
|
||||||
|
))
|
||||||
|
print("-" * 102)
|
||||||
|
for d in diffs:
|
||||||
|
print("{:<10} {:<60} ${:>10.4f} ${:>10.4f} {:>7.1f}%".format(
|
||||||
|
d["sku"], d["product"], d["catalog_price"], d["api_price"], d["diff_pct"]
|
||||||
|
))
|
||||||
|
print("\nRun with --refresh to update the catalog.")
|
||||||
|
else:
|
||||||
|
print("\nAll prices match the API. Catalog is up to date.")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print("\n{} SKUs not found in API (possibly retired):".format(len(missing)))
|
||||||
|
for s in missing[:10]:
|
||||||
|
print(" - {}".format(s))
|
||||||
|
if len(missing) > 10:
|
||||||
|
print(" ... and {} more".format(len(missing) - 10))
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_sku(part_number):
|
||||||
|
"""Fetch and display a single SKU from the API."""
|
||||||
|
print("Fetching SKU {} from API...".format(part_number))
|
||||||
|
items = fetch_single_sku(part_number)
|
||||||
|
if not items:
|
||||||
|
print(" SKU not found in API")
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
print("\n Part Number: {}".format(item.get("partNumber", "")))
|
||||||
|
print(" Display Name: {}".format(item.get("displayName", "")))
|
||||||
|
print(" Category: {}".format(item.get("serviceCategory", "N/A")))
|
||||||
|
print(" Metric: {}".format(item.get("metricName", "N/A")))
|
||||||
|
print(" Prices:")
|
||||||
|
for loc in item.get("currencyCodeLocalizations", []):
|
||||||
|
cc = loc.get("currencyCode", "?")
|
||||||
|
for price in loc.get("prices", []):
|
||||||
|
print(" - {} {} : {}".format(
|
||||||
|
cc, price.get("model", "?"), price.get("value", "?")
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def dump_all(output_path, verbose=False):
|
||||||
|
"""Dump raw API response to JSON file."""
|
||||||
|
products = fetch_all_products(currency=DEFAULT_CURRENCY, verbose=verbose)
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
json.dump(products, f, indent=2)
|
||||||
|
print("Dumped {} products to {}".format(len(products), output_path))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="OCI Deal Accelerator — SKU Catalog Refresh Tool"
|
||||||
|
)
|
||||||
|
parser.add_argument("--refresh", action="store_true",
|
||||||
|
help="Refresh catalog prices from Oracle API")
|
||||||
|
parser.add_argument("--validate", action="store_true",
|
||||||
|
help="Validate catalog prices against API")
|
||||||
|
parser.add_argument("--sku", type=str,
|
||||||
|
help="Inspect a single SKU from the API")
|
||||||
|
parser.add_argument("--dump", type=str, metavar="FILE",
|
||||||
|
help="Dump raw API data to JSON file")
|
||||||
|
parser.add_argument("--diff", action="store_true",
|
||||||
|
help="Show detailed price changes during refresh")
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true",
|
||||||
|
help="Verbose output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.sku:
|
||||||
|
inspect_sku(args.sku)
|
||||||
|
elif args.refresh:
|
||||||
|
refresh_catalog(verbose=args.verbose or args.diff)
|
||||||
|
elif args.validate:
|
||||||
|
validate_catalog(verbose=args.verbose)
|
||||||
|
elif args.dump:
|
||||||
|
dump_all(args.dump, verbose=args.verbose)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user