Proposal - deterministic by-scope PDF¶
| Stage | Building |
|---|---|
| Status | Partial |
| Design status | In progress |
| Spec status | In review |
| Estimate | M-L |
| Confidence | Low |
| Linear | PIN-1073 ↗ PIN-134 ↗ PIN-838 ↗ PIN-853 ↗ |
| Linear status | Done · +2 queued |
| Cycle | C14 |
| Design | Design ↗ |
| Linear epic | Bid Intake & Pipeline |
| Module | Bids ↗ |
Route: /bids/:bidId (Proposal drawer on the estimate)
Scope¶
A deterministic, non-AI proposal PDF generated from a completed estimate: Pinley-branded template, pricing rolled up by scope only (not per line item), rendered from a frozen, versioned ProposalRevision snapshot. The renderer is pure-Go fpdf — no LLM in the generation path, no Chromium. Proposals are versioned (v1, v2, …), persisted as a Document linked to the bid, and can be emailed to the GC contact(s) from the module. The forward work is (1) the official Pinley branding template and (2) correct mapping of Alternates and Clarifications into the layout. (Source: PIN-134, PIN-838, PIN-853.)
Acceptance criteria¶
Happy path:
- "Generate Proposal" in the estimate's Proposal drawer, enabled for the base-bid path (not gated on an existing revision).
- Official Pinley-branded template (logo, address, contact from AppSetting KV); pricing grouped by CSI scope only, not per line item.
- Auto-populated from the estimate snapshot: scope summary, by-scope (CSI-division) totals with markup applied, grand total, inclusions/exclusions (from the Clarifications tab toggles), alternates.
- Editable cover letter, terms & conditions, validity period, payment terms.
- Renders from a frozen
ProposalRevision— no live re-read of the estimate after the revision is created. - Export to PDF, persisted as
File+BidDocument(folder=PROPOSAL=8); DB never holds PDF bytes. - Send to GC contact email(s) directly from the module via the app's email pipeline.
Negative / error:
- Concurrent generate calls serialize on the
Proposal.BidIdunique index and the(ProposalId, Version)composite; a duplicate-key retries once then surfacesCodeAlreadyExists. - Generating with a $0 contract shows a warning (unselected vendor rows) but lets the user proceed ("Generate anyway").
Validation rules:
doGenerate()awaitsflushEstimate()beforeAddProposalRevisionso debounced estimate edits (markup, tax) are captured — never a stale snapshot (PIN-834).- Markup stored as exact float64; the Grand Total is the sum of marked-up CSI group values from
snapshotCsiGroups, never re-summed raw subtotals (PIN-631/833). ProposalRevisionis append-only: onlyis_activeand the one-timefile_idstamp change after insert.
Walkthrough scenario(s)¶
Happy path.
- Estimator finishes the estimate (vendors selected, markup set) and opens the Proposal drawer.
- Clicks Generate; the client flushes pending estimate edits, then calls
AddProposalRevision, which freezes section/CSI totals + toggled clarifications into a new revision. - The server renders the PDF via
internal/proposalpdf(Scope of Work → Pricing Summary → Inclusions/Exclusions → Cover Letter → Terms → footer), persists it as a File + BidDocument, and returns a link. - Estimator reviews, edits the cover letter, regenerates v2, and emails it to the GC contact.
Exception.
- Estimator changed markup and immediately clicked Generate.
- The awaited flush writes the new markup first, so the frozen
snapshotMarkupPercentand the PDF total are correct (not the pre-change value).
Edge cases¶
- Alternates / Clarifications mapping — the current gap: alternates and clarifications must render in the right sections; this is Conor's explicit ask and the main C14 work beyond the template.
SnapshotContractvs section subtotals — derive the grand total from the sum of live (then frozen) marked-up section subtotals, notactiveER.SnapshotContract(frozen earlier), so the PDF total matches the breakdown.- Freshly-created revision — "estimate empty" check uses live section totals / vendor quotes, not
snapshotContractor DBsubtotal, to avoid false "$0 contract" warnings. - Multi-GC — a per-GC proposal swaps price/markup/GC-specific clarifications and the GC contact/signature block.
Flow¶
stateDiagram-v2
[*] --> Estimate
Estimate --> Flushed: doGenerate awaits flushEstimate (PIN-834)
Flushed --> RevisionFrozen: AddProposalRevision snapshots CSI totals + clarifications
RevisionFrozen --> Rendered: proposalpdf (pure-Go fpdf, no LLM)
Rendered --> Persisted: File + BidDocument(folder=PROPOSAL=8)
Persisted --> Sent: email to GC contact(s)
Sent --> [*]
RevisionFrozen --> RevisionFrozen: v2, v3 … (append-only)
Backend / API notes¶
- Renderer:
apps/api/internal/proposalpdf(NewRenderer(), pure-Go fpdf; injectFakeRendererin tests). Section order fixed per PIN-610 (no forced blank page before the cover letter). - Snapshot:
AddProposalRevisionfreezesEstimateRevisiontotals +SnapshotMarkupPercent;snapshotCsiGroups(markupPercent)applies markup per CSI division;GenerateProposalreads only snapshot fields. - Concurrency:
Proposal.BidIdunique +ProposalRevision (ProposalId, Version)composite unique;FindOrCreateByBidre-reads on duplicate;AddProposalRevisionrecomputes max(version)+1, retries once. - Branding: company_name / logo_file_id / address / phone / email from AppSetting KV (not Organization).
- Frontend:
ProposalDrawerneedssections+flushEstimateprops; pre-generate guardfindUnselectedRowswarns on scope lines with quotes but no selection (non-blocking).
Email / notification flow¶
- Trigger: estimator sends the generated proposal from the module.
- Template intent: cover email to the GC contact(s) with the proposal PDF attached/linked; sent via the app's rmq → SendGrid pipeline.
AI integration¶
- None in the generation path — PIN-134 is a deterministic templater. This is the staleness correction: the prototype's "AI-generated scope narrative" and browser
print()-to-PDF are not the shipped design; the server-side fpdf renderer from a frozen snapshot is.
Conor's comments¶
The proposal needs to be formatted and scoped correctly to the Pinley branding templates. Alternates, clarifcations, etc. all need to be mapped correctly. The AI deterministic scope can follow.
Open questions for Conor¶
- Official Pinley Branding Center HTML template — Conor to send; blocks final layout.
[design] - Confirm by-scope only (CSI-division rollup, no per-line-item breakdown) is the v1 layout.
[scope] - Confirm alternates + clarifications placement in the template.