/** * Unit tracking on goods receipt — serial numbers and charges (lot + MHD) as * native iDempiere M_AttributeSetInstance (ASI) rows. * * Ported from the blut24 branch (server/utils/bloodUnit.ts, lieferschein/import), * generalised for the LogShip catalogue: * - a product is "tracked" when m_product.M_AttributeSet_ID points at one of the * three LogShip sets below (created lazily, by Name, all fields optional); * - a SERIAL is one unit → one qty-1 m_inoutline carrying its own ASI (SerNo); * - a CHARGE is one batch → one qty-n m_inoutline carrying a shared ASI (Lot + * GuaranteeDate = MHD). Identical (set, org, lot, mhd) charges are REUSED so one * physical batch never fragments into several m_storageonhand rows. * * REST facts this module relies on (verified against prod, see plan): * - writes are lowercase-first (`serNo`, `lot`, `guaranteeDate`, `description`), * reads come back PascalCase (`SerNo`, `Lot`, `GuaranteeDate`); * - iDempiere does NOT build the ASI Description over REST — we send it; * - `SerNo in ('a','b')` and `M_AttributeSetInstance_ID in (1,2)` filters work; * - m_storageonhand has a composite PK: no `id` (use `uid`), always pass `$orderby`; * - the OData not-equal operator is `neq`, date literals are unquoted. */ import fetchHelper from './fetchHelper' export type TrackingMode = 'none' | 'serial' | 'charge' | 'mhd' | 'both' export interface TrackingFlags { isSerNo: boolean; isLot: boolean; isGuaranteeDate: boolean } export interface TrackingSet { id: number name: string mode: TrackingMode flags: TrackingFlags /** true for the three sets this module owns (name starts with "LogShip ") */ isLogShip: boolean } export const TRACKING_SET_DEFS: Record, { name: string; description: string isSerNo: boolean; isLot: boolean; isGuaranteeDate: boolean; useGuaranteeDateForMPolicy: boolean }> = { serial: { name: 'LogShip Seriennummer', description: 'LogShip — Seriennummer je Einheit (eine Wareneingangsposition pro Stück).', isSerNo: true, isLot: false, isGuaranteeDate: false, useGuaranteeDateForMPolicy: false }, charge: { name: 'LogShip Charge', description: 'LogShip — Charge (Lot) mit MHD; FEFO über UseGuaranteeDateForMPolicy.', isSerNo: false, isLot: true, isGuaranteeDate: true, useGuaranteeDateForMPolicy: true }, mhd: { name: 'LogShip MHD', description: 'LogShip — nur MHD (kein Lot); FEFO über UseGuaranteeDateForMPolicy.', isSerNo: false, isLot: false, isGuaranteeDate: true, useGuaranteeDateForMPolicy: true }, both: { name: 'LogShip Seriennummer + Charge', description: 'LogShip — Seriennummer je Einheit plus Charge (Lot) und MHD.', isSerNo: true, isLot: true, isGuaranteeDate: true, useGuaranteeDateForMPolicy: true } } /** Client → server payload on a receive line (`line.tracking`). */ export interface LineTrackingPayload { attributeSetId: number /** units with a serial; lot/mhd optional (set "both") */ serials?: Array /** batches without serials */ charges?: Array<{ lot?: string | null; mhd?: string | null; qty: number }> } export interface ExpandResult { ok: boolean lines: any[] warnings: string[] errors: { productId: number | null; message: string }[] createdAsiIds: number[] } // --- small helpers ---------------------------------------------------------- const enc = encodeURIComponent // quoted OData string literal (the repo's `odataQuote` in odataHelper.ts only escapes) const odataStr = (s: any) => `'${String(s ?? '').replace(/'/g, "''")}'` const truthy = (v: any) => v === true || v === 'Y' || v === 'true' /** ISO-normalise yyyymmdd / ISO-ish / dd.mm.yyyy. Returns '' for anything else. */ export const isoDate = (raw: any): string => { const s = String(raw ?? '').trim() if (!s) return '' if (/^\d{8}$/.test(s)) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10) const m = s.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})$/) if (m) { const y = m[3].length === 2 ? '20' + m[3] : m[3] return `${y}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}` } return '' } const addDays = (iso: string, days: number) => { const d = new Date(`${iso}T00:00:00Z`) d.setUTCDate(d.getUTCDate() + days) return d.toISOString().slice(0, 10) } const chunk = (arr: T[], size: number): T[][] => { const out: T[][] = [] for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)) return out } /** Run `fn` over `items` with at most `limit` in flight (order of results preserved). */ const mapPool = async (items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise => { const results: R[] = new Array(items.length) let next = 0 const worker = async () => { while (next < items.length) { const i = next++ results[i] = await fn(items[i], i) } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) return results } export const modeFromFlags = (f: TrackingFlags): TrackingMode => { const batch = f.isLot || f.isGuaranteeDate if (f.isSerNo && batch) return 'both' if (f.isSerNo) return 'serial' if (f.isLot) return 'charge' if (f.isGuaranteeDate) return 'mhd' return 'none' } const toTrackingSet = (r: any): TrackingSet => { const flags: TrackingFlags = { isSerNo: truthy(r?.IsSerNo ?? r?.isSerNo), isLot: truthy(r?.IsLot ?? r?.isLot), isGuaranteeDate: truthy(r?.IsGuaranteeDate ?? r?.isGuaranteeDate) } const name = String(r?.Name ?? r?.name ?? '').trim() return { id: Number(r?.id), name, mode: modeFromFlags(flags), flags, isLogShip: name.startsWith('LogShip ') } } // --- attribute-set cache ------------------------------------------------------ // Resolving the sets costs one REST call (four POSTs the very first time); the // receive pages and the products grid ask for flags on every product, so keep // them per process with a TTL. Single-flight so two concurrent first requests // cannot both create the sets. const TTL_MS = 10 * 60 * 1000 const g = globalThis as any interface SetCache { byMode: Record<'serial' | 'charge' | 'mhd' | 'both', TrackingSet> | null expires: number byId: Map inflight: Promise> | null } const cache: SetCache = g.__logshipTrackingSets ?? (g.__logshipTrackingSets = { byMode: null, expires: 0, byId: new Map(), inflight: null }) /** Drop the cache (tests / after manual edits of the sets in iDempiere). */ export const resetTrackingSetCache = () => { cache.byMode = null; cache.expires = 0; cache.byId.clear() } /** * Token used to CREATE the shared master data (the three sets live in org 0 so * every organisation sees them). A normal staff session may lack org-0 write * access, so the service token is preferred when configured. */ const masterDataToken = (token: string) => { try { const config = useRuntimeConfig() return (config.api as any)?.idempieretoken || token } catch { return token } } const ALL_MODES = ['serial', 'charge', 'mhd', 'both'] as const export const ensureTrackingSets = async (event: any, token: string) => { // a cache written by an older build may lack a mode → re-resolve instead of trusting it if (cache.byMode && cache.expires > Date.now() && ALL_MODES.every((m) => cache.byMode![m])) return cache.byMode if (cache.inflight) return cache.inflight cache.inflight = (async () => { try { const res: any = await fetchHelper(event, `models/m_attributeset?$filter=${enc("startswith(Name,'LogShip ')")}&$top=20`, 'GET', token, null) const rows: any[] = res?.records ?? [] const byMode: any = {} for (const mode of ALL_MODES) { const def = TRACKING_SET_DEFS[mode] let row = rows.find((r) => String(r?.Name ?? r?.name ?? '').trim() === def.name) if (!row) { row = await fetchHelper(event, 'models/m_attributeset', 'POST', masterDataToken(token), { AD_Org_ID: 0, name: def.name, description: def.description, isSerNo: def.isSerNo, isSerNoMandatory: false, isLot: def.isLot, isLotMandatory: false, isGuaranteeDate: def.isGuaranteeDate, isGuaranteeDateMandatory: false, useGuaranteeDateForMPolicy: def.useGuaranteeDateForMPolicy, mandatoryType: 'N', isInstanceAttribute: true, isActive: true, tableName: 'M_AttributeSet' }) if (!row?.id) throw new Error(`Attribut-Set "${def.name}" konnte nicht angelegt werden`) } const set = toTrackingSet(row) byMode[mode] = set cache.byId.set(set.id, { set, expires: Date.now() + TTL_MS }) } cache.byMode = byMode cache.expires = Date.now() + TTL_MS return byMode as Record<'serial' | 'charge' | 'mhd' | 'both', TrackingSet> } finally { cache.inflight = null } })() return cache.inflight } /** Flags of ANY attribute set (LogShip or custom); null for 0 / missing. Cached. */ export const getTrackingSet = async (event: any, token: string, setId: any): Promise => { const id = Number(setId?.id ?? setId) || 0 if (!id) return null const hit = cache.byId.get(id) if (hit && hit.expires > Date.now()) return hit.set try { const row: any = await fetchHelper(event, `models/m_attributeset/${id}`, 'GET', token, null) if (!row?.id) return null const set = toTrackingSet(row) cache.byId.set(id, { set, expires: Date.now() + TTL_MS }) return set } catch { return null } } /** The set for a requested mode (creating the sets on first use). */ export const trackingSetForMode = async (event: any, token: string, mode: TrackingMode): Promise => { if (mode === 'none') return null const sets = await ensureTrackingSets(event, token) return sets[mode] ?? null } // --- ASI create / lookup -------------------------------------------------------- export const buildAsiDescription = (u: { serNo?: string | null; lot?: string | null; guaranteeDate?: string | null }) => { const parts: string[] = [] if (u.serNo) parts.push(`SN ${u.serNo}`) if (u.lot) parts.push(`Lot ${u.lot}`) if (u.guaranteeDate) parts.push(`MHD ${u.guaranteeDate}`) return parts.join(' · ') } export const createAsi = async (event: any, token: string, orgId: number, u: { setId: number; serNo?: string | null; lot?: string | null; guaranteeDate?: string | null }): Promise => { const serNo = String(u.serNo ?? '').trim() const lot = String(u.lot ?? '').trim() const guaranteeDate = isoDate(u.guaranteeDate) const payload: any = { AD_Org_ID: { id: Number(orgId), tableName: 'AD_Org' }, M_AttributeSet_ID: { id: Number(u.setId), tableName: 'M_AttributeSet' }, description: buildAsiDescription({ serNo, lot, guaranteeDate }), isActive: true, tableName: 'M_AttributeSetInstance' } if (serNo) payload.serNo = serNo if (lot) payload.lot = lot if (guaranteeDate) payload.guaranteeDate = guaranteeDate const asi: any = await fetchHelper(event, 'models/m_attributesetinstance', 'POST', token, payload) if (!asi?.id) throw new Error('M_AttributeSetInstance konnte nicht angelegt werden') return Number(asi.id) } /** ASI ids (of the given list) that currently carry stock somewhere. */ export const asiIdsInStock = async (event: any, token: string, asiIds: number[]): Promise> => { const out = new Set() const ids = Array.from(new Set(asiIds.map(Number).filter((n) => n > 0))) for (const part of chunk(ids, 100)) { const res: any = await fetchHelper(event, `models/m_storageonhand?$filter=${enc(`M_AttributeSetInstance_ID in (${part.join(',')}) AND QtyOnHand gt 0`)}` + `&$orderby=${enc('M_AttributeSetInstance_ID asc')}&$top=1000`, 'GET', token, null) for (const r of res?.records ?? []) { const id = Number(r?.M_AttributeSetInstance_ID?.id ?? r?.M_AttributeSetInstance_ID) || 0 if (id) out.add(id) } } return out } export interface KnownSerial { serNo: string; asiId: number; setId: number; lot: string; mhd: string; inStock: boolean } /** * Existing ASIs of this org for the given serials. When a serial has several * ASIs (re-received returns), an in-stock one wins (it blocks), else the newest. */ export const findAsisBySerials = async (event: any, token: string, orgId: number, serNos: string[]): Promise> => { const out = new Map() const wanted = Array.from(new Set(serNos.map((s) => String(s ?? '').trim()).filter(Boolean))).slice(0, 500) if (!wanted.length) return out const rows: any[] = [] for (const part of chunk(wanted, 25)) { const res: any = await fetchHelper(event, `models/m_attributesetinstance?$filter=${enc(`AD_Org_ID eq ${Number(orgId)} AND SerNo in (${part.map(odataStr).join(',')})`)}` + `&$orderby=${enc('M_AttributeSetInstance_ID desc')}&$top=500`, 'GET', token, null) rows.push(...(res?.records ?? [])) } if (!rows.length) return out const inStock = await asiIdsInStock(event, token, rows.map((r) => Number(r.id))) for (const r of rows) { const serNo = String(r?.SerNo ?? r?.serNo ?? '').trim() if (!serNo) continue const cand: KnownSerial = { serNo, asiId: Number(r.id), setId: Number(r?.M_AttributeSet_ID?.id ?? r?.M_AttributeSet_ID) || 0, lot: String(r?.Lot ?? r?.lot ?? '').trim(), mhd: String(r?.GuaranteeDate ?? r?.guaranteeDate ?? '').slice(0, 10), inStock: inStock.has(Number(r.id)) } const prev = out.get(serNo) if (!prev || (cand.inStock && !prev.inStock) || (cand.inStock === prev.inStock && cand.asiId > prev.asiId)) { out.set(serNo, cand) } } return out } /** Reuse lookup for a batch ASI: same set, org, lot and MHD, no serial. */ export const findChargeAsi = async (event: any, token: string, orgId: number, setId: number, lot: string, mhd: string): Promise => { const lotV = String(lot ?? '').trim() const mhdV = isoDate(mhd) if (!lotV && !mhdV) return null let filter = `M_AttributeSet_ID eq ${Number(setId)} AND AD_Org_ID eq ${Number(orgId)}` if (lotV) filter += ` AND Lot eq ${odataStr(lotV)}` else filter += ` AND GuaranteeDate ge ${mhdV} AND GuaranteeDate lt ${addDays(mhdV, 1)}` const res: any = await fetchHelper(event, `models/m_attributesetinstance?$filter=${enc(filter)}&$orderby=${enc('M_AttributeSetInstance_ID desc')}&$top=50`, 'GET', token, null) for (const r of res?.records ?? []) { if (String(r?.SerNo ?? '').trim()) continue if (String(r?.Lot ?? '').trim() !== lotV) continue if (String(r?.GuaranteeDate ?? '').slice(0, 10) !== mhdV) continue return Number(r.id) } return null } // --- line expansion ------------------------------------------------------------- const normSerial = (s: any) => String(s ?? '').replace(/[\r\n]/g, '').replace(/\s+/g, ' ').trim() /** * Turn receive lines that carry `tracking` into iDempiere lines: one qty-1 line * per serial, one line per charge group, the untracked remainder as a plain * line. Validation (over-count, serial already in stock, duplicates) runs * BEFORE any ASI is created, so an error response leaves nothing behind. */ export const expandTrackedLines = async (event: any, token: string, orgId: number, inOutLines: any[]): Promise => { const result: ExpandResult = { ok: true, lines: [], warnings: [], errors: [], createdAsiIds: [] } const org = Number(orgId) type Unit = { serNo: string; lot: string; mhd: string } type Prep = { base: any; productId: number | null; qty: number; setId: number units: Unit[]; charges: { lot: string; mhd: string; qty: number }[]; remainder: number } const prepared: (Prep | { passthrough: any })[] = [] const seenSerials = new Map() for (const raw of inOutLines ?? []) { const t: LineTrackingPayload | undefined = raw?.tracking const { tracking: _drop, ...base } = raw ?? {} if (!t || !t.attributeSetId) { prepared.push({ passthrough: base }); continue } const productId = Number(base?.M_Product_ID?.id ?? base?.M_Product_ID) || null const qty = Number(base?.movementQty ?? base?.qtyEntered ?? 0) const units: Unit[] = [] for (const s of t.serials ?? []) { const serNo = normSerial(typeof s === 'string' ? s : s?.serNo) if (!serNo) continue if (seenSerials.has(serNo)) { result.errors.push({ productId, message: `Seriennummer ${serNo} ist doppelt erfasst` }) continue } seenSerials.set(serNo, productId) units.push({ serNo, lot: typeof s === 'string' ? '' : String(s?.lot ?? '').trim(), mhd: typeof s === 'string' ? '' : isoDate(s?.mhd) }) } const charges = (t.charges ?? []) .map((c) => ({ lot: String(c?.lot ?? '').trim(), mhd: isoDate(c?.mhd), qty: Number(c?.qty ?? 0) })) .filter((c) => c.qty > 0) const used = units.length + charges.reduce((a, c) => a + c.qty, 0) if (used > qty) { result.errors.push({ productId, message: `Erfasste Einheiten (${used}) übersteigen die Menge (${qty})` }) } prepared.push({ base, productId, qty, setId: Number(t.attributeSetId), units, charges, remainder: qty - used }) } // Serial duplicates against existing stock — one lookup for all lines. const allSerials = prepared.flatMap((p) => 'units' in p ? p.units.map((u) => u.serNo) : []) if (allSerials.length > 500) { result.errors.push({ productId: null, message: `Zu viele Seriennummern in einer Buchung (${allSerials.length}, max. 500)` }) } const known = allSerials.length && allSerials.length <= 500 ? await findAsisBySerials(event, token, org, allSerials) : new Map() for (const [serNo, k] of known) { if (k.inStock) result.errors.push({ productId: seenSerials.get(serNo) ?? null, message: `Seriennummer ${serNo} ist bereits im Bestand` }) } if (result.errors.length) { result.ok = false; return result } // Create / reuse ASIs (reads first, then a bounded pool of POSTs). const chargeAsiCache = new Map() const resolveCharge = async (setId: number, lot: string, mhd: string): Promise => { if (!lot && !mhd) return null const key = `${setId}|${lot}|${mhd}` if (chargeAsiCache.has(key)) return chargeAsiCache.get(key)! let id = await findChargeAsi(event, token, org, setId, lot, mhd) if (!id) { id = await createAsi(event, token, org, { setId, lot, guaranteeDate: mhd }) result.createdAsiIds.push(id) } chargeAsiCache.set(key, id) return id } let lineNo = 0 const nextLine = () => { lineNo += 10; return lineNo } const withAsi = (base: any, qty: number, asiId: number | null, extra: any = {}) => ({ ...base, ...extra, line: nextLine(), qtyEntered: qty, movementQty: qty, ...(asiId ? { M_AttributeSetInstance_ID: { id: asiId, tableName: 'M_AttributeSetInstance' } } : {}) }) for (const p of prepared) { if ('passthrough' in p) { result.lines.push({ ...p.passthrough, line: nextLine() }); continue } // serial units — pool of 4 POSTs const serialAsiIds = await mapPool(p.units, 4, async (u) => { const k = known.get(u.serNo) if (k && !k.inStock && k.setId === p.setId && k.lot === u.lot && k.mhd === u.mhd) return k.asiId const id = await createAsi(event, token, org, { setId: p.setId, serNo: u.serNo, lot: u.lot, guaranteeDate: u.mhd }) result.createdAsiIds.push(id) return id }) p.units.forEach((u, i) => { result.lines.push(withAsi(p.base, 1, serialAsiIds[i], { description: buildAsiDescription({ serNo: u.serNo, lot: u.lot, guaranteeDate: u.mhd }) })) }) // charge groups for (const c of p.charges) { const asiId = await resolveCharge(p.setId, c.lot, c.mhd) if (!asiId) { result.warnings.push(`Charge ohne Lot und MHD wurde ohne Tracking gebucht (${c.qty} Stk)`) result.lines.push(withAsi(p.base, c.qty, null)) continue } result.lines.push(withAsi(p.base, c.qty, asiId, { description: buildAsiDescription({ lot: c.lot, guaranteeDate: c.mhd }) })) } // untracked remainder if (p.remainder > 0) { if (p.units.length || p.charges.length) { result.warnings.push(`${p.remainder} Stk ohne Seriennummer/Charge gebucht (Artikel ${p.productId ?? '?'})`) } result.lines.push(withAsi(p.base, p.remainder, null)) } } return result } /** Best-effort cleanup of ASIs created for a booking that then failed. */ export const deleteAsisQuietly = async (event: any, token: string, asiIds: number[]) => { for (const id of asiIds) { try { await fetchHelper(event, `models/m_attributesetinstance/${id}`, 'DELETE', token, null) } catch { /* orphan is harmless */ } } } // --- reading units ---------------------------------------------------------------- export interface AsiInfo { id: number; setId: number; serNo: string; lot: string; mhd: string; description: string } /** Full ASI records for a list of ids (chunked `in (...)`). */ export const fetchAsis = async (event: any, token: string, asiIds: number[]): Promise> => { const out = new Map() const ids = Array.from(new Set(asiIds.map(Number).filter((n) => n > 0))) for (const part of chunk(ids, 100)) { const res: any = await fetchHelper(event, `models/m_attributesetinstance?$filter=${enc(`M_AttributeSetInstance_ID in (${part.join(',')})`)}&$top=${part.length}`, 'GET', token, null) for (const r of res?.records ?? []) { out.set(Number(r.id), { id: Number(r.id), setId: Number(r?.M_AttributeSet_ID?.id ?? r?.M_AttributeSet_ID) || 0, serNo: String(r?.SerNo ?? '').trim(), lot: String(r?.Lot ?? '').trim(), mhd: String(r?.GuaranteeDate ?? '').slice(0, 10), description: String(r?.Description ?? '') }) } } return out } export const daysUntil = (iso: string): number | null => { if (!iso) return null const today = new Date(); today.setHours(0, 0, 0, 0) const d = new Date(`${iso}T00:00:00`) if (Number.isNaN(d.getTime())) return null return Math.round((d.getTime() - today.getTime()) / 86400000) }