Status: π’ Live against real data, no external dependency. The whole loop β
open a bid request, share a public link, collect itemized bids with attachments,
compare, and award straight into a draft Purchase Order β is real Hub code
running on real tables (bid_requests,bids,bid_lines). The only π‘ pieces
are downstream: e-signing the awarded PO runs through Documenso (config-gated,
see Procurement), and inviting bidders is a
manual link-share β there is no built-in "email these subs the RFQ" blast.
Audience: staff (PM / office) + Sub/Vendor. This is the reference for how a
Job gets competitive pricing before committing spend. Companion deep-dives:
Portals (the token-only public surfaces) and
Procurement (what happens to the awarded PO).
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). A Sub is your field crew; a Vendor is a
materials/pricing counterparty β the distinction matters here.
Three tables, one flow (app/models/bid.py):
| Table | Model | Role |
|---|---|---|
bid_requests |
BidRequest (bid.py:16) |
The RFQ β one bid package on a Job (a trade, a scope). Holds the shareable public_token. |
bids |
Bid (bid.py:41) |
One submitted bid against a request β company, amount, notes, attachments. |
bid_lines |
BidLine (bid.py:79) |
Optional itemized lines on a bid; become PO lines on award. |
A BidRequest has many Bids, ordered cheapest-first (Bid.amount_cents,
bid.py:35). The winner is pointed to by awarded_bid_id β deliberately a
plain UUID, not a DB foreign key, to dodge a circular constraint between the
two tables (bid.py:28, bid.py:5).
Staff only. From a Job, an office/PM user posts
POST /portal/jobs/{job_id}/bid-requests (app/routers/jobextras.py:362). You
give it a title, optional trade, description, and due date. The
route stamps status="open" and mints the shareable link token:
public_token=secrets.token_urlsafe(16) # jobextras.py:377
Two fields carry forward to the eventual PO so committed cost buckets correctly
(bid.py:29):
cost_code β the code the awarded spend lands under.category β a CostCategory, defaulting to subcontractor (bid.py:31).The Job Command Center surfaces open requests as action signals
(app/services/job_command.py:359): "π N bid request(s) have bids in β ready to
award", or "π£ request(s) with no bids yet β share the sub link".
The public surface is app/routers/bid_portal.py, mounted at /bid/{token}
with no login. This is a deliberately bare-token, forwardable link.
The lookup is nothing but a token match β _req does
select(BidRequest).where(BidRequest.public_token == token) and returns whoever
holds the string (bid_portal.py:21). There is no per-recipient token, no
allow-list, no gate. That is intentional: a PM texts one link to three drywall
subs, any of them forwards it to a fourth, and all four can bid. Anyone with the
URL can submit. Treat the token as a shared secret for a scope, not proof of
identity.
There is no automated invite in the code. Creating the request does not email
or notify any Sub or Vendor β staff copy the /bid/{token} link and send it
themselves (SMS, email, whatever). "Inviting" == "sharing the link."
Any Sub who opens their field app at /sub/{clock_token} sees every
open bid request for the org on their home screen (app/routers/sub_portal.py:146):
open_bids = db.scalars(
select(BidRequest).where(BidRequest.organization_id == sub.organization_id,
BidRequest.status == "open") ...) # sub_portal.py:146
Note this is org-wide β not filtered to the Sub's assigned Jobs. Every crew
with a live clock_token sees the same open packages and can jump into the same
/bid/{token} form.
GET /bid/{token} (bid_portal.py:27) renders the page for any matching
request regardless of status β a 404 only when the token doesn't resolve. If the
request is no longer open, the template drops the form and shows "This bid
request is closed." (templates/bid_public.html:26, :77). And the POST is
double-guarded: submit bounces with a redirect when br.status != "open"
(bid_portal.py:47). So a forwarded-late link opens but can't be
submitted against once awarded/closed.
Audience: Sub/Vendor. On /bid/{token}, the bidder fills a plain form and
posts POST /bid/{token}/submit (bid_portal.py:39). Fields:
vendor_name, required) β free text, capped at 255 chars.amount_cents.line_desc / line_qty / line_unit / line_price arrays parsed into BidLine rows (bid_portal.py:85).Two things worth knowing:
Itemize-and-total fallback. If the bidder itemizes but leaves the headline
amount blank, the sum of the lines becomes the bid amount
(bid_portal.py:121). If they give a headline and no lines, that's the amount.
Upload hardening. This is a public endpoint, so attachments go through
upload_guard: count- and size-capped, and stored with the sniffed
content-type, never the client-declared one β so a bidder can't smuggle a
text/html file that later renders inline in an office user's session. Bad or
oversized files are silently skipped, not fatal (bid_portal.py:55β77).
The submitted Bid lands with status="submitted" and its attachment
Document ids in document_ids (bid_portal.py:97). The bidder is redirected
to /bid/{token}?submitted=1 β a thank-you state. A submitted bid is anonymous
free text: the public form does not set Bid.sub_id, so at this stage the
bid is not linked to any known Sub or Vendor record (bid.py:47 is nullable and
left null here). Identity gets resolved later, on award.
Bids come back to staff cheapest-first via the ordered relationship
(bid.py:35). Two read surfaces:
BidRequest.bids.app/services/reporting.py:2361) rolls up, per package, bids received,report_specs.py:1276, reporting.py:1713) drills into every bid. SeeEach Bid exposes computed_total_cents β the itemized line total when present,
else the headline (bid.py:73) β so comparison is apples-to-apples even when one
bidder itemized and another didn't.
Staff only. POST /portal/bid-requests/{br_id}/award with the winning
bid_id (app/routers/jobextras.py:383). The route:
status="awarded" and resets every other bid tosubmitted (so re-awarding is clean) (jobextras.py:393).br.awarded_bid_id and flips the request to status="awarded"jobextras.py:400).po_svc.create_po_from_bid (jobextras.py:409 βapp/services/purchase_orders.py:208).The award is wrapped so it still succeeds even if the PO build hiccups
(jobextras.py:410) β the bid is awarded first, PO is best-effort.
create_po_from_bid (purchase_orders.py:208) pre-fills a draft
PurchaseOrder:
BidLine becomes a PO line, carrying the request's cost_code andcategory (purchase_orders.py:219). No lines? One lump-sum line at the bidpurchase_orders.py:223).sub_id,_vendor_for_name (purchase_orders.py:213).source_bid_id links the PO back to the bid, and bid.purchase_order_id linkspurchase_orders.py:235,bid.py:60).lead_time_days seeds a placeholder expected-delivery anchor; the PM sets apurchase_orders.py:216).On success the user lands on the Job Command Center's Purchase Orders folder
to finish the draft (jobextras.py:414). From there the PO runs its own
lifecycle β release β approve/e-sign β receive β all in
Procurement. The awarded bid's PDFs stay linked to the
bid; they don't auto-copy onto the PO.
Deleting a request (POST /portal/bid-requests/{br_id}/delete,
jobextras.py:419) cascades its bids and lines (bid.py:36, :63).
These are two different tables and the distinction drives who a bid β and its
awarded PO β is for. House terms:
Sub (subs, app/models/client.py:25) |
Vendor (vendors, app/models/cost.py:88) |
|
|---|---|---|
| Means | Field labor β your crew | Materials / pricing counterparty |
| Login | clock_token β walled field app /sub/{clock_token} |
No login; approves a PO at /po/{token} |
| In bidding | Sees org-wide open requests in the field app; can bid | Bids via the public /bid/{token} link |
| On the Bid row | Bid.sub_id FK (nullable) |
Matched by vendor_name on award |
| Awarded PO | PurchaseOrder.sub_id β reviewable/approvable inside the Sub portal (sub_portal.py:254) |
PurchaseOrder to a Vendor; approved off the emailed link |
Key nuance: on the public bid form, the bidder is only free-text
vendor_name β the code path never sets sub_id (bid_portal.py:97). So
"Sub bid vs Vendor bid" isn't decided at submission; it's decided at award,
when create_po_from_bid either honors an existing sub_id or spins up a Vendor
from the typed name (purchase_orders.py:212β215). A crew and a supply house
submit through the exact same door.
The Sub and Vendor tables overlap in one spot worth flagging: a Vendor can
be flagged is_subcontractor for T5018/1099 payment reporting
(cost.py:101) β that's a tax/AP classification, independent of whether the
party is a GoBuild Sub with a field login.
| Action | Method / route | Code | Audience |
|---|---|---|---|
| Create bid request | POST /portal/jobs/{job_id}/bid-requests |
jobextras.py:362 |
Staff π’ |
| Public bid page | GET /bid/{token} |
bid_portal.py:27 |
Sub/Vendor π’ |
| Submit a bid | POST /bid/{token}/submit |
bid_portal.py:39 |
Sub/Vendor π’ |
| Subs see open requests | GET /sub/{clock_token} |
sub_portal.py:146 |
Sub π’ |
| Award β draft PO | POST /portal/bid-requests/{br_id}/award |
jobextras.py:383 |
Staff π’ |
| Delete request | POST /portal/bid-requests/{br_id}/delete |
jobextras.py:419 |
Staff π’ |
| Bid comparison report | Reports β Bid / Tender Comparison | reporting.py:2361 |
Staff π’ |
cost_code / category flows into committed cost.