# Common Recipes

> This file is imported into `CLAUDE.md` via `@reference/recipes.md`, so everything
> here loads into context exactly as if it were inline. It was split out only to keep
> `CLAUDE.md` under its size limit. **Add new recipes here, not in `CLAUDE.md`.**

Living reference for non-obvious patterns — extend this section whenever a
pattern would otherwise have to be rediscovered by reading code or trial and
error. If you're about to write custom plumbing that another file already
solves, look here first.

> Note: recipe paths that read `components/…`, `pages/…`, `composables/…`,
> `assets/…` live under `app/` (Nuxt 4 srcDir). The `~/` and `@/` aliases
> still resolve them unchanged; only bare relative/OS paths gained the `app/` prefix.

### Get the c_bpartner linked to an ad_org
Every organization has a linked business partner (`ad_org.C_BPartner_ID`).
Use the dedicated endpoint — do NOT re-resolve via `models/ad_org/{id}` plus
a second filter call.

```typescript
// Returns the full c_bpartner record (all fields, no $select)
const bpartner = await $fetch(`/api/admin/organizations/${orgId}/bpartner`, {
  headers: useRequestHeaders(['cookie'])
})
```

Server route: `server/api/admin/organizations/[id]/bpartner.get.ts`

### Hourly rate for billing (tasks / requests)
Field on `c_bpartner`: **`fulfillment_service_hourly_rate`** (number, EUR).
Standard fallback chain used across the app:
1. request's own `C_BPartner_ID`
2. the org's linked c_bpartner (recipe above)
3. `33` EUR hard fallback

Reference implementations:
- Desktop: `components/requests/RequestForm.vue` → `fetchHourlyRate()`
- Mobile: `pages/mobile/tasks.vue` → `resolveOrgBpartner()` + `extractHourlyRate()`

When reading from a c_bpartner record, check multiple case variants
(`fulfillment_service_hourly_rate`, `Fulfillment_Service_Hourly_Rate`) because
iDempiere's OData response casing varies by model.

### Appending time-billing notes to a request
When billing time (either via desktop "Zeit abrechnen" or mobile task stop),
append a `[Zeitabrechnung]` block alongside `[START:]` / `[END:]` / `[DURATION:]`
so how the amount was calculated is recorded on the request itself:

```
[Zeitabrechnung]
Zeit erfasst: 1h 15min
Stundensatz: 45,00 €/h
Berechneter Betrag: 56,25 €
```

Desktop writes this to a chat `request-update` entry; mobile appends it to
the request's `summary` field (no chat on mobile).

### iDempiere boolean flags with a DB-side default
When a `C_*` column has a DB default (e.g. `C_Order.isFulfillmentOrder` defaults
to `true`), do NOT send the field on every create call. Send it only when the
user picks the non-default value — saves payload bytes and keeps the DB default
authoritative for any flow that doesn't know about the field yet.

Pattern in `*/store.post.ts` (or any creator route):
```typescript
// Only send when explicitly false — DB column defaults to true.
if (body.isFulfillmentOrder === false) {
  newObjValue = { ...newObjValue, isFulfillmentOrder: false }
}
```

For background creators that should set the non-default value (e.g.
`server/api/fulfillment/generate-orders.post.ts` creates bookkeeping orders that
must NOT be treated as fulfilment), inline the override directly on the payload:
```typescript
orderPayload = {
  AD_Org_ID: organizationId,
  IsSOTrx: true,
  isFulfillmentOrder: false,
  // …
}
```

Key always lowercase-first on writes (`isFulfillmentOrder`, not
`IsFulfillmentOrder`) — see the existing memory note. On reads, accept both
casings because iDempiere's OData response varies by model:
```typescript
isFulfillmentOrder: res?.isFulfillmentOrder ?? res?.IsFulfillmentOrder ?? true,
```

### Partner reminder notes — auto-popup on order open
`C_BPartner.PartnerInvoiceReminderNotes` is a per-partner HTML field
(`<ul><li>…</li></ul>`) that staff use as a manual checklist when finalising
invoice-type orders. The reminder modal auto-opens on `pages/sales/orders/[id]/{view,edit}.vue`
when ALL of:
- `getMenuType() !== 'c'` (non-limited role)
- `form.value.isFulfillmentOrder === false`
- `docStatusId` ∉ {`CO`, `VO`, `CL`}
- partner has non-empty notes

Cached on the page in `reminderNotesContent` so the manual "🔔 Erinnerungen"
button can re-open the modal without another partner fetch. Components:
- `components/sales/ReminderNotesModal.vue` — bound to `useReminderNotes()` /
  `useReminderNotesParam()` (see `composables/states.ts`)
- `components/partners/PartnerReminderNotesEditor.vue` — list editor with `+` /
  reorder / remove. Editor parses both HTML (`<li>`) and legacy newline-separated
  plain text; always writes back as `<ul class="invoice-reminder-list">…</ul>`.

The editor is dropped into the customer-tab section of `PartnerForm.vue`.
Persisted via `addIfChanged('partnerInvoiceReminderNotes', 'partnerInvoiceReminderNotes')`
in `server/api/partners/partners/update.put.ts` and read tolerantly:
`res?.partnerInvoiceReminderNotes || res?.PartnerInvoiceReminderNotes || ''`.

### Adding a new editable field to the Business Partner edit form
A new `C_BPartner` column becomes editable on `/partners/partners/[id]/edit` only after
it is wired through **four** places (the edit page does NOT pass the form through
generically — it explicitly maps every field three times). Miss one and the field
silently won't load or won't save.

1. **`components/partners/PartnerForm.vue`** — the shared form UI.
   - Add the key(s) to the `form = ref({...})` init (`fooBar: props.modelValue.fooBar ?? …`).
     For an FK add both `fooBar` (identifier string) and `fooBarId`.
   - Render the control in the right tab. Booleans → an `ff-toggle-card` in the
     fulfillment tab's *Feature Flags* section; FKs → a `SelectBox` mirroring the
     `models/ad_org` select (`@click="setOptions('models/<table>', '<key>')"`,
     `@change="(val, opt) => {form.foo = opt.Name; save()}"`). Every control calls `save()`.
2. **`pages/partners/partners/[id]/edit.vue`** — three edits:
   - add the key(s) to its own `form = ref({...})`;
   - map them in `retrieve()` (`res?.Foo_ID?.identifier`/`.id`, or for a Yes/No flag parse
     tolerantly: `(res?.IsFoo ?? res?.isFoo) === true || … === 'Y'`);
   - add them to the `fieldMapping` in `getChangedFields()` (FK → list the `…Id` key;
     this is what actually gets sent on save).
3. **`server/api/partners/partners/update.put.ts`** — `addIfChanged('fooBar','fooBar')` for
   scalars/booleans (**lowercase-first key** per the iDempiere write-casing rule, even when
   the DB column is PascalCase), or `addRefIfChanged('fooBarId','Foo_ID','FooTable')` for FKs
   (sending `''` clears it to `0`).
4. **`assets/langs/shared.js`** — add label/tooltip keys to en-US / de-DE / es-ES.

`store.post.ts` is OPTIONAL for parity (create-path is robust), but **`create.vue` is
deliberately NOT wired** for detailed fulfillment fields — it sends only a subset at
creation, so those fields are edit-only (the tab still renders on create, just won't persist).
The custom read-only `[id]/view.vue` does not use `PartnerForm` and is intentionally left out.

Example (this is how `Accounting_AD_Org_ID`, an `ad_org` FK in the *Accounting Assignment*
sub-head of the LogShip-account section, and `IsEditProductImage`, a *Feature Flags* toggle
that lets a limited-role customer manage product images — read by
`pages/materials/products/[id]/edit.vue` to expose the Gallery tab — were added).

### Adding a new `FulfillTypeAccounting` to the fulfillment invoice generator
`server/api/fulfillment/generate-orders.post.ts` fetches all `cust_fulfillmentfeeline`
records with `A_Processed eq false` and routes each line through a per-type
`if / else if` chain. **A line with an unhandled `FulfillTypeAccounting` value is
silently skipped but still marked `a_processed='Y'` at the end** (`batch-process-lines`
sweeps every fetched line ID regardless), so missing a branch = silent data loss
on the invoice. Always extend the chain when a new type appears in the table.

Currently handled types (in branch order): `fulfillment`, `return`, `spacerentqm3`,
`spacerentflat`, `parcel`, `subscription`, `shippingfee`, `request`.

Two billing shapes are in use:
- **Aggregated** (default — `fulfillment`/`return`/`parcel`/etc.): bucket lines
  by `M_Product_ID` into a keyed object, sum `LineTotalAmt`, emit one order line
  per product with a summary description like `${t.parcel}: ${qty} ${t.parcels}`.
- **Per-entry** (`request`): push each fee line into a flat array, emit one
  order line per fee line, copy `line.Description` verbatim onto the order line
  so the ticket reference stays on the invoice.

Adding a type touches **six** places in `generate-orders.post.ts`:
1. Bucket declaration near `const fulfillmentLines: any = {}` (keyed object for
   aggregated, `any[] = []` for per-entry).
2. New `else if (line.FulfillTypeAccounting === 'X')` branch in the grouping
   loop (around the existing `shippingfee` branch).
3. `orderLines.push({...})` loop — the actual iDempiere `c_orderline` payload.
4. `previewOrderLines.push({...})` loop (used for the preview modal).
5. `responseOrderLines.push({...})` loop (post-create API response).
6. `reportOrderLines.push({...})` loop (PDF report generation).

If you add a translation label for the description, also update `t.X` /
`t.Xs` (singular/plural) across all four language blocks in
`assets/langs/shared.js`. Mirror the new branch in
`server/api/fulfillment/test-grouping.post.ts` (diagnostic endpoint — same
grouping, no order creation).

`components/fulfillment/FulfillmentGenerateOrdersModal.vue` already falls back
to a 4-column detail layout (Fee Line ID + Date + Qty + Unit Price + Amount)
for any `line.type` it doesn't have an explicit branch for, so a per-entry type
needs no UI change unless it has order/shipment links worth surfacing.

### Nuxt 4 + Nuxt UI v4 conventions (post-migration)
The app was migrated from Nuxt 3 (flat layout) to **Nuxt 4** with the idiomatic
`app/` srcDir and **Nuxt UI v4**. Key things that bite if forgotten:

- **srcDir is `app/`.** `~`/`@` → `app/`; `~~`/`@@` → repo root. `server/`, `public/`,
  `data/`, `scripts/` and configs stay at root. When importing a root-level file
  from inside `app/`, use `~~/…` (e.g. `~~/node_modules/...`) — though for
  node_modules CSS prefer **bare specifiers** (`'bulma/css/…'`).
- **Reference public assets as `/assets/…`, never `/public/assets/…`.** `public/`
  is served at the URL root; the `/public/` prefix 404s at runtime AND breaks the
  production build (Rollup tries to resolve it as a module).
- **Custom router config** lives at `app/router.options.ts` (Nuxt-conventional).
  Its dynamic page imports use the `~/pages/...` alias (not relative `../pages`).
  `app/forms/*.ts` and `app/windows/index.vue` are custom helpers it imports.
- **Nuxt UI v4:** single `@nuxt/ui` module (no `@nuxt/ui-pro`); Pro components
  (e.g. `UFooter`) need the `NUXT_UI_PRO_LICENSE` env (set in `.env*`). `<UApp>`
  now provides the tooltip context — there is no `<UTooltipProvider>`. Theme is
  `app.config.ts` → `ui: { colors: { primary, neutral } }` (the old `gray` key is
  now `neutral`; `'cool'`/other UIv2 aliases are invalid — use a Tailwind color
  like `'slate'`). On components, `color="gray"` → `color="neutral"`.
- **Fonts:** `@nuxt/fonts` (bundled with UI v4) rejects `provider: 'none'`. Fonts
  are self-hosted via `app/assets/css/fonts.css`, so use `provider: 'local'`.

### Dev/build need an elevated Node heap (large app)
This codebase is large enough that under Nuxt 4 + Vite 7 the default Node heap is
exhausted, killing the dev `vite-node` SSR worker — surfacing as **`IPC connection
closed`** or a stale-socket **`connect ENOENT …nuxt-vite-node-*.sock`** with every
route returning 500 (no V8 OOM trace because the OS kills the worker). The
production build OOMs similarly.

- **Dev:** the `dev` script sets `TMPDIR=/tmp NODE_OPTIONS=--max-old-space-size=8192 nuxt dev`.
  (Append host flag as `npm run dev -- --host`.) The `TMPDIR=/tmp` prefix is the
  macOS socket-length fix below — keep it.
- **Build/deploy:** do NOT hardcode `NODE_OPTIONS` in the `build` script —
  `ci/deploy-production.sh` already `export`s `NODE_OPTIONS="--max-old-space-size=12288"`
  before `npm run build`; an inline value would override and lower it.
- If a fresh `nuxt dev` misbehaves right after a `nuxt build`, do a clean restart:
  kill node, `rm -rf .nuxt .output node_modules/.cache`, then `npm run dev`.

**Two distinct causes produce the same `connect ENOENT …nuxt-vite-node-*.sock`:**
1. **OOM** — the SSR worker is killed; fixed by the `--max-old-space-size` heap above.
2. **macOS 104-char unix-socket limit (the subtle one).** macOS caps socket paths
   at 104 bytes. The default macOS `$TMPDIR` (`/var/folders/<hash>/T/`, ~49 chars)
   pushes the vite-node socket path to ~110 — over the limit. A recent
   `@nuxt/vite-builder` security step `chmodSync`s the socket right after `listen()`;
   because the kernel truncated the over-long name at bind, the chmod targets a path
   that doesn't exist → it logs **`Failed to restrict vite-node socket permissions;
   closing`** and closes the server, so every request then ENOENTs. **Tell-tale:** the
   `chmod ENOENT …` line *above* the ENOENT spam, and a socket path under
   `/var/folders/…/T/`. Clean restarts don't help (the path is long every boot).
   **Fix:** shorten `$TMPDIR` — the `dev` script forces `TMPDIR=/tmp` (socket path
   drops to ~66 chars). Must live *inside* the npm script (not a shell prefix),
   because IDE runners (PhpStorm) launch with their own launchd `$TMPDIR`.

**Only one `nuxt dev` per project directory.** Nuxt's dev-lock refuses a second `nuxt dev`
against the same repo (`Another Nuxt dev server is already running: PID …` — the second
process exits immediately) — so a stray/IDE-launched instance on a different port (e.g.
`nuxt dev --port 3010 --host` from a PhpStorm run config) silently coexists with a
terminal-launched one only until something breaks the lock. Running `rm -rf .nuxt` while
BOTH are alive (e.g. the terminal one just exited and its lock file lingered) lets a second
`npm run dev` start clean and acquire a new lock — leaving two servers writing into the same
`.nuxt`/Vite cache concurrently, which corrupts shared build state and makes the older one
start 503ing. **Symptom:** a page that works in one tab but looks broken/unstyled in another,
even after "fixing" and restarting — first check `ps aux | grep "nuxt dev"` for more than one
matching this repo's path before assuming a code/CSS problem. Fix: kill every one of them,
`rm -rf .nuxt node_modules/.cache`, start exactly one.

### Non-blocking list pages — `lazy: true` + AgGrid `:loading`
In Nuxt 4 a top-level `await useFetch(...)` in `<script setup>` **blocks navigation**:
clicking a menu item freezes on the *previous* page until the request resolves. On
the dashboard (many parallel fetches) this was egregious, but every list page has
the same class of stall. Fix = make the fetch lazy and show a spinner instead of a
frozen page.

```js
// page <script setup> — render the shell immediately, fill in when data arrives
const { data, pending } = await useFetch('/api/materials/stock-units', {
  headers: useRequestHeaders(['cookie']),
  lazy: true
})
```
```vue
<!-- AgGrid.vue has a `loading` prop bound to AG Grid's built-in spinner overlay
     (default false, backward-compatible). Pass pending so the grid shows a spinner
     instead of a "No Rows" flash while data is null. -->
<AgGrid :entries="rowData" :columns="colDefs" :loading="pending" />
```
Requirements / gotchas:
- The `rowData`/list computed MUST be null-safe (`data.value?.records?.map(...) || []`)
  because `data.value` is `null` until the fetch resolves. The standard list-page
  pattern (null-safe computed + `alert` via `?.` + `watch(data)`) already satisfies this.
- Custom card-layout pages (not AgGrid) usually already have a `v-if="pending"`
  spinner block — for those just add `lazy: true`; the spinner then actually shows on
  first navigation (it never did before, because the page only rendered post-await).
- Multi-fetch pages: making the option/dropdown fetches lazy unblocks nav even when
  the grid loads its own rows separately (`AgGridServer` / `onMounted`).
- **Skip** tiny lookup tables and create/edit pages whose top-level await is one small
  fast fetch — the stall is imperceptible and not worth the churn.

### `useLookup` — cached fetch for shared reference data
`app/composables/useLookup.ts` wraps `useFetch` with a URL-keyed cache (`getCachedData`)
+ `lazy: true` + cookie headers. Use it for near-static reference lists fetched on many
pages (countries, organizations `ad_org`, `order-sources`, …) so they aren't re-fetched
on every navigation:
```js
const { data: organizations } = await useLookup('/api/models/ad_org')
const { data: orderSources }  = await useLookup('/api/settings/order-sources')
```
**Do NOT use it on an entity's own CRUD/management list page** (e.g.
`settings/order-sources/index.vue`) — those must always refetch to show just-created/
edited rows. `useLookup` is for the *consumer* dropdown fetches only; keep plain
`useFetch` on the management page.

### Staff live-chat module (WebSocket presence + 1:1 DMs)
An **isolated, bolt-on** real-time chat for staff (`ad_user.IsSystemUser`, non-limited
roles only). Independent of the requests/ticket system. It is the **only WebSocket** in
the app — everything else is SSE. Designed so it can be disabled with **zero risk**.

**Kill-switch:** `CHAT_ENABLED` env (private `config.chatEnabled` + public
`config.public.chatEnabled`). When not `true`: client opens no socket and renders nothing,
server routes return 404, `/chat/ws` closes immediately. Set in `.env`, `.env-dev`,
`.env-prod`. Toggling one env var fully enables/disables the feature.

**Transport:** Nitro native WebSocket — `nitro.experimental.websocket: true` +
`server/routes/chat/ws.ts` (`defineWebSocketHandler`). One socket per tab carries messages,
presence, heartbeats and activity signals. REST (`server/api/chat/{history,unread,read}`)
only loads history / seeds unread / marks read.

**Presence reflects ACTIVITY, not just connection.** Three states (online / away / offline).
The client (`useChatSocket`) watches `mousemove/keydown/scroll/touch/click` + visibility and
reports `{type:'activity',active}`; after **5 min idle** (or hidden tab) the user flips to
**away** even with a tab open. 25s ping + 35s server stale-sweep reap zombie sockets.
Presence is an in-memory `Map<userId,…>` in `server/utils/chatPresence.ts` (refcounted per
tab — last tab closing → offline).

**Access gate is server-authoritative** (`server/utils/chatAccess.ts`). The JWT carries only
`AD_User_ID`/`AD_Role_ID`, so `assertStaffAccess()` resolves the role's `FrontendMenu`
(reject `'c'`) and the user's `IsSystemUser` against iDempiere once, caches the verdict
(10-min TTL), **fail-closed**. Enforced in the WS `open()` hook and every REST route
(`gateFromEvent`). Client `getMenuType() !== 'c'` is defense-in-depth only.

**Storage:** frontend-owned `data/chat.db` (better-sqlite3, `server/utils/chatDb.ts`,
lazy/try-catch open — a DB failure degrades chat only, never crashes boot). NOT an iDempiere
table. **Offline delivery** reuses web-push: `sendPushToUser()` in `pushNotifier.ts` fires
when the recipient has no live socket.

**Isolation rules (keep them):** nothing in the app imports chat code (chat → `pushNotifier`
is the only, one-way, dependency). The 4 shared-file edits are additive only: `nuxt.config.ts`
(websocket flag + `chatEnabled`), `states.ts` (6 `useChat*` keys), `pushNotifier.ts`
(`sendPushToUser` + `buildChatMessagePayload`), `NewNavBar.vue` (one `<ChatBadge>` block
inside `<ClientOnly><NuxtErrorBoundary>`). UI: `components/chat/{Badge,Drawer}.vue` (Drawer
is teleported → its `<style>` is **non-scoped** per the teleport+scoped-CSS gotcha).
Gotcha: don't name a chat util export `parseCookies` — it shadows h3's auto-import
(use `parseCookieHeader`).

**Two chat surfaces — desktop drawer + mobile page (KEEP IN SYNC):** the chat UI exists in
**two** files that render the *same* state/composables (`useChat`, `useStaffDirectory`, the
`useChat*` state, `useChatEligible`):
- `components/chat/Drawer.vue` — desktop: a teleported right-side slide-in overlay.
- `pages/chat.vue` — mobile: a dedicated full-screen route (the teleport+overlay renders
  unreliably on phones — only the backdrop paints, panel content doesn't).

`<ChatBadge>` routes by viewport: `window.innerWidth <= 1024` → `navigateTo('/chat')`, else it
toggles the desktop drawer's `open` state. The drawer self-hides on mobile via an `isNarrow`
(`<= 1024`) gate so a stray socket-driven `open` can't surface the broken overlay on a phone.
**Rule: any change to the chat conversation UI/logic (message rendering, composer, search,
share cards, typing, read receipts, presence helpers) must be applied to BOTH `Drawer.vue` and
`pages/chat.vue` together** — they are intentional clones, not a shared component, so they drift
silently if you edit only one. (`pages/chat.vue` is single-pane with a back button and `mchat-*`
class names; `Drawer.vue` is two-pane with `chat-*` names — adapt class names accordingly.)

**Ops to enable in prod:** set `CHAT_ENABLED=true`; add the nginx `/chat/ws` upgrade block
(`proxy_http_version 1.1`, `Upgrade`/`Connection: upgrade`, long `proxy_read_timeout`); keep
PM2 at `instances: 1` — presence + registry are **per-process** (same caveat as
`notificationBus`); scaling to 2+ needs a Redis backplane (documented in `chatPresence.ts`).

### Commissioning-table → camera relation (which Tisch packed a shipment)
Each physical commissioning workstation ("Tisch 1/2/3…") has its **own label printer
AND its own surveillance camera**. To play the right commissioning video for a shipment
we must know which table packed it. Source of truth is the iDempiere table
**`CUST_CommissionTable`** (one row per Tisch), managed in-app at
`Settings → Commission Tables` (full CRUD). Columns: `Name`, `CommissionTableNumber`
(int Tisch no.), `shipping_printer` (e.g. `labelprinter-1` — the lookup key), `a4_printer`
(e.g. `commission-a4-1`), `synology_camera_id` (int), `Description`, `IsActive`.

**The join key is the shipping printer** the operator already picks on the commissioning
page. Flow:
- **Write (completion):** all 6 parcel routes (`commission/parcels/{index,other,dhl/index,
  dhl/multi,dpd/index,dpd/multi}.post.ts`) call `commissionTableFkPatch(event, shippingPrinter,
  token)` from `server/utils/commissionTable.ts` and merge the returned
  `CUST_CommissionTable_ID` FK onto the `m_inout` PUT. No-op when the printer is unmapped.
- **Read (video):** `surveillance/shipment-recording/init.post.ts` `$expand`s that FK off the
  shipment and uses the row's `synology_camera_id`, falling back to `SYNOLOGY_CAMERA_IDS` for
  old/un-linked shipments.
- **Picker (desktop + mobile):** `integrations/commission.vue`, `CommissionModal.vue` and
  `mobile/commission.vue` build the Tisch dropdown/buttons from `GET /api/commission/tables`
  (cached via `useLookup`), and `getA4Printer()` reads the row's `a4_printer`. **All fall back
  to hardcoded `Tisch 1/2/3` defaults** when the table is empty/unavailable, so the hot path
  can't break before the table is seeded. A `watch(printerOptions)` snaps a stale remembered
  printer to the first valid option.

`server/utils/commissionTable.ts` caches the rows per-org (10-min TTL, fail-soft → empty list).
Adding a Tisch later = add one row in the CRUD (printer + camera id + number). No code/env change.
Requires the iDempiere `CUST_CommissionTable` table + a nullable FK column
`M_InOut.CUST_CommissionTable_ID`. NB `manual-label.vue` still hardcodes `Tisch 1/2/3` — it's
a separate page, not part of the commissioning flow.

### DHL CN23 customs — no "Comments" field; use `exportDescription`
DHL's **Parcel DE Shipping API v2** customs object (the CN23 declaration) has **no
dedicated comments/remarks field**. Its only free-text fields are `invoiceNo`,
`exportType`, `exportDescription` (the export-purpose text, DHL-required when
`exportType === 'OTHER'`), plus the ID-number refs `shipperCustomsRef` /
`consigneeCustomsRef`. So anything you want to surface as CN23 "Comments" must go
into **`exportDescription`** (≤80 chars — matches the modal `maxlength` and the
existing `.substring(0, 80)` cap). DPD's `<international>` block likewise has no
remarks field (its `comment` element lives on the *address*, not customs).

The `customs` object is built **server-side** in
`server/api/commission/parcels/dhl/{index,multi}.post.ts` from the request body —
the customs item descriptions, `customsExportType`, etc. all come from the frontend,
NOT from a server-side order fetch. The CN23 is created whenever `body.parcelItems`
is present (every DHL commission label), defaulting `exportType` to `'OTHER'`.

`exportDescription` is resolved server-side through a **4-step chain** (all capped
at 80 chars): explicit modal description when `OTHER` → `body.orderDescription`
(= `c_order.Description`, passed by the frontend — the shipment is loaded with a
full `$expand=C_Order_ID`, no `$select`, so no extra fetch) → **joined customs item
descriptions** (per-parcel from `parcel.items` in `multi.post.ts`) → literal
`'Merchandise'`. The last two steps are the fail-safe that makes `exportType:
'OTHER'` always DHL-valid — important because `CommissionModal.vue` and
`mobile/commission.vue` never send `customsExportType`/`customsExportDescription`
at all (they have no customs modal) and orders often have no `Description`
(original failure: `customs.exportDescription: A description is necessary for the
type "OTHER"`).
```js
if(customsObj.exportType === 'OTHER' && body.customsExportDescription && String(body.customsExportDescription).trim()) {
  customsObj.exportDescription = String(body.customsExportDescription).trim().substring(0, 80)
} else if(body.orderDescription && String(body.orderDescription).trim()) {
  customsObj.exportDescription = String(body.orderDescription).trim().substring(0, 80)
} else if(customsObj.exportType === 'OTHER') {
  const itemDesc = (body.parcelItems || [])   // parcel.items in multi.post.ts
    .map((it) => String(it.description ?? '').trim()).filter(Boolean).join(', ')
  customsObj.exportDescription = (itemDesc || 'Merchandise').substring(0, 80)
}
```
**All three DHL entry points** pass `orderDescription`: `integrations/commission.vue`
(single `printParcelLabel` + multi `printMultiParcelLabels`),
`CommissionModal.vue`, and `mobile/commission.vue`. `dhl/manual.post.ts` is excluded
— a manual label has no linked order. The CN23 doc DHL returns is saved to Strapi
attachments as `CN23-{shipmentNo}-{timestamp}.pdf` (table 259 = C_Order).

**EU special territories need customs despite an EU country code.** The customs
modal gate in `integrations/commission.vue` is `isInternational =
!EU_COUNTRIES.includes(countryCode) || isEuSpecialTerritory(countryCode, postalCode)`.
`isEuSpecialTerritory` matches postal prefixes of destinations outside the EU
customs/fiscal territory: ES 35xxx/38xxx (Canary Islands), 51xxx (Ceuta), 52xxx
(Melilla); FR 97x/98x (overseas departments); FI 22xxx (Åland); GR 63086 (Mount
Athos); IT 23041 (Livigno). The multi-flow `enableCN23` (`printMultiParcelLabels`)
recomputes the same expression separately — keep both in sync. Only the desktop
page has the customs modal; `CommissionModal.vue` and `mobile/commission.vue` rely
on the server fallback chain above.

### Dual build target — web SSR + bundled Capacitor app SPA
The Android app has TWO build targets from ONE codebase. The existing web/SSR build and the
`/mobile/*` pages it serves are UNTOUCHED — all app-only behavior is new, additive, env-gated code
(every branch keys off `process.env.BUILD_TARGET === 'capacitor'`, so the web build is byte-identical
when it is unset). **Do NOT clone the mobile pages and do NOT edit existing `/api` routes** — that is
the whole point of this design.

- **Web (default):** `npm run build` → SSR site, all pages. Unchanged.
- **App:** `npm run build:app` (`BUILD_TARGET=capacitor`) → static SPA (`ssr:false`) in
  `.output/public` containing ONLY `/mobile/**` + auth routes. Then `npm run cap:sync:app`
  (`CAP_BUNDLED=1`) copies it into `android/`. The old remote-URL shell still builds when
  `CAP_BUNDLED` is unset (a one-flag fallback). **Run `build:app` BEFORE `cap:sync:app`** (sync copies
  `.output/public`). **One-command release: `scripts/release-app.sh`** does the whole cycle — bump
  version (build.gradle + version.json) → build:app → cap:sync:app → `assembleDebug` → stage into
  `android/update-files/` → hand off to `scripts/upload-mobile-update.sh`. Because the mobile pages are
  BAKED INTO the APK, any `/mobile` UI change needs this re-release; only server/API changes ship via a
  normal backend deploy.

**The switch — `nuxt.config.ts`:** `ssr:false`; injects `runtimeConfig.public.apiBase` (default
`https://app.logship.de`); a `pages:extend` hook filters routes to `/mobile/**` + auth; an
`app:resolve` hook drops server-coupled/desktop plugins (amcharts, service-worker, push,
notifications-stream/SSE, chat) from the APP build AND drops the app-only API plugin from the WEB
build (so `@capacitor/preferences` never enters the web bundle); `vite.define __CAP_BUILD__`.
`app/router.options.ts` wraps the desktop `/windows,/create,…` routes in
`typeof __CAP_BUILD__ !== 'undefined' ? [{path:'/',redirect:'/mobile'}] : [ …desktop… ]`.
`capacitor.config.ts` `CAP_BUNDLED=1` drops `server.url` and serves `webDir`; `androidScheme:'https'`
→ app origin `https://localhost` (a secure context for getUserMedia + a clean CORS origin).

**Data layer — ZERO edits to the mobile pages.** `app/plugins/00.capacitor-api.client.ts` (app build
only; early-returns when `!apiBase`) overrides BOTH `$fetch`/`useFetch` AND raw `window.fetch` to:
rebase relative `/api/...` → `${apiBase}/api/...`, inject `Authorization: Bearer` + `X-Logship-*`
headers, rewrite the login call to `/api/app-auth/login` and capture it, and refresh the token once
on a 401. `window.fetch` MUST be wrapped too — `tasks.vue` transcribe and the Strapi
`/media-api/upload` calls use raw `fetch`. Absolute (Strapi) + `data:` URLs pass through, so
multipart/binary uploads (ocr-scan, galleries, attachments, voice-command, transcribe) keep native
WebView semantics. `app/composables/useAuthSession.ts` is a Preferences-backed token/refresh/context
store; at login it ALSO writes the `logship_*` context cookies into the WebView's own jar so the
existing pages' `useCookie('logship_*')` reads and the `auth` middleware work unchanged.

**Auth — token based, existing routes untouched.** `server/middleware/00.app-auth.ts` (runs first via
`00.`) is a no-op for web; for app traffic (`X-Logship-App: 1`) it synthesizes
`event.node.req.headers.cookie` from the Bearer token + `X-Logship-*` headers, so getTokenHelper /
getCookie / `auth.ts` / every route serve the app unchanged. It also emits CORS for the app origin
and answers the `OPTIONS` preflight (we use normal WebView fetch + server CORS, NOT CapacitorHttp,
because CapacitorHttp is unreliable for binary `FormData`). `server/api/app-auth/login.post.ts`
proxies the existing `/api/idempiere-auth/login` (reusing the full auto-finalize flow) and surfaces
`refresh_token` — which the web flow keeps httpOnly — by reading the inner Set-Cookie.
`token-refresh.post.ts` calls iDempiere `auth/refresh` from the body.

**Ops / gotchas:** deploy the new middleware + `app-auth` endpoints to the API host (the app calls
them remotely); add the app origin `https://localhost` to **Strapi's CORS** for `/media-api/upload`;
ensure **nginx passes `OPTIONS` preflight + `Authorization`/`X-Logship-*`** to Nitro; the
`app-auth/login` refresh_token relies on Nitro internal `$fetch.raw` surfacing the inner Set-Cookie —
verify on device, it falls back to re-login if absent.

### Rich-text (WYSIWYG) — use Nuxt UI's `<UEditor>`, do NOT add TipTap yourself
Nuxt UI v4 (already a dependency) ships a **full TipTap v3 editor**: `<UEditor>` + `<UEditorToolbar>`
(plus `useEditorMenu`, mention/emoji menus, drag handle). **All `@tiptap/*` packages are direct deps
of `@nuxt/ui`** and already in `node_modules` — so the app has rich text for free. Do **not** `npm
install @tiptap/*` separately: `@nuxt/ui@4.x` pins TipTap to `^3`, so adding `@tiptap/...@^2` fails
with an ERESOLVE peer conflict (that error is the tell-tale that v3 is already present). StarterKit v3
includes bold/italic/**underline**/strike/headings/lists/undo-redo, so no extra extensions are needed.

Key `<UEditor>` facts (from `node_modules/@nuxt/ui/dist/runtime/components/Editor.vue`):
- `v-model` is the content; `contentType` auto-infers **`'html'`** for a string model (objects → `json`).
  So binding a plain HTML string just works and round-trips as HTML.
- It forwards TipTap lifecycle callbacks as props — pass **`:on-blur`** to persist on blur (the app's
  autonomous-editor convention). `onUpdate` is overridden internally to emit `update:modelValue`.
- Image + Mention extensions are **on by default** → set **`:image="false" :mention="false"`** for a
  plain notes editor (otherwise "@" opens a mention menu and paste-image handling kicks in).
- `:editable="!readonly"`, `:placeholder="…"` supported. It renders nothing during SSR (`v-if="editor"`),
  but wrap usage in `<ClientOnly>` anyway (matches the app's `SelectBox` pattern).
- Toolbar items are objects keyed by a built-in handler `kind`, e.g.
  `{ kind:'mark', mark:'bold', icon:'i-mdi-format-bold' }`, `{ kind:'heading', level:2 }`,
  `{ kind:'bulletList' }`, `{ kind:'orderedList' }`, `{ kind:'undo' }`, `{ kind:'redo' }`.
  **Only the `mdi` iconify collection is installed** — use `i-mdi-*` icon names (lucide names render blank).

Reusable wrapper already built: **`app/components/RichTextEditor.vue`** — `v-model` (HTML) + `@blur`,
B/I/U/S + H2 + bullet/ordered list + undo/redo toolbar, image/mention disabled, `<ClientOnly>` + list
CSS. Reuse it instead of re-wiring `<UEditor>` each time.

### Partner agreements — per-partner child notes (date + rich-text), autonomous editor
Free-form **agreements made with a partner**, recorded on `/partners/partners/[id]/edit` (**General
tab**, always visible — for customers *and* vendors). Each agreement = one row in the iDempiere table
**`CUST_Agreement_CBartner`** (note the literal spelling **"CBartner"**, model-name
`cust_agreement_cbartner`) with `DateFrom` (date), `AgreementNote` (HTML), `C_BPartner_ID`,
`AD_Org_ID`, `IsActive`.

This is a **clone of the invoice-reminder-notes pattern** (see the "Partner reminder notes —
auto-popup on order open" recipe above) — an autonomous child-table editor that owns its own CRUD and does **not** touch
`pages/partners/partners/[id]/edit.vue` or `partners/partners/update.put.ts`:
- **UI:** `app/components/partners/PartnerAgreementNotesEditor.vue` (modeled on
  `PartnerReminderNotesEditor.vue`) — props `bpartnerId`/`readonly`; each row = a `tedir-calendar`
  `DatePicker` (`@update:modelValue` with an equality guard so the picker's mount-echo doesn't persist)
  + a `<RichTextEditor>`; coalesced **save-on-blur** (`saving`/`_dirty`); newest-first; "save partner
  first" hint when `bpartnerId` is empty (create page). Mounted in `PartnerForm.vue` as
  `<PartnersPartnerAgreementNotesEditor :bpartner-id="form.id" />` in the `tab === 'partner'` block.
- **Server:** `server/api/partners/agreements/{index.get,store.post,update.put,destroy.delete}.ts` —
  same try/catch + `refreshTokenHelper` wrapper as the reminder routes. List filters
  `C_BPartner_ID eq <id> AND IsActive eq true`, orderby `DateFrom desc`. Writes use **lowercase-first
  scalars** (`dateFrom`, `agreementNote`, `isActive`) with PascalCase FK objects (`C_BPartner_ID`,
  `AD_Org_ID` → `{ id, tableName }`); `tableName: 'CUST_Agreement_CBartner'` on POST/PUT
  (per the iDempiere REST write-casing rule).
- Requires the iDempiere `CUST_Agreement_CBartner` table to exist (already created). Translation keys
  `agreement_*` live in `assets/langs/shared.js` (en/de/es), with inline German fallbacks.

### Querying iDempiere production read-only to discover new fields
When asked to "check for new fields" (backend columns not yet surfaced in the frontend), query
production iDempiere REST **read-only** directly from the dev machine — no dev server needed.

- **Base URL:** `https://app.logship.de/api/v1` (the `URLV1` in the active `.env`). Do NOT use
  `.env-prod`'s `URLV1` (`http://127.0.0.1:80/api/v1`) — that only resolves on the prod host.
- **Auth:** `.env-prod` has `IDEMPIERETOKEN` — a non-expiring **SuperUser** JWT (tenant 1000000 =
  "logyou"). Use it as `Authorization: Bearer <token>` for GETs. Read it without printing:
  ```bash
  TOKEN=$(grep "^IDEMPIERETOKEN=" .env-prod | sed "s/^IDEMPIERETOKEN=//; s/^'//; s/'$//")
  curl -s -H "Authorization: Bearer $TOKEN" "https://app.logship.de/api/v1/models/m_product/1001334" | jq 'keys'
  ```
- Keep load light: fetch **single records by id** (e.g. `models/m_inout/1020127`) or a small bounded
  page — never an unbounded list of products/bpartners.

**Two complementary discovery methods:**
1. **Populated-field union** — iDempiere REST OMITS NULL columns, so one record under-reports. Union
   keys across a recent slice + an older slice to see fields populated on ≥1 row:
   ```bash
   curl -s -H "Authorization: Bearer $TOKEN" \
     "https://app.logship.de/api/v1/models/c_order?\$top=40&\$orderby=Updated%20desc" \
     | jq '[.records[]|keys[]] | group_by(.) | map({k:.[0], n:length}) | sort_by(-.n)'
   ```
   Gotcha: `$select=id` is rejected ("id is not a valid column") — iDempiere wants real column names
   (`M_Product_ID`); just fetch the full bounded page instead.
2. **Authoritative custom-column scan** (catches columns NULL in every record) — list a table's
   columns from `ad_column` and keep the **custom** ones (`EntityType.id != 'D'`; `'U'` = User-
   maintained = LogShip custom, `'EE0x'` = iDempiere modules):
   ```bash
   TID=$(curl -s -H "Authorization: Bearer $TOKEN" \
     "https://app.logship.de/api/v1/models/ad_table?\$filter=TableName%20eq%20%27M_Product%27" | jq -r '.records[0].id')
   curl -s -H "Authorization: Bearer $TOKEN" \
     "https://app.logship.de/api/v1/models/ad_column?\$filter=AD_Table_ID%20eq%20$TID&\$top=500" \
     | jq -r '.records[] | select(.EntityType.id!="D") | "\(.ColumnName)\t[\(.EntityType.id)]\t\(.AD_Reference_ID.identifier)\t\(.Name)"'
   ```
   Gotchas: the OData `ne` operator is NOT supported (filter client-side); `EntityType` and
   `AD_Reference_ID` come back as **expanded objects**, so compare `.EntityType.id` (not `.EntityType`)
   and read `.AD_Reference_ID.identifier` for the data type (List / Yes-No / Amount / Table / Search…).

Then diff the populated/custom column sets against the frontend Form components + create/view/edit
pages + `server/api/*/store.post.ts`/`update.put.ts`. Most `EntityType=U` columns are already wired;
genuine gaps are the user-meaningful ones not referenced anywhere (vs integration/sync flags like
`isExported*`, `*_Label_Base64`, `marketplace_confirm_log`, which stay backend-only).

### Sequential (oldest-first) shipment generation — per-order runs of `m_inout_generate`
The iDempiere process `m_inout_generate` (`POST /api/processes/inouts/generate`) picks open
orders in **unspecified order** and has **NO order-level parameter** — verified against the
prod process definition (`GET /api/v1/processes/m_inout_generate`): its only params are
`M_Warehouse_ID`, `MovementDate`, `C_BPartner_ID`, `DatePromised`, `IsUnconfirmedInOut`,
`DocAction`, `ConsolidateDocument`, `SubtractOnHand`. So "generate for ONE order" really means
**one call per partner+warehouse pair** (the order's `C_BPartner_ID` + `M_Warehouse_ID`) —
the pattern of the per-order "Lieferscheine generieren" button in
`pages/sales/orders/[id]/edit.vue` (`generateShipmentFromOrder`). One such call ships ALL open
orders of that partner in that warehouse (accepted behavior), so dedupe processed
`${warehouseId}|${partnerId}` pairs when iterating.

Built on this: two independent opt-in checkboxes on `pages/generate-shipments.vue` and
`pages/mobile/generate-shipments.vue` — **"Sequentiell nach Bestelldatum"** (`form.isSequential`)
and **"Nur Einzelpositions-Aufträge"** (`form.isSingleQty1`, exactly one item-type line with
`QtyOrdered = 1`). `submit()` branches into `submitSequential()` when EITHER is set (the qty-1
filter also needs the queue-driven per-order iteration — the iDempiere process cannot filter by
quantity); the default per-warehouse flow is untouched when both are off. The qty-1 option
filters the queue seeds only; the partner-level process call can still ship the partner's other
open orders.

- **Queue endpoint:** `server/api/orders/openso-generation-queue.get.ts` — direct-Postgres
  (`pg` Pool like the other `openso-*` routes; `PG_HOST=localhost` → only answers ON the prod
  host, dev needs a tunnel). Same universe as the dashboard "Offene Aufträge"
  (`openso-list.get.ts`) but **fulfillable-only** (inverted `is_unfulfillable` stock check)
  and ordered `dateordered ASC` (oldest first). Params: `warehouseIds` (comma-separated,
  empty = all — covers the pages' single/selected/all warehouse scopes), `singleQty1`,
  optional `partnerId`.
- **Client loop:** sequential awaited POSTs; per-call failures collected into `seqErrors`
  (run continues); live progress in `seqProgress`. Desktop gotcha: **buffer** results and
  commit to `summaryLogs`/`summaryLog.logs` only AFTER the loop — the results view is gated
  on `summaryLog?.summary || summaryLogs.length >= 1` and any mid-run push unmounts the form
  + progress UI. Tag each response's `org_name` before pushing so the existing grouped
  results view + picklist/print actions work unchanged.
- Translation keys `sequential_*` / `only_single_qty1*` in `assets/langs/shared.js`.

### M_Shipper custom flags — full CRUD wiring + shipping-cost accounting link
`M_Shipper` carries 7 custom (`EntityType=U`) columns, all managed at Materials → Shippers
(`ShipperForm.vue` "Shipper Options" section, with per-flag help text): `IsReadTrackingCommission`
+ `readtrackinglength` (commissioning captures a scanned barcode of exactly that length as
tracking no. — `integrations/commission.vue`, `mobile/commission.vue`), `isAttachmentRequired` +
`isPrintDINA4` (orders-grid attachment action — `AgCustomAttachment.vue`),
`isPickupTrackingNumberMandatory` (`AttachmentModal.vue`), `isAmazonFba` (`useFbaShipper.ts`),
`isNoShippingAccounting` (accounting exclusion, below). REST reads return the ColumnName casing
verbatim (`IsReadTrackingCommission`, but `isAttachmentRequired` etc.) — map tolerantly with both
casings; writes are lowercase-first per the global write-casing rule. Adding a field touches:
`ShipperForm.vue`, all four pages `pages/materials/shippers/{create,[id]/edit,[id]/view,
[id]/duplicate}.vue` (each maps fields explicitly — no generic pass-through), `index.vue`
(grid column; name column links via `components/AgGrid/Shippers/AgCustomLink.vue`), and
`server/api/materials/shippers/{update.put,store.post}.ts`. Gotchas: `update.put.ts` only sends
fields present in the body (the grid inline-edit posts just `name`/`isActive` — unconditional
sends would clobber flags); `store.post.ts` sends flags only when non-default (DB default `'N'`).

**`isNoShippingAccounting` = shipper whose shipments the customer pays directly** (e.g. handover
to another service) → they must NEVER produce shipping-fee invoice lines. The shipping-cost
accounting does NOT live in this repo but in the sibling **`../logship-scripts`** repo (deployed
at `/opt/logship-scripts` on the prod host, cron-driven; see its `accounting/crontab_info.txt`).
Pipeline: `accounting/01-Prepare-Accounting/shipping-carriers/dhl_costs_internal_calc/calculate.py`
computes `m_inout.int_freight_total` (and `dhl_costs_import` imports `ext_freight_total` from real
DHL invoices) → `accounting/02-Fill-Accounting/sql_shippingfee_lines.sql` + `create_fulfillment.py
--type shippingfee` turn shipments with either freight total into `cust_fulfillmentfeeline`
rows (`FulfillTypeAccounting='shippingfee'`, marks `isShippingFeeAccounted='Y'`) → the ERP's
`fulfillment/generate-orders.post.ts` invoices them. The flag is enforced in BOTH script layers
(`LEFT JOIN m_shipper` + `COALESCE(shp.isnoshippingaccounting,'N')='N'` — in `calculate.py` twice:
preview AND main UPDATE CTE, next to the existing `useForeignDHLAccount` skip): excluded shipments
get no `int_freight_total` and, even if freight data exists, are never selected for fee lines —
they simply stay `isShippingFeeAccounted='N'`. `debug_shipping_fallback.py` mirrors the production
WHERE clause — keep it in sync. `dhl_costs_import` is deliberately NOT filtered (real cost data
still lands on the shipment; it just never becomes a customer fee line). The ERP frontend needs
no change for the exclusion — fee lines for such shipments never exist.

### Shopify stock sync — locations + `inventorySetQuantities` gotchas
The Shopify stock page (`app/pages/fulfillment/shopify-stocks/index.vue`) syncs local
`qtyOnHand` to Shopify per location. Two marketplace flavors share the page, switched by
the order source's `Marketplace.identifier`: `shopify` (REST 2024-01) and `shopify-new`
(GraphQL 2025-10, auth via `server/utils/shopifyNewAuthHelper.ts`). Server routes mirror
that split: `server/api/{shopify,shopify-new}/{products,locations,stocks,stocks/sync}`.

**GraphQL `inventorySetQuantities` REQUIRES `ignoreCompareQuantity: true`** (or a
`compareQuantity` per entry). Without it every quantity is rejected with userError
`COMPARE_QUANTITY_REQUIRED` — the symptom is EVERY selected item failing at once
("Shopify sync errors: Array(N)"). Also request `code` in `userErrors` for matching.

**First sync to a location the item isn't stocked at:**
- GraphQL: `inventorySetQuantities` fails ("not stocked at location" / code contains
  `NOT_STOCKED`) → fall back to `inventoryActivate(inventoryItemId, locationId,
  available: qty)`, which connects the location AND sets the qty in one call.
- REST: `inventory_levels/set.json` throws 422 → POST `inventory_levels/connect.json`
  (`{ location_id, inventory_item_id, relocate_if_necessary: false }`), retry set once.
Both fallbacks are implemented in `…/stocks/sync.post.ts`.

**The order-source default column is `c_ordersource.shopify_location_id` — ALL lowercase.**
iDempiere returns the ColumnName casing verbatim, so `os?.Shopify_Location_ID` reads are
always undefined (this silently broke the configured-default path for months). Read
tolerantly (`os?.shopify_location_id ?? os?.Shopify_Location_ID`) and write the lowercase
key. `PUT /api/settings/order-sources/[id]/shopify-location` (body `{ locationId }`, empty
clears) persists it — used by the stock page's "Set as Default" button next to the "Sync
Target Location" dropdown (and by "Save as Default" on the manual-ID fallback input).

**Locations & default:** `GET /api/{shopify,shopify-new}/locations?orderSourceId=` returns
`{ locations, defaultLocationId, defaultFromOrderSource }`. Default priority: the
order-source column above (a configured ID is injected into the list even if the scan
missed it) → shop primary (REST `shop.json?fields=primary_location_id`; GraphQL: first
active with `shipsInventory`/`fulfillsOnlineOrders`) → first active. The page fetches this
on order-source selection (watcher at the END of `<script setup>` — it resets all per-shop
state incl. items/errors), preselects the default in the dropdown; clicking a location
column header sets the same `selectedLocationId`. The sync routes ALSO resolve
body → order source → item's stocked location → shop locations server-side.

**Missing `read_locations` scope — layered fallbacks (all only need `read_inventory`):**
the locations endpoints fall back to deriving locations from inventory levels of a sample
of variants (GraphQL: `productVariants→inventoryItem→inventoryLevels→location`; REST:
`products.json`→`inventory_levels.json`, which returns plain `location_id` values with NO
scope beyond read_inventory — names unknown → `Location <id>`). The page additionally
derives the location columns/dropdown from `stocksByLocation` in the stocks response, and
the sync routes look up the item's own stocked location before erroring. Failure messages
now carry the REAL Shopify error (`locationsError` shown in the warning banner) — don't
re-hide it behind a generic "configure Shopify_Location_ID" text.

GID helpers: `parseShopifyGid`/`buildShopifyGid` — frontend passes numeric location IDs,
routes build `gid://shopify/Location/<id>`; `inventoryItemId` may arrive as full GID.
SKU lookup is batched via GraphQL aliases (~40/query, cost-throttle sleep) in
`shopify-new/stocks/index.post.ts` (POST body, not GET — URL length).

### Production ↔ ticket link (`R_Request.M_Production_ID`) + auto-created production tickets
`R_Request` carries a custom (`EntityType=U`, optional Search) column **`M_Production_ID`**
so every production run can have ONE ticket tracking its time and work steps.

**Ticket-side wiring (mirror of the order/invoice/shipment relations):** the relation is
managed on `/requests/requests/[id]/edit` and flows through the standard four layers —
`server/api/requests/requests/{store.post,update.put}.ts` (`body.productionId` →
`M_Production_ID: {id, tableName: 'M_Production'}`), `pages/requests/requests/[id]/edit.vue`
(form init + `retrieve()` mapping + PUT body + changelog `columnLabels`), and
`components/requests/RequestForm.vue`. In `RequestForm.vue` a relation "kind" touches SIX
spots: form init (`production`/`productionId`), `buildPutBody` (else auto-save won't persist
it), `refRoutes`, `pickerConfig`, `refPreview`/`refPreviewLoading` + `loadRefPreview` URL +
an immediate watcher, and the ref card in the shortcuts column (plus optionally a SelectBox
in the Verknupfungen tab). Preview fetches `/api/manufacturings/productions/{id}`; production
product identifiers may carry a `-1_` search-key prefix — strip it for display.

**Auto-created tickets — `server/utils/productionTicket.ts` → `createProductionTicket()`:**
shared helper that POSTs the `r_request`. Rules baked in:
- **`C_BPartner_ID` is the org-linked partner** (`ad_org.C_BPartner_ID`), NOT the order's
  customer — time on the ticket bills the merchant. `SalesRep_ID` = `logship_user_id` cookie;
  request type = first active `r_requesttype` (same fallback as the create page); Priority 5 /
  DueType 5 / Confidential I/I; German summary `Produktion <doc>: <product> (<qty> Stk) —
  Auftrag <doc>`.
- **Fail-soft is mandatory:** callers wrap it in its own try/catch — a ticket error must never
  fail the already-created production. Surface it as `requestError` next to
  `requestId`/`requestDocumentNo` in the response.
- Takes an optional `cache` object (org-partner + request type) for batch loops.

Callers: `server/api/manufacturings/bom-productions/store.post.ts` creates a ticket for EVERY
production (links production + sales order + org; results modal on
`pages/manufacturings/bom-productions/index.vue` shows a Ticket column), and
`server/api/manufacturings/productions/store.post.ts` only when `body.createTicket === true` —
set by an opt-in checkbox in `ProductionForm.vue` that is create-only (`v-if="!form.id"`,
the form is shared with the edit page; no order link in the manual flow).

### Marketplace shipment confirmation lives in `../laravel-middleware`, NOT here
**Do not grep this repo for the Shopify/Amazon/eBay fulfilment call — it does not exist here.**
This repo only *triggers* the push; the marketplace API call is in the sibling Laravel repo.
Chain, end to end:

```
commissioning / label creation
  server/api/commission/parcels/{index,other,dhl/index,dhl/multi,dpd/index,dpd/multi,resubmit}.post.ts
  + server/api/commission/labels/index.post.ts
    → gated on  resp2.C_Order_ID.<marketplace>_order_id  &&  resp3.marketplace_url
    → laravelHelper(event, 'sales/orders/mark-<mp>-order-delivery', 'POST', { orderSource: resp3, id, trackingCodes, mail })
      → ../laravel-middleware  routes/api.php + routes/sale.php
        → app/Http/Controllers/Sales/OrderController.php  markShopifyOrderDelivery() etc.
          → app/Repositories/<Mp>OrderRepository.php  markOrderDelivery()
    → on success PUT m_inout { IsCommissionedConfirmed: true, ack_commissioned_laravel: true }
```

`orderSource` is the **raw `c_ordersource` record fetched from iDempiere** (`models/c_ordersource/{id}`)
and forwarded verbatim, so any new `C_OrderSource` column is readable in Laravel *without* a
Laravel migration. (Mirror it in `OrderSourceRepository::toArray()` + the two sync blocks + the
`order_sources` migration anyway, for the flows that build the array from the local DB.)

Gotchas worth knowing before touching this:
- **Shopify is synchronous** inside the HTTP request — unlike Amazon/eBay/Plenty/JTL/Temu, which
  dispatch `Process<Mp>OrderDelivery` queue jobs. There is no retry; the manual retry is
  `/api/commission/parcels/resubmit` (body `{ inoutId }`).
- **Failures must keep propagating.** The ERP treats *any* non-throwing Laravel response as
  success and then sets `IsCommissionedConfirmed`. `OrderController` discards the repository's
  return value, so an exception reaching the controller is the ONLY thing that marks a
  confirmation failed. If you add a `catch` in a repository, log and then **re-throw**.
- `ShopifyOrderRepository.php` has **no `use Exception;`** — an unqualified `catch (Exception $e)`
  there silently resolves to `App\Repositories\Exception` and never matches. Use `\Exception`.
- Shopify writes **no `M_Inout.marketplace_confirm_log`** (only plentyone does), so
  `MarketplaceConfirmLogModal.vue` is always empty for Shopify — its log lives in the Laravel
  `logs` table (`origin = 'shopify'`, `model = 'order'`, `method = 'patch'`).

### Shopify shipping confirmation — `isUseShopifyNotification`, not `isExcludetrackingmail`
Three *different* channels can notify the buyer that a parcel shipped. They are controlled by
three separate `C_OrderSource` flags and must not be conflated:

| Flag | Controls | Where enforced |
|---|---|---|
| `isExcludetrackingmail` | whether **DHL/DPD** may email the buyer | `commission/parcels/**` swap `customerEmail` to `fulfillcustomer@logyou.de` / `notification@logyou.de` |
| `isCustomTrackingMail` / `isSentCustomTrackingMail` | whether **we** send our own tracking mail | Laravel `PhpMailerCommissionRepository::send()` |
| `isUseShopifyNotification` | whether **Shopify** sends its shipping confirmation | `notify_customer` / `notifyCustomer` in the two Shopify repositories |

Historically `isExcludetrackingmail` also drove Shopify's `notify_customer`, which contradicted its
own description ("use when the marketplace notifies the buyer itself") and silently suppressed the
Shopify Versandbestätigung for any shop that had it on. That is fixed — **never re-couple them.**

- **A fulfillment alone does NOT trigger Shopify's email.** `notify_customer` is a required opt-in
  on the create call (the API equivalent of the "Send shipment details to your customer now"
  checkbox). The shop's own notification template must *also* be active, but with
  `notify_customer: false` nothing is sent regardless of shop configuration.
- **Default is `true` everywhere** (`?? true` in the form, both read mappings, and both PHP
  repositories), so an absent/NULL column means "notify". Create the iDempiere Yes-No column with
  default **`'Y'`** — if it lands as `'N'`, REST returns `false` and *every* Shopify shop silently
  stops confirming shipments.
- `store.post.ts` sends the field **only when explicitly `false`** (the DB-default pattern above),
  so order-source creation keeps working before the column exists — iDempiere rejects unknown
  column names on write.
- Adding a `C_OrderSource` flag touches: `components/settings/OrderSourceForm.vue` (init + toggle;
  put Shopify-only flags inside the `v-if="isAnyShopify"` block), `pages/settings/order-sources/`
  `{create,[id]/edit,[id]/view}.vue` (each maps fields explicitly — init, `retrieve()` mapping,
  and edit's `getChangedFields()` list), and `server/api/settings/order-sources/{store.post,update.put}.ts`.

**What we actually send** (one call — there is no separate `fulfillmentTrackingInfoUpdate`
anywhere; tracking always rides along with the fulfillment creation):
- `shopify` → REST **`POST /admin/api/{ver}/fulfillments.json`** via `phpclassic/php-shopify`
  (`$shopify->Fulfillment->post()`; the SDK wraps the body in `{"fulfillment": …}`), API version
  pinned at **`2022-10`** in `ShopifyTokenRepository.php` — years past support, so Shopify falls
  forward to its current default.
- `shopify-new` → GraphQL **`fulfillmentCreateV2`** (deprecated in favour of `fulfillmentCreate`),
  API version `2025-10` from `config/shopify-new.php`.

Known open issues in that code (verify before blaming a merchant's shop config):
- The carrier is **hardcoded to `'Deutsche Post DHL'`** in both flavours, so DPD shipments get the
  wrong carrier and a broken tracking link. The `company` this repo sends is discarded, as is the
  tracking `url`.
- **`shopify-new` order sources never reach the GraphQL path** — every commission route calls
  `mark-shopify-order-delivery`, and `mark-shopify-new-order-delivery` has zero call sites. Routing
  is on `shopify_order_id`, not on `Marketplace.identifier`.
- Only `$fulfillmentOrder[0]` is fulfilled, so split/multi-location orders stay partially open.

### Querying prod iDempiere to answer "is this flag set for merchant X?"
The read-only prod query recipe above is the fastest way to settle a support question before
touching code. Useful one-liner for per-order-source flags:

```bash
TOKEN=$(grep "^IDEMPIERETOKEN=" .env-prod | sed "s/^IDEMPIERETOKEN=//; s/^'//; s/'\$//")
curl -s -H "Authorization: Bearer $TOKEN" "https://app.logship.de/api/v1/models/c_ordersource?\$top=100" \
  | jq -r '.records[] | select((.Marketplace.identifier // "") | test("shopify")) |
      "\(.Name)\texcl=\(.isExcludetrackingmail // false)\tcustom=\(.isCustomTrackingMail // false)"'
```

To check whether a column exists at all before wiring it, scan `ad_column` for the table's
custom (`EntityType.id != 'D'`) columns — see the discovery recipe above. Remember REST **omits
NULL columns**, so "field missing from the response" means NULL, not "column absent".

### My-Tickets split view — shared detail component, two surfaces
`/requests/my-tickets` is a **master-detail split**, not a board: left = one filterable ticket
list (the old kanban lanes live on as per-card **state tags**: viewer-must-act amber / other-side
indigo / done green), right = the full working ticket detail (chat, composer, attachments,
references, status actions). The detail is ONE shared component with two mount points:

- **`app/components/requests/MyTicketDetail.vue`** — the entire detail (extracted from the old
  1200-line page). Props `ticketId` + `embedded`; emits `loaded` / `updated` / `back`.
- `pages/requests/my-tickets/index.vue` — split view; embeds it with `embedded` and refreshes
  the list (`refresh()` + `loadLatestUpdates()`) on every `updated` emit.
- `pages/requests/my-tickets/[id].vue` — **thin wrapper only** (layout + breadcrumb + back).
  Kept for deep links (notification/email URLs) and small screens.

**Rule: any ticket-detail change goes into `MyTicketDetail.vue`, never into the pages** — both
surfaces render the same component, so there is nothing to keep in sync (unlike the staff-chat
Drawer/page clones). Gotchas & patterns baked in:

- **≤1024px the right pane is hidden** (CSS) and the card click handler navigates to the
  full-page route instead (`window.innerWidth <= 1024` check in `selectTicket`).
- **`loadSeq` race guard:** every async loader captures the counter and drops its result if the
  `ticketId` prop changed mid-flight (users click through tickets faster than fetches resolve).
  Keep the guard when adding new loaders.
- **Status actions (non-limited roles)** in the detail header: complete↔reopen and
  hand-to-merchant↔take-back both PUT `/api/requests/requests/update` (`{ id, isComplete }` /
  `{ id, isMerchantAction }`) and fire `POST …/[id]/action-notification` with
  `'merchant-action'` / `'support-action'` — the same endpoint the merchant flows use, so
  email + push stay consistent. `update.put.ts` sends `isComplete` unconditionally (undefined
  is stripped by JSON), so **`isComplete: false` = reopen works**; `isMerchantAction` is gated
  on `!== undefined`.
- Limited role `'c'` rules are enforced inside the component: `[Zeitabrechnung]` chat entries
  filtered, audit-diff entries hidden, "Ticket gelöst" bar instead of the admin toggles.
- Embedded mode collapses the internal 2fr/1fr reference layout to one column below 1700px
  viewport (the pane is much narrower than the window — plain media queries lie there).
- Translation keys: `ticket_action_close/reopen/to_merchant/to_us`, `ticket_select_hint`,
  `ticket_open_full` (en/de/es in `assets/langs/shared.js`).

### PayPal transaction import — 2nd provider on the EBICS banking pipeline
PayPal receipts get full accounting via the SAME pipeline as EBICS: fetch → stage
(`../laravel-middleware` tables `bank_statements`/`bank_transactions`) → import as Drafted
`c_bankstatement` → reconcile in `/accounting/banking`. The discriminator is
**`bank_connections.provider`** (`'ebics'` default | `'paypal'`); the staging tables and
`IdempiereBankStatementRepository::import()` are shared. Provider dispatch happens in exactly
three places (all `match`/ternary on `provider`): `BankConnectionController::processRepositoryFor()`
(testFetch/fetch), `ProcessEbicsStatement::handle()` (queue job — class name kept for queued-payload
compatibility; the daily 05:00 scheduler needs NO change, it already selects all active
`auto_fetch` connections), and `PayPalFetch` (`php artisan paypal:fetch {uid} --preview|--list` —
the CLI test harness; `ebics:fetch` refuses paypal connections and vice versa).

**Laravel side** (`app/Repositories/PayPalClientRepository.php` — transport only;
`ProcessPayPalStatementRepository.php` — orchestrator mirroring the EBICS one, `config/paypal.php`):
- OAuth2 client_credentials per connection (`paypal_client_id` + `paypal_client_secret` with
  Laravel **`encrypted` cast** + `$hidden` — the secret NEVER leaves the server; the API exposes
  only `has_credentials`). Transaction Search (`GET /v1/reporting/transactions`,
  `fields=transaction_info,payer_info`, `transaction_status=S`) is chunked to the API's 31-day
  window limit and paged (500/page). **The PayPal app MUST have the "Transaction Search" feature
  enabled** — without it the token works but every reporting call 403s NOT_AUTHORIZED (the
  test-connection error message says so; activation takes up to ~9 h after enabling).
- **One statement per Europe/Berlin day**; default window ends **YESTERDAY** (reporting data lags
  up to ~24 h) with a 2-day refetch overlap. `camt_statement_id` = `paypal-{merchant|uid}-{day}`,
  **`iban = ''` (empty string, NOT null)** — the statement identity index and `where('iban',…)`
  lookups need comparable values, and `resolveBankAccount()` already resolves an empty IBAN via
  the connection's `idempiere_bankaccount_id` (no repo change was needed). Dedupe:
  `sha256('paypal|{transaction_id}|{line_type}')`. Raw day JSON archived to `paypal/{uid}/{yyyy}/`.
- **Fee handling = TWO staged rows per fee-bearing transaction** (`bank_transactions.line_type`):
  the gross row (StmtAmt=TrxAmt=gross — matches the invoice; PayPal `invoice_id` →
  `end_to_end_id` → `C_BankStatementLine.ReferenceNo`, feeding the docNo suggestion signal;
  payer e-mail lands in `counterpart_iban`/`EftPayeeAccount` for display) and a `line_type='fee'`
  row that `buildLinePayload()` imports **pre-booked**: `trxAmt=0, chargeAmt=fee, C_Charge_ID` from
  `bank_connections.idempiere_charge_id` — it arrives already "matched" (charge) in the reconcile
  UI; without a configured charge it imports as a normal line (manual booking). Balance math holds
  because Σ StmtAmt = gross + fee = net. Do NOT switch to a single net line (StmtAmt=net,
  TrxAmt=gross) without also making the suggestion engine, `match-invoice` payAmt and the unlink
  PUT TrxAmt-aware — the unlink path restores `trxAmt=StmtAmt, chargeAmt=0`.
- **EUR-only v1:** non-EUR rows are staged with `import_status='skipped'` + message (visible in
  every preview as verdict `skip_non_eur`), never imported, and excluded from the EUR balance
  chain. `planImport()`/`import()` skip them explicitly.

**Reconciliation is un-pinned from bank account 1000000** (this was the port): `BANK_ACCOUNT_ID`
was removed from `server/utils/bankReconciliation.ts` — `statements.get.ts` lists Drafted
statements of ALL accounts (+ `bankAccountId`/`bankAccountName` via `$expand=C_BankAccount_ID`),
`match-invoice.post.ts` writes the payment onto the **statement's own** `C_BankAccount_ID`
(409 when missing), and `unreconciled-payments.get.ts` scopes candidates via `?bankAccountId=` or
`?statementId=` (the match modal passes the line's statementId; candidates are cached per
statement). NB `QuickPaymentModal` elsewhere still hardcodes 1000000.

**Frontend:** `BankConnectionForm.vue` + settings pages have a Provider select (locked after
creation — the backend rejects provider changes) and a PayPal block (secret input is write-only:
frontend sends the field ONLY when non-empty, backend unsets `''` — blank = unchanged; fee-charge
picker loads `/api/settings/charges`). `BankConnectionCard.vue` renders a paypal variant
(`ti-brand-paypal`, mode badge, yesterday-capped default window, lag hint;
`canTestFetch = has_credentials` instead of `bank_keys_verified_at`). Import/fetch previews label
`line_type='fee'` ("Gebühr · Charge vorbelegt" when `precharged`) and non-EUR skips; counterpart
values containing `@` bypass `formatIban()` (it would 4-group an e-mail). Translation keys
`banking_paypal_*`, `banking_line_fee`, `banking_verdict_skip_non_eur`, `banking_precharged*`.

**Prod iDempiere ids:** PayPal `C_BankAccount` = **1000001** (C_Bank 1000002, EUR, org LogYou,
AccountNo `info@logyou.de`, no IBAN); fee charge `C_Charge` = **1000007** "PayPal Gebühren".
Volksbank stays 1000000. Deploy = Laravel `php artisan migrate` (2026_08_25_* migrations) +
normal frontend deploy; then create the connection in Settings → Bank Connections and follow
Test Connection → Activate → manual Test-Abruf → fetch/import/reconcile → `auto_fetch`.

### Migrating a field off `tedir-select` to the new `SelectBox.vue` (Nuxt UI v4 wrapper)
`tedir-select`/`tedir-calendar`/`tedir-dropzone` are being phased out (unmaintained-looking,
pre-1.0, zero TS types, no portaling — see the z-index/clipping hacks scattered across forms)
in favour of components built on `@nuxt/ui` v4, which is already a dependency and already
ships to prod (`<UEditor>`, plain `<USelect>`). Given the scale (233 files / 921 usages for
`SelectBox` alone), the approach is **one compatibility wrapper, migrated one call site at a
time** — not a scripted mass rewrite. `AttachBox`/`DatePicker` wrappers don't exist yet;
`SelectBox` is the only one built so far, piloted on `components/sales/LexofficeCategoryModal.vue`.

**`app/components/SelectBox.vue`** wraps Nuxt UI's `<USelectMenu>` behind the *exact* prop/event
contract `tedir-select`'s `SelectBox` already has — `v-model` (coerced by `datatype`), `options`,
`prop`/`dataprop` (label/value field names), `size` (accepted, not functionally mapped),
`loading`, `disabled`, `placeholder`, `up` (opens upward), `clearable`, events
`@click`/`@load`/`@search`/`@change="(val, opt) => …"`, and a default slot exposing
`{option, selected, index}` for `type="slot"`-style custom item rendering. Because it's a
top-level `app/components/` file, Nuxt auto-registers it globally as `<SelectBox>` — so
**migrating a file is just deleting its `import { SelectBox } from 'tedir-select'` line**, no
template changes. (One addition beyond the original API: `descriptionProp` — opt-in, renders a
second field as muted subtext under the label via Nuxt UI's native item-description slot; unused
by default so it's safe on every other not-yet-migrated call site.)

**Two confirmed Tailwind v4 generation gaps in this app** — the selector for a given utility
class exists in the compiled CSS, but with an **empty declaration body** (verified via
`getComputedStyle` + walking `document.styleSheets` recursively, since `@layer`-nested rules
don't show up in a flat `sheet.cssRules` scan). Hit so far: the item highlight state
(`data-highlighted:not-data-disabled:before:bg-elevated/50` — a 3-deep stacked compound variant)
and, surprisingly, the trigger's own basic sizing (`px-2.5`/`py-1.5`/`text-sm` all resolved to
0/16px browser defaults). Root cause not pinned down (suspected: Tailwind's `@source "./ui"` scan
of the Nuxt-UI-generated `.nuxt/ui/*.ts` theme files racing their own generation) and not
consistent enough to be worth chasing further per-component. **Workaround, not a fix**: both are
hardcoded directly in `SelectBox.vue` via marker classes set through the `ui` prop
(`ui: { item: 'sb-item', base: 'sb-trigger' }`) plus a **non-scoped** `<style>` block using the
same `--ui-*` design tokens (plain CSS custom properties — unaffected by the generation gap, and
confirmed real via the theme's own `:root` rules). Non-scoped is required, not just simpler: the
dropdown is teleported to `<body>`, and `<style scoped>` doesn't reliably reach teleported content
in this app (see the Teleport + scoped CSS memory note) — scope by the marker classes instead. If
this gap resurfaces on the next migrated field, check computed styles the same way before assuming
it's a fresh bug; it may be the same pattern and need the same kind of explicit override.

**When migrating the next field**, also check whether it needs something not yet exercised by the
pilot: multi-select (0 usages found in the original audit, but `USelectMenu` supports `multiple`
if one turns up), grouping (`type: 'label'` items — supported, untested), or a `size` bigger than
default (currently a no-op — would need mapping to `max-height` on the `content` slot if a field
actually needs a taller/shorter dropdown).

**Codebase-wide crash risk already fixed in the wrapper — know why, don't remove it.** Reka UI's
`<ComboboxItem>` hard-throws ("must have a value prop that is not an empty string") the instant
ANY item in the list resolves to `''` at `dataprop`/`valueKey`. The app-wide fallback pattern used
at hundreds of `tedir-select` call sites — `:options="getOptions?.[key]?.records || [{id: item.x,
Name: item.y}]"` (show the current selection before the real options have loaded) — produces
exactly that `{id: ''}` whenever the field has no value yet, which is every "add new" row before a
product/etc. is picked. This is what actually broke `PartnerExtraOrderLinesEditor`'s product
picker (reported as "shows results, can't search / can't select a product" — it wasn't a search
bug, the whole popover was crashing on open because the not-yet-filled row's fallback option had
`id: ''`). `SelectBox.vue` filters these out itself (`sanitizedOptions`, before the `:items`
binding) so every call site is protected automatically — don't strip that filter as
"unnecessary" when refactoring. If a future symptom looks like "the dropdown just won't open" or
"no items ever render" on a migrated field, check the browser console for this exact
`ComboboxItem` error first before assuming it's a data/fetch problem.

**Org-scoping products in a picker**: use the generic `filters/m_product/{encoded odata}`
endpoint (never `models/m_product`, which has no caller-supplied filter at all and returns every
product in the whole tenant — confirmed the actual root cause of one picker "loading forever" /
feeling broken). Pattern: `['isActive eq true', organizationId.value && 'AD_Org_ID eq ' +
organizationId.value, search && "contains(Name,'" + search + "')"].filter(Boolean).join(' AND
')`, from `useCookie('logship_organization_id')` — the *logged-in user's* current org, not
necessarily the record being edited's own org. Route both the initial (`@click`, no search text)
and the search (`@search`) load through the same filter builder so the picker is never unfiltered
even before the user types.

**Second codebase-wide fix already in the wrapper: numeric ids showed as the raw id instead of
the label.** After picking an item, the trigger displayed e.g. `1003417` (the product's numeric
`id`) instead of its name — confirmed by fetching the raw API response directly and comparing
`id`/`Name`/`Value`. Cause: `SelectBox`'s `datatype` prop coerces the app-facing `v-model` to a
STRING by design (`datatype="string"` is what most existing call sites pass, matching
tedir-select's own contract), but Reka UI resolves which item's label to show by matching the
current value against `items` by `valueKey` — and iDempiere's `_ID` foreign keys come back from
the API as JS **numbers**, not strings. `"1003417" !== 1003417`, the match fails, and Reka falls
back to printing the raw (coerced, string) value instead of a label. This didn't show up on the
Lexoffice pilot because Lexoffice category ids are already native strings (UUIDs) — no type
mismatch there, so it's easy to miss until a field with numeric ids (i.e. almost every iDempiere
FK) gets migrated. Fixed generically in `SelectBox.vue`: the value fed to `USelectMenu` is looked
up from the matching option's own *raw* `dataprop` value (whatever type it actually is), while
what's emitted externally via `update:modelValue`/`change` stays coerced per `datatype` as before
— so the app's own state keeps its expected type, only Reka's internal match gets the right one.
If a future migrated field shows the id instead of a label, this is almost certainly already
fixed by that lookup — check whether `SelectBox.vue`'s `modelValue` getter is still doing the
match-and-reuse-raw-value thing before re-diagnosing from scratch.

**Product picker label convention**: SKU/Value first, then Name — e.g. `"1003278 Rabatt"`, built
as `[p.Value, p.Name].filter(Boolean).join(' ')` on the fetched records before handing them to
`SelectBox` (a bare product Name is often ambiguous across a large catalog). `SelectBox`'s `prop`
only takes one field name, so the caller computes this combined field itself (see
`productLabel()` in `PartnerExtraOrderLinesEditor.vue`) rather than the wrapper trying to support
multi-field labels generically.

### Lexoffice `posting-categories` has no account-number/SKR mapping — don't re-investigate
Lexware deliberately abstracts SKR03/SKR04 account numbers behind plain-language categories
("Miete", "Warenverkauf", …) so users don't have to think in account numbers — confirmed in their
own Bookkeeping Cookbook (developers.lexware.io/cookbooks/bookkeeping): categories map to SKR
accounts internally, but **that mapping is not exposed via the public API**. Verified two ways:
a direct query against `GET /v1/posting-categories` returns only `id`, `name`, `groupName`,
`type`, `contactRequired`, `splitAllowed` (no account number, no longer description); and the
full Lexware API endpoint list (Articles, Contacts, Invoices, Vouchers, Posting Categories, …) has
no chart-of-accounts/financial-accounts resource at all. If a request for "show the account number
next to the category" comes up again (e.g. on `LexofficeCategoryModal.vue`, which shows `groupName`
as the closest available subtext), the answer is still no — don't burn time re-probing endpoints;
the only path would be a manually-maintained mapping table, which risks silently wrong account
numbers reaching real bookkeeping if the mapping ever drifts from Lexware's actual internal one.

### OPOS-Liste (open items, DATEV-style) — `/accounting/opos`
`app/pages/accounting/opos/index.vue` + `server/api/accounting/opos/index.get.ts` reproduce DATEV's
"OPOS-Liste Posten" (research + source links in `reference/datev-opos-liste-research.md`). Menu
entries live in BOTH `composables/menu/admin/accounting.ts` (left) and `MainNavigationMenu.vue`
(top), translation keys `opos_*` in `shared.js`.

**Data source is direct Postgres, not REST.** Open amounts come from iDempiere's own SQL functions
(`invoiceopen(id, 0)`, `invoiceopentodate(id, 0, date)` for the Stichtag, `paymenttermduedate`,
`paymentavailable`) — the `RV_OpenItem` view is rejected by the REST layer ("No PK, UU nor FK").
Conventions baked into the route, keep them when extending:
- AR/AP split follows the app (`issotrx='Y' OR c_doctypetarget_id = 1000006` — that "AP CreditMemo"
  doctype is used for customer credit notes); `docstatus='CO'` only (CL = written off in this app).
- Candidate set = `ispaid='N'` (plus, in Stichtag mode, invoices allocated after the Stichtag); rows
  with `|open| < 0.005` are dropped. Mirrors `/sales/invoices`: when an invoice has NO allocation but
  a direct `c_invoice.c_payment_id` link to a completed payment, that payment counts as paid
  (`paidVia: 'link'`).
- Signs are document-perspective (`invoiceOpen` semantics: invoice +, credit memo/payment −); the
  page derives DATEV's Betrag Soll / Betrag Haben / Saldo S/H from `amount` and `paidAmt`, and the
  signed `fällig` column = −daysDue (negative = overdue, DATEV convention). Kz `K` = partially paid and
  within 1 % / 1,00 € of zero; Mahnstufe from `c_dunningrunline` (currently no dunning runs exist).
- `includePayments=true` adds unallocated completed payments (`paymentavailable <> 0`, not referenced
  by any invoice's `c_payment_id`) as Haben-Posten. On prod these are mostly ± reversal pairs, hence
  default OFF.
- Limited roles (`FrontendMenu 'c'`) are forced to their own org; admins default to the current org
  with an "Alle Organisationen" option (org list = `useLookup('/api/models/ad_org')` — the
  `logship_organizations` cookie can contain stale FetchError objects, don't build dropdowns from it).

**Testing pg-Pool routes locally needs a tunnel** — `.env` has `PG_HOST=localhost`, so every
direct-Postgres route (`openso-*`, `open-fulfillment-invoices`, `opos`, …) 500s on dev until:
```bash
ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -L 5432:localhost:5432 root@192.168.12.72
```
is running (check with `nc -z localhost 5432`). For ad-hoc read-only SQL, run a small node script
through the tunnel using the repo's `pg` package and the `.env` PG_* keys rather than `psql` on the
host.

### Contract package, digital signing portal & legal documents (AGB/AVV)
"Vertrag erstellen" (`components/offers/ContractModal.vue`, `composables/useOfferContract.ts`)
sends a **package**, not one PDF: contract + Anlage 1 (last sent offer), 2a AGB, 2b ADSp 2017,
2c Logistik-AGB 2019, 3 AVV. Assembled by `server/utils/offers/contractPackage.ts`
(`assembleContractPackage()` — fail-soft per annex, `warnings[]` surfaced to the modal).
Sources: last offer = newest `Angebot_LogYou_*.pdf` Strapi attachment on the record, else
re-rendered from `offer_conditions.quote` (`offerAttachments.ts`); AGB/AVV = versioned
**SQLite store** `data/legal-docs.db` (`server/utils/legalDocsDb.ts`, seeded from
`offers/agbDefault.ts`, edited at Settings → Rechtsdokumente, rendered by `offers/agbPdf.ts`
which converts the RichTextEditor HTML to pdfmake); ADSp/Logistik-AGB = static PDFs bundled as
Nitro server assets (`server/assets/offers/*.pdf`, loaded via `useStorage('assets:server')` in
`staticAnnexes.ts`). Contract parts can be deselected (`contract.sections = { key: false }`,
keys = `CONTRACT_SECTION_KEYS` in `contractTexts.ts`, served by `offers/contract/sections`);
`contractPdf.ts` skips them and drops annex wording for deselected attachments. Everything
(sections, attachments, signing prefs) persists in the existing `offer_conditions` JSON — **no
new iDempiere columns**.

**Digital signing** (`signing.enabled`, default on, link valid `expiryDays` = 7 or an explicit
date): `send.post.ts` creates a row in `data/contract-signing.db` (`contractSigningDb.ts`,
64-hex `crypto.randomBytes(32)` token, older pending links of the record → `superseded`) and
stores the exact PDFs under `data/contract-signing/<token>/` (git-ignored). The mail carries a
CTA to the **public** page `pages/sign/[token].vue` (`layout: false`, DE/EN toggle defaulting
to the contract language, vue-pdf thumbnails via `components/sign/DocThumb.vue`, iframe
viewer, signer form with 8-week start-date cap and the DE-only VAT hint, four unchecked
declarations whose wording is SERVER-side in `acceptanceTexts()`, canvas signature).
Public routes live in `server/api/public/sign/[token]/{index.get,index.post,
request-renewal.post,document/[key].get}.ts` — **the POST must be `index.post.ts`**; a
`sign.post.ts` there maps to `/sign/<token>/sign`. `finalizeSignature()` in
`contractSigning.ts` re-renders the contract with the signature in the customer block + a
"Signaturprotokoll" page (IP, UA, SHA-256 per document, AVV version, exact checkbox texts),
attaches it to Lead_User / C_BPartner with the **service token** (`config.api.idempieretoken`),
patches `offer_conditions.contract.signature` (`patchOfferConditionsSection()` keeps
`savedAt`), mails the signer all documents and notifies info@logyou.de with the record link.
Expired/superseded links show a "Neue Unterlagen anfordern" form → mail to info@logyou.de
(rate-limited 6 h). Status for the modal chip / lead page: `offers/contract/signing-status`.

Gotchas: the app's global sidebar CSS styles bare `<aside>` (`width:250px` +
`translateX(-250px)`) — public pages must use plain `<div>`s. Mails go through
`localhost:25`, so on a dev Mac sending fails (fail-soft, logged). To test the portal locally,
create a request with a scratch script whose `cwd` is the repo root (both DBs are resolved via
`process.cwd()`), then open `http://localhost:3010/sign/<token>`; delete rows with
`record_id = 0` afterwards. Contract wording that was MOVED to the AGB seed (don't re-add to
`contractTexts.ts`): MHD/FEFO, Pfand-/Zurückbehaltungsrecht, Aussonderung, Zugang im
Ausnahmefall, Versicherung (Inhaltsversicherung only), Inventur (on request, paid), cut-off
only for orders released without holding back.

### Lexoffice/Lexware uploads — the API is async, every call goes through `lexFetch`
**Do not add "wait for OCR" logic and do not re-investigate upload slowness from that angle.**
`POST /v1/files` and `POST /v1/vouchers/{id}/files` return `202 Accepted` with the file/voucher
id immediately; the voucher starts as `blank`, Lexware's text recognition (OCR) runs
asynchronously on their side, and the status flips to `unchecked` later (the "processing" state
seen in the Lexoffice UI). One upload call still costs ~2 s of Lexware-side latency — that is
their multipart handling, not something we poll.

**All Lexware traffic must go through `lexFetch()` in `server/utils/lexofficeClient.ts`** —
never a raw `fetch('https://api.lexware.io/…')`. `lexFetch` is the process-wide throttle for
Lexware's limit of **2 requests/second per API client, all endpoints combined** (token bucket,
429 when exceeded): it paces call starts ≥ 520 ms apart, keeps ≤ 2 calls in flight, and retries
429/503 with backoff (honouring `Retry-After`). It is per Node process, so with PM2 at 2
instances the retry is what absorbs the occasional overshoot. Because of this throttle, callers
must NOT sprinkle their own `setTimeout` sleeps between Lexware calls (the upload route used to
lose 1 s per invoice to two such sleeps).

`server/api/invoices/lexoffice-upload.post.ts` processes **3 invoices concurrently**
(`INVOICE_CONCURRENCY`, worker pool) so the ~1.2 s iDempiere Jasper PDF render + ZUGFeRD embed
of one invoice overlaps the Lexware round-trips of another (~4.7 s → ~1.5 s per invoice). Rules
baked in, keep them when extending:
- **Contact resolution is memoised per partner for the run** (`contactByPartner` map of
  promises) — without it, concurrent invoices of one partner each search, miss and create
  duplicate Lexoffice contacts before `lexware_contact_uuid` is saved on `c_bpartner`.
- `processInvoice` never throws — every failure lands in `errors[]` per invoice; the response
  contract (`uploadedIds`/`skippedIds`/`errors`/`logs`) is what the settlement pages patch
  their grids from, and the id lists are re-sorted into the caller's input order.
- Per-invoice log lines are prefixed `[inv <id>]` because concurrent logs interleave; the
  route still dumps the full timestamped log to the PM2 out-log
  (`grep "LEXOFFICE UPLOAD LOGS" -A… /root/.pm2/logs/erpfrontend-out-*.log`), which is the
  fastest way to see where time goes per step.
- Callers chunk: `pages/sales/invoices/index.vue` and `amazon-settlements.vue` send **20 ids
  per request** sequentially — one request with hundreds of ids would outlive the proxy timeout.
- If an invoice falls back to the plain file drop with "No Lexoffice contact resolved for this
  partner", the voucher lands **uncategorised** (fail-soft by design) — fix the partner's
  contact, don't loosen the fallback.

**Settlement grids patch status in place — refresh the AG Grid set-filter value lists too.**
`amazon-settlements.vue` mutates `rowData` objects (`applyInvoiceStatus` / `applyCsvStatus`)
instead of re-fetching, then `refreshLexCells()`. AG Grid's `agSetColumnFilter` caches its value
list from the initially loaded rows, so a period that started with only "Nein" never offered
"Ja" after an in-place upload/export (symptom: "filter only works after reload"). The helper
therefore calls `api.getColumnFilterInstance(colId)` → `refreshFilterValues()` for
`LEX_STATUS_COLS` before `onFilterChanged()`, and both columns carry
`filterParams: { refreshValuesOnOpen: true }`. Any new in-place-patched status column with a set
filter must be added to `LEX_STATUS_COLS`.

### Marketing flyer "LogYou × LogShip" — opt-in attachment for quotes and contracts
`server/utils/offers/flyerPdf.ts` + `flyerTexts.ts` (DE/EN copy) render a 15-page 16:9 PDF
(720×405 pt, the Hive-deck format) with pdfmake: cover, why/stats, services, feature slides,
LogShip Cloud, tracking/VideoShip, integrations, special services, pricing STRUCTURE (no amounts),
next steps. Assets under `server/assets/offers/flyer/`: photos from `../logship/images`
(pre-cropped with sharp: `*_p.jpg` portrait 640×810, `*_t.jpg` thumbs, `*_w.jpg` wide), integration
logos, and **app screenshots `screen-*.png` rendered from the DUMMY-DATA demo pages
`/flyer-demo/{dashboard,orders,shipments,returns,stock,tickets,mobile,tracking}`**
(`app/pages/flyer-demo/[screen].vue`, public, layout-less — the only source of "app" screenshots;
never screenshot real customer data). To refresh a screenshot: open the demo page in Chrome, set
`document.documentElement.style.zoom` so the content fills the viewport (1.8 for app screens, 1.2
for the phone), screenshot, crop to the viewport with sharp, save as PNG.

**Attachment is OPT-IN (default off) in both modals**: contract → `attachments.flyer === true`
(`contractPackage.ts` requires the explicit true, unlike the other annexes; not restored from
saved conditions; excluded from the portal's declaration wording), quote → `form.attachments.flyer`
→ `quote/send.post.ts`. Preview/download: `GET /api/offers/flyer?language=de|en[&download=1]`
(auth; 1-h in-memory cache per language, `&refresh=1` re-renders).

pdfmake gotchas learned here: an absolutely-positioned `text` ignores `width` and wraps against
the page edge — wrap it in `{ columns: [{ width, text }] }` (the `txt()` helper); pdfkit has no
`rgba()` colours — use canvas `fillOpacity`; Roboto (the only vfs font) lacks `→`; `{ svg }`
nodes work for the eBay/GLS logos; `logyou-logo-dark.png` is the WHITE wordmark for dark
backgrounds (same convention as the quote header).

### Activities for offer/contract interactions — `[Dokumente]` convention
Every customer-facing sales step writes a `C_ContactActivity` (type `EM`) on the lead/partner via
`server/utils/offers/contactActivity.ts` → `logContactActivity(event, token, { source, recordId,
description, comments, documents: [{ title, url }], contactEmail })`. Callers: `quote/send.post.ts`
(quote sent), `contract/send.post.ts` (contract sent), `contractSigning.ts` → `finalizeSignature()`
(contract signed), `quoteConfirmation.ts` → `finalizeQuoteConfirmation()` (quote confirmed).
Rules baked in:
- `Description` (255, mandatory) = short headline; `Comments` = details + a parseable block
  `[Dokumente]\n<title> | <url>` per document. `LeadDetailRecordActivity.vue` (`splitNote()`)
  renders those lines as document buttons and shows `Comments || Description` (legacy rows only
  have Description). The manual modal has ONE note field; Description is derived from the first line.
- Lead → `AD_User_ID = recordId`; partner → the partner's contact user (e-mail match, else first
  active) — no contact user = activity skipped (fail-soft, never breaks the send).
- Org: cookie `logship_organization_id` → record's org. Public routes pass the SERVICE token
  (`config.api.idempieretoken`) and no cookies, so the record's org is used.
- Strapi file urls for buttons: `strapiPublicFileUrl(fileUrl)` (`<public.strapi>/files-api/<hash>.<ext>`,
  same pattern as AttachmentModal); portal copies use `/api/public/{sign,confirm}/<token>/document/<key>`
  (served only while the link is pending/signed).
- Always fail-soft: the helper returns `null` on any error; callers wrap in try/catch anyway.

### Quote online confirmation — `/confirm/<token>` (shares the signing store)
Opt-out checkbox `form.confirmation.enabled` (default on) in `QuoteModal.vue` → `quote/send.post.ts`
creates a row in `data/contract-signing.db` with **`kind = 'quote'`** (column added by an in-place
migration in `contractSigningDb.ts`; superseding is per `kind`, so a new quote never kills an open
contract link and vice versa), stores the exact offer PDF and adds a CTA to the mail (`confirmCtaHtml`
in `server/utils/offers/quoteConfirmation.ts`). Expiry = the quote's `validDays`. Public routes:
`server/api/public/confirm/[token]/{index.get,index.post,document/[key].get,request-renewal.post}.ts`
(mirror of the `/sign` routes; the `/sign` routes now reject `kind !== 'contract'` tokens). Page:
`app/pages/confirm/[token].vue` (layout false, DE/EN, single PDF viewer, declarations from
`confirmationAcceptanceTexts()` — server-owned wording, never pre-selected).
`finalizeQuoteConfirmation()`: appends a confirmation page to the offer PDF with **pdf-lib**
(`buildConfirmedOfferPdf`, WinAnsi-safe text), stores it as `<offer>_bestaetigt.pdf`, attaches it to the
record (service token), patches `offer_conditions.quote.confirmation`, logs the activity, mails the
customer and info@logyou.de. `signing-status.get.ts` returns `latest` (contract), `latestQuote` and
`history` (both kinds, `daysLeft`, document urls) — consumed by `components/offers/SigningLinksPanel.vue`
on the lead page. `signing-cancel` works for both kinds.

### PDF images — normalise before pdfmake (`server/utils/offers/pdfImages.ts`)
pdfmake throws `Unknown image format` for anything but PNG/JPEG. A lead's `AD_Image` (uploaded logo)
can be WebP/GIF/SVG and the lead page only sniffs JPEG-vs-"PNG", so the quote preview used to fail for
such leads. `normalizePdfImage(dataUrl)` sniffs the real magic bytes, passes PNG/JPEG through (mime
corrected), converts everything else to PNG with **sharp** (already a dependency) and returns `null` for
junk — callers then leave the image out. Applied in `generateQuotePdf` (customer logo + website
screenshot) and `generateContractPdf` (signature image; the contract falls back to the typed name).
Use it for any new pdfmake image that originates from user uploads or browser captures.

### Invoice PDF as a binary URL — `GET /api/invoices/[id]/pdf`
`/api/invoices/[id]/print/PDF` returns iDempiere's JSON (`reportFile` base64 — what `PrintPreview.vue`
consumes). Anything that needs a plain URL (`SignDocThumb` thumbnails via pdf.js, `<iframe>` previews)
uses `/api/invoices/[id]/pdf`, which decodes it and serves `application/pdf` (`?download=1` →
attachment) with `Cache-Control: private, max-age=300` so thumbnail + preview of the same invoice cost
one Jasper render. Used by the bank-match modal (`components/accounting/BankLineMatchModal.vue`).

### Nuxt UI `UCalendar` / `UPopover` in this app — pin the grid
`components/accounting/SettlementSourceFields.vue` is the reference for a range picker on Nuxt UI v4
(`<UPopover>` + `<UCalendar range :number-of-months="2">`, `CalendarDate` from
`@internationalized/date`, presets + "last N periods" mode). Same Tailwind-generation gap as
`SelectBox.vue`: the calendar `tbody` came out as `display:grid` and the cells stacked. Pin the
structure with non-scoped marker CSS on `[data-slot="gridBody|gridRow|headCell|cell|cellTrigger"]`
(range states via `data-selected` / `data-selection-start|end` / `data-highlighted`) and the popover
content via `:ui="{ content: 'z-[20000] <marker>' }"` (teleported → non-scoped styles).

### Global checkbox `transform: scale(2)` — override in modals
Native `input[type=checkbox]` in this app renders with a `transform: scale(2)` from a global sheet
(origin not found by grep; the `.check .checkInput` alga styles are separate), so a plain checkbox
inside a `label.checkbox` overlaps its text and neighbours. In the offer/contract modals the fix is a
component rule `.modal-overlay input[type="checkbox"] { transform: none !important; width/height 18px }`
plus `label.checkbox { display:inline-flex; gap }` (see the end of `QuoteModal.vue` / `ContractModal.vue`
styles). Reuse that block for new modals with native checkboxes.

### Product page stock actions — `ProductStockLocatorsCard` + `ProductStockActionModal`
`/materials/products/[id]/edit` shows an admin-only (`IsClientAdministrator`, same gate as
the pricing card) "Bestand je Lagerplatz" card directly under the product form: one colourful
tile per locator with on-hand > 0 (return locators orange, negative rows in a warning strip)
and four actions — **Bestand ändern** (physical inventory), **Wareneingang** (freehand receipt),
**Umlagern** (movement) run **in place** via `components/materials/ProductStockActionModal.vue`
(one component, `mode` = `count | move | receive`); **Etiketten drucken** opens
`/integrations/print-labels-desktop` in a new tab with the article preloaded.

- **Data:** the card resolves everything once via `POST /api/mobile/product-lookup
  { productId, includeAllOrgs: true }` (the same resolver the desktop pages use) — org,
  warehouse, `locators` (rows with qty ≠ 0, `isReturn`) and `availableLocators` (every
  locator of the org's warehouses) — and passes it to the modal as `context`, so opening a
  modal costs no request. Locator pickers are the app `SelectBox` wrapper with
  `prop="label" dataprop="id" datatype="number"`.
- **Document chains are 1:1 clones of the desktop pages** — keep them in sync when one
  changes: count = `/api/types/MMIPI` → `stock-takes/store` → `count-line` (`syncQtyBook:
  true`, absolute counts) → `document-action CO` (inventory-desktop); move =
  `stock-transfers/store` (doc type `1000022`, dummy partner `1015298`) → `move-line` →
  CO (movement-desktop); receive = `/api/types/MMR` → org partner + first location, fallback
  partner `1029012` → `inouts/store` with one `V+` line (needs the product's `uomId`, passed
  from the parent form) → CO (receive-desktop, freehand only — PO receipts stay on the page).
- **Live refresh, no reload:** on `completed` the card re-fetches, bumps
  `useProductStockVersion()` (`states.ts`) and emits `updated`; the edit page's
  `refreshQuantities()` re-reads the product and patches ONLY the storage-derived qty fields
  in `form` AND `formSnapshot` (unsaved edits survive), and `ProductDetailRecordLocatedAt.vue`
  watches the version counter to refetch its rows.
- **Hand-off to the full pages** (`fullPageLink()`): all desktop pages consume
  `?productId=&productValue=`; print-labels and movement need the VALUE (an id alone is
  ignored), so the link falls back to SKU/UPC when `Value` is empty.
- The "Located at" tab defaults to rows with any on-hand / ordered / reserved ≠ 0 (toggle
  "Nur Lagerplätze mit Bestand"); the receive-desktop org picker ignores cookie entries
  without id+name (`logship_organizations` can hold stale FetchError objects).

### Product labels — page size from printer registry / CUPS, vector CODE128
`POST /api/print/product` (`server/api/print/product.post.ts`; callers: print-labels-desktop,
mobile print-labels, receive/return/shopify-stocks/bom-productions/product-edit …) renders each
printer group at the **real label size** and draws the barcode as **vector bars**. History: pages
were always A8 (74×52) and `lp -o fit-to-page` shrank them by height onto e.g. the 35×17 mm labels
of labelprinter-2 — a third of the label stayed blank and the barcode was a ~20 mm blurry PNG.
Also fixed: the old `addPage()`/`setPage(no)` dance drew every label on the page created for the
NEXT one, so the first label of a batch landed on a default-format page.

- **Size resolution** — `server/utils/labelMedia.ts` → `resolveLabelMedia(event, queue)`:
  1. app printer registry `CUST_Printer` (`LabelWidthMm`/`LabelHeightMm`, plus `LabelType` = the
     label product for reordering) matched by `CupsName`, maintained at Settings → Printers
     (`PrinterForm.vue` has an "Aus CUPS übernehmen" button → `GET /api/printers/cups-media?queue=`);
  2. the CUPS queue's own default (`server/utils/cupsMedia.ts` → `getQueueMediaMm`: queue
     `lpoptions -p` option → PPD `*DefaultPageSize:` in `/etc/cups/ppd/<queue>.ppd` → starred
     `lpoptions -l` token). **`lpoptions -l` shows only the generic `*Custom.WIDTHxHEIGHT` for custom
     label sizes — the real dims are only in the PPD.** Cached 10 min, fail-soft (null on a dev Mac);
  3. null → legacy formats (`[35,17]` for b, `[54,52]` for f, `a8` otherwise).
  Prod PPD defaults (2026-09): lp-1/4/5 `w288h432` (102×152), lp-2 `Custom.3.5x1.7cm`,
  lp-3 `Custom.5.6x7.5cm`, lp-6 `Custom.5.4x2.9cm`; all Zebra EPL2 (203/300 dpi).
- **`-o media=Custom.WxHmm` is sent only for registry sizes** (explicit app override of the queue
  default); `-o fit-to-page` stays as the safety net. Response `printResults[].media` /
  `contents[].media` carry `{ w, h, source: 'registry'|'cups', labelType? }` — shown in the
  print-labels-desktop results panel.
- **Layouts** (`product.post.ts`): `h < 40 mm` → compact (`drawCompactProductLabel`: bold name
  [2 lines when h ≥ 24] / `SKU:` + 2-digit org / barcode / cleartext value), else table
  (`drawTableProductLabel`: wrapped name / SKU · Art.-Nr.+EAN · org+`Erst.:` / barcode ≤ 30 mm).
  `customSettings` (Shopify stock page) keeps the legacy fixed A8 layouts verbatim.
- **Barcode = vector** (`server/utils/labelBarcode.ts` → `drawCode128`): bar geometry parsed from
  bwip-js `toSVG` (node build works in Nitro; `raw()` is NOT the module API), filled `rect`s with
  ≥10-module / ≥2.5 mm quiet zones, module width auto (cap `maxModuleMm`). Raster PNGs get
  resampled + halftoned by CUPS on the 203 dpi grid → blurry; vector rasterises with hard edges.
  The clients' `labelBarcode` PNG is only a fallback; the bars always encode `productValue`.
- iDempiere columns `LabelWidthMm`/`LabelHeightMm`/`LabelType` are optional: reads are done without
  `$select` and tolerate both casings, writes send the keys only when filled (0 clears a size).
- **Local render test** (no dev server): stub the h3 auto-imports on `globalThis`, import the route
  via a resolve hook that appends `.ts`, point `CUPS_PPD_DIR` at fake PPDs, call the handler with
  `downloadOnly: true`, then `pdftoppm -r 203 -mono` to see what the Zebra sees.

### Paper vs label routing in the print routes — A5 Lieferschein gets `-o media=A5`
The Jasper "Shipment A5" packing slip (420 × 595 pt) is narrower than the old 500 pt
"label" threshold, so `print/attachment-smart` and `print/order-attachments` sent it to the
Zebra with fit-to-page. Since 2026-09 all four PDF print routes (`attachment-smart`,
`order-attachments`, `picklist`, `index`) share `server/utils/pdfPageMedia.ts`:
- `detectPdfPage(file)` → `pdfinfo`, ghostscript fallback, A4 assumed if both fail.
- `classifyPdfPage(w, h)` (orientation-agnostic): A5 → `a5`, A4 → `a4`, other paper
  (short side ≥ 500 pt, e.g. Letter) → `other`, everything smaller → `label`.
- Only `label` goes to the shipping label printer (`-o fit-to-page`). `a5`/`a4` go to the
  paper queue (`a4Printer` / `default`) with `-o sides=one-sided` plus `-o media=A5|A4`
  (`withMediaOption` never overrides a caller-supplied `media=`). The `default` queue's own
  PageSize default is **A5**, the `commission-a4-*` Brother queues default to A4 — hence the
  explicit media on both. Responses carry `pageKind` / `pdfAnalysis.kind` + `media`.
- Physical side: the A5 sheets go into the tray the way the printer's A5 guide expects;
  CUPS/pdftopdf rotates the page to the sheet, no landscape flag needed.

### iDempiere REST load rules — no `$expand` of child tables over big lists, no blind retries
Learned from the 2026-09-10 outage (app "slow", then 502/504 for everyone, 44 forced logouts).
A Jenkins build on the prod host starved iDempiere; the all-products call
`m_product?$expand=M_Storage` crossed nginx's 60 s, got retried, and 178 of 200 Jetty threads
ended up BLOCKED on one monitor. Postgres was idle the whole time. Three rules now enforced in code:

- **Never `$expand` a CHILD table (m_storage, m_inoutline, …) on a list of more than a page.**
  In idempiere-rest a detail expand runs `ExpandParser.getChildPOs → DefaultQueryConverter
  .convertStatement` once per parent, and that method is `synchronized` on a singleton — a
  3.3k-product list = ~6.6k serialized queries under ONE global lock that every other `$filter`
  request also needs. Expanding a to-one FK (`AD_Org_ID($select=Name)`, `C_Country_ID`,
  `M_RMA_ID`) is a different code path and fine. For products + stock use
  `server/utils/productStorageHelper.ts` → `fetchProductsWithStorage(event, token, urlWithoutStorageExpand)`
  (plain product query + paged `m_storage` join in Node, same `M_Storage: [...]` shape; 30 s
  storage cache; by-id chunks for a page, all rows for a full list). Diagnose a stall with
  `jstack <pid> | grep -c "DefaultQueryConverter.convertStatement"` on the prod host.
- **`fetchHelper` / `event.context.fetch` send `retry: 0` + a 90 s timeout** (ofetch otherwise
  retries every GET once on 500/502/503/504). They record the last iDempiere error on
  `event.context.lastIdempiereError`, and **`refreshTokenHelper` throws instead of refreshing
  when that error is not 401/403** — so the standard route pattern (catch → refresh → retry)
  no longer re-runs a query that just timed out. Routes need no change; the inner catch gets
  the original error via `errorHandlingHelper`.
- **Every unbounded list needs `$top`** (the inouts lists already cap at 2000; the all-products
  endpoint `materials/products` accepts `?top=&skip=` and caches its response 60 s per
  client/role/org with single-flight).

Synology helper (`server/utils/synologyHelper.ts`) follows the same idea: every NAS call has an
`AbortSignal.timeout` (15 s API / 5 min download), one shared login is reused for 30 min, and
error codes 105/106/107/119 trigger exactly one re-login + retry. `synologyLogout()` is a no-op
unless `force = true`.

### Click-to-dial (FreePBX/Asterisk AMI) — `<DialButton>` next to any phone field
Staff users with `AD_User.IsSystemUser = 'Y'` **and** a non-empty `AD_User.SIP_Extension` (custom column,
edited on the user page's Internal tab — admins only, system users only) get a dial button next to every phone
number. Drop it next to a new phone field with no further wiring:
```vue
<DialButton :number="form.phone" :user-id="form.id" :label="form.name" />          <!-- AD_User → activity target -->
<DialButton :number="entry.phone" :partner-id="entry.partnerId" :label="entry.name" compact />  <!-- C_BPartner → its first contact user -->
```
It renders nothing when the session may not dial (capability from `GET /api/telephony/me`, resolved
server-side from the JWT + `models/ad_user`, 10-min cache — the `logship_user` cookie is a trimmed
whitelist without the extension) or when the number has < 3 digits. Click → popover with the PBX
extension chooser (`GET /api/telephony/extensions` = AMI `DeviceStateList` joined with ERP user names;
own extension preselected, free entry, remembered in `localStorage.logship_dial_ext`) → `POST /api/telephony/dial`.
AG Grid phone columns use `components/AgGrid/AgDialPhone.vue` as cell renderer.

Server (`server/utils/telephony/`): `amiClient.ts` (raw TCP AMI, one socket per call, every wait has a
timeout), `dialAccess.ts` (gate + cache, `invalidateDialer(userId)` is called by the user update route),
`callTracker.ts` (originate + detached state machine), `phoneActivity.ts` (C_ContactActivity type `PC`
with the SERVICE token, German cause texts), `extensions.ts`. The HTTP request returns right after the
Originate is accepted (1.5 s fast-fail window for an unreachable phone); the tracker correlates events
via the inherited channel var `__LOGSHIP_CALL_ID` (exposed by `channelvars=LOGSHIP_CALL_ID` in
`manager.conf [general]`), finalizes on the last Hangup (+2 s grace), 3 h hard cap, unref'd timers,
per PM2 process (each instance tracks only the calls it originated). Config `runtimeConfig.asterisk` ←
`CLICK_TO_DIAL_ENABLED`, `ASTERISK_AMI_{HOST,PORT,USER,SECRET}`, `ASTERISK_DIAL_CONTEXT`,
`ASTERISK_ALLOW_FEATURE_CODES` (dev only: allows `*43` echo test). PBX 192.168.12.73: manager user
`[logship]` in `/etc/asterisk/manager_custom.conf` (deny all, permit 192.168.12.0/24, read
`call,originate,reporting`, write `originate,call,reporting` — manager ACTIONS are gated by WRITE
classes; `PJSIPShowEndpoints` would need `system`, deliberately not granted), `asterisk -rx "manager reload"`.

Verified gotchas — don't re-investigate: originate `PJSIP/<ext>` for locally registered devices and
`Local/<ext>@from-internal/n` only for dialplan-routed extensions (2XX → remote PBX via trunk). NEVER
use Local for local devices: FreePBX Follow-Me's announcement answers the Local leg before anyone picks
up → Asterisk reports "answered" and dials the customer while the agent still rings (and re-dials the
follow-me mobile in a loop; clear stuck legs with `channel request hangup Local/...`). CallerID on the
originate must be the TARGET (`"Anruf: <label>" <number>`): with the agent's own extension as caller
number the softphone rejects the INVITE (404). The outbound leg always leaves via .73's routes
(easybell trunk, CID forced to the trunk number). Q.850 cause 20 "Subscriber absent" = callee's mobile
off / no coverage.

### Call recordings + whisper transcription on the lead page (`TelephonyCallRecordingsModal`)
"Aufnahmen" button on `/edit/lead-<id>` → `app/components/telephony/CallRecordingsModal.vue`
(props `source` lead|user|partner, `recordId`, `label`, `numbers`; works for any contact page).
Step 1 lists every FreePBX recording matched by the contact's phone numbers (all pre-selected,
in-app `<audio>` player + download per row), step 2 shows the transcripts (copy / download single
or all, optional timestamps). Pipeline:
- **PBX side:** `/opt/logship-ivr/recordings.js` on 192.168.12.73 (systemd `logship-recordings`,
  port **8096**, LAN + dev-Mac allow-list AND `X-IVR-Secret` = the existing `IVR_SHARED_SECRET`)
  serves `/var/spool/asterisk/monitor/YYYY/MM/DD/<file>` read-only: `POST /recordings/stat`
  `{names:[…]}` (POST, not GET — **Node 8 on the PBX caps request headers at 8 KB**, a query
  string with 150 names overflowed) and `GET /recordings/file/<name>`. Name regex + no `..`,
  dir resolved from the `-YYYYMMDD-HHMMSS-` in the file name. ERP config `runtimeConfig.pbxRecordings`
  (`PBX_RECORDINGS_URL`).
- **ERP:** `server/utils/telephony/recordings.ts` — `findRecordings(numbers)` queries the CDR
  (`recordingfile <> ''`, `src/dst LIKE %<last 7 digits>`, then exact suffix compare on the
  national number via `nsn()`), ONE row per `recordingfile` (direction from which side matched,
  answered/billsec, agent extension from the channel), then `statRecordings()` drops files that
  are missing or ≤ 1 KB (**44-byte WAVs = never-answered legs**, FreePBX writes them anyway).
  Routes `server/api/telephony/recordings/{index.get,transcribe.post,audio.get}.ts`, gated by
  `gateFromEvent()` (staff: system user + non-limited role).
- **Transcription reuses the ticket/chat whisper pipeline:** `transcribeAudioDetailed()` in
  `server/utils/chatTranscribe.ts` (new; `transcribeAudio()` is now a thin wrapper) returns text
  + timestamped segments and takes `{ timeoutMs, extraArgs }`. `transcribeRecording()` caches per
  file in `data/call-transcripts/<file>.json` (git-ignored) and runs whisper **serially per
  process** (promise queue) with `-t <cpus-1, max 4>` and a timeout of 3× audio length. The
  modal POSTs one file per request so progress is real. Gotcha fixed on the way: the helper's
  temp WAV was `<src without ext>.wav` — identical to the source when the input already IS a
  `.wav` (ffmpeg refuses in-place edits) — it is now `.16k.wav`.
- Dev Mac has no `/opt/whisper-cpp`; for local tests `brew install whisper-cpp`, symlink
  `node_modules/whisper-node/lib/whisper.cpp/main → whisper-cli` and download `ggml-base.bin` into
  its `models/` (the helper's node_modules fallback). Prod: `/opt/whisper-cpp/{main,ggml-base.bin}`.

### Reverse caller-ID lookup (`cid-lookup`) — in-memory iDempiere directory, no Elasticsearch
`server/api/telephony/cid-lookup/[number].get.ts` is what FreePBX's "CallerID Lookup Sources"
(HTTP type) CURLs on every inbound call to set `CALLERID(name)` (see the file's own header
comment for the Asterisk-side contract: plain text, always 200, Basic Auth, FreePBX already
dedupes per call + across calls via AstDB). It used to query an Elasticsearch `ad_user` index
fed by `../idempiere-elastic` (Postgres triggers → `es_sync_queue` → a Python daemon) — that
pipeline can lag or stall, so a name changed in iDempiere didn't reliably show up on the phone
display (reported 2026-09-16). **Switched to querying iDempiere directly, no ES involved.**

Not a per-call live query, though — Asterisk is waiting synchronously, and per the
"iDempiere REST load rules" recipe above a synchronous per-call dependency on iDempiere is
exactly the wrong shape (the 2026-09-10 incident: iDempiere REST can stall for tens of seconds
under a Jenkins build). Instead: the whole `ad_user` directory (`IsActive eq true and (phone
neq null or phone2 neq null)`, `$select=Name,Phone,Phone2` — ~470 of ~62k rows on prod, ~150 ms)
is fetched with the SERVICE token (`config.api.idempieretoken`, same pattern as
`telephony/phoneActivity.ts`/`telephony/extensions.ts` — there's no user session to use, this is
an Asterisk-initiated request) into a **module-level in-memory cache, 60 s TTL,
stale-while-revalidate**, in `server/utils/telephony/cidDirectory.ts` (`getCidDirectory()`):
a warm/expired cache is served instantly and refreshed in the background; only the very first
lookup after a process restart blocks (bounded to 3 s, fail-open to the raw number on any
error) — and `server/plugins/warm-cid-lookup.ts`, a Nitro plugin (`server/plugins/*.ts`,
auto-registered, runs once at server boot — the first plugin of that kind in this codebase),
calls `getCidDirectory()` fire-and-forget on startup so that cold-start window is normally
already closed before the first real inbound call. Matching is last-8-significant-digit comparison in
plain JS (`tailsMatch`, symmetric `endsWith` so a shorter stored/caller number still matches) —
a straightforward port of the old ES `regexp` query's "ends with these digits, any separators
in between" behavior, now simpler because the digits are pre-normalized in Node.

**Gotcha confirmed live against prod:** iDempiere REST's `neq null` filter works, but `ne` does
NOT (`{"detail":"Unsupported operator: ne"}` — matches the "OData `ne` not supported" note in
the discovery recipe above, just for null checks too, not only `EntityType`). Response field
casing is **`Name`/`Phone`/`Phone2`** (capitalized) regardless of the `$select` casing sent —
read defensively (`r?.Name ?? r?.name`) per the usual iDempiere-casing rule.

### SEPA-Lastschriftmandat — contract annex + standalone portal
Shared server pieces: `server/utils/offers/sepaTexts.ts` (DE/EN wording per Deutsche Kreditwirtschaft /
EPC: CORE with the 8-week refund notice, B2B without; pre-notification line; declarations),
`sepaPdf.ts` (**pdf-lib**, one A4 page, branded, NATIVE fillable AcroForm fields `sepa.*` for
company / account holder / address / IBAN / BIC / bank / place-date; the signed variant sets the
values, draws the signature, adds a protocol line and `form.flatten()`s — pdf-lib gotcha: call
`tf.addToPage()` BEFORE `setFontSize()/setText()` or it throws "No /DA entry"), `sepaMandate.ts`
(creditor from `runtimeConfig.sepa` ← `SEPA_CREDITOR_ID` (Gläubiger-ID, **fill it in**, empty prints
"—"), `SEPA_PRENOTIFICATION_DAYS`; `validateDebtorInput`, `renderAndAttachSignedMandate`,
`finalizeSepaMandate`). IBAN: `app/utils/iban.ts` (`validateIban` = ISO 13616 charset + per-country
length + MOD 97-10 + SEPA country set, `validateBic`, `formatIbanDisplay` — NOT `formatIban`, that
name is already auto-imported from `useBankingFormat.ts`), used live in both portals and re-checked
by the routes. The full IBAN is never written to iDempiere — only masked into `offer_conditions.sepa`
and the activity; the signed PDF holds it.
- **Contract annex:** `attachments.sepa` (opt-in, default off, never restored from saved conditions —
  same rule as `flyer`) + `form.sepa = { scheme: 'B2B'|'CORE', recurring, prenotificationDays }` in
  `useOfferContract.ts` / `ContractModal.vue`. `contractPackage.ts` adds doc key `sepa` ("Anlage 4",
  `fillable: true`) and returns `mandate`, which `contract/send.post.ts` stores as `payload.mandate`.
  `/sign/<token>` shows step 1b (account holder, IBAN, BIC, bank; `index.get` exposes `sepa` +
  prefill) and two extra declarations (`acceptanceTexts(language, documents, scheme)`);
  `finalizeSignature` re-renders the filled+signed mandate, attaches it to Lead_User / C_BPartner and
  patches `offer_conditions.sepa`.
- **Standalone:** `components/offers/SepaMandateModal.vue` ("SEPA" toolbar button on
  `/partners/partners/[id]/edit` and the lead page; DE default, EN toggle; prefill from
  `partners/[id]/users` + `/locations`; preview via `POST /api/offers/sepa/preview`) →
  `POST /api/offers/sepa/send` (signing row **kind `'sepa'`**, superseding is per kind; mail with
  `/sepa/<token>`; blank mandate attached to the record; activity) → `app/pages/sepa/[token].vue` +
  `server/api/public/sepa/[token]/{index.get,index.post,document/[key].get,request-renewal.post}.ts`
  (guard `req.kind !== 'sepa'`) → `finalizeSepaMandate` attaches the signed PDF to the sending record,
  mails customer + info@. `contract/signing-status.get.ts` and `SigningLinksPanel.vue` know the kind
  (the panel is mounted on the partner page too). Test locally with `recordId: 0` requests (no record
  touched; mails fail-soft without SMTP) and delete the `record_id = 0` rows + `data/contract-signing/<token>/`
  afterwards. NB the CONTRACT send route still hard-fails when SMTP is unreachable (pre-existing).

**Top-bar quick dial + dashboard "Letzte Anrufe" card (admin roles).** `components/QuickDial.vue` sits in
`TopMegaBar.vue` inside the `user-menu-navitem` container (a separate `.navbar-item` there lands on a hidden
grid row): Enter dials with the own extension, the `<DialButton>` opens the chooser. The dashboard card
`components/dashboard/RecentCallsCard.vue` (mounted after the tickets card in `pages/index.vue`; the page's
`.fcard` styles are scoped, so the card carries its own copies) reads `GET /api/telephony/recent-calls`
→ `server/utils/telephony/cdr.ts`: **mysql2** pool to the FreePBX MariaDB (`asteriskcdrdb.cdr`, read-only
user `logship_cdr` granted for 192.168.12.% + the dev Mac; `PBX_CDR_*` env, `PBX_TRUNK_PATTERNS`),
last 30 days / 1500 rows, grouped by **`linkedid`** (FreePBX writes one cdr row per leg — queues/follow-me
produce 20+ rows per call) into ONE entry per call: inbound = a leg whose `channel` is a trunk channel,
outbound = a leg whose `dstchannel` is a trunk channel; inbound "answered" needs an ANSWERED `Dial` leg with
billsec > 0 to a non-trunk device (voicemail legs don't count). The card shows missed inbound + answered
outbound only; caller-id names strip the PBX's `LogYou:` inbound prefix and our `Anruf:` label; numbers are
clickable (own extension) with a chooser button per row. MariaDB 5.5 on the PBX: use
`GRANT … IDENTIFIED BY` (no `CREATE USER IF NOT EXISTS` / `ALTER USER`).

### Amazon SP-API: app-level LWA Client Secret rotation ≠ seller "Reconnect Amazon Account"
An Amazon order source going dark with the ERP's "Test Connection" showing red — even
right after clicking "Reconnect Amazon Account" — is almost always the **wrong** fix
being applied to the wrong problem. There are two completely independent Amazon
credentials in play, and confusing them wastes a lot of debugging time (real incident,
order source 1000026, 2026-09-14):

- **Seller-level refresh token** (`c_ordersource.marketplace_token`) — what "Connect /
  Reconnect Amazon Account" (`server/api/oauth/amazon/authorize/[uid].get.ts` →
  `callback.get.ts`) renews. Fixes an expired/revoked *merchant* authorization.
- **App-level LWA Client Secret** (`AMAZON_LWA_CLIENT_SECRET` / the app's `marketplace_key`
  + `marketplace_secret` pair) — a completely separate Amazon SP-API app credential that
  Amazon **mandates rotating every 180 days** (announced 2023-02-06; Amazon sends a
  reminder email ~180 days out and again as the deadline approaches). This is scoped to
  the *application* (client_id), not any one seller.

**Symptom when the app secret is overdue, not the seller token:** the LWA
`grant_type=refresh_token` exchange (`https://api.amazon.com/auth/o2/token`) still
succeeds (200, valid access token) — but literally every real SP-API call (Sellers,
Orders, the restricted-data-token step) is rejected with
`{"errors":[{"code":"Unauthorized","message":"Access to requested resource is denied.",
"details":"The LWA secret token you provided has expired."}]}`. Diagnose by replaying the
LWA exchange + one SP-API call directly with curl using the stored `marketplace_key`/
`secret`/`token` (see the "Querying iDempiere production read-only" recipe for the
service-token pattern) — if LWA succeeds but every SP-API call gives that exact message,
it's the app secret, not the seller token. **Re-doing "Reconnect Amazon Account" will
NOT fix this** — it mints a new refresh token under the SAME (already-expiring/expired)
app client_id/secret, so the symptom persists identically after "successfully"
reconnecting.

**Fix:** rotate the LWA Client Secret in the Amazon Developer/Seller Central console
for the app that OWNS that client_id (Apps & Services → Develop Apps → the app → Edit
App, or the Login-with-Amazon security profile page) — a merchant's own Seller Central
login can't do this, only whoever registered the app. Then update the new secret in
every place it's read: ERP `.env`/`.env-prod` (`AMAZON_LWA_CLIENT_ID`/`_SECRET`, read by
`callback.get.ts` for every future Connect/Reconnect), Laravel's `.env`
(`AMAZON_KEY`/`AMAZON_SECRET`, fallback default in `AmazonTokenRepository::token()`), and
directly on any already-connected `c_ordersource.marketplace_secret` (fastest via the
ERP's own edit form — `update.put.ts` already persists `marketplaceSecret`).

**Multiple "central" apps can coexist without anyone noticing.** Querying every Amazon
order source's `marketplace_key` can reveal each one is actually on a *different*
Developer Console app (registered at different times) rather than one shared app — the
`amazonViaLogshipApp` flag (`server/api/settings/order-sources/[id]/index.get.ts`) only
ever matches the CURRENT `AMAZON_LWA_CLIENT_ID` env value, so an order source connected
under a previous incarnation of "the central app" silently shows as if using its own
separate credentials. If standardizing on one app going forward (e.g. switching from a
Draft app to a Published one), the cleanest path for an already-working order source is
just clicking "Reconnect Amazon Account" again once the env points at the new app — no
manual secret pasting needed, since the callback always writes whatever
`config.api.amazonLwaClientId/Secret` currently is onto that order source's record.

**The built-in automatic rotation system may not actually be running.** This codebase
has a full SQS-notification-driven auto-rotation pipeline for exactly this 180-day
deadline (`../laravel-middleware`: `ProcessAmazonClientSecretRotation` job, hourly;
`AmazonTokenRepository::{ensureSubscription,pollSqsCredentials,rotateClientSecret}`;
`amazon_oauth_clients` table). Don't assume it's live — verify first:
```bash
ssh root@<laravel-host> "cd /path/to/laravel-middleware && php artisan tinker --execute=\"echo App\\\\Models\\\\AmazonOauthClient::query()->get()->toJson(JSON_PRETTY_PRINT);\""
```
If every row's `destination_id`/`subscribed_at`/`secret_expiry`/`rotated_at` is `null`,
enrollment has never succeeded for ANY app and nothing is auto-rotating — check the
Laravel `.env` for `AMAZON_OAUTH_SQS_URL`/`AMAZON_OAUTH_SQS_ARN`/AWS credentials before
trusting this mechanism, and don't call `rotateClientSecret()` (irreversible — Amazon
only delivers the new secret via SQS, with no other way to retrieve it) until SQS
delivery is confirmed actually working end to end.

### PM2 `reload` does NOT apply a changed env var for an already-built `runtimeConfig` value
A `runtimeConfig` key set via `process.env.X` in `nuxt.config.ts` (e.g. the Amazon app
config above) gets its value **baked into the compiled output** at `nuxt build` time —
editing `.env`/`.env-prod` on the server and running `pm2 reload erpfrontend
--update-env` restarts the process with the new file on disk, but does **not** change
what's already compiled into `.output/server/chunks/nitro/nitro.mjs`. Verified directly:
grepped the old literal value out of that file with `grep -o
'amzn1\.sp\.solution\.[a-f0-9-]*' .output/server/chunks/nitro/nitro.mjs` — still present
after a reload with the new `.env`. (Root cause of the confusing "PM2 shows the process
restarted, but the app clearly hasn't picked up the config" symptom.)

**Fix without a full rebuild:** Nitro also generates a runtime env-override layer keyed
by `envPrefix: "NUXT_"` (confirmed present in the build's config — grep
`/opt/erp-nuxt-frontend/.output/server/chunks/nitro/nitro.mjs` for `envPrefix`). Setting
the equivalent `NUXT_`-prefixed var — nested `runtimeConfig.api.amazonAppId` becomes
`NUXT_API_AMAZON_APP_ID` (underscore-joined path, upper-cased) — DOES get picked up at
each process start, superseding the stale baked-in default, no rebuild required. Keep
both the plain-named var (documentation/consistency, and what a future rebuild will pick
up) and the `NUXT_`-prefixed override (what actually takes effect until that rebuild
happens) in sync in `.env`/`.env-prod`. A real `nuxt build` + redeploy (e.g. via the
Jenkins pipeline on a `main` push) makes the override redundant but leaving it in place
is harmless. This applies to ANY `runtimeConfig` key, not just Amazon's — worth
remembering any time "I changed the env var and reloaded PM2 but nothing changed" comes up.
