Prolo — DB Schema (Quotes + RFQ)

PK primary key   FK foreign key   UQ unique   enum   NN not null.   One RDS instance; the two sides meet only at rfq_line_matches.quote_line_id → line_items.

Quotes side — ingestion · pure extraction record, zero RFQ data

Relationships: quotes 1—∞ line_items 1—∞ quote_locations  ·  quotes 1—∞ quote_locations  ·  quotessupersedes_id (revisions)

quotesparent · one row per extracted quote version
ColumnTypeKeyNotes
iduuidPKapp-generated uuid7 (time-ordered)
data_tenancy_idtextNN CHECKtenancy (RLS); format a.b.c
message_idtextNN UQGmail id → idempotency
body_reftextS3 key: raw email body
attachment_reftextS3 key: attachment
quote_group_iduuidNN UQstable across all revisions
revisionintNN1, 2, 3 … (def 1)
is_latestbooleanNNexactly one true per group
supersedes_iduuidFK → quotesversion this replaces (null on v1)
vendor_identifiertextbrief: Vendor identifier
quote_numbertextsupplier's quote # (revision key)
quote_datedateoptional
currencychar(3)ISO-4217, optional
taxnumeric(14,2)brief: Financial totals
shippingnumeric(14,2)
netnumeric(14,2)validated: Σ = net
vendor_confidencereal0..1
totals_confidencereal0..1
statusquote_statusenum NNlifecycle flag — no RFQ ref (def EXTRACTED)
attemptsintNNdef 1
error_code / error_messagetextfailure detail
created / updatedtimestamptzNNdef now()
Indexes / constraints: UQ(data_tenancy_id, message_id) dedupe · UQ(quote_group_id, revision) · ix(data_tenancy_id, status) · UNIQUE ix(quote_group_id) WHERE is_latest — one current version.
enum quote_status = EXTRACTED · NEEDS_REVIEW · MATCHED · SUBMITTED · SUPERSEDED · FAILED
line_itemschild · one row per pricing-table line
ColumnTypeKeyNotes
iduuidPK
quote_iduuidFK → quotes NNON DELETE CASCADE
positionintNNpreserves table order
descriptiontextNNbrief
quantitynumeric(14,4)NNbrief
unit_of_measuretextNNbrief
pricenumeric(14,4)NNbrief: per-unit price
confidencereal0..1
description_embeddingvector(1024)precomputed Stage 4 — semantic key for matching
createdtimestamptzNNdef now()
Indexes: ix(quote_id) · HNSW(description_embedding vector_cosine_ops) — kNN for matching.
quote_locationsprovenance · page + bbox per grounded value
ColumnTypeKeyNotes
iduuidPK
quote_iduuidFK → quotes NNCASCADE
line_item_iduuidFK → line_itemsnull = header/total field
fieldtextNNe.g. 'net', 'price', 'vendor_identifier'
sourcetextNN'attachment' | 'body'
pageint
x0, y0, x1, y1realbounding box
relative_positionreal
createdtimestamptzNNdef now()
Index: ix(quote_id).
Raw SQL DDL — quotes side
CREATE TYPE quote_status AS ENUM
  ('EXTRACTED','NEEDS_REVIEW','MATCHED','SUBMITTED','SUPERSEDED','FAILED');

CREATE TABLE quotes (
    id                uuid PRIMARY KEY,
    data_tenancy_id   text NOT NULL
        CONSTRAINT check_tenancy_format CHECK (data_tenancy_id ~ '^[^.]+\.[^.]+\..+$'),
    message_id        text NOT NULL,
    body_ref          text,  attachment_ref text,
    quote_group_id    uuid NOT NULL,
    revision          int  NOT NULL DEFAULT 1,
    is_latest         boolean NOT NULL DEFAULT true,
    supersedes_id     uuid REFERENCES quotes(id),
    vendor_identifier text, quote_number text, quote_date date, currency char(3),
    tax numeric(14,2), shipping numeric(14,2), net numeric(14,2),
    vendor_confidence real, totals_confidence real,
    status            quote_status NOT NULL DEFAULT 'EXTRACTED',
    attempts int NOT NULL DEFAULT 1, error_code text, error_message text,
    created timestamptz NOT NULL DEFAULT now(),
    updated timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT uq_quote_per_message UNIQUE (data_tenancy_id, message_id),
    CONSTRAINT uq_group_revision    UNIQUE (quote_group_id, revision)
);
CREATE INDEX ix_quotes_tenancy_status ON quotes (data_tenancy_id, status);
CREATE UNIQUE INDEX uq_one_latest_per_group ON quotes (quote_group_id) WHERE is_latest;

CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE line_items (
    id uuid PRIMARY KEY,
    quote_id uuid NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
    position int NOT NULL, description text NOT NULL,
    quantity numeric(14,4) NOT NULL, unit_of_measure text NOT NULL,
    price numeric(14,4) NOT NULL, confidence real,
    description_embedding vector(1024),
    created timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_line_items_quote ON line_items (quote_id);
CREATE INDEX ix_line_items_embedding ON line_items USING hnsw (description_embedding vector_cosine_ops);

CREATE TABLE quote_locations (
    id uuid PRIMARY KEY,
    quote_id uuid NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
    line_item_id uuid REFERENCES line_items(id) ON DELETE CASCADE,
    field text NOT NULL, source text NOT NULL, page int,
    x0 real, y0 real, x1 real, y1 real, relative_position real,
    created timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_locations_quote ON quote_locations (quote_id);

RFQ side — matching · read-model synced from core + the match association

Relationships: rfqs 1—∞ rfq_line_items 1—∞ rfq_line_matches  ∞—1  line_items (quote side). The match lives here, not on the quote.

rfqsparent · read-model synced from the core system
ColumnTypeKeyNotes
iduuidPKuuid7
data_tenancy_idtextNN CHECKtenancy (RLS)
source_rfq_idtextNN UQcore system's RFQ id — sync key
customer_identifiertext
rfq_numbertext
rfq_datedate
currencychar(3)ISO-4217
synced_attimestamptzNNread-model freshness watermark
created / updatedtimestamptzNNdef now()
Constraint / index: UQ(data_tenancy_id, source_rfq_id) upsert key on sync · ix(data_tenancy_id).
rfq_line_itemschild · requested line + embedding + match flag
ColumnTypeKeyNotes
iduuidPK
rfq_iduuidFK → rfqs NNCASCADE
positionintNNrequest order
descriptiontextNN
quantitynumeric(14,4)NN
unit_of_measuretextNN
description_embeddingvector(1024)SAME model/dim as quote side, or kNN is meaningless
match_statusrfq_line_match_statusenum NNroll-up of this line's edges (def UNMATCHED)
synced_at / createdtimestamptzNNdef now()
Indexes: ix(rfq_id) · HNSW(description_embedding vector_cosine_ops).
enum rfq_line_match_status = UNMATCHED · MATCHED · AMBIGUOUS
rfq_line_matchesmatch edges · RFQ line ← provider's quote line (many-to-many)
ColumnTypeKeyNotes
iduuidPK
rfq_line_iduuidFK → rfq_line_items NNthe requested line
quote_line_iduuidFK → line_items NNthe two sides meet here (quote side)
quote_iduuidNNdenormalized parent quote — fast filter
providertextNNvendor_identifier of the matched quote
match_confidencerealkNN → rerank → LLM score
match_evidencejsonbneighbor scores + LLM rationale
edge_statusmatch_edge_statusenum NNdef PROPOSED
createdtimestamptzNNdef now()
Constraint / indexes: UQ(rfq_line_id, quote_line_id) one edge per pair · ix(rfq_line_id) · ix(quote_line_id) reverse · ix(quote_id).
enum match_edge_status = PROPOSED · CONFIRMED · REJECTED
Raw SQL DDL — RFQ side
CREATE TABLE rfqs (
    id uuid PRIMARY KEY,
    data_tenancy_id text NOT NULL
        CONSTRAINT check_tenancy_format CHECK (data_tenancy_id ~ '^[^.]+\.[^.]+\..+$'),
    source_rfq_id text NOT NULL, customer_identifier text,
    rfq_number text, rfq_date date, currency char(3),
    synced_at timestamptz NOT NULL DEFAULT now(),
    created timestamptz NOT NULL DEFAULT now(),
    updated timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT uq_rfq_source UNIQUE (data_tenancy_id, source_rfq_id)
);
CREATE INDEX ix_rfqs_tenancy ON rfqs (data_tenancy_id);

CREATE TYPE rfq_line_match_status AS ENUM ('UNMATCHED','MATCHED','AMBIGUOUS');
CREATE TABLE rfq_line_items (
    id uuid PRIMARY KEY,
    rfq_id uuid NOT NULL REFERENCES rfqs(id) ON DELETE CASCADE,
    position int NOT NULL, description text NOT NULL,
    quantity numeric(14,4) NOT NULL, unit_of_measure text NOT NULL,
    description_embedding vector(1024),
    match_status rfq_line_match_status NOT NULL DEFAULT 'UNMATCHED',
    synced_at timestamptz NOT NULL DEFAULT now(),
    created timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_rfq_lines_rfq ON rfq_line_items (rfq_id);
CREATE INDEX ix_rfq_lines_embedding ON rfq_line_items USING hnsw (description_embedding vector_cosine_ops);

CREATE TYPE match_edge_status AS ENUM ('PROPOSED','CONFIRMED','REJECTED');
CREATE TABLE rfq_line_matches (
    id uuid PRIMARY KEY,
    rfq_line_id   uuid NOT NULL REFERENCES rfq_line_items(id) ON DELETE CASCADE,
    quote_line_id uuid NOT NULL REFERENCES line_items(id)     ON DELETE CASCADE,
    quote_id uuid NOT NULL, provider text NOT NULL,
    match_confidence real, match_evidence jsonb,
    edge_status match_edge_status NOT NULL DEFAULT 'PROPOSED',
    created timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT uq_rfqline_quoteline UNIQUE (rfq_line_id, quote_line_id)
);
CREATE INDEX ix_matches_rfq_line   ON rfq_line_matches (rfq_line_id);
CREATE INDEX ix_matches_quote_line ON rfq_line_matches (quote_line_id);
CREATE INDEX ix_matches_quote      ON rfq_line_matches (quote_id);

Revisions — a revised quote is a new version, not an overwrite

Answering the brief's "update a quote if a revision is received": a revision is a new quotes row, appended, never an in-place edit — so full price history is preserved (auditable) and matching/submission always act on the current version.