Initial commit: Business Case Automation with Excel generation

This commit is contained in:
denisacirstea
2025-09-12 10:22:04 +03:00
commit 46b9c4cf28
12 changed files with 5091 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependency directories
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
# Optional npm cache directory
.npm
# Environment variables
.env
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Log files
logs
*.log
# Generated files
output/
*.xlsx

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# Retail Media Business Case Calculator
This application helps retail media professionals generate business cases by collecting key metrics and calculating potential reach and impressions across different channels.
## Features
- Clean, user-friendly form for collecting retail media data
- Automatic calculation of key metrics:
- Potential reach in-store (digital screens and radio)
- Unique impressions in-store
- Potential reach on-site
- Unique impressions on-site
- Potential reach off-site
- Unique impressions off-site
- Results saved to a JSON file for reporting
- Thank you page with confirmation message
## Installation
1. Clone the repository
2. Install dependencies:
```bash
npm install
```
## Running the Application
Start the server:
```bash
npm start
```
For development with auto-restart:
```bash
npm run dev
```
The application will be available at http://localhost:3000
## Project Structure
- `index.html` - Main form interface for collecting user data
- `thank-you.html` - Confirmation page after form submission
- `server.js` - Express server handling form submissions and routing
- `index.js` - Business logic for calculating retail media metrics
- `config.json` - Configuration file with constants and coefficients
- `results.json` - Output file where calculation results are stored
- `public/` - Static assets directory
## How It Works
1. Users fill out the business case form with their retail media data
2. The form validates input and submits data to the server
3. Server processes the data using formulas in `index.js`
4. Results are saved to `results.json` and user is redirected to thank-you page
5. Retail media specialists follow up with the user with a customized business case
## Technologies Used
- Node.js and Express for the backend
- HTML/CSS/JavaScript for the frontend
- TailwindCSS for styling
- Vanilla JavaScript for form validation and interactions
## Configuration
The application uses a `config.json` file that contains constants and coefficients for the formulas. You can modify these values to adjust the calculation logic.
## Development Notes
- Form styling uses a clean white design with accent colors
- Form validation ensures complete and accurate data collection
- The server includes error handling for form submissions
- Calculations are based on industry-standard formulas for retail media

61
config.json Normal file
View File

@@ -0,0 +1,61 @@
{
"user_data": {
"first_name": "Denisa",
"last_name": "Cirstea",
"company_name": "Footprints AI",
"email": "denisa@example.com",
"phone": "+40 712 345 678",
"store_name": "Media Romania",
"country": "Romania",
"starting_date": "2025-02-01",
"duration": 36,
"store_types": ["convenience", "supermarket", "hypermarket"],
"open_days_per_month": 26,
"convenience_store_type": {
"stores_number": 120,
"monthly_transactions": 900000,
"has_digital_screens": true,
"screen_count": 300,
"screen_percentage": 70,
"has_in_store_radio": true,
"radio_percentage": 60,
"open_days_per_month": 26
},
"supermarket_store_type": {
"stores_number": 80,
"monthly_transactions": 450000,
"has_digital_screens": true,
"screen_count": 200,
"screen_percentage": 50,
"has_in_store_radio": true,
"radio_percentage": 80,
"open_days_per_month": 26
},
"hypermarket_store_type": {
"stores_number": 5,
"monthly_transactions": 60000,
"has_digital_screens": false,
"screen_count": 0,
"screen_percentage": 0,
"has_in_store_radio": true,
"radio_percentage": 100,
"open_days_per_month": 26
},
"on_site_channels": ["Homepage Banners", "Search Results", "Category Pages"],
"website_visitors": 1200000,
"app_users": 350000,
"loyalty_users": 500000,
"off_site_channels": ["Social Display", "Programmatic Video", "Search Ads"],
"facebook_followers": 250000,
"instagram_followers": 180000,
"google_views": 4200000,
"email_subscribers": 300000,
"sms_users": 220000,
"whatsapp_contacts": 150000
}
}

249
create_excel.py Executable file
View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
import json
import os
import shutil
import datetime
import re
from pathlib import Path
from dateutil.relativedelta import relativedelta
import openpyxl
def create_excel_from_template():
"""
Create a copy of the Excel template, replacing {store_name} with the value from config.json
and save it to the output folder.
"""
# Define paths
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, 'config.json')
template_path = os.path.join(script_dir, 'template', 'Footprints AI for {store_name} - Retail Media Business Case Calculations.xlsx')
output_dir = os.path.join(script_dir, 'output')
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Read config.json to get store_name, starting_date, and duration
try:
with open(config_path, 'r') as f:
config = json.load(f)
user_data = config.get('user_data', {})
store_name = user_data.get('store_name', '')
starting_date = user_data.get('starting_date', '')
duration = user_data.get('duration', 36)
# If store_name is empty, use a default value
if not store_name:
store_name = "Your Store"
# Calculate years array based on starting_date and duration
years = calculate_years(starting_date, duration)
print(f"Years in the period: {years}")
except Exception as e:
print(f"Error reading config file: {e}")
return False
# Use first and last years from the array in the filename
year_range = ""
if years and len(years) > 0:
if len(years) == 1:
year_range = f"{years[0]}"
else:
year_range = f"{years[0]}-{years[-1]}"
else:
# Fallback to current year if years array is empty
current_year = datetime.datetime.now().year
year_range = f"{current_year}"
# Create output filename with store_name and year range
output_filename = f"Footprints AI for {store_name} - Retail Media Business Case Calculations {year_range}.xlsx"
output_path = os.path.join(output_dir, output_filename)
# Copy the template to the output directory with the new name
try:
shutil.copy2(template_path, output_path)
print(f"Excel file created successfully: {output_path}")
# Now inject variables from config.json into the Variables sheet
inject_variables(output_path, config)
return True
except Exception as e:
print(f"Error creating Excel file: {e}")
return False
def calculate_years(starting_date, duration):
"""
Calculate an array of years that appear in the period from starting_date for duration months.
Args:
starting_date (str): Date in format dd/mm/yyyy or dd.mm.yyyy
duration (int): Number of months, including the starting month
Returns:
list: Array of years in the period [year1, year2, ...]
"""
# Default result if we can't parse the date
default_years = [datetime.datetime.now().year]
# If starting_date is empty, return current year
if not starting_date:
return default_years
try:
# Try to parse the date, supporting both dd/mm/yyyy and dd.mm.yyyy formats
if '/' in starting_date:
day, month, year = map(int, starting_date.split('/'))
elif '.' in starting_date:
day, month, year = map(int, starting_date.split('.'))
else:
# If format is not recognized, return default
return default_years
# Create datetime object for starting date
start_date = datetime.datetime(year, month, day)
# Calculate end date (starting date + duration months - 1 day)
end_date = start_date + relativedelta(months=duration-1)
# Create a set of years (to avoid duplicates)
years_set = set()
# Add starting year
years_set.add(start_date.year)
# Add ending year
years_set.add(end_date.year)
# If there are years in between, add those too
for y in range(start_date.year + 1, end_date.year):
years_set.add(y)
# Convert set to sorted list
return sorted(list(years_set))
except Exception as e:
print(f"Error calculating years: {e}")
return default_years
def inject_variables(excel_path, config):
"""
Inject variables from config.json into the Variables sheet of the Excel file.
Args:
excel_path (str): Path to the Excel file
config (dict): Configuration data from config.json
"""
try:
# Load the workbook
workbook = openpyxl.load_workbook(excel_path)
# Try to find the Variables sheet
sheet_names = workbook.sheetnames
variables_sheet = None
# Print all sheet names for debugging
print("Available sheets:", sheet_names)
# Look for the Variables sheet by name (case-insensitive)
for sheet_name in sheet_names:
if "variable" in sheet_name.lower():
variables_sheet = workbook[sheet_name]
print(f"Found Variables sheet: '{sheet_name}'")
break
# If Variables sheet not found by name, try the last sheet
if variables_sheet is None and sheet_names:
last_sheet_name = sheet_names[-1]
variables_sheet = workbook[last_sheet_name]
print(f"Using last sheet '{last_sheet_name}' as Variables sheet")
# If still not found, try all sheets and look for specific cell patterns
if variables_sheet is None:
for sheet_name in sheet_names:
sheet = workbook[sheet_name]
# Check if this sheet has a cell B2 with a value
if sheet["B2"].value is not None:
variables_sheet = sheet
print(f"Using sheet '{sheet_name}' as it has data in cell B2")
break
if variables_sheet is None:
print("Warning: Variables sheet not found. No variables were injected.")
return
# Get user data from config
user_data = config.get("user_data", {})
# Map cell references to config values based on the image
cell_mappings = {
"B2": user_data.get("store_name", ""),
"B31": user_data.get("starting_date", ""),
"B32": user_data.get("duration", 36),
"B37": user_data.get("open_days_per_month", 0),
# Convenience store type
"H37": user_data.get("convenience_store_type", {}).get("stores_number", 0),
"C37": user_data.get("convenience_store_type", {}).get("monthly_transactions", 0),
# Convert boolean to 1/0 for has_digital_screens
"I37": 1 if user_data.get("convenience_store_type", {}).get("has_digital_screens", False) else 0,
"J37": user_data.get("convenience_store_type", {}).get("screen_count", 0),
"K37": user_data.get("convenience_store_type", {}).get("screen_percentage", 0),
# Convert boolean to 1/0 for has_in_store_radio
"M37": 1 if user_data.get("convenience_store_type", {}).get("has_in_store_radio", False) else 0,
"N37": user_data.get("convenience_store_type", {}).get("radio_percentage", 0),
# Supermarket store type
"H38": user_data.get("supermarket_store_type", {}).get("stores_number", 0),
"C38": user_data.get("supermarket_store_type", {}).get("monthly_transactions", 0),
# Convert boolean to 1/0 for has_digital_screens
"I38": 1 if user_data.get("supermarket_store_type", {}).get("has_digital_screens", False) else 0,
"J38": user_data.get("supermarket_store_type", {}).get("screen_count", 0),
"K38": user_data.get("supermarket_store_type", {}).get("screen_percentage", 0),
# Convert boolean to 1/0 for has_in_store_radio
"M38": 1 if user_data.get("supermarket_store_type", {}).get("has_in_store_radio", False) else 0,
"N38": user_data.get("supermarket_store_type", {}).get("radio_percentage", 0),
# Hypermarket store type
"H39": user_data.get("hypermarket_store_type", {}).get("stores_number", 0),
"C39": user_data.get("hypermarket_store_type", {}).get("monthly_transactions", 0),
# Convert boolean to 1/0 for has_digital_screens
"I39": 1 if user_data.get("hypermarket_store_type", {}).get("has_digital_screens", False) else 0,
"J39": user_data.get("hypermarket_store_type", {}).get("screen_count", 0),
"K39": user_data.get("hypermarket_store_type", {}).get("screen_percentage", 0),
# Convert boolean to 1/0 for has_in_store_radio
"M39": 1 if user_data.get("hypermarket_store_type", {}).get("has_in_store_radio", False) else 0,
"N39": user_data.get("hypermarket_store_type", {}).get("radio_percentage", 0),
# Website, App, Loyalty
"B43": user_data.get("website_visitors", 0),
"B44": user_data.get("app_users", 0),
"B45": user_data.get("loyalty_users", 0),
# Social Media
"B49": user_data.get("facebook_followers", 0),
"B50": user_data.get("instagram_followers", 0),
"B51": user_data.get("google_views", 0)
}
# Inject values into the Variables sheet
print(f"Injecting variables into sheet: {variables_sheet.title}")
for cell_ref, value in cell_mappings.items():
try:
# Check if cell exists
if cell_ref in variables_sheet:
variables_sheet[cell_ref] = value
print(f"Set {cell_ref} = {value}")
else:
print(f"Warning: Cell {cell_ref} not found in sheet")
except Exception as e:
print(f"Warning: Could not set value for cell {cell_ref}: {e}")
# Save the workbook
workbook.save(excel_path)
print(f"Variables successfully injected into {excel_path}")
except Exception as e:
print(f"Error injecting variables: {e}")
if __name__ == "__main__":
create_excel_from_template()

381
create_excel_xlwings.py Executable file
View File

@@ -0,0 +1,381 @@
#!/usr/bin/env python3
import json
import os
import shutil
import datetime
import re
import traceback
from pathlib import Path
from dateutil.relativedelta import relativedelta
import sys
import unicodedata
from openpyxl import load_workbook
import zipfile
from xml.etree import ElementTree as ET
def create_excel_from_template():
"""
Create a copy of the Excel template, replacing {store_name} with the value from config.json
and save it to the output folder.
"""
# Define paths
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, 'config.json')
template_path = os.path.join(script_dir, 'template', 'Footprints AI for {store_name} - Retail Media Business Case Calculations.xlsx')
output_dir = os.path.join(script_dir, 'output')
print(f"[DEBUG] script_dir={script_dir}")
print(f"[DEBUG] config_path={config_path}")
print(f"[DEBUG] template_path={template_path}")
print(f"[DEBUG] output_dir={output_dir}")
print(f"[DEBUG] cwd={os.getcwd()}")
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
if not os.path.exists(config_path):
print(f"[ERROR] config.json not found at: {config_path}")
return False
# Read config.json to get store_name, starting_date, and duration
try:
with open(config_path, 'r') as f:
config = json.load(f)
user_data = config.get('user_data', {})
store_name = user_data.get('store_name', '')
starting_date = user_data.get('starting_date', '')
duration = user_data.get('duration', 36)
# If store_name is empty, use a default value
if not store_name:
store_name = "Your Store"
# Calculate years array based on starting_date and duration
years = calculate_years(starting_date, duration)
print(f"Years in the period: {years}")
except Exception as e:
print(f"Error reading config file: {e}")
print(traceback.format_exc())
return False
# Use first and last years from the array in the filename
year_range = ""
if years and len(years) > 0:
if len(years) == 1:
year_range = f"{years[0]}"
else:
year_range = f"{years[0]}-{years[-1]}"
else:
# Fallback to current year if years array is empty
current_year = datetime.datetime.now().year
year_range = f"{current_year}"
# Create output filename with store_name and year range
output_filename = f"Footprints AI for {store_name} - Retail Media Business Case Calculations {year_range}.xlsx"
output_path = os.path.join(output_dir, output_filename)
print(f"[DEBUG] output_path={output_path}")
if not os.path.exists(template_path):
print(f"[ERROR] Template not found at: {template_path}")
return False
# Copy the template to the output directory with the new name
try:
shutil.copy2(template_path, output_path)
if not os.path.exists(output_path):
print(f"[ERROR] Copy reported success but file missing: {output_path}")
return False
print(f"Excel file created successfully: {output_path}")
# Rename any sheets that contain the {store_name} token
try:
renamed_count = rename_store_placeholders(output_path, store_name)
print(f"[RENAME] Sheets renamed: {renamed_count}")
except Exception as e:
print(f"[RENAME] Unexpected error while renaming sheets: {e}")
# Now inject variables from config.json into the Variables sheet
ok = inject_variables(output_path, config)
if not ok:
print("[ERROR] inject_variables failed.")
return False
return True
except Exception as e:
print(f"Error creating Excel file: {e}")
print(traceback.format_exc())
return False
def calculate_years(starting_date, duration):
"""
Calculate an array of years that appear in the period from starting_date for duration months.
Args:
starting_date (str): Date in format dd/mm/yyyy, dd.mm.yyyy, or yyyy-mm-dd
duration (int): Number of months, including the starting month
Returns:
list: Array of years in the period [year1, year2, ...]
"""
# Default result if we can't parse the date
default_years = [datetime.datetime.now().year]
# If starting_date is empty, return current year
if not starting_date:
return default_years
try:
# Try to parse the date, supporting multiple formats
if '/' in starting_date:
day, month, year = map(int, starting_date.split('/'))
elif '.' in starting_date:
day, month, year = map(int, starting_date.split('.'))
elif '-' in starting_date:
# Handle yyyy-mm-dd format (from HTML date input)
parts = starting_date.split('-')
if len(parts) == 3:
year, month, day = map(int, parts)
else:
return default_years
else:
# If format is not recognized, return default
return default_years
# Create datetime object for starting date
start_date = datetime.datetime(year, month, day)
# Calculate end date (starting date + duration months - 1 day)
end_date = start_date + relativedelta(months=duration-1)
# Create a set of years (to avoid duplicates)
years_set = set()
# Add starting year
years_set.add(start_date.year)
# Add ending year
years_set.add(end_date.year)
# If there are years in between, add those too
for y in range(start_date.year + 1, end_date.year):
years_set.add(y)
# Convert set to sorted list
return sorted(list(years_set))
except Exception as e:
print(f"Error calculating years: {e}")
return default_years
def _normalize_name(s: str) -> str:
"""Normalize sheet names to avoid issues with en-dash/nbsp/casing."""
if s is None:
return ""
s = unicodedata.normalize("NFKC", s)
return s.replace("\u2013", "-").replace("\u00A0", " ").strip().lower()
def _diagnose_xlsx(path: str):
"""Inspect the XLSX container to list sheets and their types when openpyxl sees none."""
try:
with zipfile.ZipFile(path, 'r') as z:
print("[DIAG] ZIP entries:", len(z.namelist()))
# Workbook relationships and workbook xml
if 'xl/workbook.xml' in z.namelist():
xml = z.read('xl/workbook.xml')
root = ET.fromstring(xml)
ns = {'ns': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
sheets = root.findall('.//ns:sheets/ns:sheet', ns)
if not sheets:
print("[DIAG] No <sheet> nodes found in xl/workbook.xml")
for s in sheets:
print(f"[DIAG] sheet name={s.get('name')!r} id={s.get('sheetId')} r:id={s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id')}")
# Check for 'fileVersion' and workbookPr flags that sometimes confuse parsers
wbpr = root.find('.//ns:workbookPr', ns)
if wbpr is not None:
print("[DIAG] workbookPr attrs:", wbpr.attrib)
else:
print("[DIAG] Missing xl/workbook.xml (file may be corrupted or not an xlsx).")
# Look for worksheet vs chartsheet parts
worksheet_parts = [n for n in z.namelist() if n.startswith('xl/worksheets/sheet') and n.endswith('.xml')]
chartsheet_parts = [n for n in z.namelist() if n.startswith('xl/chartsheets/sheet') and n.endswith('.xml')]
dialogsheets = [n for n in z.namelist() if n.startswith('xl/dialogsheets/') and n.endswith('.xml')]
print(f"[DIAG] worksheets={len(worksheet_parts)}, chartsheets={len(chartsheet_parts)}, dialogsheets={len(dialogsheets)}")
if chartsheet_parts and not worksheet_parts:
print("[DIAG] This workbook appears to contain only chart sheets (no worksheets). openpyxl will show zero sheetnames.")
except Exception as e:
print(f"[DIAG] Failed to inspect xlsx: {e}")
print(traceback.format_exc())
def _sanitize_sheet_title(title: str) -> str:
"""
Make a worksheet title Excel-safe:
- Replace invalid characters : \ / ? * [ ]
- Trim to 31 chars
"""
invalid = r'[:\\/\?\*\[\]]'
safe = re.sub(invalid, ' ', title).strip()
if len(safe) > 31:
safe = safe[:31]
return safe
def rename_store_placeholders(excel_path: str, store_name: str) -> int:
"""
Rename any worksheet whose title contains '{store_name}' by replacing the token
with the provided store_name, enforcing Excel naming rules and uniqueness.
Returns the number of sheets renamed.
"""
try:
wb = load_workbook(excel_path, data_only=False)
except Exception as e:
print(f"[RENAME] Could not open workbook for renaming: {e}")
return 0
renamed = 0
existing = set(ws.title for ws in wb.worksheets)
for ws in wb.worksheets:
old = ws.title
if "{store_name}" not in old:
continue
new_title_raw = old.replace("{store_name}", store_name or "Your Store")
new_title = _sanitize_sheet_title(new_title_raw)
# Ensure uniqueness by appending (2), (3), ...
candidate = new_title
suffix = 2
while candidate in existing and candidate != old:
base = new_title
# leave room for " (nn)"
max_base = 31 - (len(str(suffix)) + 3)
if len(base) > max_base:
base = base[:max_base]
candidate = f"{base} ({suffix})"
suffix += 1
if candidate != old:
try:
ws.title = candidate
existing.discard(old)
existing.add(candidate)
renamed += 1
print(f"[RENAME] '{old}''{candidate}'")
except Exception as e:
print(f"[RENAME] Failed to rename '{old}' to '{candidate}': {e}")
if renamed > 0:
try:
wb.save(excel_path)
print(f"[RENAME] Saved workbook after renaming {renamed} sheet(s).")
except Exception as e:
print(f"[RENAME] Failed to save workbook after renames: {e}")
else:
print("[RENAME] No sheets contained '{store_name}'.")
return renamed
def inject_variables(excel_path, config):
"""
Inject variables from config.json into the Variables sheet of the Excel file.
Linux-only path: uses openpyxl (no Excel required). This reads/writes .xlsx safely; .xlsm VBA projects are not preserved if you re-save them.
"""
user_data = config.get("user_data", {})
# Map cell references to config values based on the image
cell_mappings = {
"B2": user_data.get("store_name", ""),
"B31": user_data.get("starting_date", ""),
"B32": user_data.get("duration", 36),
"B37": user_data.get("open_days_per_month", 0),
"H37": user_data.get("convenience_store_type", {}).get("stores_number", 0),
"C37": user_data.get("convenience_store_type", {}).get("monthly_transactions", 0),
"I37": 1 if user_data.get("convenience_store_type", {}).get("has_digital_screens", False) else 0,
"J37": user_data.get("convenience_store_type", {}).get("screen_count", 0),
"K37": user_data.get("convenience_store_type", {}).get("screen_percentage", 0),
"M37": 1 if user_data.get("convenience_store_type", {}).get("has_in_store_radio", False) else 0,
"N37": user_data.get("convenience_store_type", {}).get("radio_percentage", 0),
"H38": user_data.get("supermarket_store_type", {}).get("stores_number", 0),
"C38": user_data.get("supermarket_store_type", {}).get("monthly_transactions", 0),
"I38": 1 if user_data.get("supermarket_store_type", {}).get("has_digital_screens", False) else 0,
"J38": user_data.get("supermarket_store_type", {}).get("screen_count", 0),
"K38": user_data.get("supermarket_store_type", {}).get("screen_percentage", 0),
"M38": 1 if user_data.get("supermarket_store_type", {}).get("has_in_store_radio", False) else 0,
"N38": user_data.get("supermarket_store_type", {}).get("radio_percentage", 0),
"H39": user_data.get("hypermarket_store_type", {}).get("stores_number", 0),
"C39": user_data.get("hypermarket_store_type", {}).get("monthly_transactions", 0),
"I39": 1 if user_data.get("hypermarket_store_type", {}).get("has_digital_screens", False) else 0,
"J39": user_data.get("hypermarket_store_type", {}).get("screen_count", 0),
"K39": user_data.get("hypermarket_store_type", {}).get("screen_percentage", 0),
"M39": 1 if user_data.get("hypermarket_store_type", {}).get("has_in_store_radio", False) else 0,
"N39": user_data.get("hypermarket_store_type", {}).get("radio_percentage", 0),
"B43": user_data.get("website_visitors", 0),
"B44": user_data.get("app_users", 0),
"B45": user_data.get("loyalty_users", 0),
"B49": user_data.get("facebook_followers", 0),
"B50": user_data.get("instagram_followers", 0),
"B51": user_data.get("google_views", 0),
"B53": user_data.get("sms_users", 0)
}
# Warn if trying to process a macro-enabled workbook: openpyxl will not preserve VBA
if excel_path.lower().endswith(".xlsm"):
print("Warning: .xlsm detected. openpyxl cannot preserve VBA projects; consider switching to a .xlsx template or running this step on Windows/Excel.")
# ---- openpyxl fallback (works on Linux, no Excel required) ----
try:
wb = load_workbook(excel_path, data_only=False)
if not wb.sheetnames:
print("[WARN] openpyxl reports no worksheets. Running container diagnostics…")
_diagnose_xlsx(excel_path)
print("Available sheets (openpyxl):", [repr(s) for s in wb.sheetnames])
# Find Variables sheet (case-insensitive, normalized)
target_idx = None
for idx, name in enumerate(wb.sheetnames):
if "variable" in _normalize_name(name):
target_idx = idx
break
if target_idx is None:
target_idx = len(wb.sheetnames) - 1 if wb.sheetnames else None
if target_idx is not None:
print(f"Variables sheet not found by name; using last sheet: {wb.sheetnames[target_idx]}")
else:
print("Suggestion: Ensure the template has at least one normal worksheet (not only chartsheets). Open and 'Save As' a regular .xlsx in Excel.")
if target_idx is None:
print("Warning: Workbook has no sheets. No variables were injected.")
return False
ws = wb[wb.sheetnames[target_idx]]
# Write values
for cell_ref, value in cell_mappings.items():
try:
ws[cell_ref].value = value
print(f"[openpyxl] Set {cell_ref} = {value}")
except Exception as e:
print(f"Warning: Could not set value for cell {cell_ref}: {e}")
# Ensure we're saving to .xlsx path to avoid accidental macro loss if template was .xlsm
save_path = excel_path
if save_path.lower().endswith(".xlsm"):
save_path = save_path[:-5] + ".xlsx"
print(f"Saving as {save_path} to avoid stripping VBA from .xlsm.")
wb.save(save_path)
print(f"Variables successfully injected into {save_path} using openpyxl")
return True
except Exception as e:
print(f"Error in openpyxl fallback: {e}")
print(traceback.format_exc())
return False
if __name__ == "__main__":
try:
ok = create_excel_from_template()
sys.exit(0 if ok else 1)
except Exception as e:
print(f"[FATAL] Unhandled exception: {e}")
print(traceback.format_exc())
sys.exit(2)

1602
index.html Normal file

File diff suppressed because it is too large Load Diff

187
index.js Normal file
View File

@@ -0,0 +1,187 @@
const fs = require('fs');
const path = require('path');
// Function to update config.json with form data
async function updateConfig(formData) {
return new Promise((resolve, reject) => {
const configPath = path.join(__dirname, 'config.json');
// Read the existing config file
fs.readFile(configPath, 'utf8', (err, data) => {
if (err) {
reject(new Error(`Failed to read config file: ${err.message}`));
return;
}
try {
// Parse the existing config
const configData = JSON.parse(data);
// Update user_data in the config with form data
configData.user_data = {
// Contact information
first_name: formData.firstName || "",
last_name: formData.lastName || "",
company_name: formData.company || "",
email: formData.email || "",
phone: formData.phone || "",
store_name: formData.storeName || "",
country: formData.country || "",
starting_date: formData.startingDate || "",
duration: parseInt(formData.duration) || 36,
// Store information
store_types: getSelectedStoreTypes(formData),
open_days_per_month: parseInt(formData.openDays) || 0,
// Store type specific data
convenience_store_type: {
stores_number: isStoreTypeSelected(formData, 'Convenience') ? parseInt(formData.convenience_stores) || 0 : 0,
monthly_transactions: isStoreTypeSelected(formData, 'Convenience') ? parseInt(formData.convenience_transactions) || 0 : 0,
has_digital_screens: isStoreTypeSelected(formData, 'Convenience') ? formData.convenience_screens === "Yes" : false,
screen_count: isStoreTypeSelected(formData, 'Convenience') ? parseInt(formData.convenience_screen_count) || 0 : 0,
screen_percentage: isStoreTypeSelected(formData, 'Convenience') ? parseInt(formData.convenience_screen_percentage) || 0 : 0,
has_in_store_radio: isStoreTypeSelected(formData, 'Convenience') ? formData.convenience_radio === "Yes" : false,
radio_percentage: isStoreTypeSelected(formData, 'Convenience') ? parseInt(formData.convenience_radio_percentage) || 0 : 0,
open_days_per_month: parseInt(formData.openDays) || 0
},
supermarket_store_type: {
stores_number: isStoreTypeSelected(formData, 'Supermarket') ? parseInt(formData.supermarket_stores) || 0 : 0,
monthly_transactions: isStoreTypeSelected(formData, 'Supermarket') ? parseInt(formData.supermarket_transactions) || 0 : 0,
has_digital_screens: isStoreTypeSelected(formData, 'Supermarket') ? formData.supermarket_screens === "Yes" : false,
screen_count: isStoreTypeSelected(formData, 'Supermarket') ? parseInt(formData.supermarket_screen_count) || 0 : 0,
screen_percentage: isStoreTypeSelected(formData, 'Supermarket') ? parseInt(formData.supermarket_screen_percentage) || 0 : 0,
has_in_store_radio: isStoreTypeSelected(formData, 'Supermarket') ? formData.supermarket_radio === "Yes" : false,
radio_percentage: isStoreTypeSelected(formData, 'Supermarket') ? parseInt(formData.supermarket_radio_percentage) || 0 : 0,
open_days_per_month: parseInt(formData.openDays) || 0
},
hypermarket_store_type: {
stores_number: isStoreTypeSelected(formData, 'Hypermarket') ? parseInt(formData.hypermarket_stores) || 0 : 0,
monthly_transactions: isStoreTypeSelected(formData, 'Hypermarket') ? parseInt(formData.hypermarket_transactions) || 0 : 0,
has_digital_screens: isStoreTypeSelected(formData, 'Hypermarket') ? formData.hypermarket_screens === "Yes" : false,
screen_count: isStoreTypeSelected(formData, 'Hypermarket') ? parseInt(formData.hypermarket_screen_count) || 0 : 0,
screen_percentage: isStoreTypeSelected(formData, 'Hypermarket') ? parseInt(formData.hypermarket_screen_percentage) || 0 : 0,
has_in_store_radio: isStoreTypeSelected(formData, 'Hypermarket') ? formData.hypermarket_radio === "Yes" : false,
radio_percentage: isStoreTypeSelected(formData, 'Hypermarket') ? parseInt(formData.hypermarket_radio_percentage) || 0 : 0,
open_days_per_month: parseInt(formData.openDays) || 0
},
// On-site channels
on_site_channels: getSelectedChannels(formData, 'onSiteChannels'),
website_visitors: isChannelSelected(formData, 'onSiteChannels', 'Website') ? parseInt(formData.websiteVisitors) || 0 : 0,
app_users: isChannelSelected(formData, 'onSiteChannels', 'Mobile App') ? parseInt(formData.appUsers) || 0 : 0,
loyalty_users: isChannelSelected(formData, 'onSiteChannels', 'Loyalty Program') ? parseInt(formData.loyaltyUsers) || 0 : 0,
// Off-site channels
off_site_channels: getSelectedChannels(formData, 'offSiteChannels'),
facebook_followers: isChannelSelected(formData, 'offSiteChannels', 'Facebook Business') ? parseInt(formData.facebookFollowers) || 0 : 0,
instagram_followers: isChannelSelected(formData, 'offSiteChannels', 'Instagram Business') ? parseInt(formData.instagramFollowers) || 0 : 0,
google_views: isChannelSelected(formData, 'offSiteChannels', 'Google Business Profile') ? parseInt(formData.googleViews) || 0 : 0,
email_subscribers: isChannelSelected(formData, 'offSiteChannels', 'Email') ? parseInt(formData.emailSubscribers) || 0 : 0,
sms_users: isChannelSelected(formData, 'offSiteChannels', 'SMS') ? parseInt(formData.smsUsers) || 0 : 0,
whatsapp_contacts: isChannelSelected(formData, 'offSiteChannels', 'WhatsApp') ? parseInt(formData.whatsappContacts) || 0 : 0,
// Preserve existing calculation results if they exist
potential_reach_in_store: 0,
unique_impressions_in_store: 0,
potential_reach_on_site: 0,
unique_impressions_on_site: 0,
potential_reach_off_site: 0,
unique_impressions_off_site: 0
};
// Write the updated config back to the file
const updatedConfig = JSON.stringify(configData, null, 2);
fs.writeFile(configPath, updatedConfig, 'utf8', (writeErr) => {
if (writeErr) {
reject(new Error(`Failed to write to config file: ${writeErr.message}`));
return;
}
resolve();
});
} catch (parseError) {
reject(new Error(`Failed to parse config file: ${parseError.message}`));
}
});
});
}
// Helper function to check if a channel is selected
function isChannelSelected(formData, channelType, channelName) {
const selectedChannels = getSelectedChannels(formData, channelType);
return selectedChannels.includes(channelName);
}
// Helper function to get selected channels from form data
function getSelectedChannels(formData, channelType) {
console.log(`Getting selected channels for ${channelType} from formData:`, formData[channelType]);
let channels = [];
if (formData[channelType]) {
if (Array.isArray(formData[channelType])) {
channels = formData[channelType];
} else {
channels = [formData[channelType]];
}
}
console.log(`Selected ${channelType}:`, channels);
return channels;
}
// Helper function to check if a store type is selected
function isStoreTypeSelected(formData, storeType) {
const selectedTypes = getSelectedStoreTypes(formData);
return selectedTypes.includes(storeType);
}
// Helper function to get selected store types from form data
function getSelectedStoreTypes(formData) {
console.log('Getting selected store types from formData:', formData);
// Check if storeTypes is an array or single value
let storeTypes = [];
if (formData.storeTypes) {
if (Array.isArray(formData.storeTypes)) {
storeTypes = formData.storeTypes;
} else {
storeTypes = [formData.storeTypes];
}
}
console.log('Selected store types:', storeTypes);
return storeTypes;
}
// Function to fetch config.json
async function fetchConfig() {
return new Promise((resolve, reject) => {
fs.readFile(path.join(__dirname, 'config.json'), 'utf8', (err, data) => {
if (err) {
reject(new Error(`Failed to read config file: ${err.message}`));
return;
}
try {
const config = JSON.parse(data);
resolve(config);
} catch (parseError) {
reject(new Error(`Failed to parse config file: ${parseError.message}`));
}
});
});
}
// For Node.js environment, export the functions
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
updateConfig,
fetchConfig
};
}

View File

@@ -0,0 +1,92 @@
# 🧠 LLM Prompt Retail Media Calculation Agent
## Purpose
You are a smart data agent. Your job is to:
1. **Extract input values** from the existing form ( `index.html`).
2. **Read constants and formulas** from an existing `config.json`.
3. **Normalize input**:
- For any question that asks for a percentage (e.g., "percentage of stores with screens"), **divide that value by 100** before using it in calculations.
4. **Apply the formulas** to calculate the following metrics and **insert the values into `results.json`** under the following keys:
```json
{
"potential_reach_in_store": <calculated_value>,
"unique_impressions_in_store": <calculated_value>,
"potential_reach_on_site": <calculated_value>,
"unique_impressions_on_site": <calculated_value>,
"potential_reach_off_site": <calculated_value>,
"unique_impressions_off_site": <calculated_value>
}
```
---
## 🔢 Formulas
- **% stores with retail media**
`= min(stores_with_screens, stores_with_radio) + abs(stores_with_screens - stores_with_radio) / 2`
- **potential_reach_in_store**
`= (transactions × % stores with retail media / frequency) × visitor_coefficient`
- **unique_impressions_in_store**
`= ((dwell_time + 60 × ad_duration) × frequency × capture_rate_screen × paid_screen × screen_count) + ((dwell_time + 60 × ad_duration) × frequency × (radio_percentage / 0.5) × paid_radio)`
- **potential_reach_on_site**
`= (website_visits × (1 - website_bounce_rate) / website_frequency) + (app_users × (1 - app_bounce_rate)) + (loyalty_users × (1 - loyalty_bounce_rate))`
- **unique_impressions_on_site**
`= average_impressions_website × website_frequency × if_website + average_impressions_app × app_frequency × if_app + average_impressions_loyalty × loyalty_frequency × if_loyalty`
- **potential_reach_off_site**
`= sum of (followers × (1 - off_site_bounce_rate))` for each channel selected
- **unique_impressions_off_site**
`= frequency × avg_impressions × if_channel` for each selected channel (e.g., Facebook, Instagram, etc.)
---
## ✅ Boolean Inputs
Use `if_channel = 1` if selected, `0` otherwise.
---
## ⚙️ Additional Behavior
After the user clicks the **Submit** button on the form:
- The formulas must be executed using the inputs.
- The calculated values must be generated and replaced into the `results.json`.
- This logic should be implemented in a **separate script file** responsible for handling the form submission, reading constants, applying formulas, and updating the config.
---
## 📁 Output: results.json
We maintain a JSON file named `results.json` with the following structure:
```json
{
"potential_reach_in_store": <calculated_value>,
"unique_impressions_in_store": <calculated_value>,
"potential_reach_on_site": <calculated_value>,
"unique_impressions_on_site": <calculated_value>,
"potential_reach_off_site": <calculated_value>,
"unique_impressions_off_site": <calculated_value>
}
```
On **each form submission**, the formulas must be:
- **Executed using the latest input values**
- **The `results.json` file must be updated (overwritten) with the new results**
This logic is to be implemented in **Node.js**, in a dedicated script that handles:
- Reading user input
- Parsing `config.json`
- Performing calculations
- Writing updated values into `results.json`

2290
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "retail-media-calculator",
"version": "1.0.0",
"description": "Retail Media Business Case Calculation Agent",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"body-parser": "^1.20.2",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"fs-extra": "^11.3.1",
"node-xlsx": "^0.24.0",
"python-shell": "^5.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

69
server.js Normal file
View File

@@ -0,0 +1,69 @@
const express = require('express');
const bodyParser = require('body-parser');
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
const { updateConfig } = require('./index');
// Create Express app
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(express.static(__dirname)); // Serve static files
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// Route to serve the HTML form
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
// Route to serve the thank you page
app.get('/thank-you.html', (req, res) => {
res.sendFile(path.join(__dirname, 'thank-you.html'));
});
// API endpoint to handle form submissions
app.post('/calculate', async (req, res) => {
try {
console.log('Received form submission');
const formData = req.body;
console.log('Form data received:', JSON.stringify(formData, null, 2));
// Update config file with form data
await updateConfig(formData);
console.log('Config file updated successfully');
// Run Python script to create Excel file with variables injection
exec('python3 create_excel_xlwings.py', (error, stdout, stderr) => {
if (error) {
console.error(`Error executing Python script: ${error}`);
console.error(`stderr: ${stderr}`);
} else {
console.log(`Python script output: ${stdout}`);
}
});
// Send success response
res.json({
success: true,
message: 'Form data saved successfully'
});
console.log('Success response sent');
} catch (error) {
console.error('Error processing form data:', error);
console.error('Error stack:', error.stack);
res.status(500).json({
success: false,
message: 'Error processing form data',
error: error.message
});
console.error('Error response sent');
}
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

33
thank-you.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thank You - Retail Media Business Case</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-white min-h-screen flex items-center justify-center py-8">
<div class="w-full max-w-[600px] mx-auto px-4 sm:px-6">
<div class="text-center mb-6">
<h1 class="text-4xl font-bold text-black mb-2">Thank You!</h1>
</div>
<div class="bg-gray-50 p-8 rounded-lg shadow-sm text-center">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-[#1f1a3e] mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-base text-[#1f1a3e] mb-8">
Your submission has been received successfully. Our retail media specialists will reach out to you soon.
</p>
<a href="/"
class="inline-block px-10 py-3 bg-gradient-to-r from-yellow-400 to-orange-500 text-white rounded-[10px] hover:from-yellow-500 hover:to-orange-600 font-bold text-lg uppercase tracking-wide transition-all shadow-md hover:shadow-lg">
Return Home
</a>
</div>
</div>
</body>
</html>