Files
mpm-project-kickoff/SKILL.md
T

664 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: mpm-project-kickoff
description: Run MPM's internal project kickoff when a purchase order arrives — verify the customer PO against the Odoo Sales Order, fill missing line costs (warranty = MSRP × 0.7569) and write them back to Odoo, pull the end-agency contacts, build the internal kickoff deck and the three-tab Google Sheet project budget (Hardware Calculator formulas, current HS-code tariffs on Actuals), copy the Test Project Folder System, and open the project's GChat space. Use whenever Andy or anyone at MPM drops a PO and/or quotation into chat and says "we got the PO", "kick off this project", "set up the project", "kickoff deck", "project budget for S0xxxx", "new project folder", or similar — even if they only name one of these pieces.
---
# MPM Project Kickoff
A PO just landed. This skill turns the PO + quotation into everything the internal kickoff meeting needs, and sets the project up in Drive, Odoo and GChat. The user drops the PO and the quotation (usually PDFs) into chat and calls the skill.
Deliverables, in order:
1. Project-team inputs gathered (mounting, promised timelines, hardware customizations, software customizations, anything else)
2. PO ↔ Odoo Sales Order verification, with every discrepancy catalogued and flagged
3. Every line has a cost; missing costs filled and written back to Odoo
4. End-agency contacts from Odoo (name, title, email, phone)
5. Internal kickoff deck covering 1–4 (Google Slides)
6. Project budget Google Sheet with three tabs: Summary, Project Budget, Actuals
7. A GChat space for the project, with the PM of record added
8. A copy of **Test Project Folder System**, renamed for the project, shared in the GChat space
9. Kickoff deck uploaded to `Meeting Notes`
10. Project budget uploaded to `Financials`
11. Source documents filed: a work order with an SOW attached goes to `Financials/Contract Documents`, and a purchase order goes to `Financials/Purchase & Change Orders`
Set up a task list for these eleven steps plus a final verification step, because the run is long and the user wants to see where things stand.
## Where things live
| Thing | Location |
|---|---|
| Odoo | `odoo-mpm` MCP tools (mpmedia.odoo.com). Prefer `odoo_search_read` with explicit fields. |
| Hardware Calculator | `Shared drives/SALES/Product Cost Information/Hardware Calculator.xlsx`. Read it from the connected folder, or find it with Drive search `title = 'Hardware Calculator'`. |
| Folder template | `Shared drives/PROJECT MANAGMENT/Test Project Folder System` (Drive id `1ZxrBJUxL75oRtqSE3qgqSNhtGxwcTQ9K`, parent shared drive `0AD6TBqTBJYuOUk9PVA`). If the id doesn't resolve, search by title. |
| Deck template | `Test Project Folder System/Meeting Notes/Kickoff Deck.pptx`. Slide 1 is the cover and slide 2 is the content layout (dark left title panel, body on the right). |
| New project folders | Siblings of the template in the PROJECT MANAGMENT shared drive, named for the agency (e.g. `OshKosh`, `LakeTran`). |
Paths under `/Users/<name>/Library/CloudStorage/...` are specific to one person's machine. Look up the user's connected folders (`get_device_info`) rather than assuming Andy's path.
## Step 0 — Read the inputs and find the order
1. Read the PO and the quotation. Capture: PO number and date, issuing organization (usually the reseller: Vontas, Avail, Passio, TripSpark, Swiftly, etc.), bill-to, **ship-to / end agency**, every line (code, description, qty, unit price, discount, extended), total, payment terms, requested delivery dates, and any special terms or notes.
2. Find the Odoo Sales Order. Use the SO number printed on the quote or PO (`search_sales_orders` with `S0xxxx`). If there isn't one, search by `client_order_ref` = PO number, then by customer and amount. If more than one candidate matches, ask which.
3. Pull the header:
`odoo_search_read('sale.order', [['id','=',ID]], ['name','state','partner_id','partner_invoice_id','partner_shipping_id','client_order_ref','opportunity_id','user_id','amount_untaxed','margin','date_order','commitment_date','payment_term_id','x_studio_quote_name'])`
4. Pull the lines in one call:
`odoo_search_read('sale.order.line', [['order_id','=',ID]], ['sequence','display_type','name','product_id','product_template_id','product_uom_qty','price_unit','discount','price_subtotal','purchase_price'], order='sequence,id')`
`display_type = 'line_section'` rows are section headers. Keep track of which section each product line falls under, and treat anything under a section whose name starts with "Optional" as optional. Optional lines usually sit at qty 0 under an "Optional Products" section. There is no `sale.order.option` model in this database, so don't query for one.
5. Pull the opportunity: `odoo_search_read('crm.lead', [['id','=',OPP]], ['name','x_studio_project_manager','user_id','partner_id'])`. The PM of record is `x_studio_project_manager` (a res.users record). Read that user's `login`/`email` for GChat.
## Step 1 — Project-team inputs
Ask these in one chat message, not one at a time. Fill in anything the PO or quote already says so the user only has to confirm or correct it (for example: "Quote says 10–12 weeks ARO. Is that what was promised?"):
1. **Mounting considerations**: shelter, pole, wall, in-vehicle, custom brackets, site survey needed?
2. **Promised timelines**: delivery, install, go-live, and any dates the customer was promised
3. **Hardware customizations**: colors, sizes, enclosures, modems, ADA, anything off-catalog
4. **Software customizations**: integrations (GTFS/GTFS-RT, AVL/API vendor), custom apps, design work
5. **Anything else** the project team should know
If the user is plainly away, record "not provided" and keep going. Everything else can proceed without these answers.
## Step 2 — PO ↔ Sales Order verification
Compare the PO against the Odoo SO, and the quotation against the SO (the quote may be an older revision). Catalog every difference in a table with: Severity | Item | PO says | Odoo/Quote says | Recommended action.
Check:
- **PO number and SO number.** The PO should reference the SO/quote number. `client_order_ref` should equal the PO number, and if it's blank, offer to write it.
- **SO state.** It should be `sale` (confirmed). If it's still `draft`/`sent`, flag it **High** and tell the user. Don't confirm the SO yourself.
- **Line by line.** Product/code, description, qty, unit price, discount, extended price. Also: lines on the PO but not the SO, lines on the SO but not the PO (optional sections are the usual cause), and $0 lines.
- **Totals.** Untaxed total, tax, and grand total. Tie out any difference to specific lines.
- **Parties.** Bill-to vs `partner_invoice_id`, ship-to vs `partner_shipping_id`.
- **Terms.** Payment terms, delivery dates vs `commitment_date`, Buy America/DBE/FTA clauses, retainage, and anything the SO doesn't cover.
- **Execution.** Is the PO or work order signed and numbered? Resellers such as TripSpark send work orders that aren't valid until executed and paired with a PO.
- **Cross-border.** If the end agency is outside the US (for example Canada), flag currency, sales tax/GST, and importer of record, and ask how the hardware ships (see Step 6).
Before flagging a contact detail as wrong, check it. MPM uses several email domains: `mpmedia.tv`, `messagepoint.tv` and `messagepoint.media` are all real.
Severity: **High** means money, quantity, product, or a missing or unconfirmed SO. **Medium** means reference fields, addresses, or terms. **Low** means cosmetic differences in descriptions or names. If everything matches, say so explicitly. "No discrepancies found" is a real result.
## Step 3 — Every line has a cost
For each product line, `purchase_price` is the SO unit cost. If a line is $0, read that product variant's `standard_price` first. It's often already populated (for example the calculator cost), so offer it as the default and let the user confirm it.
- **Warranty lines** (product/template name contains "Warranty") with no cost: unit cost = **unit price (MSRP, before discount) × 0.7569**. That's 13% off MSRP for resellers, then another 13% margin on what remains. Apply it to every warranty line whether or not the order carries a reseller discount. Warranty cost depends on the order, so write it to the **SO line only**, never to the product variant.
- **Warranty at $0 price and $0 cost** (bundled): there's no MSRP to work from, so ask the user.
- **Any other line with $0 cost**: list them and ask the user for each unit cost in one message. Some really are $0 (pass-through or bundled items). Accept "0" as a confirmed answer and label it "User confirmed $0".
- **Hardware on the Hardware Calculator**: flag any line where the SO cost and the calculator's budget unit cost (Step 6) differ by more than 10%. Report it only; don't change the SO.
Then write back to Odoo. Show the full change table first (line, product, old cost → new cost, and the target: SO line, variant, or both) and get a clear yes before writing:
- SO line: `bulk_update_records('sale.order.line', [line ids], {'purchase_price': cost})`. Group lines that share a value.
- Product variant, for **user-entered costs only**: `bulk_update_records('product.product', [variant id], {'standard_price': cost})`. First check the product's category valuation (`categ_id.property_valuation`). If it's `real_time`, changing cost posts a revaluation entry, so warn the user and let them decide.
- Read the records back to confirm the values took. If Odoo refuses a write (for example on a locked order), report it and move on. Don't retry in a loop.
## Step 4 — End-agency contacts
The end agency is usually the SO **delivery address** (`partner_shipping_id`). Often the delivery address is the reseller or its OEM (for example "Passio Technologies, Ashlyn Reep" or "GILLIG"). In that case find the agency from the PO ship-to, the quote name (`x_studio_quote_name`), or the opportunity name, then `search_contacts` for it.
For the agency company: `odoo_search_read('res.partner', ['|',['id','=',AGENCY],['parent_id','=',AGENCY]], ['name','function','email','phone','mobile','type','street','city','state_id','zip'])`.
Collect name, **title** (`function`), email, phone and mobile. Also include the delivery contact and address. Add the reseller's contact on the SO as a separate group. Flag contacts that are missing a title or email. If Odoo has no agency record at all, say so and list whatever contacts appear on the PO.
## Step 5 — Internal kickoff deck
Stage `Kickoff Deck.pptx` into the workspace and build the deck with `build_deck.py` (Appendix B). Slides, each an entry in `slides`:
1. Cover: agency name, with subtitle "Internal Project Kickoff | S0xxxx | PO xxxx"
2. **Project snapshot**: SO, PO number and date, reseller, end agency, salesperson, PM of record, one-time value, Year-1 recurring value, projected margin (from the budget)
3. **Scope summary**: hardware with quantities, services, installation, warranty term, and recurring licenses
4. **Project team inputs**: the five Step 1 answers
5. **PO vs SO verification**: the discrepancy table. If clean, a single line saying it's clean.
6. **Cost verification**: lines that were missing cost, how each was resolved, whether Odoo was updated, and any calculator-vs-SO flags over 10%
7. **Customer contacts**: a table per organization
8. **Budget snapshot**: category, sale price, projected cost, and margin %, plus the hardware forecast at current tariffs vs the budget
9. **Links & open items**: project folder, budget sheet, GChat space, and unresolved questions
Render it to PDF and look at every slide before uploading, because text that overflows is easy to miss otherwise. Keep table headings under ~40 characters. Put any explanatory text *before* a table in the same slide entry, because text after a table starts a new slide. A clean run is about 9 slides.
## Step 6 — Project budget (Google Sheet, three tabs)
**Categorize** each SO product line into exactly one section:
- **Travel**: `PS-Travel`, or "Travel" / "Per-Diem" in the name
- **DBE Installation**: codes starting `DBE-` (installation and dispatch)
- **Warranty**: warranty products
- **Annual Recurring**: `LIC-`, `SUP-`, API hosting, and anything under a "...Recurring" or "Licensing" section
- **MPM Professional Services**: `PS-` (other than travel), `SVC-`, project management, design, training, provisioning
- **Hardware**: everything physical, including `DSP-`, `MNT-`, `ACC-`, `KIT-`, `PRT-`, controllers, modems, and `PKG-` shipping
Leave out optional lines that aren't on the PO, and note them. Keep $0-price lines that carry a cost (for example, a mounting budget).
**Hardware Calculator.** Stage the calculator and run `read_calculator.py` (Appendix C). For each hardware line, match `default_code` against `skus` (uppercase). Try the template code if the variant doesn't match, or the M-series model name against `models`. Where there's a match with a factory cost, add `calc: {hs_col, hs_code, factory_cost, gross_kg}` to the line. The budget sheet then computes the unit cost with the calculator's chain:
`Factory Cost × (1 + Total Tariff% + Roger Lu% + Overhead%) + Customs Agent $175/unit + Gross KG × freight $/kg (Ocean by default)`.
The hardware subtotal on Project Budget is the sum of these formula-driven costs, so it ties to the projected budget. If the calculator's rate column disagrees with its own HS code, trust the HS code, and flag it for a calculator fix. (Known case: `MNT-Post-EX37sw-CNTLVR` is listed in the 8528 column but its HS code is 7308.90.6000 steel.) Lines with no calculator match (or no factory cost) use the SO unit cost; flag those as "not in calculator". If a match is uncertain, ask rather than guess.
**Budget rates** come straight from the calculator's Inputs sheet: the rates the quote was built on.
**Ask how the hardware ships** before researching actual rates. If it goes from the factory straight to a non-US end user (for example China → Canada), US tariffs don't apply, so the Actuals tab uses the destination country's duties instead (for Canada: CBSA MFN rates, the China steel surtax and steel-derivative surtax, and GST). The Project Budget tab still uses the normal calculator rates.
**Actual rates** have to be current. For each HS code on the order (for example 8471.41.0150 intelligent displays, 8528.xx secondary monitors and LED, Ch. 72/73 steel mounts; country of origin China unless the user says otherwise), research today's prevailing rates with web search. Use the USITC HTS (hts.usitc.gov), CBP CSMS messages, and Federal Register notices. Get the general duty rate and each additional program in effect (reciprocal/Section 122, IEEPA, Section 301, Section 232 steel, MPF). Map them into the four tariff rows, relabel rows through `labels` if the program names differ, and put the source and as-of date for each rate in `notes`. If a rate is ambiguous, show your reading to the user before building. Roger Lu, overhead, customs agent, and freight stay the same as the calculator.
Write `budget_input.json` (schema below) and run `build_budget.py` (Appendix A). Then recalculate and scan for errors (LibreOffice headless, or the xlsx skill's `recalc.py`). **Sale subtotal per line must equal Odoo `price_subtotal`, and the total must equal the verified PO total.** If they don't, fix the input before going further.
```json
{"project": {"name","agency","reseller","so","po","po_date","pm","salesperson","built"},
"freight_mode": "Ocean",
"budget_rates": <calc.json rates, plus "source": "Hardware Calculator.xlsx › Inputs (modified <date>)">,
"actual_rates": {"columns":["8471","8528","Steel","Other"], "as_of":"YYYY-MM-DD",
"base":[4], "s122":[4], "liberation":[4], "customs_fee":[4], "roger_lu":[4], "customs_agent":[4],
"air":[4], "express":[4], "ocean":[4], "overhead":[4], "labels":{...}, "notes":{"base":"source…"}},
"lines": [{"section","code","name","qty","unit_price","discount_pct","so_unit_cost","cost_source","warranty":bool,
"calc":{"hs_col","hs_code","factory_cost","gross_kg"} | null}]}
```
`discount_pct` is Odoo's percent (25 means 25%). `unit_price` is the pre-discount price.
What the tabs contain:
- **Summary**: sale price, projected cost, margin $ and %, forecast cost at current tariffs, actual cost to date, and remaining budget for each of the six categories. Then a one-time total (excluding recurring) and a total including Year-1 recurring.
- **Project Budget**: a rate table and line items by section with subtotals. Budget unit cost is formula-driven for calculator hardware, and the sheet compares budget vs SO cost and shows margin.
- **Actuals**: the same rows and formulas, but the tariff table holds current HS-code rates. It shows the forecast unit cost at today's tariffs. Yellow cells are for entering the actual factory cost or unit cost, vendor/bill reference, date and notes as bills arrive, and actual extended cost and variance vs budget are calculated from them.
## Step 7–10 — Folder, uploads, GChat
Start the folder copy as soon as you know the agency name. It takes about 40 Drive calls, and the uploads need its folder ids.
**Copy the folder (Drive MCP).** A local `cp -R` fails on cloud-only files ("Resource deadlock avoided"), so recreate the folder server-side:
1. Folder name: the agency's short name, matching existing folders. If that name already exists (a repeat customer), ask whether to use `<Agency> - <Project>` or nest it inside the existing folder.
2. `create_file(title=name, contentMimeType='application/vnd.google-apps.folder', parentId=<template's parentId>)`. Use `contentMimeType`, not the deprecated `mimeType`. Then walk the template breadth-first: for each child from `search_files("parentId = '<id>'", excludeContentSnippets=true)`, re-create folders the same way, and `copy_file(fileId, parentId=<new folder>, title=<same title>)` for files. Keep the new ids of `Meeting Notes` and `Financials`.
3. Compare the new folder tree with the template (count of folders and files) before moving on.
**Uploads.** The deck (2–5 MB) and the budget are too large to push through a tool call as base64, so write them to the mounted Drive folder instead:
1. Copy both files to `/mnt/user-data/outputs/`, then `device_commit_files` them to the Mac paths `…/<Project>/Meeting Notes/<Agency> Internal Kickoff Deck.pptx` and `…/<Project>/Financials/<Agency> Project Budget.xlsx`. This works right away, even before `device_bash ls` can see the new folders (it reports "Resource deadlock avoided" until Drive for Desktop hydrates them), so don't wait for the mount. Confirm they landed with `search_files("parentId = '<Meeting Notes id>' or parentId = '<Financials id>'")`, and check that no duplicate folder appeared.
2. Convert to Google-native files. If the MPM Google Workspace MCP is connected and has a convert/import tool, use it. Otherwise use Chrome: open `docs.google.com/spreadsheets/d/<xlsx id>/edit` (or `/presentation/d/<pptx id>/edit`), then click **File → Save as Google Sheets / Save as Google Slides**. The native copy opens in a new tab, lands in the same folder, and keeps its formulas. Then trash the Office original with `trash_file`, but only after the Google copy exists. Close the tabs you opened.
3. If neither route works, leave the Office files in place (Slides and Sheets open them directly) and tell the user conversion is the one step left.
**File the source documents** the user dropped in, keeping their original file names. Copy each one from the uploads to `/mnt/user-data/outputs/`, then `device_commit_files` it:
- **Work order with an SOW attached** (for example a TripSpark Work Order with "Attachment 1 – Statement of Work"), or a standalone SOW or contract → `Financials/Contract Documents/`
- **Purchase order** → `Financials/Purchase & Change Orders/`
- A work order with no SOW is filed as the PO.
- If one PDF holds both a PO and an SOW, put a copy in **both** folders: Contract Documents and Purchase & Change Orders.
- Leave the quotation where it is; it's already in Odoo.
**GChat space.**
1. If the MPM Google Workspace MCP is connected and offers Chat tools (for example, create space or add members), use them. Otherwise drive chat.google.com in Chrome: **New chat → Create a space** → type the name → **Create** → **Add members** → type the PM's email → pick the suggestion → **Add**. Name it `<Agency> – <Project> (S0xxxx)`. Existing spaces use names like "LakeTran Project Information" or "COTA Reflective Project".
2. PM of record comes from `crm.lead.x_studio_project_manager`. If it's empty, or there's no opportunity, **ask the user who the PM is**. Don't guess.
3. Post a first message: the project name, SO/PO, the folder link (the Drive `viewUrl`), the budget link, and the deck link. Pressing Enter opens a "Space members need access to these items" dialog. Keep **Share with the space – Commenter** and click **Send message**.
4. If Chrome isn't available either, give the user the space name, member list and message text to paste, and mark the step open.
## Test runs
If the user says this is a test (for example, the real kickoff already happened), still run every step, but prefix the folder, file and space names with `Skilltest-`, and start the GChat message with "[SKILL TEST]" so the PM knows to ignore it. Odoo cost write-backs still need the user's approval.
## Guardrails
- Odoo writes happen only after the user approves the change table. Never confirm, cancel or lock orders.
- Never overwrite or delete anything in the template folder. All work goes into the new copy.
- Money values the user gives go into Odoo and the sheet exactly as given. Warranty costs come from the formula.
- When something can't be done (connector missing, write refused), finish everything else and list the gaps at the end.
## Wrap-up message
Keep it short: links to the project folder, deck, budget and GChat space; the count of discrepancies by severity; the Odoo changes made; open items. Following the standing instruction, add a short **"MCP improvements"** note listing any friction you hit that a new Odoo or Workspace MCP tool would remove. Examples: no folder-copy tool, no Office-to-Google conversion, `fields_get` returning every field, no single call that returns SO lines with cost and margin.
## Appendix — bundled scripts
Write each script to the workspace **verbatim** (e.g. `scripts/<name>`) and run it with python3 (needs `openpyxl` and `python-pptx`). They're tested. Don't rewrite them from scratch; fix the input JSON instead.
### Appendix A — `build_budget.py`
Builds the three-tab budget workbook. Every cost is a live formula, blue cells are inputs, and yellow cells on Actuals are filled in as bills arrive.
```python
#!/usr/bin/env python3
"""Build the MPM project budget workbook (Summary / Project Budget / Actuals).
Usage: python3 build_budget.py budget_input.json out.xlsx
Every cost figure is a live formula; blue cells are inputs; yellow cells on Actuals get filled as bills arrive."""
import json, sys
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.utils import get_column_letter as CL
SECTIONS = ["Hardware", "MPM Professional Services", "Travel", "DBE Installation", "Warranty", "Annual Recurring"]
RATE_ROWS = [("base", "Base Tariff"), ("s122", "Section 122 Reciprocal"), ("liberation", "Liberation Tariff"),
("customs_fee", "Customs Fee"), (None, "Total Tariff"), ("roger_lu", "Roger Lu Fee"),
("customs_agent", "Customs Agent ($/unit)"), ("air", "Air"), ("express", "Express"), ("ocean", "Ocean"),
("overhead", "Overhead / Spoilage")]
R0, HDR = 4, 18 # rate-table header row, line-table header row
DARK, GOLD = "1C1D22", "D4AF37"
F_HDR = PatternFill("solid", fgColor=DARK); F_SEC = PatternFill("solid", fgColor="E7E6E6")
F_SUB = PatternFill("solid", fgColor="F2F2F2"); F_IN = PatternFill("solid", fgColor="DDEBF7")
F_ACT = PatternFill("solid", fgColor="FFF2CC"); F_TOT = PatternFill("solid", fgColor=GOLD)
WHITE_B = Font(bold=True, color="FFFFFF"); B = Font(bold=True); BLUE = Font(color="0000FF")
USD = '$#,##0.00;[Red]-$#,##0.00'; PCT = '0.00%'; THIN = Border(bottom=Side(style="thin", color="BFBFBF"))
def rate_table(ws, rates, title):
ws.cell(R0 - 1, 1, title).font = B
ws.cell(R0, 1, "Rate (by HS column)").font = WHITE_B
cols = rates["columns"]
for j, c in enumerate(cols):
ws.cell(R0, 2 + j, str(c)).font = WHITE_B
ws.cell(R0, 6, "Source / notes").font = WHITE_B
for j in range(1, 7):
ws.cell(R0, j).fill = F_HDR
for i, (key, label) in enumerate(RATE_ROWS, start=1):
r = R0 + i
ws.cell(r, 1, rates.get("labels", {}).get(key, label) if key else label)
for j in range(4):
c = ws.cell(r, 2 + j)
if key is None:
c.value = f"=SUM({CL(2+j)}{R0+1}:{CL(2+j)}{R0+4})"; c.font = B
else:
c.value = rates[key][j]; c.font = BLUE; c.fill = F_IN
c.number_format = USD if key in ("customs_agent", "air", "express", "ocean") else PCT
note = rates.get("notes", {}).get(key or "total", "")
ws.cell(r, 6, note)
fr = R0 + len(RATE_ROWS) + 1 # freight-mode row (16)
ws.cell(fr, 1, "Freight mode").font = B
c = ws.cell(fr, 2, rates.get("freight_mode", "Ocean")); c.font = BLUE; c.fill = F_IN
dv = DataValidation(type="list", formula1='"Air,Express,Ocean"', allow_blank=False); ws.add_data_validation(dv); dv.add(c)
ws.cell(fr, 6, rates.get("source", ""))
return fr
def refs():
h = f"$B${R0}:$E${R0}"
row = lambda n: f"$B${R0+n}:$E${R0+n}"
return dict(hdr=h, tariff=row(5), roger=row(6), agent=row(7), ovh=row(11),
frt_block=f"$B${R0+8}:$E${R0+10}", frt_lbl=f"$A${R0+8}:$A${R0+10}", mode=f"$B${R0+12}")
def header(ws, cols, widths):
for j, (name, w) in enumerate(zip(cols, widths), start=1):
c = ws.cell(HDR, j, name); c.font = WHITE_B; c.fill = F_HDR
c.alignment = Alignment(wrap_text=True, vertical="center"); ws.column_dimensions[CL(j)].width = w
ws.row_dimensions[HDR].height = 42; ws.freeze_panes = ws.cell(HDR + 1, 4)
def main(src, out):
d = json.load(open(src)); p = d["project"]; lines = d["lines"]
wb = Workbook(); sm = wb.active; sm.title = "Summary"
bud = wb.create_sheet("Project Budget"); act = wb.create_sheet("Actuals")
for ws, t in ((bud, "Project Budget"), (act, "Actuals")):
ws.cell(1, 1, f"{t} — {p.get('name','')}").font = Font(bold=True, size=14)
ws.cell(2, 1, f"SO {p.get('so','')} | PO {p.get('po','')} | {p.get('agency','')} | Reseller: {p.get('reseller','')}")
rate_table(bud, {**d["budget_rates"], "freight_mode": d.get("freight_mode", "Ocean")},
"Budget rates — Hardware Calculator Inputs (the rates the quote was built on)")
rate_table(act, {**d["actual_rates"], "freight_mode": d.get("freight_mode", "Ocean")},
f"Actual rates — prevailing HS-code tariffs as of {d['actual_rates'].get('as_of','')}")
rf = refs()
hs = lambda col, r: f"MATCH(${col}{r},{rf['hdr']},0)"
header(bud, ["Section", "Product Code", "Description", "Qty", "Unit Price (MSRP)", "Disc %", "Sale Subtotal",
"SO Unit Cost", "Cost Source", "HS Col", "HS Code", "Factory Cost", "Gross KG", "Tariff %",
"Roger Lu %", "Overhead %", "Customs Agent $", "Freight $/unit", "Budget Unit Cost",
"Budget Ext Cost", "SO Ext Cost", "Budget − SO", "Margin $", "Margin %"],
[16, 22, 44, 7, 14, 8, 15, 13, 24, 9, 14, 12, 9, 9, 9, 9, 11, 11, 14, 15, 15, 13, 14, 9])
header(act, ["Section", "Product Code", "Description", "Qty", "HS Col", "HS Code", "Factory Cost (budget)",
"Gross KG", "Tariff % (current)", "Roger Lu %", "Overhead %", "Customs Agent $", "Freight $/unit",
"Forecast Unit Cost @ current tariff", "Budget Unit Cost", "ACTUAL factory / unit cost (enter)",
"Actual Unit Cost", "Actual Ext Cost", "Budget Ext − Actual", "Vendor / Bill Ref", "Date", "Notes"],
[16, 22, 44, 7, 9, 14, 13, 9, 10, 9, 9, 11, 11, 15, 14, 16, 14, 15, 15, 20, 12, 30])
r = HDR + 1; sub = {}
for sec in SECTIONS:
items = [l for l in lines if l["section"] == sec]
if not items:
continue
for ws in (bud, act):
ws.cell(r, 1, sec).font = B
for j in range(1, 25 if ws is bud else 23):
ws.cell(r, j).fill = F_SEC
r += 1; first = r
for l in items:
calc = l.get("calc")
b = lambda j, v: bud.cell(r, j, v)
b(1, sec); b(2, l.get("code", "")); b(3, l.get("name", "")); b(4, l["qty"])
b(5, l["unit_price"]).number_format = USD
b(6, (l.get("discount_pct") or 0) / 100).number_format = PCT
b(7, f"=D{r}*E{r}*(1-F{r})").number_format = USD
if l.get("warranty") and not l.get("so_unit_cost"):
b(8, f"=E{r}*0.7569"); b(9, "Warranty formula (MSRP × 0.7569)")
else:
c = b(8, l.get("so_unit_cost", 0)); b(9, l.get("cost_source", "Odoo SO line"))
if l.get("cost_source", "").lower().startswith("user"):
c.font = BLUE; c.fill = F_IN
bud.cell(r, 8).number_format = USD
if calc:
b(10, str(calc["hs_col"])); b(11, calc.get("hs_code", ""))
b(12, calc["factory_cost"]).font = BLUE; b(13, calc["gross_kg"]).font = BLUE
b(14, f"=INDEX({rf['tariff']},{hs('J', r)})"); b(15, f"=INDEX({rf['roger']},{hs('J', r)})")
b(16, f"=INDEX({rf['ovh']},{hs('J', r)})"); b(17, f"=INDEX({rf['agent']},{hs('J', r)})")
b(18, f"=M{r}*INDEX({rf['frt_block']},MATCH({rf['mode']},{rf['frt_lbl']},0),{hs('J', r)})")
b(19, f"=L{r}*(1+N{r}+O{r}+P{r})+Q{r}+R{r}")
else:
b(19, f"=H{r}")
b(20, f"=D{r}*S{r}"); b(21, f"=D{r}*H{r}"); b(22, f"=T{r}-U{r}"); b(23, f"=G{r}-T{r}")
b(24, f'=IF(G{r}=0,"",W{r}/G{r})')
for j, fmt in ((12, USD), (14, PCT), (15, PCT), (16, PCT), (17, USD), (18, USD), (19, USD), (20, USD),
(21, USD), (22, USD), (23, USD), (24, PCT)):
bud.cell(r, j).number_format = fmt
# ---- Actuals row (same row number) ----
a = lambda j, v: act.cell(r, j, v)
BP = "'Project Budget'!"
a(1, sec); a(2, f"={BP}B{r}"); a(3, f"={BP}C{r}"); a(4, f"={BP}D{r}")
a(15, f"={BP}S{r}")
if calc:
a(5, f"={BP}J{r}"); a(6, f"={BP}K{r}"); a(7, f"={BP}L{r}"); a(8, f"={BP}M{r}")
a(9, f"=INDEX({rf['tariff']},{hs('E', r)})"); a(10, f"=INDEX({rf['roger']},{hs('E', r)})")
a(11, f"=INDEX({rf['ovh']},{hs('E', r)})"); a(12, f"=INDEX({rf['agent']},{hs('E', r)})")
a(13, f"=H{r}*INDEX({rf['frt_block']},MATCH({rf['mode']},{rf['frt_lbl']},0),{hs('E', r)})")
a(14, f"=G{r}*(1+I{r}+J{r}+K{r})+L{r}+M{r}")
a(17, f'=IF(P{r}="","",P{r}*(1+I{r}+J{r}+K{r})+L{r}+M{r})')
else:
a(14, f"=O{r}"); a(17, f'=IF(P{r}="","",P{r})')
act.cell(r, 16).fill = F_ACT; act.cell(r, 20).fill = F_ACT; act.cell(r, 21).fill = F_ACT
act.cell(r, 22).fill = F_ACT
a(18, f'=IF(Q{r}="","",Q{r}*D{r})'); a(19, f'=IF(R{r}="","",{BP}T{r}-R{r})')
for j, fmt in ((7, USD), (9, PCT), (10, PCT), (11, PCT), (12, USD), (13, USD), (14, USD), (15, USD),
(16, USD), (17, USD), (18, USD), (19, USD)):
act.cell(r, j).number_format = fmt
for ws in (bud, act):
ws.cell(r, 3).alignment = Alignment(wrap_text=True, vertical="top")
for j in range(1, 25): ws.cell(r, j).border = THIN
r += 1
last = r - 1
# subtotal rows
bud.cell(r, 1, f"{sec} subtotal").font = B
for j in (7, 20, 21, 22, 23):
c = bud.cell(r, j, f"=SUM({CL(j)}{first}:{CL(j)}{last})"); c.font = B; c.number_format = USD
c = bud.cell(r, 24, f'=IF(G{r}=0,"",W{r}/G{r})'); c.font = B; c.number_format = PCT
act.cell(r, 1, f"{sec} subtotal").font = B
c = act.cell(r, 14, f"=SUMPRODUCT(D{first}:D{last},N{first}:N{last})"); c.font = B; c.number_format = USD
c = act.cell(r, 15, f"=SUMPRODUCT(D{first}:D{last},O{first}:O{last})"); c.font = B; c.number_format = USD
for j in (18, 19):
c = act.cell(r, j, f"=SUM({CL(j)}{first}:{CL(j)}{last})"); c.font = B; c.number_format = USD
for ws in (bud, act):
for j in range(1, 25 if ws is bud else 23): ws.cell(r, j).fill = F_SUB
sub[sec] = r; r += 2
# ---------------- Summary ----------------
sm.cell(1, 1, f"Project Financial Summary — {p.get('name','')}").font = Font(bold=True, size=16)
info = [("End-customer agency", p.get("agency")), ("Reseller / customer", p.get("reseller")),
("Sales Order", p.get("so")), ("Customer PO", p.get("po")), ("PO date", p.get("po_date")),
("Project Manager", p.get("pm")), ("Salesperson", p.get("salesperson")), ("Budget built", p.get("built"))]
for i, (k, v) in enumerate(info, start=3):
sm.cell(i, 1, k).font = B; sm.cell(i, 2, v or "")
h = 12
cols = ["Category", "Sale Price", "Projected Cost", "Projected Margin $", "Projected Margin %",
"Forecast Cost @ Current Tariffs", "Actual Cost to Date", "Remaining (Projected − Actual)", "% of Budget Spent"]
for j, n in enumerate(cols, start=1):
c = sm.cell(h, j, n); c.font = WHITE_B; c.fill = F_HDR; c.alignment = Alignment(wrap_text=True)
sm.column_dimensions[CL(j)].width = 28 if j == 1 else 18
sm.row_dimensions[h].height = 36
BP, AC = "'Project Budget'!", "Actuals!"
r = h + 1; rows = {}
for sec in SECTIONS:
sm.cell(r, 1, sec)
s = sub.get(sec)
vals = [f"={BP}G{s}", f"={BP}T{s}", f"={AC}N{s}", f"={AC}R{s}"] if s else [0, 0, 0, 0]
sm.cell(r, 2, vals[0]); sm.cell(r, 3, vals[1]); sm.cell(r, 6, vals[2]); sm.cell(r, 7, vals[3])
rows[sec] = r; r += 1
def fill_derived(rr):
sm.cell(rr, 4, f"=B{rr}-C{rr}"); sm.cell(rr, 5, f'=IF(B{rr}=0,"",D{rr}/B{rr})')
sm.cell(rr, 8, f"=C{rr}-G{rr}"); sm.cell(rr, 9, f'=IF(C{rr}=0,"",G{rr}/C{rr})')
for j in range(2, 10):
sm.cell(rr, j).number_format = PCT if j in (5, 9) else USD
for rr in rows.values():
fill_derived(rr)
one_time = [rows[s] for s in SECTIONS if s != "Annual Recurring"]
for label, rng, fill in (("One-time project total (excl. recurring)", one_time, F_SUB),
("Total incl. Year-1 annual recurring", list(rows.values()), F_TOT)):
sm.cell(r, 1, label).font = B
for j in (2, 3, 6, 7):
sm.cell(r, j, "=" + "+".join(f"{CL(j)}{x}" for x in rng)).font = B
fill_derived(r)
for j in range(1, 10): sm.cell(r, j).fill = fill
r += 1
sm.cell(r + 1, 1, "Projected = Hardware Calculator rates the quote was built on. Forecast = same formulas at today's "
"HS-code tariffs (Actuals rate table). Actual = costs entered on the Actuals tab as bills arrive.").font = Font(italic=True, color="595959")
sm.freeze_panes = "B13"
wb.save(out); print("saved", out, "| section subtotal rows:", sub)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
```
### Appendix B — `build_deck.py`
Builds the internal kickoff deck from the Kickoff Deck.pptx template. Input: `{"agency", "subtitle", "slides":[{"title":"line1\nline2", "blocks":[{"heading","text"} | {"heading","bullets":[...]} | {"heading","table":{"columns":[...],"rows":[[...]]}}]}]}`. Text and tables share a slide while they fit; long content continues onto "(cont.)" slides automatically.
```python
#!/usr/bin/env python3
"""Build the MPM internal kickoff deck from the Kickoff Deck.pptx template look.
Usage: python3 build_deck.py "Kickoff Deck.pptx" deck_input.json out.pptx
Template slide 1 = cover, slide 2 = content master (dark left title panel + right body). All other template
slides are dropped. Each JSON slide becomes one or more content slides (long tables/text auto-continue)."""
import copy, json, sys
from pptx import Presentation
from pptx.util import Emu, Pt
from pptx.dml.color import RGBColor
DARK, GOLD, GREY = RGBColor(0x1C, 0x1D, 0x22), RGBColor(0xD4, 0xAF, 0x37), RGBColor(0xF2, 0xF2, 0xF2)
BODY_L, BODY_T, BODY_W, BODY_H = 9596190, 1660731, 13594280, 11700000
CHARS_PER_LINE = 60
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
def dup_slide(prs, src):
new = prs.slides.add_slide(src.slide_layout)
for shp in list(new.shapes):
shp._element.getparent().remove(shp._element)
for el in src.shapes._spTree.iterchildren():
if el.tag.endswith(("}nvGrpSpPr", "}grpSpPr")):
continue
new.shapes._spTree.append(copy.deepcopy(el))
for node in new.shapes._spTree.iter(): # re-point image relationships
for attr in ("embed", "link", "id"):
rid = node.get(R_NS + attr)
if rid and rid in src.part.rels:
rel = src.part.rels[rid]
if not rel.is_external:
node.set(R_NS + attr, new.part.relate_to(rel.target_part, rel.reltype))
return new
def drop_slide(prs, slide):
lst = prs.slides._sldIdLst
for sid in list(lst):
if prs.part.related_part(sid.rId) is slide.part:
prs.part.drop_rel(sid.rId); lst.remove(sid); return
def shape(slide, name, nth=0):
"""Find a template shape by name; fall back to the nth text box if the template was edited."""
hit = [s for s in slide.shapes if s.name == name]
return hit[0] if hit else [s for s in slide.shapes if s.has_text_frame and s.text_frame.text.strip()][nth]
def set_lines(tf, lines):
"""Replace text keeping the first run's formatting."""
p0 = tf.paragraphs[0]; r0 = copy.deepcopy(p0.runs[0]._r) if p0.runs else None
for p in tf.paragraphs[1:]:
p._p.getparent().remove(p._p)
for r in list(p0.runs):
r._r.getparent().remove(r._r)
for i, line in enumerate(lines):
p = p0 if i == 0 else tf.add_paragraph()
if i:
p._p.insert(0, copy.deepcopy(p0._p.pPr)) if p0._p.pPr is not None else None
if r0 is not None:
rr = copy.deepcopy(r0); p._p.append(rr); p.runs[-1].text = line
else:
p.text = line
def est_lines(block):
n = 0
if block.get("heading"): n += 1.3
for t in ([block["text"]] if block.get("text") else []) + block.get("bullets", []):
n += max(1, -(-len(t) // CHARS_PER_LINE))
return n + 0.6
def write_body(tb, blocks):
tf = tb.text_frame; tf.word_wrap = True
head_r = copy.deepcopy(tf.paragraphs[0].runs[0]._r) # Playfair heading run
body_r = copy.deepcopy(next(r for p in tf.paragraphs[1:] for r in p.runs)._r) # Karla body run
for p in tf.paragraphs[1:]:
p._p.getparent().remove(p._p)
p0 = tf.paragraphs[0]
for r in list(p0.runs):
r._r.getparent().remove(r._r)
first = [True]
def para(run_xml, text, size):
p = p0 if first[0] else tf.add_paragraph(); first[0] = False
r = copy.deepcopy(run_xml); p._p.append(r); p.runs[-1].text = text; p.runs[-1].font.size = Pt(size)
for b in blocks:
if b.get("heading"): para(head_r, b["heading"].upper(), 36)
if b.get("text"):
for line in b["text"].split("\n"): para(body_r, line, 30)
for t in b.get("bullets", []): para(body_r, "• " + t, 30)
para(body_r, "", 18)
def table_rows_emu(cols, rows):
"""Estimated height of each row, allowing for text wrapping in narrow columns."""
per_col = max(6, int(92 / max(1, len(cols)))) # usable chars per column at 20pt, after word-wrap loss
return [max(1, max(-(-len(str(v or "")) // per_col) for v in r)) * 310000 + 160000 for r in [cols] + rows]
def table_emu(b, with_heading=True):
return (950000 if with_heading and b.get("heading") else 0) + sum(table_rows_emu(b["table"]["columns"], b["table"]["rows"])) + 450000
def add_table(slide, heading, cols, rows, top):
"""Draw a table at `top`; return the y position just below it."""
if heading:
tb = slide.shapes.add_textbox(BODY_L, top, BODY_W, Emu(700000))
r = tb.text_frame.paragraphs[0].add_run(); r.text = heading.upper()
r.font.size, r.font.bold, r.font.underline, r.font.name, r.font.color.rgb = Pt(36), True, True, "Playfair Display", DARK
top += 950000
heights = table_rows_emu(cols, rows)
shp = slide.shapes.add_table(len(rows) + 1, len(cols), BODY_L, top, BODY_W, Emu(sum(heights)))
t = shp.table
for i, hgt in enumerate(heights):
t.rows[i].height = Emu(hgt)
for j, c in enumerate(cols):
cell = t.cell(0, j); cell.text = str(c); cell.fill.solid(); cell.fill.fore_color.rgb = DARK
for i, row in enumerate(rows, start=1):
for j, v in enumerate(row):
cell = t.cell(i, j); cell.text = "" if v is None else str(v)
cell.fill.solid(); cell.fill.fore_color.rgb = GREY if i % 2 else RGBColor(0xFF, 0xFF, 0xFF)
for i, row in enumerate(t.rows):
for cell in row.cells:
for p in cell.text_frame.paragraphs:
for r in p.runs:
r.font.size = Pt(20); r.font.name = "Karla"
r.font.bold = i == 0
r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) if i == 0 else DARK
if i and str(r.text).upper() in ("HIGH", "CRITICAL"): r.font.color.rgb = RGBColor(0xC0, 0x00, 0x00)
return top + sum(heights) + 450000
def paginate(spec):
"""Split one JSON slide into pages that fit. Text and tables share a page while they fit;
text never follows a table on the same page (the body text box sits at the top)."""
pages, cur, used, has_table = [], [], 0, False
for b in spec.get("blocks", []):
if "table" in b:
rows = b["table"]["rows"] or [["—"] * len(b["table"]["columns"])]
chunk, k = [], 0
while k < len(rows): # split long tables by height, not a fixed row count
chunk = [rows[k]]; k += 1
while k < len(rows) and table_emu({**b, "table": {**b["table"], "rows": chunk + [rows[k]]}}) <= BODY_H:
chunk.append(rows[k]); k += 1
part = {**b, "table": {**b["table"], "rows": chunk}}
need = table_emu(part)
if cur and used + need > BODY_H:
pages.append(cur); cur, used = [], 0
cur.append(part); used += need; has_table = True
continue
need = est_lines(b) * 560000
if cur and (has_table or used + need > BODY_H):
pages.append(cur); cur, used, has_table = [], 0, False
cur.append(b); used += need
if cur: pages.append(cur)
return pages or [[]]
def main(template, src, out):
d = json.load(open(src)); prs = Presentation(template)
cover, master = prs.slides[0], prs.slides[1]
originals = list(prs.slides)[1:]
set_lines(shape(cover, "Neoride…").text_frame, [d["agency"]])
tf = shape(cover, "Neoride…").text_frame
sub = tf.add_paragraph(); r = sub.add_run(); r.text = d.get("subtitle", "Internal Project Kickoff")
r.font.size = Pt(40); r.font.color.rgb = GOLD
for spec in d["slides"]:
pages = paginate(spec)
for k, blocks in enumerate(pages):
s = dup_slide(prs, master)
title = spec["title"].split("\n") + (["(cont.)"] if k else [])
set_lines(shape(s, "Transitpoint Software and Services").text_frame, title)
body = shape(s, "TextBox 6", 1)
text_blocks = [b for b in blocks if "table" not in b]
if text_blocks:
write_body(body, text_blocks)
else:
body._element.getparent().remove(body._element)
top = BODY_T + (Emu(int(sum(est_lines(b) for b in text_blocks) * 560000)) if text_blocks else 0)
for b in blocks:
if "table" in b:
top = add_table(s, b.get("heading", ""), b["table"]["columns"], b["table"]["rows"], top)
for s in originals:
drop_slide(prs, s)
prs.save(out); print("saved", out, "slides:", len(prs.slides))
if __name__ == "__main__":
main(*sys.argv[1:4])
```
### Appendix C — `read_calculator.py`
Pulls budget rates and per-SKU factory cost, gross KG and HS column out of Hardware Calculator.xlsx into calc.json.
```python
#!/usr/bin/env python3
"""Pull budget rates + per-SKU factory cost / gross KG / HS column out of Hardware Calculator.xlsx.
Usage: python3 read_calculator.py "Hardware Calculator.xlsx" calc.json"""
import json, sys
from openpyxl import load_workbook
wb = load_workbook(sys.argv[1], data_only=False)
inp = wb["Inputs"]
cols = [str(inp.cell(1, c).value).replace(".0", "") for c in range(2, 6)] # 8471, 8528, Steel, Other HS Code
rowmap = {"base": 2, "s122": 3, "liberation": 4, "customs_fee": 5, "roger_lu": 8, "customs_agent": 9,
"air": 11, "express": 12, "ocean": 13, "overhead": 15}
rates = {k: [float(inp.cell(r, c).value or 0) for c in range(2, 6)] for k, r in rowmap.items()}
rates["columns"] = ["8471", "8528", "Steel", "Other"]
rates["labels"] = {k: inp.cell(r, 1).value for k, r in rowmap.items() if k in ("base", "s122", "liberation", "customs_fee")}
rates["reseller_b_margin"] = inp["B17"].value; rates["msrp_margin"] = inp["B18"].value
col_of = lambda hs: {8471: "8471", 8528: "8528"}.get(int(float(hs)) if str(hs).replace(".", "").isdigit() else 0,
"Steel" if str(hs).lower().startswith("steel") else "Other")
skus = {}
pe = wb["Pricing Engine"]
for r in range(2, pe.max_row + 1):
code = pe.cell(r, 2).value
if code:
skus[code.strip().upper()] = {"name": pe.cell(r, 3).value, "category": pe.cell(r, 4).value,
"hs_col": col_of(pe.cell(r, 5).value or 0), "hs_code": pe.cell(r, 6).value,
"factory_cost": pe.cell(r, 8).value, "gross_kg": pe.cell(r, 9).value,
"source": f"Pricing Engine row {r}"}
models = {}
md = wb["Models"]
for r in range(2, md.max_row + 1):
m = md.cell(r, 1).value
if m:
models[str(m).strip().upper()] = {"hs_col": col_of(md.cell(r, 2).value or "other"), "gross_kg": md.cell(r, 3).value,
"factory_cost": md.cell(r, 4).value, "source": f"Models row {r}"}
json.dump({"rates": rates, "skus": skus, "models": models}, open(sys.argv[2], "w"), indent=1, default=str)
print(f"rates for {cols}; {len(skus)} SKUs ({sum(1 for s in skus.values() if s['factory_cost'])} with factory cost); {len(models)} models")
```