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

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