- Added create_excel_xlsxwriter.py and update_excel_xlsxwriter.py - Implemented comprehensive store_name replacement across all formula types - Added special handling for ArrayFormula objects (H25-AG27 range) - Added external link breaking to prevent Excel security warnings - Enhanced sheet renaming with formula reference preservation - Updated server.js to use new xlsxwriter scripts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
420 lines
22 KiB
Python
420 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
import json
|
||
import os
|
||
import re
|
||
import openpyxl
|
||
from openpyxl.utils import get_column_letter
|
||
|
||
def update_excel_variables(excel_path):
|
||
"""
|
||
Update the Variables sheet in the Excel file with values from config.json
|
||
and hide forecast sheets that aren't in the calculated years array.
|
||
|
||
This version uses openpyxl exclusively to preserve all formatting, formulas,
|
||
and Excel features that xlsxwriter cannot handle when modifying existing files.
|
||
While this is named "xlsxwriter", it actually uses openpyxl for the best
|
||
approach to modify existing Excel files while preserving all features.
|
||
|
||
Args:
|
||
excel_path (str): Path to the Excel file to update
|
||
|
||
Returns:
|
||
bool: True if successful, False otherwise
|
||
"""
|
||
# Define paths
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
config_path = os.path.join(script_dir, 'config.json')
|
||
|
||
try:
|
||
# Load config.json
|
||
with open(config_path, 'r') as f:
|
||
config = json.load(f)
|
||
user_data = config.get('user_data', {})
|
||
|
||
# Load Excel workbook
|
||
print(f"Opening Excel file: {excel_path}")
|
||
wb = openpyxl.load_workbook(excel_path)
|
||
|
||
# Break any external links to prevent unsafe external sources error
|
||
print("Breaking any external links...")
|
||
try:
|
||
# Clear external links if they exist
|
||
if hasattr(wb, '_external_links'):
|
||
wb._external_links.clear()
|
||
if hasattr(wb, 'external_links'):
|
||
wb.external_links.clear()
|
||
|
||
# Remove any defined names that might contain external references
|
||
names_to_remove = []
|
||
for name in wb.defined_names:
|
||
if name.value and ('[' in str(name.value) or 'store_name' in str(name.value)):
|
||
names_to_remove.append(name.name)
|
||
print(f"Removing potentially problematic defined name: {name.name}")
|
||
|
||
for name_to_remove in names_to_remove:
|
||
del wb.defined_names[name_to_remove]
|
||
|
||
# Set calculation mode to manual to prevent external link issues
|
||
if hasattr(wb, 'calculation') and hasattr(wb.calculation, 'calcMode'):
|
||
wb.calculation.calcMode = 'manual'
|
||
print("Set calculation mode to manual")
|
||
|
||
print("External links handling completed")
|
||
except Exception as e:
|
||
print(f"Warning during external links handling: {e}")
|
||
|
||
# Try to access the Variables sheet
|
||
try:
|
||
# First try by name
|
||
sheet = wb['Variables']
|
||
except KeyError:
|
||
# If not found by name, try to access the last sheet
|
||
sheet_names = wb.sheetnames
|
||
if sheet_names:
|
||
print(f"Variables sheet not found by name. Using last sheet: {sheet_names[-1]}")
|
||
sheet = wb[sheet_names[-1]]
|
||
else:
|
||
print("No sheets found in the workbook")
|
||
return False
|
||
|
||
# Map config variables to Excel cells based on the provided mapping
|
||
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),
|
||
|
||
# Minimarket store type
|
||
'H38': user_data.get('minimarket_store_type', {}).get('stores_number', 0),
|
||
'C38': user_data.get('minimarket_store_type', {}).get('monthly_transactions', 0),
|
||
# Convert boolean to 1/0 for has_digital_screens
|
||
'I38': 1 if user_data.get('minimarket_store_type', {}).get('has_digital_screens', False) else 0,
|
||
'J38': user_data.get('minimarket_store_type', {}).get('screen_count', 0),
|
||
'K38': user_data.get('minimarket_store_type', {}).get('screen_percentage', 0),
|
||
# Convert boolean to 1/0 for has_in_store_radio
|
||
'M38': 1 if user_data.get('minimarket_store_type', {}).get('has_in_store_radio', False) else 0,
|
||
'N38': user_data.get('minimarket_store_type', {}).get('radio_percentage', 0),
|
||
|
||
# Supermarket store type
|
||
'H39': user_data.get('supermarket_store_type', {}).get('stores_number', 0),
|
||
'C39': user_data.get('supermarket_store_type', {}).get('monthly_transactions', 0),
|
||
# Convert boolean to 1/0 for has_digital_screens
|
||
'I39': 1 if user_data.get('supermarket_store_type', {}).get('has_digital_screens', False) else 0,
|
||
'J39': user_data.get('supermarket_store_type', {}).get('screen_count', 0),
|
||
'K39': user_data.get('supermarket_store_type', {}).get('screen_percentage', 0),
|
||
# Convert boolean to 1/0 for has_in_store_radio
|
||
'M39': 1 if user_data.get('supermarket_store_type', {}).get('has_in_store_radio', False) else 0,
|
||
'N39': user_data.get('supermarket_store_type', {}).get('radio_percentage', 0),
|
||
|
||
# Hypermarket store type
|
||
'H40': user_data.get('hypermarket_store_type', {}).get('stores_number', 0),
|
||
'C40': user_data.get('hypermarket_store_type', {}).get('monthly_transactions', 0),
|
||
# Convert boolean to 1/0 for has_digital_screens
|
||
'I40': 1 if user_data.get('hypermarket_store_type', {}).get('has_digital_screens', False) else 0,
|
||
'J40': user_data.get('hypermarket_store_type', {}).get('screen_count', 0),
|
||
'K40': user_data.get('hypermarket_store_type', {}).get('screen_percentage', 0),
|
||
# Convert boolean to 1/0 for has_in_store_radio
|
||
'M40': 1 if user_data.get('hypermarket_store_type', {}).get('has_in_store_radio', False) else 0,
|
||
'N40': user_data.get('hypermarket_store_type', {}).get('radio_percentage', 0),
|
||
|
||
# On-site channels
|
||
'B43': user_data.get('website_visitors', 0),
|
||
'B44': user_data.get('app_users', 0),
|
||
'B45': user_data.get('loyalty_users', 0),
|
||
|
||
# Off-site channels
|
||
'B49': user_data.get('facebook_followers', 0),
|
||
'B50': user_data.get('instagram_followers', 0),
|
||
'B51': user_data.get('google_views', 0),
|
||
'B52': user_data.get('email_subscribers', 0),
|
||
'B53': user_data.get('sms_users', 0),
|
||
'B54': user_data.get('whatsapp_contacts', 0)
|
||
}
|
||
|
||
# Update the cells
|
||
for cell_ref, value in cell_mappings.items():
|
||
try:
|
||
# Force the value to be set, even if the cell is protected or has data validation
|
||
cell = sheet[cell_ref]
|
||
cell.value = value
|
||
print(f"Updated {cell_ref} with value: {value}")
|
||
except Exception as e:
|
||
print(f"Error updating cell {cell_ref}: {e}")
|
||
|
||
# Save the workbook with variables updated
|
||
print("Saving workbook with updated variables...")
|
||
wb.save(excel_path)
|
||
|
||
# Rename sheets containing store_name placeholder with actual store name
|
||
store_name = user_data.get('store_name', 'Your Store')
|
||
if store_name and store_name.strip():
|
||
print(f"Renaming sheets with store_name placeholder to: {store_name}")
|
||
|
||
# Create a list of sheets to rename to avoid modifying during iteration
|
||
sheets_to_rename = []
|
||
for sheet_name in wb.sheetnames:
|
||
if 'store_name' in sheet_name:
|
||
new_sheet_name = sheet_name.replace('store_name', store_name)
|
||
sheets_to_rename.append((sheet_name, new_sheet_name))
|
||
|
||
# Update all formulas and references before renaming sheets
|
||
if sheets_to_rename:
|
||
print("Updating formulas to prevent external link errors...")
|
||
|
||
# Go through all sheets and update any formulas that reference sheets with store_name
|
||
for ws in wb.worksheets:
|
||
for row in ws.iter_rows():
|
||
for cell in row:
|
||
if cell.value and isinstance(cell.value, str) and cell.value.startswith('='):
|
||
original_formula = cell.value
|
||
updated_formula = original_formula
|
||
|
||
# Replace sheet references in formulas
|
||
for old_name, new_name in sheets_to_rename:
|
||
# Handle different formula reference patterns
|
||
patterns_to_replace = [
|
||
f"'{old_name}'!", # 'Sheet Name'!
|
||
f"{old_name}!", # SheetName! (if no spaces)
|
||
f"'{old_name}'.", # 'Sheet Name'. (alternative reference)
|
||
]
|
||
|
||
for pattern in patterns_to_replace:
|
||
if pattern in updated_formula:
|
||
new_pattern = pattern.replace(old_name, new_name)
|
||
updated_formula = updated_formula.replace(pattern, new_pattern)
|
||
|
||
# Also replace ALL store_name placeholder variations within formula content
|
||
store_name_variations = ['store_name', '{store_name}', 'store_name}', '{store_name']
|
||
for variation in store_name_variations:
|
||
if variation in updated_formula:
|
||
updated_formula = updated_formula.replace(variation, store_name)
|
||
|
||
# Update the cell if formula changed
|
||
if updated_formula != original_formula:
|
||
try:
|
||
cell.value = updated_formula
|
||
print(f"Updated formula in {ws.title}!{cell.coordinate}")
|
||
except Exception as e:
|
||
print(f"Warning: Could not update formula in {ws.title}!{cell.coordinate}: {e}")
|
||
|
||
# Also check for store_name in regular cell values (non-formula)
|
||
elif cell.value and isinstance(cell.value, str) and 'store_name' in cell.value:
|
||
try:
|
||
original_value = cell.value
|
||
updated_value = original_value.replace('store_name', store_name)
|
||
cell.value = updated_value
|
||
print(f"Updated cell value in {ws.title}!{cell.coordinate}: '{original_value}' -> '{updated_value}'")
|
||
except Exception as e:
|
||
print(f"Warning: Could not update cell value in {ws.title}!{cell.coordinate}: {e}")
|
||
|
||
# Now safely rename each sheet
|
||
for old_name, new_name in sheets_to_rename:
|
||
try:
|
||
sheet_obj = wb[old_name]
|
||
sheet_obj.title = new_name
|
||
print(f"Renamed sheet '{old_name}' to '{new_name}'")
|
||
except Exception as e:
|
||
print(f"Error renaming sheet '{old_name}' to '{new_name}': {e}")
|
||
|
||
# COMPREHENSIVE pass: Replace store_name in ALL cells throughout the workbook
|
||
print("=== COMPREHENSIVE SCAN: Checking all sheets for store_name placeholders ===")
|
||
total_formulas_updated = 0
|
||
total_text_updated = 0
|
||
|
||
for ws in wb.worksheets:
|
||
print(f"Scanning sheet: {ws.title}")
|
||
sheet_formulas_updated = 0
|
||
sheet_text_updated = 0
|
||
|
||
for row in ws.iter_rows():
|
||
for cell in row:
|
||
if cell.value:
|
||
# Handle ArrayFormula objects specially
|
||
if hasattr(cell.value, 'text'): # ArrayFormula
|
||
formula_text = cell.value.text
|
||
store_name_variations = ['store_name', '{store_name}', 'store_name}', '{store_name']
|
||
has_store_name = any(variation in formula_text for variation in store_name_variations)
|
||
|
||
if has_store_name:
|
||
original_formula = formula_text
|
||
updated_formula = original_formula
|
||
|
||
# Replace all variations
|
||
for variation in store_name_variations:
|
||
if variation in updated_formula:
|
||
updated_formula = updated_formula.replace(variation, store_name)
|
||
|
||
if updated_formula != original_formula:
|
||
try:
|
||
cell.value.text = updated_formula
|
||
sheet_formulas_updated += 1
|
||
total_formulas_updated += 1
|
||
print(f" ✓ Updated store_name in ArrayFormula {cell.coordinate}: {original_formula}")
|
||
except Exception as e:
|
||
print(f" ✗ Could not update ArrayFormula {cell.coordinate}: {e}")
|
||
|
||
elif isinstance(cell.value, str):
|
||
# Handle regular string cells
|
||
store_name_variations = ['store_name', '{store_name}', 'store_name}', '{store_name']
|
||
has_store_name = any(variation in cell.value for variation in store_name_variations)
|
||
|
||
if has_store_name:
|
||
if cell.value.startswith('='):
|
||
# Formula with store_name variations
|
||
original_formula = cell.value
|
||
updated_formula = original_formula
|
||
|
||
# Replace all variations
|
||
for variation in store_name_variations:
|
||
if variation in updated_formula:
|
||
updated_formula = updated_formula.replace(variation, store_name)
|
||
|
||
if updated_formula != original_formula:
|
||
try:
|
||
cell.value = updated_formula
|
||
sheet_formulas_updated += 1
|
||
total_formulas_updated += 1
|
||
print(f" ✓ Updated store_name in formula {cell.coordinate}: {original_formula[:50]}...")
|
||
except Exception as e:
|
||
print(f" ✗ Could not update formula {cell.coordinate}: {e}")
|
||
else:
|
||
# Regular text with store_name variations
|
||
original_value = cell.value
|
||
updated_value = original_value
|
||
|
||
# Replace all variations
|
||
for variation in store_name_variations:
|
||
if variation in updated_value:
|
||
updated_value = updated_value.replace(variation, store_name)
|
||
|
||
if updated_value != original_value:
|
||
try:
|
||
cell.value = updated_value
|
||
sheet_text_updated += 1
|
||
total_text_updated += 1
|
||
print(f" ✓ Updated store_name in text {cell.coordinate}: '{original_value}' -> '{updated_value}'")
|
||
except Exception as e:
|
||
print(f" ✗ Could not update text {cell.coordinate}: {e}")
|
||
|
||
if sheet_formulas_updated > 0 or sheet_text_updated > 0:
|
||
print(f" → Sheet {ws.title}: {sheet_formulas_updated} formulas, {sheet_text_updated} text cells updated")
|
||
else:
|
||
print(f" → Sheet {ws.title}: No store_name placeholders found")
|
||
|
||
print(f"=== TOTAL UPDATES: {total_formulas_updated} formulas, {total_text_updated} text cells ===")
|
||
|
||
# Save after sheet renaming and formula updates
|
||
if sheets_to_rename:
|
||
print("Saving workbook after sheet renaming and formula updates...")
|
||
wb.save(excel_path)
|
||
|
||
# Get the calculated years array from config
|
||
starting_date = user_data.get('starting_date', '')
|
||
duration = user_data.get('duration', 36)
|
||
calculated_years = []
|
||
|
||
# Import datetime at the module level to avoid scope issues
|
||
import datetime
|
||
from dateutil.relativedelta import relativedelta
|
||
|
||
# Calculate years array based on starting_date and duration
|
||
try:
|
||
# Try to parse the date, supporting both dd/mm/yyyy and dd.mm.yyyy formats
|
||
if starting_date:
|
||
if '/' in str(starting_date):
|
||
day, month, year = map(int, str(starting_date).split('/'))
|
||
elif '.' in str(starting_date):
|
||
day, month, year = map(int, str(starting_date).split('.'))
|
||
elif '-' in str(starting_date):
|
||
# Handle ISO format (yyyy-mm-dd)
|
||
date_parts = str(starting_date).split('-')
|
||
if len(date_parts) == 3:
|
||
year, month, day = map(int, date_parts)
|
||
else:
|
||
# Default to current date if format is not recognized
|
||
current_date = datetime.datetime.now()
|
||
year, month, day = current_date.year, current_date.month, current_date.day
|
||
elif isinstance(starting_date, datetime.datetime):
|
||
day, month, year = starting_date.day, starting_date.month, starting_date.year
|
||
else:
|
||
# Default to current date if format is not recognized
|
||
current_date = datetime.datetime.now()
|
||
year, month, day = current_date.year, current_date.month, current_date.day
|
||
|
||
# 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
|
||
calculated_years = sorted(list(years_set))
|
||
print(f"Calculated years for sheet visibility: {calculated_years}")
|
||
else:
|
||
# Default to current year if no starting date
|
||
calculated_years = [datetime.datetime.now().year]
|
||
except Exception as e:
|
||
print(f"Error calculating years for sheet visibility: {e}")
|
||
calculated_years = [datetime.datetime.now().year]
|
||
|
||
# Hide forecast sheets that aren't in the calculated years array
|
||
# No sheet renaming - just check existing sheet names
|
||
for sheet_name in wb.sheetnames:
|
||
# Check if this is a forecast sheet
|
||
# Forecast sheets have names like "2025 – Forecast"
|
||
if "Forecast" in sheet_name:
|
||
# Extract the year from the sheet name
|
||
try:
|
||
sheet_year = int(sheet_name.split()[0])
|
||
# Hide the sheet if its year is not in the calculated years
|
||
if sheet_year not in calculated_years:
|
||
sheet_obj = wb[sheet_name]
|
||
sheet_obj.sheet_state = 'hidden'
|
||
print(f"Hiding sheet '{sheet_name}' as year {sheet_year} is not in calculated years {calculated_years}")
|
||
except Exception as e:
|
||
print(f"Error extracting year from sheet name '{sheet_name}': {e}")
|
||
|
||
# Save the workbook with updated variables and hidden sheets
|
||
print("Saving workbook with all updates...")
|
||
wb.save(excel_path)
|
||
|
||
print(f"Excel file updated successfully: {excel_path}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"Error updating Excel file: {e}")
|
||
return False
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# For testing purposes
|
||
import sys
|
||
if len(sys.argv) > 1:
|
||
excel_path = sys.argv[1]
|
||
update_excel_variables(excel_path)
|
||
else:
|
||
print("Please provide the path to the Excel file as an argument") |