Initial commit: Business Case Automation with Excel generation
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
77
README.md
Normal 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
61
config.json
Normal 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
249
create_excel.py
Executable 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
381
create_excel_xlwings.py
Executable 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
1602
index.html
Normal file
File diff suppressed because it is too large
Load Diff
187
index.js
Normal file
187
index.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
92
llm_prompt_retail_media.md
Normal file
92
llm_prompt_retail_media.md
Normal 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
2290
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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
69
server.js
Normal 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
33
thank-you.html
Normal 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>
|
||||||
Reference in New Issue
Block a user