# 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.

### 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`.
