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.