/** * Commissioning-table relation (Tisch ↔ printer ↔ camera). * * Each physical commissioning workstation ("Tisch 1", "Tisch 2", …) has its * own label printer AND its own surveillance camera. To play the correct * video for a shipment we must know which Tisch it was packed at. * * Source of truth is the iDempiere table **CUST_CommissionTable** — one row * per Tisch, editable in the ERP UI, so adding a table needs NO code or env * change. Expected columns (lowercase-first per iDempiere write-casing; * reads tolerate both casings): * * CUST_CommissionTable_ID PK * AD_Org_ID org (0 = shared across orgs) * Name e.g. "Tisch 1" * shipping_printer e.g. "labelprinter-1" (lookup key at completion) * a4_printer e.g. "commission-a4-1" (optional) * synology_camera_id integer (resolved at video time) * IsActive * * Flow: * - On commissioning completion the parcel routes call * `commissionTableFkPatch()` with the selected shipping printer; it * resolves the matching row and returns the FK fragment to merge onto * the m_inout PUT (`CUST_CommissionTable_ID`). * - At video time `init.post.ts` $expands that FK off the shipment and * reads `synology_camera_id` directly — no call into this util needed. */ import fetchHelper from './fetchHelper' import getTokenHelper from './getTokenHelper' import refreshTokenHelper from './refreshTokenHelper' export type CommissionTableRow = { id: number number: number | null name: string shippingPrinter: string | null a4Printer: string | null cameraId: number | null } // Per-org cache. The config is tiny (a handful of rows) and near-static, so // we avoid a round-trip to iDempiere on every shipment completion. 10-min TTL // mirrors chatAccess; a stale entry is served if a refresh fails. const TTL_MS = 10 * 60 * 1000 const cache = new Map() const toCameraId = (v: any): number | null => { const n = Number(v) return Number.isFinite(n) && n > 0 ? n : null } // Trim to null. The printer columns are the join key between the commissioning // picker and the shipment-completion FK lookup, so any stray/trailing // whitespace (iDempiere CHAR-padding, copy-paste) on one side but not the other // would silently break the exact-string match and leave CUST_CommissionTable_ID // unset. Normalising at the single read point keeps the dropdown value and the // resolver value identical. const trimToNull = (v: any): string | null => { const s = (v === null || v === undefined) ? '' : String(v).trim() return s || null } const mapRow = (r: any): CommissionTableRow => ({ id: Number(r?.id), number: toCameraId(r?.commissionTableNumber ?? r?.CommissionTableNumber), name: r?.Name ?? r?.name ?? '', // iDempiere OData response casing varies by model — accept both. shippingPrinter: trimToNull(r?.shipping_printer ?? r?.Shipping_Printer), a4Printer: trimToNull(r?.a4_printer ?? r?.A4_Printer), cameraId: toCameraId(r?.synology_camera_id ?? r?.Synology_Camera_ID), }) const loadRows = async (event: any, orgId: number, authToken: any = null): Promise => { const token = authToken ?? await getTokenHelper(event) // Include org-shared rows (AD_Org_ID eq 0) so a single physical-table setup // can serve every org without duplicating rows. const res: any = await fetchHelper( event, `models/cust_commissiontable?$filter=(AD_Org_ID eq ${orgId} or AD_Org_ID eq 0) and IsActive eq true`, 'GET', token, null ) const records = Array.isArray(res?.records) ? res.records : [] return records.map(mapRow).filter((r: CommissionTableRow) => Number.isFinite(r.id) && r.id > 0) } /** * All active commissioning-table rows for an org (org-specific + shared), * cached per org. Falls back to a token refresh, then to stale cache, then * to an empty list — never throws, so a config-table hiccup degrades the * relation gracefully instead of breaking shipment completion. */ export async function getCommissionTables(event: any, orgId: number, authToken: any = null): Promise { if (!Number.isFinite(orgId) || orgId <= 0) return [] const now = Date.now() const cached = cache.get(orgId) if (cached && now - cached.at < TTL_MS) return cached.rows let rows: CommissionTableRow[] | null = null try { rows = await loadRows(event, orgId, authToken) } catch { try { const refreshed = await refreshTokenHelper(event) rows = await loadRows(event, orgId, refreshed) } catch { if (cached) return cached.rows rows = [] } } cache.set(orgId, { rows, at: now }) return rows } /** Resolve the CUST_CommissionTable row for a shipping printer, or null. */ const matchRowByPrinter = (rows: CommissionTableRow[], key: string): CommissionTableRow | null => { // Case-insensitive on top of the trim (both sides already trimmed via mapRow) // so a printer renamed with a different case in Settings still links. const k = key.toLowerCase() return rows.find(r => r.shippingPrinter && r.shippingPrinter.toLowerCase() === k) ?? null } /** Resolve the CUST_CommissionTable row id for a shipping printer, or null. */ export async function resolveCommissionTableId( event: any, orgId: number, shippingPrinter: string | null | undefined, authToken: any = null ): Promise { if (!shippingPrinter) return null const key = String(shippingPrinter).trim() if (!key) return null const rows = await getCommissionTables(event, orgId, authToken) const match = matchRowByPrinter(rows, key) return match ? match.id : null } /** * Patch fragment recording which commissioning table a shipment was packed * at, derived from the selected shipping printer. Returns {} when the printer * is missing/unmapped so callers can spread it onto an m_inout PUT payload * unconditionally. Reads the org from the request cookie (same source the * parcel routes already use). * * Logs every outcome: the FK is the join the surveillance-video modal relies on * to pick the right camera, so a silently-unset value is exactly the failure we * need to be able to see in the logs. */ export async function commissionTableFkPatch( event: any, shippingPrinter: string | null | undefined, authToken: any = null ): Promise> { if (!shippingPrinter) { console.warn('[commissionTable] completion sent no shippingPrinter → CUST_CommissionTable_ID not set') return {} } const orgId = Number(getCookie(event, 'logship_organization_id')) const key = String(shippingPrinter).trim() const rows = await getCommissionTables(event, orgId, authToken) const match = key ? matchRowByPrinter(rows, key) : null if (!match) { // Most common cause: the operator picked a printer from the frontend's // built-in "Tisch 1/2/3" fallback (shown when no rows exist for this org), // so there is no CUST_CommissionTable row to link. The available list makes // that obvious at a glance. console.warn('[commissionTable] no table matched selected printer → CUST_CommissionTable_ID not set', { orgId, selectedPrinter: key, available: rows.map(r => ({ id: r.id, name: r.name, printer: r.shippingPrinter })), }) return {} } console.log('[commissionTable] linked shipment to commission table', { orgId, selectedPrinter: key, tableId: match.id, name: match.name, }) return { CUST_CommissionTable_ID: { id: match.id, tableName: 'CUST_CommissionTable' } } }