Status: π‘ Live price book, grown not imported, with two dormant edges. The
catalog is π’ live against real data: items and assemblies persist, the price
book re-prices every proposal/estimate line it can match, and the AI takeoff
matcher (Gemini) maps takeoff items to catalog entries or proposes new ones. But
there is β no bulk importer β the book grows from your own estimate history
(catalog.py:21) β thetimes_usedpopularity signal is βͺ never incremented in
product (bump()atcatalog.py:47is dead), and thevendor_idlink on a
catalog item is βͺ dormant (no route ever sets it).
Audience: engineers + finance-literate operators. This is the deep reference for
GoBuild's reusable pricing layer β what lives in the catalog, how it fills up, and how
it feeds line pricing across the money model. Legend: π’ live Β· π‘ WIP / config-gated Β·
βͺ dormant / stub Β· β not built.
House terms: a unit of work is a Job (jobs); the person you build for is a
Client (clients). Never "Project", never "Customer".
Cross-links: Job costing & the financial loop Β·
Proposals & estimates Β·
Procurement β POs & vendors.
The cost catalog (a.k.a. price book) is an org-scoped library of reusable
priced items. Each row is a saved cost line β a material, a labor rate, a sub scope β
that the contractor drops into estimates so pricing stays consistent and data entry is
fast (models/catalog.py:1).
A CatalogItem (catalog.py:16) carries:
| Field | Meaning | Notes |
|---|---|---|
name |
Item description | The primary match key, lower-cased/stripped |
category |
material / labor / subcontractor / equipment / permit / other | Built-in CostCategory set, plus org-custom categories |
unit |
sf, lf, ea, hr, ls, cy⦠| Optional |
unit_cost |
The contractor's cost per unit (pre-markup) | This is the number the book resolves lines to |
markup_pct |
Default markup for this item | Optional |
cost_code |
Free-text cost code string | See Β§5 β free-text by design |
trade |
Trade label | Optional; feeds AI code suggestions |
vendor_id |
FK to a vendor | βͺ Dormant β no route populates it |
times_used |
Popularity counter | βͺ Never incremented β see Β§6 |
Everything is org-scoped via TenantMixin; the catalog page (catalog.py:85) and all
mutations require pm or office role (catalog.py:21).
An Assembly (catalog.py:30) is a named group of items β e.g. "Bathroom
rough-in" β stored as a JSON list of line dicts ({description, category, unit, unit_cost, markup_pct, quantity}). Assemblies are created from an estimate section
(money.py:497, "save-assembly") and expanded back into estimate lines grouped
under the assembly name (money.py:520, "insert-assembly"). They are the "kit" layer
on top of individual catalog items.
There is β no bulk / CSV / spreadsheet importer. This is the defining design
choice: the catalog is meant to be a byproduct of estimating, not a data-entry
chore. There are exactly four ways items land in it, and none of them is an upload.
Manual add (catalog.py:115, POST /portal/catalog). One item at a time from
the catalog page form.
Seed from history π’ (catalog.py:179 β seed_from_history at
catalog.py:21). The headline mechanism. It walks every past EstimateLine for
the org, and for each line with a real cost (unit_cost > 0) that isn't already in
the book, mints a CatalogItem copying description β name, plus category, unit,
cost, markup, and cost code (catalog.py:29-41). Dedupe key is
(name.lower(), category) (catalog.py:32), so re-running Seed is safe β it only
adds what's new. Returns the count created.
Save one line to catalog π’ (catalog.py:265, POST /estimate-lines/{line_id}/to-catalog). The "keep this one" button on a single
estimate line.
Accepted AI takeoff proposals π’ (see Β§4) β but only lines the user explicitly
ticks add_to_catalog with a cost > 0 (documents.py:1271).
Because the book is grown from real work, a brand-new org starts with an empty
catalog and fills it as it estimates. The competitive wedge lives in the cost-code
library, not the price book β new orgs get a deep CSI-lite default chart of codes
(costcodes.py:20), but $ pricing is always the contractor's own.
Because Seed and Auto-code are bulk operations, an org can lock the catalog
read-only (catalog.py:192 lock / catalog.py:203 unlock; flag lives in
org.settings['catalog_locked'], catalog.py:66). When locked, every mutation route β
add, edit, delete, seed, suggest-code, auto-code, line-to-catalog β early-returns
without writing (catalog.py:122, :144, :163, :186, :220, :243, :276).
This guards a hand-tuned price book against an accidental Seed wiping it (catalog.py:194).
The catalog is the source of truth for line pricing, with a graceful fallback for
one-off lines (catalog.py:53). The resolver is resolve_lines (catalog.py:89):
price_index (catalog.py:56): by costitems_for orders by times_used desc then namecatalog.py:14), the intent is "most-used item wins" β see the Β§6 caveat.resolve_line (catalog.py:70) matches cost code first, thenunit_price with the catalog item's unit_costcatalog.py:81) and backfills unit / cost_codecatalog.py:78). That is the deliberateresolve_lines is a no-op when the line list or the catalog is empty
(catalog.py:95), so callers wrap line creation unconditionally. It is wired into the
proposal / estimate path in three places (sales.py:928, sales.py:1507,
sales.py:1536) β proposal build and estimate line saves. This is what keeps a
contractor's numbers consistent across quotes.
Scope note: the resolver re-prices estimate/proposal lines. PO lines commit
actual costs and follow the procurement path; catalog pricing informs them upstream
(via the estimate β budget flow in Job costing) rather than
re-resolving at PO time.
takeoff_pricing.price() (takeoff_pricing.py:144) turns confirmed takeoff items
(label + quantity + unit) into priced estimate lines by matching each against the
catalog. Two-tier:
takeoff_pricing.py:160). Source tag catalog.takeoff_pricing.py:104), which for each unmatchedsource: ai-matchtakeoff_pricing.py:126), orsource: ai-new (takeoff_pricing.py:129). The prompt forbids a $0 proposaltakeoff_pricing.py:96).Fails soft: no Gemini configured or empty catalog β lines come back unpriced
(unit_cost 0) with a keyword-guessed cost code (takeoff_pricing.py:73), never an
error. Catalog prompt is capped at 250 items to bound token size
(takeoff_pricing.py:28).
Nothing is written by the pricer itself (takeoff_pricing.py:6). The AI's ai-new
proposal only becomes a real CatalogItem when the user reviews the plan and ticks
add_to_catalog on that row, and only if its cost > 0 (documents.py:1271). So an
ai-new line prices the current estimate whether or not it's saved to the book β
adding it to the catalog is a separate, human-confirmed step. This is the guardrail
against AI guesses silently polluting the price book (but see the open questions β the
guardrail is per-line, not per-value, and there's no post-hoc review queue).
A deliberate owner decision: cost codes are free-text strings, not a foreign key
into the code library. CatalogItem.cost_code is a plain String(64)
(catalog.py:24), and the resolver matches on the string, lower-cased
(catalog.py:63). The catalog does not enforce that a code exists in the org's
cost-code library.
This means the price book is a suggestion list, not a constraint. GoBuild ships
AI cost-code assistance to keep it tidy without locking it down:
catalog.py:136, suggest_code_for_item).catalog.py:156); library match where possible, new code where nothing fits.The owner keeps full editorial control: any string is legal, the AI only suggests.
The tradeoff is drift β see the open questions on hygiene below.
times_used is never incremented in product. bump() (catalog.py:47) existsinsert-assembly (money.py:520)Assembly.times_used. So both counters sit at their default 0catalog.py:27, catalog.py:36). Consequence: the ORDER BY times_used DESCitems_for (catalog.py:17) and the assembly list (catalog.py:93) collapseprice_indexcatalog.py:58) is effectively arbitrary-but-stable, not popularity-ranked.vendor_id on a catalog item is dormant. The column exists (catalog.py:25) butcatalog.py:116, :213) or seed path ever sets it, so catalog| Route | Method | Does | Status |
|---|---|---|---|
/portal/catalog |
GET | Catalog page, grouped by category (catalog.py:85) |
π’ |
/portal/catalog |
POST | Add one item (catalog.py:115) |
π’ |
/portal/catalog/{id} |
POST | Edit item (catalog.py:212) |
π’ |
/portal/catalog/{id}/delete |
POST | Delete item (catalog.py:237) |
π’ |
/portal/catalog/seed |
POST | Grow book from estimate history (catalog.py:179) |
π’ |
/portal/catalog/suggest-code |
POST | AI code for one item (catalog.py:136) |
π‘ (Gemini) |
/portal/catalog/auto-code-all |
POST | Bulk AI-code uncoded items (catalog.py:156) |
π‘ (Gemini) |
/portal/catalog/lock Β· /unlock |
POST | Toggle read-only (catalog.py:192,:203) |
π’ |
/portal/estimate-lines/{id}/to-catalog |
POST | Save one line to book (catalog.py:265) |
π’ |
/portal/assemblies/{id}/delete |
POST | Delete assembly (catalog.py:251) |
π’ |
/portal/estimates/{id}/save-assembly |
POST | Section β assembly (money.py:497) |
π’ |
/portal/estimates/{id}/insert-assembly |
POST | Assembly β lines (money.py:520) |
π’ |
| Takeoff auto-price | POST | Gemini price plan (documents.py:1225) |
π‘ (Gemini) |
Source of truth: app/routers/catalog.py, app/services/catalog.py,
app/models/catalog.py, app/services/takeoff_pricing.py,
app/routers/documents.py:1225. Grown-not-imported design anchored at
catalog.py:21.