Case Study 2: Maya Generates Client Proposal Documents
Background
Maya has 14 active prospective clients in various stages of her sales process. Each prospect who reaches the formal proposal stage receives a customized project proposal document: a 4–6 page Word file with her branding, the client's name and organization, a description of project scope, a fee schedule, terms, and a signature section.
For the past three years, Maya's process has been: open last month's proposal, save it with the new client name, manually find and replace every instance of the previous client's name, update the scope, the fees, the dates. Every time. The process takes about 45 minutes per proposal and introduces errors — she once sent a proposal to Meridian Healthcare that still had the previous client's name in the footer.
She wants to build a template-based system: one master .docx template with {{PLACEHOLDER}} markers, a Python script that fills the template from a data dictionary, and a folder of generated proposals organized by date.
The Template
Maya designs her proposal template in Word as she normally would — fonts, colors, her logo, her standard terms. Wherever client-specific content goes, she types a placeholder in double curly braces:
{{CLIENT_NAME}} → "Meridian Healthcare"
{{CLIENT_ORG}} → "Meridian Healthcare System"
{{PROJECT_TITLE}} → "Operational Efficiency Assessment"
{{PROJECT_SCOPE}} → Multi-paragraph description
{{PROJECT_START}} → "March 1, 2025"
{{TOTAL_FEE}} → "$24,000"
{{FEE_BREAKDOWN}} → Line items in a table
{{PREPARED_DATE}} → "February 15, 2025"
{{EXPIRY_DATE}} → "March 15, 2025" (30 days after prepared date)
She saves this as proposal_template.docx in her templates folder.
The Project Data Structure
Maya keeps her active prospects in a JSON file (she could equally use a CSV or a row in her maya_projects.csv):
[
{
"client_name": "Dr. Sarah Chen",
"client_org": "Meridian Healthcare System",
"project_title": "Operational Efficiency Assessment",
"project_description": "Comprehensive review of patient intake workflows, scheduling systems, and administrative processes across three clinic locations. Deliverables include a gap analysis report, process improvement recommendations, and a 90-day implementation roadmap.",
"start_date": "2025-03-01",
"duration_weeks": 12,
"rate_type": "fixed",
"total_fee": 24000,
"fee_schedule": [
{"milestone": "Project Kickoff & Discovery", "amount": 6000, "due": "Upon signing"},
{"milestone": "Gap Analysis Delivery", "amount": 9000, "due": "Week 4"},
{"milestone": "Recommendations Report", "amount": 6000, "due": "Week 8"},
{"milestone": "Final Report & Presentation", "amount": 3000, "due": "Week 12"}
],
"contact_email": "s.chen@meridianhealthcare.org"
},
{
"client_name": "James Hartmann",
"client_org": "Hartmann Logistics Partners",
"project_title": "Warehouse Operations Audit",
"project_description": "Assessment of receiving, storage, and fulfillment operations at the Indianapolis distribution center. Focus on identifying throughput bottlenecks and labor utilization opportunities.",
"start_date": "2025-03-15",
"duration_weeks": 8,
"rate_type": "hourly",
"total_fee": 14000,
"fee_schedule": [
{"milestone": "Retainer (20 hrs @ $175)", "amount": 3500, "due": "Upon signing"},
{"milestone": "Site Assessment (30 hrs @ $175)", "amount": 5250, "due": "Week 2"},
{"milestone": "Final Report (30 hrs @ $175)", "amount": 5250, "due": "Week 8"}
],
"contact_email": "j.hartmann@hartmannlogistics.com"
}
]
The Generation Script
"""
generate_proposals.py
Generates client proposal Word documents from a template and project data.
Each proposal is saved as: proposals/YYYY-MM-DD_ClientName_Proposal.docx
Usage:
python generate_proposals.py
python generate_proposals.py --template /path/to/template.docx
python generate_proposals.py --data /path/to/projects.json
python generate_proposals.py --client "Dr. Sarah Chen" (generate one)
"""
import argparse
import datetime
import json
import sys
from pathlib import Path
import docx
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
# ── CONFIGURATION ─────────────────────────────────────────────────────────────
TEMPLATES_DIR = Path.home() / "consulting" / "templates"
PROPOSALS_DIR = Path.home() / "consulting" / "proposals"
PROJECTS_DATA = Path.home() / "consulting" / "active_proposals.json"
TEMPLATE_FILE = TEMPLATES_DIR / "proposal_template.docx"
# Maya's standard 30-day proposal validity
PROPOSAL_VALIDITY_DAYS = 30
# ── DATA PREPARATION ──────────────────────────────────────────────────────────
def build_replacements(project: dict) -> dict:
"""
Build the placeholder-to-value dictionary for a single project.
Converts raw project data into formatted strings ready for insertion
into the proposal template.
"""
today = datetime.date.today()
start_date_raw = project.get("start_date", "")
# Parse and format the start date
try:
start_date = datetime.date.fromisoformat(start_date_raw)
start_date_formatted = start_date.strftime("%B %d, %Y")
except ValueError:
start_date_formatted = start_date_raw
prepared_date = today.strftime("%B %d, %Y")
expiry_date = (today + datetime.timedelta(days=PROPOSAL_VALIDITY_DAYS))
expiry_date_formatted = expiry_date.strftime("%B %d, %Y")
# Format total fee
total_fee = project.get("total_fee", 0)
if project.get("rate_type") == "hourly":
fee_display = f"${total_fee:,.0f} (estimated, based on ${project.get('hourly_rate', 175)}/hr)"
else:
fee_display = f"${total_fee:,.0f} (fixed fee)"
# Duration display
weeks = project.get("duration_weeks", 0)
if weeks >= 4:
months = round(weeks / 4.3)
duration_display = f"Approximately {months} month{'s' if months != 1 else ''}"
else:
duration_display = f"{weeks} week{'s' if weeks != 1 else ''}"
return {
"{{CLIENT_NAME}}": project.get("client_name", ""),
"{{CLIENT_ORG}}": project.get("client_org", ""),
"{{PROJECT_TITLE}}": project.get("project_title", ""),
"{{PROJECT_SCOPE}}": project.get("project_description", ""),
"{{PROJECT_START}}": start_date_formatted,
"{{PROJECT_DURATION}}": duration_display,
"{{TOTAL_FEE}}": fee_display,
"{{PREPARED_DATE}}": prepared_date,
"{{EXPIRY_DATE}}": expiry_date_formatted,
"{{CONSULTANT_NAME}}": "Maya Reyes",
"{{CONSULTANT_EMAIL}}": "maya@mayareyes.consulting",
"{{CONSULTANT_PHONE}}": "(312) 555-0147",
}
def format_output_filename(project: dict) -> str:
"""
Build the output filename for a proposal.
Format: YYYY-MM-DD_ClientOrg_ProjectTitle_Proposal.docx
"""
today = datetime.date.today().strftime("%Y-%m-%d")
org = project.get("client_org", "Client").split()[0] # First word of org name
title_words = project.get("project_title", "Proposal").split()[:3]
title_slug = "_".join(w.capitalize() for w in title_words)
safe_org = org.replace("/", "_").replace("\\", "_")
return f"{today}_{safe_org}_{title_slug}_Proposal.docx"
# ── TEMPLATE OPERATIONS ───────────────────────────────────────────────────────
def replace_in_paragraph(paragraph, replacements: dict) -> None:
"""Replace placeholders in a paragraph, handling cross-run splits."""
for placeholder, value in replacements.items():
if placeholder not in paragraph.text:
continue
full_text = "".join(run.text for run in paragraph.runs)
if placeholder not in full_text:
continue
new_text = full_text.replace(placeholder, str(value))
if paragraph.runs:
paragraph.runs[0].text = new_text
for run in paragraph.runs[1:]:
run.text = ""
def fill_proposal_template(
template_path: Path,
output_path: Path,
replacements: dict,
fee_schedule: list[dict],
) -> Path:
"""
Fill the proposal template with project-specific content.
Handles:
1. Text placeholder replacement throughout the document
2. Fee schedule table population (finds a {{FEE_TABLE}} marker)
Args:
template_path: Path to the .docx template.
output_path: Where to save the filled proposal.
replacements: Placeholder-to-value mapping.
fee_schedule: List of {milestone, amount, due} dicts for the fee table.
Returns:
Path to the created document.
"""
doc = docx.Document(str(template_path))
# ── REPLACE TEXT PLACEHOLDERS ─────────────────────────────────────────────
for paragraph in doc.paragraphs:
replace_in_paragraph(paragraph, replacements)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
replace_in_paragraph(paragraph, replacements)
for section in doc.sections:
for paragraph in section.header.paragraphs:
replace_in_paragraph(paragraph, replacements)
for paragraph in section.footer.paragraphs:
replace_in_paragraph(paragraph, replacements)
# ── POPULATE FEE SCHEDULE TABLE ───────────────────────────────────────────
# Look for a table that has "{{FEE_TABLE}}" in its first cell
for table in doc.tables:
if table.rows and "{{FEE_TABLE}}" in table.rows[0].cells[0].text:
# Clear the placeholder row
table.rows[0].cells[0].text = ""
# Add header row content (assumes template table has correct headers in row 0)
header_texts = ["Milestone / Deliverable", "Amount", "Due"]
for col_i, header in enumerate(header_texts):
if col_i < len(table.rows[0].cells):
cell = table.rows[0].cells[col_i]
cell.text = header
for run in cell.paragraphs[0].runs:
run.font.bold = True
# Add data rows
for item in fee_schedule:
new_row = table.add_row()
values = [
item.get("milestone", ""),
f"${item.get('amount', 0):,.0f}",
item.get("due", ""),
]
for col_i, value in enumerate(values):
if col_i < len(new_row.cells):
new_row.cells[col_i].text = value
# Total row
total_row = table.add_row()
total_amount = sum(item.get("amount", 0) for item in fee_schedule)
total_row.cells[0].text = "TOTAL"
total_row.cells[1].text = f"${total_amount:,.0f}"
total_row.cells[2].text = ""
for run in total_row.cells[0].paragraphs[0].runs:
run.font.bold = True
for run in total_row.cells[1].paragraphs[0].runs:
run.font.bold = True
break # Only process the first fee table found
output_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(output_path))
return output_path
# ── FALLBACK: Build from Scratch (when no template is available) ──────────────
def build_proposal_without_template(project: dict, output_path: Path) -> Path:
"""
Build a basic proposal document from scratch using python-docx.
Used when no template file is available. Produces a functional but
less visually polished document — intended as a fallback.
"""
doc = docx.Document()
for section in doc.sections:
from docx.shared import Inches
section.top_margin = Inches(1.0)
section.bottom_margin = Inches(1.0)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
COLOR_BLUE = RGBColor(0x00, 0x35, 0x6B)
COLOR_GRAY = RGBColor(0x44, 0x44, 0x44)
# Header
header_para = doc.add_paragraph()
header_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
header_run = header_para.add_run("MAYA REYES CONSULTING")
header_run.font.bold = True
header_run.font.size = Pt(10)
header_run.font.color.rgb = COLOR_BLUE
header_para.add_run("\nmaya@mayareyes.consulting | (312) 555-0147").font.size = Pt(9)
doc.add_paragraph()
# Title
title_para = doc.add_paragraph()
title_run = title_para.add_run("PROJECT PROPOSAL")
title_run.font.size = Pt(18)
title_run.font.bold = True
title_run.font.color.rgb = COLOR_BLUE
client_para = doc.add_paragraph()
client_run = client_para.add_run(
f"{project.get('project_title', '')} — {project.get('client_org', '')}"
)
client_run.font.size = Pt(13)
client_run.font.color.rgb = COLOR_GRAY
today = datetime.date.today()
date_para = doc.add_paragraph()
date_para.add_run(
f"Prepared: {today.strftime('%B %d, %Y')} | "
f"Valid until: {(today + datetime.timedelta(days=30)).strftime('%B %d, %Y')}"
).font.size = Pt(10)
doc.add_paragraph()
# Scope
doc.add_heading("Project Scope", level=1)
doc.add_paragraph(project.get("project_description", ""))
doc.add_paragraph()
# Fee schedule
doc.add_heading("Fee Schedule", level=1)
fee_schedule = project.get("fee_schedule", [])
if fee_schedule:
table = doc.add_table(rows=1 + len(fee_schedule) + 1, cols=3)
table.style = "Light Grid Accent 1"
# Headers
headers = ["Milestone / Deliverable", "Amount", "Due"]
for col_i, header in enumerate(headers):
table.rows[0].cells[col_i].text = header
for run in table.rows[0].cells[col_i].paragraphs[0].runs:
run.font.bold = True
# Data
for row_i, item in enumerate(fee_schedule, start=1):
table.rows[row_i].cells[0].text = item.get("milestone", "")
table.rows[row_i].cells[1].text = f"${item.get('amount', 0):,.0f}"
table.rows[row_i].cells[2].text = item.get("due", "")
# Total
total = sum(item.get("amount", 0) for item in fee_schedule)
last_row = table.rows[-1]
last_row.cells[0].text = "TOTAL"
last_row.cells[1].text = f"${total:,.0f}"
for run in last_row.cells[0].paragraphs[0].runs:
run.font.bold = True
for run in last_row.cells[1].paragraphs[0].runs:
run.font.bold = True
output_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(output_path))
return output_path
# ── ORCHESTRATION ─────────────────────────────────────────────────────────────
def generate_proposals(
projects_data_path: Path,
template_path: Path,
output_dir: Path,
target_client: str = None,
) -> list[Path]:
"""
Generate proposals for all (or one) project in the data file.
Args:
projects_data_path: Path to the JSON file with project records.
template_path: Path to the .docx template.
output_dir: Where to save generated proposals.
target_client: If set, only generate for this client name.
Returns:
List of paths to created documents.
"""
projects = json.loads(projects_data_path.read_text(encoding="utf-8"))
if target_client:
projects = [p for p in projects if p.get("client_name") == target_client]
if not projects:
print(f"No project found for client: {target_client}")
return []
print(f"Generating {len(projects)} proposal(s)...\n")
use_template = template_path.exists()
if not use_template:
print(f"Template not found: {template_path}")
print("Building from scratch (reduced formatting).\n")
created = []
for project in projects:
client_name = project.get("client_name", "Unknown")
print(f" {client_name} — {project.get('project_title', '')}")
output_filename = format_output_filename(project)
output_path = output_dir / output_filename
if use_template:
replacements = build_replacements(project)
created_path = fill_proposal_template(
template_path,
output_path,
replacements,
project.get("fee_schedule", []),
)
else:
created_path = build_proposal_without_template(project, output_path)
print(f" -> {output_path.name}")
created.append(created_path)
print(f"\nGenerated {len(created)} proposal(s) in: {output_dir}")
return created
# ── ENTRY POINT ───────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate client proposal Word documents from project data."
)
parser.add_argument(
"--data",
type=Path,
default=PROJECTS_DATA,
help=f"Path to the JSON projects file (default: {PROJECTS_DATA})",
)
parser.add_argument(
"--template",
type=Path,
default=TEMPLATE_FILE,
help=f"Path to the Word template (default: {TEMPLATE_FILE})",
)
parser.add_argument(
"--output",
type=Path,
default=PROPOSALS_DIR,
help=f"Output directory for proposals (default: {PROPOSALS_DIR})",
)
parser.add_argument(
"--client",
type=str,
default=None,
help="Generate proposal for this client name only",
)
args = parser.parse_args()
if not args.data.exists():
print(f"Projects data file not found: {args.data}")
sys.exit(1)
generate_proposals(
projects_data_path=args.data.resolve(),
template_path=args.template.resolve(),
output_dir=args.output.resolve(),
target_client=args.client,
)
if __name__ == "__main__":
main()
Results
Maya runs the script for the first time:
Generating 2 proposal(s)...
Dr. Sarah Chen — Operational Efficiency Assessment
-> 2025-02-24_Meridian_Operational_Efficiency_Assessment_Proposal.docx
James Hartmann — Warehouse Operations Audit
-> 2025-02-24_Hartmann_Warehouse_Operations_Audit_Proposal.docx
Generated 2 proposals in: ~/consulting/proposals
She opens both documents. The Meridian Healthcare proposal looks exactly right: her name and contact details in the header, Dr. Chen's name and organization throughout the document, the project scope paragraph, the formatted fee schedule table with bold column headers and a total row.
The Hartmann Logistics proposal is also correct, with Hartmann's details and the hourly estimate breakdown.
One issue: in the Meridian proposal, the footer still shows the previous client's name — she forgot to add a {{CLIENT_ORG}} placeholder to the footer text when designing the template. She fixes the template file in Word, reruns the script, and the issue is gone.
What Maya Learned
Templates require careful placeholder placement. The missed footer placeholder is a common beginner mistake. Maya now does a "search for {{" check in her template after creating it — any placeholder she missed will show up in that search.
The JSON data structure is itself an asset. Before this project, Maya tracked her proposals in a combination of memory, email, and sticky notes. The active_proposals.json file is now her single source of truth for proposal status, and she updates it first before generating any document.
Regeneration is free. On two occasions since deploying this system, clients asked for a change after Maya had sent a proposal. Before the script: 45 minutes of manual editing. After: update one field in the JSON, run the script, send the new version. Two minutes.
The "works with data" mindset changes how you design everything. When Maya set up her fee schedule as a list of dictionaries rather than a string, she made it possible to sum totals in code, validate that they add up, and potentially export them to her invoicing system later. A small design decision with compounding value.