Skip to content

Proposal - deterministic by-scope PDF

StageBuilding
StatusPartial
Design statusIn progress
Spec statusIn review
EstimateM-L
ConfidenceLow
LinearPIN-1073 ↗ PIN-134 ↗ PIN-838 ↗ PIN-853 ↗
Linear statusDone · +2 queued
CycleC14
DesignDesign ↗
Linear epicBid Intake & Pipeline
ModuleBids ↗

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.BidId unique index and the (ProposalId, Version) composite; a duplicate-key retries once then surfaces CodeAlreadyExists.
  • Generating with a $0 contract shows a warning (unselected vendor rows) but lets the user proceed ("Generate anyway").

Validation rules:

  • doGenerate() awaits flushEstimate() before AddProposalRevision so 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).
  • ProposalRevision is append-only: only is_active and the one-time file_id stamp change after insert.

Walkthrough scenario(s)

Happy path.

  1. Estimator finishes the estimate (vendors selected, markup set) and opens the Proposal drawer.
  2. Clicks Generate; the client flushes pending estimate edits, then calls AddProposalRevision, which freezes section/CSI totals + toggled clarifications into a new revision.
  3. 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.
  4. Estimator reviews, edits the cover letter, regenerates v2, and emails it to the GC contact.

Exception.

  1. Estimator changed markup and immediately clicked Generate.
  2. The awaited flush writes the new markup first, so the frozen snapshotMarkupPercent and 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.
  • SnapshotContract vs section subtotals — derive the grand total from the sum of live (then frozen) marked-up section subtotals, not activeER.SnapshotContract (frozen earlier), so the PDF total matches the breakdown.
  • Freshly-created revision — "estimate empty" check uses live section totals / vendor quotes, not snapshotContract or DB subtotal, 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; inject FakeRenderer in tests). Section order fixed per PIN-610 (no forced blank page before the cover letter).
  • Snapshot: AddProposalRevision freezes EstimateRevision totals + SnapshotMarkupPercent; snapshotCsiGroups(markupPercent) applies markup per CSI division; GenerateProposal reads only snapshot fields.
  • Concurrency: Proposal.BidId unique + ProposalRevision (ProposalId, Version) composite unique; FindOrCreateByBid re-reads on duplicate; AddProposalRevision recomputes max(version)+1, retries once.
  • Branding: company_name / logo_file_id / address / phone / email from AppSetting KV (not Organization).
  • Frontend: ProposalDrawer needs sections + flushEstimate props; pre-generate guard findUnselectedRows warns 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 pathPIN-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.