# Purchase Orders (2026-09-26)

Everything about how purchase orders are created, split by company, viewed and sent to suppliers. Keep this file up to date when the PO screens change.

---

## 1. Companies, warehouses and suppliers

| Thing | Rule |
|---|---|
| A PO belongs to a **company** | The company that owns the PO's **receiving warehouse** (`inventories_warehouses.company_id`). Set in `PoCreate::resolveCompanyId()`. Falls back to the company picked on the wizard, then the user's default company. |
| "Delivery to" on the PO | The PO company's address (Companies → Edit). So a Tool365 PO delivers to Tool365, a Somerset4×4 PO to Somerset4×4. |
| A supplier is bought **for** one or more companies | Table `purchases_supplier_companies` (partner_id, company_id). Edit on **Purchase → Suppliers → Edit → General → "Buys for companies"**. Shown in the Suppliers list as the **Buys for** column. |
| Supplier with no companies assigned | Offered under **every** company (so nothing vanishes before set up). |

Old bug (fixed): `PoCreate` always used `Company::orderBy('id')->first()`, so every PO got the first company and its address.

**Second bug (fixed 2026-09-28):** the "Buys for companies" restriction was only being respected on the standalone Create/Edit form (`OrderResource`, section 3 above) — the **PO Wizard has its own separate company/warehouse resolution** (`PoCreate::resolveCompanyId()` / `fallbackWarehouseId()`) that never checked `purchases_supplier_companies` at all. Any PO created via the wizard with no warehouse explicitly picked (the normal case — "lines added by hand") fell through to whatever company tab was active, or the user's own default company, completely ignoring which company the supplier is restricted to. This is what actually produced the Festool→Somerset4×4 PO reported on 2026-09-28 (PO280920261142 / id 379) — the earlier same-day fix only covered the Edit form, not the Wizard. Both `resolveCompanyId()` and `fallbackWarehouseId()` now check `purchases_supplier_companies` first via a shared `supplierRestrictedCompanyId()` helper, same priority as the Edit form (explicit warehouse choice still wins if one was made). Test: `tests/Feature/PoWizardSupplierCompanyRestrictionTest.php`.

### Existing POs saved with the wrong company
Open the PO → Edit → set Company and Receiving Warehouse/Location.

---

## 2. PO Wizard (`purchases/src/Filament/Admin/Pages/PoCreate.php`)

1. **Step 1 – supplier list.** Company tabs across the top: **All companies · Somerset4×4 · Tool365**.
   - Choosing a company shows only suppliers assigned to it (or unassigned ones) and narrows the "below min" / "on orders" counts to that company's receiving warehouses.
2. **Step 2 – lines.** Warehouse chooser only lists that company's receiving (non-dropship) warehouses.
3. **Create.** Company = warehouse company; destination = the warehouse's Stock location; if no warehouse was picked (lines added by hand) it uses the selected/default company's first receiving warehouse.
4. A draft PO for the same supplier + receiving location is offered as "add to existing" instead of creating a second draft (so drafts are per company automatically).

Tests: `tests/Feature/PoWizardCompanyTest.php`, `SupplierCompanyAssignmentTest.php`, `SupplierEditCompaniesTest.php`, `SupplierListCompanyColumnTest.php`.

---

## 3. PO view screen (`PurchaseOrderResource/Pages/ViewPurchaseOrder.php`)

Header layout (top-left under the title): the text **Status** with a simple **status dropdown inline beside it** (no pill, no modal). Picking a status calls `setStatus()`. **Completed is automatic, not a manual choice:** it stays in the list greyed out ("Completed (automatic)", disabled) and `setStatus()` refuses it if sent anyway. A PO moves to Completed by itself when the last line is delivered (`DeliverOrder::commitPendingDeliveries()`). `getSubheading()` renders it.

**No wide tab bar** (View / Edit / Deliver) on PO screens any more — `HasInlineStatusDropdown::getHeaderWidgets()` returns none. Its only useful link, **Edit**, is now a normal header button (Draft POs only); the Edit screen has a **View** button to go back.

Header buttons (right): Edit (Draft only) · Add Supplier Reference (white button, only when Open) · **Submit to [connector logo]** (Open POs only) · Print PO · Delete.

| Button | State | Notes |
|---|---|---|
| Change status | changed | Inline dropdown beside the status pill (was a header button + modal). |
| Audit | renamed | Was the "Chatter" icon button. Now a vertical **AUDIT** tab on the right edge of the screen (see section 5). |
| Add Supplier Reference | restyled | White (gray) button instead of blue. Only shown when the PO is Open. |
| **Submit to [logo]** | changed | Was "Review & submit to supplier". Now reads "Submit to" followed by the supplier connector's logo. Opens the review page. **Only shown when the PO is Open (sent) — never on a Draft** (`SubmitToSupplierAction::isAvailable()`); move a draft to Open with the status dropdown first. Logo comes from `public/vendor/connectors/<driver>.svg`, driver from the supplier's connection (`nexmart_csv2` → `nexmart.svg`), so other connectors show their own logo automatically. Code: `supplier-connectors/.../Actions/SubmitToSupplierAction.php` (`label()`). |
| Print RFQ | **HIDDEN** | It currently errors. Commented out in `ViewPurchaseOrder::getHeaderActions()` with a note. Restore by uncommenting `OrderActions\PrintRFQAction::make()` once the RFQ print view is fixed. |
| Re-Send By Email | **HIDDEN** | Not used at the moment (POs go out via a connector or manually). Commented out with a note. Restore by uncommenting `OrderActions\SendEmailAction::make()`. |

Test: `tests/Feature/PurchaseOrderViewPageTest.php`.

---

## 4. Supplier connectors (Nexmart today)

- Review page: `/admin/supplier-orders/{id}/review` — shows header, lines, the exact file, Download, Submit.
- **Customer ID (H6)** for Tool365 is **`C253986`** (from Lennart Grose's email + sample file in `z_notes/festool_edi`). It must be entered on the Nexmart connection (Connectors → Nexmart) on each database, otherwise the review page shows "Customer ID (H6) is not set".
- Supplier ID (H5) `tooltechnic_uk`. A GLN can replace the C-ID if nexMart is given one.
- Notes on fields and where to get them are in the Nexmart connection page's notes drawer.
- **Fixes from a live-file review (2026-09-28):**
  - **H20 (error notification email)** was always sent blank. There's now a separate **Error notification email (H20)** field on the connection; leave it blank and H20 sends the same address as Acknowledgement email (H23), matching nexMart's own H23-falls-back-to-H20 rule reversed for convenience.
  - **Message ID (H4)** always carried a timestamp, so nexMart could never catch a genuine resubmit of the same PO as a duplicate. **Production** now sends the plain PO name only; **sandbox** keeps the timestamp suffix, since the same test PO gets resent on purpose.
  - **Order reference (H10)** is now a pattern field (like the file name pattern), default `{po_name}`. `{po_name_base}` drops the "-2" style suffix `Order::updateName()` adds when two POs are created in the same minute — that suffix is just a same-minute disambiguator, not a meaningful revision number, so sending it as-is is fine; the token exists so it can be dropped per connection if a supplier ever objects to it. **The "-2" suffix itself is still normal and expected** — see the uniqueness fix below, which only closes a race-condition edge case, not the suffix itself.

### PO name uniqueness (2026-09-28)

`Order::updateName()` picks a free name (`PO...`, `PO...-2`, `PO...-3`, ...) by checking `where('name', $name)->exists()` in a loop before insert. That check-then-insert wasn't atomic and `purchases_orders.name` had no DB-level uniqueness — so two POs created at the exact same instant (concurrent wizard submits, a bulk-import script) could both pass the check before either committed and end up sharing one name. Sequential, one-at-a-time creation was never at risk.

Fixed:
- Migration `2026_09_28_000002_add_unique_index_to_purchases_orders_name` adds a unique index on `purchases_orders.name`.
- `Order::save()` is overridden: on create, if the insert throws a `QueryException` matching that unique index, it calls `updateName()` again (which now sees the real DB state) and retries, up to 5 attempts, before giving up and rethrowing. Any other `QueryException` is rethrown immediately, unchanged.
- This does **not** stop `-2` suffixes appearing — that's still correct, expected behaviour whenever two real POs land in the same clock minute. It only turns a would-be silent duplicate into an automatic, transparent retry.

Test: `tests/Feature/PurchaseOrderNameUniquenessTest.php` — confirms the DB constraint is real (a raw duplicate insert throws), and exercises the actual retry path via a test-local `Order` subclass that fails its first insert with a synthetic duplicate-key exception (a genuine two-process race can't be reproduced deterministically in a single PHPUnit process).

### "Sent to [connector]" badge (2026-09-28)

Draft/Open/Confirmed/Completed says nothing about whether a PO has actually gone out to the supplier via a connector — successfully uploading to Nexmart just left the PO on whatever status it already had (Open, since "Submit to Nexmart" only shows once a PO is already Open — see section 3). Rather than adding a new status into `OrderState` (touches list filters, delivery guards and badge rendering throughout Purchases for a much bigger, riskier change), a small green **"Sent to Nexmart · 28 Sep 2026"** badge now sits next to the Status dropdown on the PO view/edit/deliver screens, sourced straight from the `supplier_submissions` audit table:
- Only counts a **production** submission with `status = 'submitted'`. A sandbox test submission (however it's dressed up — "SANDBOX — test submission only" banner) never counts, matching `SupplierOrderSubmitter`'s own rule that sandbox never changes anything else about the PO either.
- A **failed** submission doesn't count either — only shows once nexMart (or whichever connector) actually received the file.
- The connector name in the badge comes from the connection's `driver` column (`nexmart_csv2` → "Nexmart"), so other connectors label themselves automatically, same pattern as the button logo in `SubmitToSupplierAction::label()`.
- Code: `HasInlineStatusDropdown::getSubheading()`. Test: `tests/Feature/PurchaseOrderSentBadgeTest.php`.

Same rule, also on the **Purchase Orders list** as a **"Sent to Supplier"** column (toggleable, only added when the supplier-connectors plugin is installed): blank/grey `—` normally, green `Sent 28 Sep 2026` once a real production submission exists. Backed by a correlated subquery (`MAX(submitted_at)` from `supplier_submissions` filtered to `environment = 'production'` and `status = 'submitted'`) added in `OrderResource::getEloquentQuery()` so it's one query for the whole page, not one per row. Test: `tests/Feature/PurchaseOrderSentToSupplierColumnTest.php`.

---

## 5. Audit tab (replaces the Chatter button everywhere)

The `ChatterAction` (`plugins/webkul/chatter/src/Filament/Actions/ChatterAction.php`) is used on ~55 pages. Its trigger is now a purple vertical **AUDIT** tab fixed to the right edge, styled like the NOTES tab from `notes_drawer.md`, and the slide-over is titled **Audit** (lang file `chatter/resources/lang/en/filament/resources/actions/chatter-action.php`).

- Styling: `.fi-btn.cy-audit-tab` in the panel style block of `app/Providers/Filament/AdminPanelProvider.php`.
- The tab sits **above** the NOTES tab (`top: calc(50% - 6.5rem)`) so both can be on one page.
- The slide-over content (messages, logs, files, followers, activities) is unchanged.
- No icon/spinner inside the tab (the spinner made it grow while opening). The unread count only appears as a small red dot on the tab's corner when there is something unread.

## 6. Other UI rules touched in this work

- Sticky Save/Cancel bar sits above cards below the form but under the sidebar (`z-index: 10`).
- Company/supplier/partner address order is UK style: Street 1, Street 2, City, County, Postcode, Country. ("State" is called "County", "Zip" is "Postcode".)

---

## Deploy checklist (server)

```bash
php artisan migrate --force        # creates purchases_supplier_companies
php artisan view:clear
php artisan optimize:clear
php artisan optimize
```

Then: assign each supplier to its company(ies), enter the Nexmart customer ID `C253986`, and fix any old PO with the wrong company.
