From 5ad5c3cc01e376a90b0512875993bea26439132c Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 15:04:22 -0700 Subject: [PATCH 1/3] docs: quotes page matches the code (route census, QuoteLine fields, units) Add the PUT /api/v1/quotes/{id}/file entry (HandleAttachFile) and the drafts route cross reference, both missing from the page. Add the stock_uom, stock_quantity and tally QuoteLine fields. Correct the lifecycle intro (reopened is an event, not a status) and the uom description (regex pattern from the units catalogue, not a 15-code enum). Signed-off-by: colton --- docs/modules/quotes.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/modules/quotes.md b/docs/modules/quotes.md index aaa7bb53..4009d629 100644 --- a/docs/modules/quotes.md +++ b/docs/modules/quotes.md @@ -11,8 +11,8 @@ expiration, and it is the first priced document. The customer accepts or rejects the offer through the portal or the integration seam; an accepted quote becomes an order through `POST /api/v1/quotes/{id}/convert`. The lifecycle spans draft, sent, -accepted, rejected, expired and reopened, every transition writing one -outbox event. +accepted, rejected and expired; rejected and expired reopen to draft, every +transition writing one outbox event. This module is the wire template. The recipe in [`docs/refactor/MODULE-RECIPE.md`](../refactor/MODULE-RECIPE.md) was @@ -47,11 +47,18 @@ package. | POST | `/api/v1/quotes/{id}/transitions` | Move a quote along its lifecycle, on the client's revision. | | POST | `/api/v1/quotes/{id}/convert` | Accept and create the order in one transaction. | | GET | `/api/v1/quotes/{id}/file` | Download the original parsed file. | +| PUT | `/api/v1/quotes/{id}/file` | Attach the AI parse's original file after promotion, on the client's revision; `application/octet-stream` body, 5 MiB bound; only while the quote is a draft, else 409 with the `quote_not_draft` blocker. | The exposure routes at `/api/v1/quotes/exposure` and `/api/v1/quotes/{id}/exposure/...` belong to the pricing module and register under `internal/pricing` in the route census, even though -their paths sit under `quotes`. +their paths sit under `quotes`. The draft routes at +`/api/v1/drafts/quotes` and `/api/v1/drafts/quotes/{id}[/...]` belong +to the drafts module and register under `internal/quote` (the +promotion route turns a quote draft into a quote in one transaction); +the draft routes are documented in +[`docs/modules/drafts.md`](drafts.md) and are listed here only so the +route census and this page agree on the package that owns the paths. ## The main resource @@ -98,13 +105,16 @@ A `QuoteLine` carries: | `description` | text | Snapshot of the description. | | `customer_note` | text, nullable | The customer's free text note on the line. | | `quantity` | decimal string | Scale 4. | -| `uom` | uppercase enum | The sale unit (`PCS`, `EA`, `LF`, `SF`, `BF`, `MBF`, `SQ`, `BOX`, `CTN`, `RL`, `GAL`, `LBS`, `BAG`, `BUNDLE`, `PAIR`, `SET`). | +| `uom` | uppercase pattern | The sale unit, regex `^[A-Z]{1,6}$`, a code from the units catalogue (ADR 0006 section 2.1: the seed inserts 23 codes including `EA`, `PCS`, `PAIR`, `DOZ`, `C`, `M`, `SET`, `BOX`, `CTN`, `BAG`, `BUNDLE`, `RL`, `LF`, `SF`, `SQ`, `CF`, `CY`, `GAL`, `LBS`, `CWT`, `TON`, `BF`, `MBF`; dealers add their own). On a product line the value must be one of the product's own sale units (ADR 0006 section 3.4). | | `price_uom` | text | The price unit. | | `uom_qty` | decimal string | Scale 4. | | `price_uom_qty` | decimal string | Scale 4. | | `unit_price_ten_thousandths` | integer | The price per `price_uom`. | | `line_total_cents` | integer | The extension, rounded once. | | `unit_cost_ten_thousandths` | integer | The cost per stocking unit. | +| `stock_uom` | text, nullable | The product's stocking unit at the line's create (ADR 0006 section 3.4); null on a line without a product. | +| `stock_quantity` | decimal string, nullable | The line's quantity converted into the stocking unit, exact at scale 4 (R5); null on a line without a product. | +| `tally` | object, nullable | The pieces by length a random length line carries (ADR 0006 section 4): `thickness_in`, `width_in`, `rows` (pieces and length_ft), `linear_feet`, `board_feet`. Null on every other line. | | `created_at` | timestamp | RFC 3339 UTC. | The conversion pair (`uom_qty`, `price_uom_qty`) is mandatory; when From 7189e6c8d3d57012204b66b9779576f09526042e Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 15:13:49 -0700 Subject: [PATCH 2/3] docs: margin_total_cents is client-only; expand quotes and projects body, list and convert rows margin_total_cents is what the client sent on create (default 0); the service copies it through, a PUT refuses it, and nothing sums line margins. Add the create and update body, the line request, the list envelope (QuotePage of QuoteSummary), the convert response with its 409 refusals, and the PUT file row with 413, 428, the filename query parameter, the ETag move, the file_attached audit row, no outbox, and quotes:write. State that draft routes are gated by quotes:propose and quotes:commit. State ProjectItem total_cents is null on a delivery. Add the project create and update body, the headers and codes, ProjectPage, and the dashboard project key. Cite ADR 0006 sections 3.2 and 3.3 for the sale unit rule, drop the unsupported customer currency note, state freight_cents is cleared for a pickup, and write exposure_cents in integer cents. Carry the changes from PR 92 review r1 by Sonnet 5.5. Signed-off-by: colton --- docs/modules/projects.md | 53 ++++++++++++++++++++++---- docs/modules/quotes.md | 82 +++++++++++++++++++++++++++++++++++----- 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/docs/modules/projects.md b/docs/modules/projects.md index ba51ea07..e3502247 100644 --- a/docs/modules/projects.md +++ b/docs/modules/projects.md @@ -59,18 +59,57 @@ customer's project is a 404. | `created_at`, `updated_at` | timestamp | RFC 3339 UTC. | The `ProjectDashboard` (returned by `GET /api/portal/v1/projects/{id}`) -embeds the project and three lists: `orders`, `deliveries` and -`invoices`. Each item is a `ProjectItem`: `id`, `type`, `status`, -`total_cents`, `created_at`, `reference`. The totals are integer -cents, never floats. +is a `ProjectDashboard` object whose keys are the project (a nested +`Project` object under the `project` key) and three list keys, +`orders`, `deliveries` and `invoices` (each an array of `ProjectItem`, +newest first, never null). Each item is a `ProjectItem`: `id`, +`type`, `status`, `total_cents`, `created_at`, `reference`. Item +totals are integer cents (ADR 0001 section 7) and `total_cents` is +null on a delivery, which carries no money. + +`ProjectPage` is the list envelope (`core/api/fragments/project.yaml` +`components.schemas.ProjectPage`): + +| Field | Wire form | Note | +|---|---|---| +| `items` | array of `Project` | Newest first on `(created_at, id)`. | +| `next_cursor` | string, nullable | The keyset cursor (ADR 0001 section 2); null on the last page. | +| `limit` | integer | The page size in effect. | +| `total` | integer | Present only under `include=total`. | + +### Create and update body + +`ProjectRequest` is the body of `POST /api/portal/v1/projects` and +`PUT /api/portal/v1/projects/{id}`. No field is required, but create +and update differ. + +| Field | Wire form | Note | +|---|---|---| +| `name` | text | 1 to 255 characters (the column is `varchar(255)` in characters). Required on create; absent on update keeps the stored value. | +| `status` | lowercase enum | `active` or `completed`. Create ignores any `status` (the project always starts `active`); update applies the value when set. | +| `revision` | integer | The body form of the precondition on `PUT`; a create carrying one is a 400 naming `revision`. | + +Update is partial: absent fields keep their stored values. A request +that names nothing to change (no `name` and no `status`) is a no-op: +the row answers with its current revision and the service writes +no audit row, no event, and the revision does not move +(`core/internal/project/service.go` `Update`). ### Money and quantity conventions The dashboard carries no quantity, only integer cents under `total_cents`. Money is integer cents under `_cents`; the project's -own list cursor is the keyset cursor of ADR 0001 section 2; the -list envelope is `{items, next_cursor, limit}` with `total` only -under `include=total`. +own list cursor is the keyset cursor of ADR 0001 section 2. + +### Headers and codes + +`Idempotency-Key` is required on every mutating request (create and +update); `ETag` is returned on every record (GET, POST and PUT); the +create response carries a `Location` header. A write without +`If-Match` and without a body `revision` is `428 +precondition_required`; a body or header that disagrees with the +server revision is `409 stale_revision`; `If-Match: *` or an +`If-Match` that is a list is `400 validation_failed`. ## Lifecycle and transitions diff --git a/docs/modules/quotes.md b/docs/modules/quotes.md index 4009d629..7a6e2f5f 100644 --- a/docs/modules/quotes.md +++ b/docs/modules/quotes.md @@ -39,15 +39,15 @@ package. | Method | Path | One line | |---|---|---| -| GET | `/api/v1/quotes` | Cursor list of quotes, newest first; status and customer_id filter. | +| GET | `/api/v1/quotes` | Cursor list of `QuoteSummary` items, newest first; status and customer_id filter; the response is `QuotePage` (no `lines`, no file fields, no exposure fields); `total` only under `include=total`. | | POST | `/api/v1/quotes` | Create a draft quote, priced through the platform rule, writes `quote.created`. | -| GET | `/api/v1/quotes/analytics` | Aggregated quote analytics; money in cents. | +| GET | `/api/v1/quotes/analytics` | Aggregated quote analytics; counts and rates (`total_quotes`, `draft_count`, `sent_count`, `accepted_count`, `rejected_count`, `expired_count`, `conversion_rate`, `ai_sourced_count`, `ai_conversion_rate`, `manual_conversion_rate`, `avg_days_to_close`) and money in cents (`avg_margin_accepted_cents`, `avg_margin_rejected_cents`, `total_quote_value_cents`, `total_accepted_value_cents`); `trend_data` rows carry `total_value_cents` and `accepted_value_cents`. | | GET | `/api/v1/quotes/{id}` | One quote with its lines and revision as ETag. | | PUT | `/api/v1/quotes/{id}` | Replace a draft quote's header and lines, on the client's revision. | | POST | `/api/v1/quotes/{id}/transitions` | Move a quote along its lifecycle, on the client's revision. | -| POST | `/api/v1/quotes/{id}/convert` | Accept and create the order in one transaction. | +| POST | `/api/v1/quotes/{id}/convert` | Accept the quote (the `quote.accepted` transition) and create the order in one transaction, answering 201 with the order (ETag and Location); the quote's `freight_cents` becomes one `FREIGHT` charge line on the order; a stocked line sold in a unit other than its product's stocking unit is 409 with the `unit_not_stock_unit` blocker until cycle 3; a quote that already has an order not cancelled is 409 with the `already_converted` blocker; events `quote.accepted` then `order.created`. | | GET | `/api/v1/quotes/{id}/file` | Download the original parsed file. | -| PUT | `/api/v1/quotes/{id}/file` | Attach the AI parse's original file after promotion, on the client's revision; `application/octet-stream` body, 5 MiB bound; only while the quote is a draft, else 409 with the `quote_not_draft` blocker. | +| PUT | `/api/v1/quotes/{id}/file` | Attach the AI parse's original file after promotion; `application/octet-stream` body, 5 MiB bound (413 above it); `filename` query parameter carries the original file name; the revision precondition (`If-Match` or body `revision`) is required (428 without); only while the quote is in status `draft`, else 409 with the `quote_not_draft` blocker; the quote is returned with its new ETag and revision plus one; the audit row `quote.file_attached` is written and no outbox event; the route is gated by `quotes:write`. | The exposure routes at `/api/v1/quotes/exposure` and `/api/v1/quotes/{id}/exposure/...` belong to the pricing module and @@ -56,7 +56,13 @@ their paths sit under `quotes`. The draft routes at `/api/v1/drafts/quotes` and `/api/v1/drafts/quotes/{id}[/...]` belong to the drafts module and register under `internal/quote` (the promotion route turns a quote draft into a quote in one transaction); -the draft routes are documented in +the draft routes are gated by `quotes:propose` and `quotes:commit` +(the propose and commit scopes of ADR 0007 section 5.1: draft read, +draft write and the draft link resolver admit both scopes, while +`promote` is reserved to `quotes:commit`), the entity routes above +keep the `:read` / `:write` pair and the rule "`quotes:write` +is the first path segment, anything but GET and HEAD" applies to the +entity routes only. The draft routes are documented in [`docs/modules/drafts.md`](drafts.md) and are listed here only so the route census and this page agree on the package that owns the paths. @@ -74,9 +80,9 @@ route census and this page agree on the package that owns the paths. | `customer_id` | UUID | The customer the quote is for. | | `customer_name` | text | Snapshot of the customer name. | | `job_id` | UUID, nullable | The project (job) this quote is for. | -| `total_cents` | integer | The customer's total, in minor units of the customer's currency. | -| `freight_cents` | integer | The freight amount. | -| `margin_total_cents` | integer | The computed margin across all lines. | +| `total_cents` | integer | The customer's total, in minor units. | +| `freight_cents` | integer | The freight amount, cleared for a pickup. | +| `margin_total_cents` | integer | The margin total the client sent on create (default 0); create-only, a PUT refuses it, and it is not recomputed from the lines. | | `delivery_type` | lowercase enum | `pickup`, `delivery`. | | `vehicle_id` | UUID, nullable | The vehicle that will deliver, when known. | | `vehicle_name` | text, nullable | The vehicle display name. | @@ -90,7 +96,7 @@ route census and this page agree on the package that owns the paths. | `original_content_type` | text, nullable | The MIME type of the original parsed file. | | `parse_map` | array, nullable | The AI parse mapping data. | | `exposure_state` | lowercase enum | The pricing module's worst exposure rollup (`ok`, `flagged`, `escalated`, `ack_required`, `acknowledged`, `blocked`, `overridden`). | -| `exposure_cents` | integer | The exposure dollar value at the rollup. | +| `exposure_cents` | integer | The exposure value at the rollup, in integer cents. | | `exposure_last_checked_at` | timestamp, nullable | When the exposure scanner last looked at this quote. | | `lines` | array of `QuoteLine` | The priced lines, in position order. | @@ -105,7 +111,7 @@ A `QuoteLine` carries: | `description` | text | Snapshot of the description. | | `customer_note` | text, nullable | The customer's free text note on the line. | | `quantity` | decimal string | Scale 4. | -| `uom` | uppercase pattern | The sale unit, regex `^[A-Z]{1,6}$`, a code from the units catalogue (ADR 0006 section 2.1: the seed inserts 23 codes including `EA`, `PCS`, `PAIR`, `DOZ`, `C`, `M`, `SET`, `BOX`, `CTN`, `BAG`, `BUNDLE`, `RL`, `LF`, `SF`, `SQ`, `CF`, `CY`, `GAL`, `LBS`, `CWT`, `TON`, `BF`, `MBF`; dealers add their own). On a product line the value must be one of the product's own sale units (ADR 0006 section 3.4). | +| `uom` | uppercase pattern | The sale unit, regex `^[A-Z]{1,6}$`, a code from the units catalogue (ADR 0006 section 2.1: the seed inserts 23 codes including `EA`, `PCS`, `PAIR`, `DOZ`, `C`, `M`, `SET`, `BOX`, `CTN`, `BAG`, `BUNDLE`, `RL`, `LF`, `SF`, `SQ`, `CF`, `CY`, `GAL`, `LBS`, `CWT`, `TON`, `BF`, `MBF`; dealers add their own). On a product line the value must be one of the product's own sale units (ADR 0006 sections 3.2 and 3.3). | | `price_uom` | text | The price unit. | | `uom_qty` | decimal string | Scale 4. | | `price_uom_qty` | decimal string | Scale 4. | @@ -120,6 +126,62 @@ A `QuoteLine` carries: The conversion pair (`uom_qty`, `price_uom_qty`) is mandatory; when the units agree, the pair is 1 and 1. +### Create and update body + +`QuoteRequest` is the body of `POST /api/v1/quotes` and +`PUT /api/v1/quotes/{id}` (`core/api/fragments/quote.yaml` +`components.schemas.QuoteRequest`). Required: `customer_id`, `lines`. + +| Field | Wire form | Note | +|---|---|---| +| `branch_id` | UUID | Create-only. | +| `customer_id` | UUID | The customer the quote is for. | +| `job_id` | UUID, nullable | The project (job), if any. | +| `expires_at` | timestamp, nullable | When the quote stops being valid, RFC 3339 UTC. | +| `delivery_type` | lowercase enum | `pickup` clears `freight_cents` and `vehicle_id`. | +| `freight_cents` | integer | Non negative; cleared when `delivery_type` is `pickup`. | +| `vehicle_id` | UUID, nullable | The vehicle that will deliver; null on a pickup. | +| `source` | enum | `manual` or `ai`; create-only. | +| `margin_total_cents` | integer | The margin total the client sends on create (default 0); create-only, never recomputed. | +| `original_file` | base64 string | The original upload, up to 5 MB; create-only. | +| `original_filename` | text | Create-only. | +| `original_content_type` | text | Create-only. | +| `parse_map` | array, nullable | The AI parse mapping; create-only. | +| `revision` | integer | The PUT precondition beside `If-Match`; a create carrying one is a 400. | +| `lines` | array of `QuoteLineRequest` | One or more lines; see the line request below. | + +A PUT refuses the create-only fields +(`branch_id`, `source`, `margin_total_cents`, `original_file`, +`original_filename`, `original_content_type`, `parse_map`) with a 400 +naming each one it carries. A create carrying `revision` is refused +with a 400 naming `revision`. + +### Line request + +`QuoteLineRequest` is one line of `QuoteRequest` (`core/api/fragments/quote.yaml` +`components.schemas.QuoteLineRequest`). The only required field is +`unit_price_ten_thousandths` (scale 4, min 0). The remaining fields +default as the schema lays out. + +| Field | Wire form | Note | +|---|---|---| +| `id` | UUID | The line id, kept on a PUT to address an existing line. | +| `product_id` | UUID, nullable | The product; `sku` and `description` default from it (required when absent). | +| `sku` | text | Defaulted from `product_id`. | +| `description` | text | Defaulted from `product_id`. | +| `customer_note` | text, nullable | A portal user's free text note on the line. | +| `quantity` | decimal string | Scale 4; required unless the line carries a `tally`. | +| `uom` | uppercase pattern | Defaulted to the product's sale unit on a product line; a line with neither `uom` nor `product_id` is a 400 naming `lines[i].uom`. | +| `price_uom` | uppercase pattern | Defaulted to the product's price unit on a product line, to `uom` on the others. | +| `uom_qty`, `price_uom_qty` | decimal string, decimal string | The conversion pair; the server resolves the pair from the product's unit set on a product line (ADR 0006 section 3.3), a sent pair must equal the resolved one as a ratio, and a line without a product carries the client's pair, required when the two units differ unless the standard sizes derive it. | +| `unit_price_ten_thousandths` | integer | Scale 4, min 0; the line's only required field. | +| `tally` | `TallyRequest` object | The pieces by length; rows only. | + +`TallyRequest` (`core/api/fragments/quote.yaml` +`components.schemas.TallyRequest`): `rows` is required (maxItems 100); +`thickness_in`, `width_in`, `linear_feet` and `board_feet` are read only, +and one in a request is a 400 naming the field. + ### Money and quantity conventions Money is integer cents under `_cents` field names; unit prices are From 4610179c558d8ea6362205a298d5f1a2e30db8ed Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 15:17:47 -0700 Subject: [PATCH 3/3] docs: quotes and projects pages state the file route, precondition and idempotency rules the code applies Review r2 of PR 92 found five sentences the first fix round added that the code contradicts. The file route takes no query parameter (noQuery refuses any) and reads its revision only from If-Match (precondition(r, nil)); the quote body's lines carry no minimum count; a malformed If-Match is a bad_request, not validation_failed; and Idempotency-Key is an opt in replay header, not a required one. Co-Authored-By: Claude Opus 5.5 Signed-off-by: colton --- docs/modules/projects.md | 6 +++--- docs/modules/quotes.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/modules/projects.md b/docs/modules/projects.md index e3502247..f07c751b 100644 --- a/docs/modules/projects.md +++ b/docs/modules/projects.md @@ -103,13 +103,13 @@ own list cursor is the keyset cursor of ADR 0001 section 2. ### Headers and codes -`Idempotency-Key` is required on every mutating request (create and -update); `ETag` is returned on every record (GET, POST and PUT); the +`Idempotency-Key` is accepted on every mutating request (create and +update) for an opt in replay; `ETag` is returned on every record (GET, POST and PUT); the create response carries a `Location` header. A write without `If-Match` and without a body `revision` is `428 precondition_required`; a body or header that disagrees with the server revision is `409 stale_revision`; `If-Match: *` or an -`If-Match` that is a list is `400 validation_failed`. +`If-Match` that is a list is `400 bad_request`. ## Lifecycle and transitions diff --git a/docs/modules/quotes.md b/docs/modules/quotes.md index 7a6e2f5f..d6783b07 100644 --- a/docs/modules/quotes.md +++ b/docs/modules/quotes.md @@ -47,7 +47,7 @@ package. | POST | `/api/v1/quotes/{id}/transitions` | Move a quote along its lifecycle, on the client's revision. | | POST | `/api/v1/quotes/{id}/convert` | Accept the quote (the `quote.accepted` transition) and create the order in one transaction, answering 201 with the order (ETag and Location); the quote's `freight_cents` becomes one `FREIGHT` charge line on the order; a stocked line sold in a unit other than its product's stocking unit is 409 with the `unit_not_stock_unit` blocker until cycle 3; a quote that already has an order not cancelled is 409 with the `already_converted` blocker; events `quote.accepted` then `order.created`. | | GET | `/api/v1/quotes/{id}/file` | Download the original parsed file. | -| PUT | `/api/v1/quotes/{id}/file` | Attach the AI parse's original file after promotion; `application/octet-stream` body, 5 MiB bound (413 above it); `filename` query parameter carries the original file name; the revision precondition (`If-Match` or body `revision`) is required (428 without); only while the quote is in status `draft`, else 409 with the `quote_not_draft` blocker; the quote is returned with its new ETag and revision plus one; the audit row `quote.file_attached` is written and no outbox event; the route is gated by `quotes:write`. | +| PUT | `/api/v1/quotes/{id}/file` | Attach the AI parse's original file after promotion; `application/octet-stream` body, 5 MiB bound (413 above it); the route takes no query parameter (any is a 400 `unsupported_query_parameter`); the revision precondition is the `If-Match` header, required (428 without); only while the quote is in status `draft`, else 409 with the `quote_not_draft` blocker; the quote is returned with its new ETag and revision plus one; the audit row `quote.file_attached` is written and no outbox event; the route is gated by `quotes:write`. | The exposure routes at `/api/v1/quotes/exposure` and `/api/v1/quotes/{id}/exposure/...` belong to the pricing module and @@ -148,7 +148,7 @@ the units agree, the pair is 1 and 1. | `original_content_type` | text | Create-only. | | `parse_map` | array, nullable | The AI parse mapping; create-only. | | `revision` | integer | The PUT precondition beside `If-Match`; a create carrying one is a 400. | -| `lines` | array of `QuoteLineRequest` | One or more lines; see the line request below. | +| `lines` | array of `QuoteLineRequest` | The quote's lines; see the line request below. | A PUT refuses the create-only fields (`branch_id`, `source`, `margin_total_cents`, `original_file`,