# Pricing & Quotation module — session handoff

**Purpose of this file.** It is the reference for a *new* chat picking up this
work with no memory of the session that built it. It says what exists, what
was decided and why, what bites, and what is left. Point a fresh session at
this path and it can continue without re-deriving anything.

- **Built on:** 9–10 September 2026, in one session.
- **Feature documentation:** [`docs/pricing-quotation.md`](pricing-quotation.md)
  — the how-it-works document. **Read that second; read this first.**
- **State:** working end to end and verified against live data. Phases 1, 3, 4
  and 5 of the original spec are done; phases 2 and 6 are not started.

---

## 1. What the module does

A mail classified `PRICING_ENQUIRY` arrives. The user opens it, presses
**Reply**, presses **Prepare quotation**. The app reads the enquiry, prices it
from the Product Master, creates a quotation, renders it as a PDF, and fills
the reply editor with a covering mail. The user edits and presses their own
Send Reply, and the PDF is attached to the outgoing message.

```
The mail  →  EnquiryExtractor (1 Gemini call, catalogue supplied)
          →  ProductMatcher   (verifies every code, scores the match)
          →  Product::priceOn(date, quantity)
          →  QuotationBuilder (quotations + quotation_items, snapshotted)
          →  QuotationPdf     (dompdf → storage/app/quotations)
          →  QuotationDraftWriter (covering mail, points at the attachment)
          →  CKEditor  →  user edits  →  Send Reply  →  PDF attached
```

**Nothing is ever sent automatically.** The draft lands in the editor; the
user's own button sends. This matches the existing AI-reply-draft behaviour and
was an explicit requirement.

---

## 2. Environment facts that will cost you time

| Fact | Consequence |
| --- | --- |
| **No git repository.** | No undo. Back up before bulk edits. Recovery: `~/.claude/file-history`, then dated zips beside the project root. |
| PHP is at `/c/xampp8_2/php/php.exe` | Not on PATH. There is no `php` command. |
| `php artisan route:list` **always fails** | AuthController requires PHPMailer by a relative path. Inspect routes by booting the kernel and walking `Route::getRoutes()` — every `tools/*-smoke.php` does this. |
| **Bash heredocs truncate around 7.6KB** | `cat > file <<'EOF'` silently cut a 9.5KB file at 7614 bytes. Use the Write tool for anything sizeable. |
| No `composer` binary installed | I downloaded `composer-stable.phar` temporarily and deleted it afterwards. Re-download from getcomposer.org if another package is needed. |
| `ext-gd` is **commented out** at `php.ini:931` | PhpSpreadsheet *declares* it, so composer refuses to install anything without `--ignore-platform-req=ext-gd`. Nothing in the app actually uses gd today; a PDF **logo** would need it enabled. |
| Headless Chrome will not rasterise a PDF | To look at the PDF, render its Blade template instead: `php tools/quotation-shot.php --pdf`. |
| Screens are auth-gated | To see one, use the `tools/*-shot.php` pattern: boot, `Auth::login()`, render the controller's view to `public/_shot.html`, screenshot over http, then `--clean`. |

---

## 3. Files

### New — schema

```
database/migrations/2026_09_09_000100_create_products_table.php
database/migrations/2026_09_09_000200_create_product_pricing_table.php
database/migrations/2026_09_09_000300_create_pricing_imports_table.php
database/migrations/2026_09_10_000100_create_quotations_table.php
database/migrations/2026_09_10_000200_create_quotation_items_table.php
```

All five are applied. Each carries a long header comment explaining the design
— those comments are the schema rationale and are worth reading before
changing a column.

### New — code

```
app/Models/Product.php              catalogue row; priceOn() lives here
app/Models/ProductPricing.php       one price, one window, one quantity band
app/Models/PricingImport.php        one spreadsheet import run
app/Models/Quotation.php            the document; recalculate() owns the totals
app/Models/QuotationItem.php        one line; price() owns the arithmetic

app/Services/Pricing/CatalogueLog.php        the pricing log (see §6)
app/Services/Pricing/CatalogueImporter.php   spreadsheet → Product Master
app/Services/Pricing/DriveSheetFetcher.php   Google Drive/Sheets link → file
app/Services/Pricing/EnquiryExtractor.php    the Gemini call
app/Services/Pricing/ProductMatcher.php      code/name/alias → Product + score
app/Services/Pricing/QuotationBuilder.php    extraction → quotation rows
app/Services/Pricing/QuotationPdf.php        dompdf renderer
app/Services/Pricing/QuotationDraftWriter.php  the covering mail

app/Http/Controllers/ProductController.php    catalogue screen + imports
app/Http/Controllers/QuotationController.php  prepare / index / show / pdf

config/pricing.php                  one key: the log file path
```

### New — views

```
resources/views/products/index.blade.php     catalogue, price books, import panel
resources/views/products/_fields.blade.php   the product form (add + edit modals)
resources/views/products/import.blade.php    one import run and its rejected rows
resources/views/quotations/index.blade.php   quotation list
resources/views/quotations/show.blade.php    internal document (print-ready)
resources/views/quotations/pdf.blade.php     the customer's copy, for dompdf
```

`show` and `pdf` deliberately share nothing: dompdf supports a CSS 2.1 subset
with no flexbox or grid, and the two documents have different audiences.

### Modified — existing files

| File | Change |
| --- | --- |
| `routes/web.php` | added the `products` group, `quotation-prepare`, the `quotations` group, and two `use` imports |
| `resources/views/partials/sidebar.blade.php` | **Products** and **Quotations** nav items under Configuration |
| `resources/views/mail/reply.blade.php` | quotation controls in the AI-draft box, the `quotation_id` hidden field, and the `loadQuotation()` JS inside `setupAiDraft()` |
| `app/Http/Controllers/MailController.php` | `quotationAttachment()` (new); multipart MIME in `sendGemilReply`; a Graph attachment call in `sendOutlookReply`; `clearReplyDraft()` also marks the quotation sent |
| `composer.json` / `composer.lock` | `dompdf/dompdf ^3.1` plus 5 transitive deps. **Nothing else moved** — verified by diffing the lock. |

`MailController.php` is 7,534 lines and has been truncated by accident before.
**Run `php tools/verify-mailcontroller.php` after every edit to it.** It should
report 19/19 + 11/11 methods present and 70 methods on the class.

---

## 4. The rules the module is built on

Break these and the module stops being trustworthy. Each was a deliberate
decision, not an accident of implementation.

**A quotation is priced at the date it was raised.** Everything else follows.
`product_pricing` holds one row per price per validity window per quantity
band; `Product::priceOn($date, $qty)` is the only sanctioned read.

**Never quote from `products.price`.** That column is a *cache* of today's
figure for list screens. Quoting from it would rewrite history the moment a
price changes.

**A price is superseded, never edited.** `ProductPricing::supersede()` closes
the row it replaces the day before the new one starts and inserts a new row.
There is no update-price route anywhere, on purpose. MySQL cannot express
"no two windows for one product and band may overlap", so that method *is* the
constraint.

**Every figure on a quotation is a snapshot.** Product name, unit, unit price,
tax rate and the terms are copied onto the quotation and never read back from
the catalogue. `Quotation::recalculate()` reads only its own lines — the moment
it needs the catalogue, the rule is broken.

**An import never deletes.** A product absent from a spreadsheet is left alone.
Price lists are routinely partial; treating an omission as a deletion would
empty a catalogue on the first one-product update.

**A line that cannot be priced is still a line.** Unmatched or unpriced items
are kept with the customer's own words, flagged, and printed as *"To be
advised"* — never ₹0.00, which on a customer's copy reads as *free*.

**A doubtful match asks a human.** `QuotationItem::CONFIDENCE_FLOOR` is 70, not
50: a line needlessly flagged costs a glance, a confidently wrong product goes
to a customer as a price commitment.

---

## 5. Non-obvious decisions, with their reasons

**Extraction is not in the classification prompt.** It would be the obvious
place. That prompt runs over every mail and only a fraction are pricing
enquiries, and it is assembled inline in **five separate blocks** (three in
`ProcessEmailQueue.php`, two in `MailController.php`) that would drift. So
extraction runs on demand instead, when someone asks for a quotation. If
anything ever *must* go into that prompt, follow the vocabularies' pattern:
one `promptSection()` referenced from all five sites, as in
`EmailCategory::promptSection()`.

**The catalogue is sent to the model with the mail.** Free-text extraction
plus fuzzy matching guesses; giving the model the list turns the job into a
choice from a menu and lets it answer with a code the app can look up exactly.
`ProductMatcher` still verifies every code — the model has been known to
return one that does not exist.

**Confidence is corroborated, not taken on trust.** Code + the sender's own
words naming the product → ≥95. Code alone → the model's number, or 90.
*No* code but this app finds a name/alias → **60**, because the model and the
text disagree and a disagreement is information: a human decides.

**Google Drive without `google/apiclient`.** A sheet shared as "anyone with the
link" has a plain export URL, so the fetch is one HTTP GET. The cost is that a
*non*-shared link returns HTTP 200 with a sign-in page, so most of
`DriveSheetFetcher` is detecting that and saying *"that link is not shared
publicly"* instead of "no header row found".

**A repeated product code in one import file is a quantity band, not a
duplicate** — that is how tiered price lists are written. Only the first row
for a code may set the product's fields; later rows add prices only.

**Requirements are acknowledged, not turned into terms.** The extractor returns
what the sender wants that is not a product line; on a real enquiry that was
five *questions*, which read absurdly under Terms & Conditions. They go in the
covering mail under "You also asked about…" and stay on the quotation in
`extraction_json`.

**The quotation travels as a PDF, not a table in the body.** Two copies of one
document invite reconciliation, and the body copy loses its formatting —
CKEditor strips inline styles before the mail is even sent.

**Attachment failures never block a send.** If the PDF cannot be produced or
attached, it is logged and the reply still goes. The user has already pressed
Send; a mail without its attachment is recoverable, one that never leaves is
not.

---

## 6. Logging — `storage/logs/pricing.log`

Every catalogue change is logged, not only imports: products created, updated,
activated, deactivated, deleted; prices added, closed by supersession,
withdrawn, reinstated; quotations created and sent; Drive fetch failures;
rejected spreadsheet rows; exceptions with stack traces.

It is written from **model events** on `Product`, `ProductPricing` and
`Quotation`, so the log cannot be bypassed by a new caller.

```
[2026-09-10 03:24:30] product created product=57 code=P001 name="ABC Product" owner=39 actor=39
[2026-09-10 03:27:19] product deactivated product=57 code=P001 owner=39 fields=is_active actor=39
```

`key=value` throughout — `grep 'run=15'` gives one import, `grep 'row rejected'`
gives every rejection. A separate file rather than `laravel.log`, which is
160MB here. `CatalogueLog::mute()` exists for the shot scripts, which write
inside transactions they roll back.

**This log has already earned its keep twice** — see §8.

`pricing_imports.error_log` is the *other* destination: the rejected rows
written for the user, capped at 500. The log file is the uncapped diagnostic
copy.

---

## 7. How to verify a change

```
php tools/pricing-smoke.php      # 59 checks — price lookup, importer, failures, logging
php tools/quotation-smoke.php    # 55 checks — matching, pricing, snapshot, draft, PDF
php tools/pricing-shot.php       # renders the catalogue screen  → public/_shot.html
php tools/quotation-shot.php     # renders the quotation screen
php tools/quotation-shot.php --pdf   # renders the customer's PDF layout
php tools/<name>-shot.php --clean    # ALWAYS — _shot.html is public
php tools/verify-mailcontroller.php  # after ANY MailController edit
```

Both smoke suites write real rows under the product-code prefixes `ZZSMOKE-`
and `ZZQUOTE-` and delete them again. `quotation-smoke.php` runs the whole
matching/pricing/PDF path **with no network call** — the extraction is handed
in as an array, which is exactly why `QuotationBuilder` takes it as an argument
instead of making the Gemini call itself. Keep it that way.

---

## 8. Bugs found during the session — do not reintroduce

| Bug | Cause | Guard |
| --- | --- | --- |
| Bulk orders quoted the wrong price | Two unbounded bands are equally *wide*, so ranking by width fell through to the older row | Rank by the band's **floor** first; smoke checks the 1-and-up vs 10-and-up case |
| Price ranking silently nonsensical | `Collection::sortBy([fn, fn, …])` treats each callable as a **comparator** `($a,$b)`, not a key extractor | Explicit comparator over rank tuples in `Product::priceRank()` |
| "Import failed" on a correct re-import | Status derived from created+updated, which are legitimately 0 when nothing changed | Derived from **rows that got through**; smoke asserts an unchanged re-import stays `completed` |
| `prices_removed=0` on every delete | Counted in the `deleted` event, after `product_pricing` had cascaded away | Counted in `deleting`; smoke asserts a real count |
| ₹0.00 printed for unpriced lines | A zero in a price column reads as *free* on a customer's copy | "To be advised" in both documents; smoke asserts no zero price in the draft |
| Customer's questions printed as Terms & Conditions | Requirements appended to the terms | Moved to "You also asked about…" in the covering mail |
| Sample workbook rejected by its own importer | The tiered second `P001` row tripped the duplicate check | Duplicate check keys on code **+ band**; smoke parses the generated sample |

---

## 9. Live data notes (this installation)

- The catalogue account is **user 39, `ami@mukesoft.com`** (`auth_id = 0`).
  Mailboxes 28/29/31 belong to it; `Product::ownerIdFor()` maps them to 39.
- **User 27, `hari@mukesoft.com`, is a separate account with no products.**
  Preparing a quotation from that mailbox legitimately reports an empty
  catalogue.
- 4 products (P001–P004), 5 price rows, 5 import records. Two imports are the
  user's own: an uploaded sample workbook and a **Google Sheet fetched by
  link** — both completed with zero failures.
- On 10 Sep the user deactivated all four products by hand, then hit *"no
  active products"* when preparing a quotation. The log established it was not
  an importer bug. They have been reactivated. The message now distinguishes
  "you have products but none are active" from "you have no products", and the
  Products screen warns when the whole catalogue is off.

---

## 10. What is not built

**Phase 2 — Question Master.** `question_master` and `product_questions`: the
information a quotation needs before it can be generated (installation? site?
AMC?). The spec's design is sound; nothing is written.

**Phase 6 — discount approval.** A discount over a threshold needs a manager.
Policy on top of a working engine, so it is genuinely last.

**A company profile.** The PDF header shows the account's name and email
because that is all the app holds. A logo, registered address and GSTIN want a
settings screen — and a logo needs `ext-gd` enabled (php.ini:931).

**No manual override screen for a quotation.** The spec asks for the user to be
able to change product, quantity, price, discount and tax before sending. Today
they can edit the covering mail freely, but the quotation lines themselves are
only editable by rebuilding. `quotation_items` already carries
`discount_percentage`, so the schema is ready for it.

**Customers are not a table.** Name and email are snapshotted onto each
quotation, which is what a quotation needs anyway. A `customers` table can be
added later without touching those columns.
