/** * Unit tracking on goods receipt — shared client logic for the desktop page * (integrations/receive-desktop), the mobile page (mobile/receive) and the * capture modal. Pure helpers over a per-line `tracking` object; the server * (`server/utils/attributeTracking.ts`, called from POST /api/inouts/store) * turns the payload into ASIs + iDempiere lines. * * line.tracking = { * attributeSetId, mode: 'none'|'serial'|'charge'|'mhd'|'both', * isSerNo, isLot, isGuaranteeDate, setName, * serials: string[], // one per unit * charges: [{ lot, mhd (ISO), qty }] // batches * } */ export type TrackingMode = 'none' | 'serial' | 'charge' | 'mhd' | 'both' export interface ChargeGroup { lot: string; mhd: string; qty: number } export interface LineTracking { attributeSetId: number | null mode: TrackingMode isSerNo: boolean isLot: boolean isGuaranteeDate: boolean setName: string serials: string[] charges: ChargeGroup[] } export const TRACKING_MODE_LABELS: Record = { none: 'Kein Tracking', serial: 'Seriennummer', charge: 'Charge (Lot + MHD)', mhd: 'Nur MHD (ohne Charge)', both: 'Seriennummer + Charge' } export const createTracking = (info?: any): LineTracking => ({ attributeSetId: Number(info?.attributeSetId) || null, mode: (info?.mode as TrackingMode) || 'none', isSerNo: !!info?.isSerNo, isLot: !!info?.isLot, isGuaranteeDate: !!info?.isGuaranteeDate, setName: String(info?.setName ?? ''), serials: [], charges: [] }) /** Apply a fresh server `tracking` info (after enabling a mode) without losing captured data. */ export const applyTrackingInfo = (t: LineTracking, info: any) => { t.attributeSetId = Number(info?.attributeSetId) || null t.mode = (info?.mode as TrackingMode) || 'none' t.isSerNo = !!info?.isSerNo t.isLot = !!info?.isLot t.isGuaranteeDate = !!info?.isGuaranteeDate t.setName = String(info?.setName ?? info?.name ?? '') return t } export const isBatchTracked = (t?: LineTracking | null) => !!t && (t.isLot || t.isGuaranteeDate) export const isTracked = (t?: LineTracking | null) => !!t && (t.isSerNo || t.isLot || t.isGuaranteeDate) /** Serials are opaque: trim, strip line breaks, collapse inner whitespace — never change case. */ export const normalizeSerial = (raw: any) => String(raw ?? '').replace(/[\r\n]/g, '').replace(/\s+/g, ' ').trim() /** All serials captured on the receipt (for the cross-line duplicate check). */ export const allSerials = (lines: any[]) => { const out = new Map() for (const l of lines ?? []) for (const s of l?.tracking?.serials ?? []) if (!out.has(s)) out.set(s, l) return out } export const addSerial = (line: any, raw: any, lines: any[] = []): { ok: boolean; reason?: 'empty' | 'duplicate'; serial: string; line?: any } => { const serial = normalizeSerial(raw) if (!serial) return { ok: false, reason: 'empty', serial } const t: LineTracking = line.tracking if (t.serials.includes(serial)) return { ok: false, reason: 'duplicate', serial, line } const other = allSerials(lines).get(serial) if (other && other !== line) return { ok: false, reason: 'duplicate', serial, line: other } t.serials.push(serial) return { ok: true, serial } } export const removeSerial = (line: any, index: number) => { const t: LineTracking = line.tracking if (index >= 0 && index < t.serials.length) t.serials.splice(index, 1) } export const addCharge = (line: any, group: Partial = {}) => { const t: LineTracking = line.tracking const qty = group.qty ?? Math.max(remainder(line), 0) const g: ChargeGroup = { lot: String(group.lot ?? ''), mhd: String(group.mhd ?? ''), qty: Number(qty) || 0 } t.charges.push(g) return g } export const removeCharge = (line: any, index: number) => { const t: LineTracking = line.tracking if (index >= 0 && index < t.charges.length) t.charges.splice(index, 1) } export const chargeSum = (line: any) => (line?.tracking?.charges ?? []).reduce((a: number, c: ChargeGroup) => a + (Number(c.qty) || 0), 0) export const chargeErrors = (line: any): string[] => { const out: string[] = [] const t: LineTracking | undefined = line?.tracking if (!t) return out t.charges.forEach((c, i) => { if (!(Number(c.qty) > 0)) out.push(`Charge ${i + 1}: Menge muss größer 0 sein`) if (!String(c.lot ?? '').trim() && !c.mhd) out.push(`Charge ${i + 1}: weder Lot noch MHD angegeben`) }) return out } /** * Units covered by tracking data. For set "both" the charge groups DESCRIBE the * serialised units (serials are assigned to charges sequentially, see * buildTrackingPayload), so they must not be double-counted. */ export const trackedUnits = (line: any) => { const t: LineTracking | undefined = line?.tracking const sn = t?.serials?.length ?? 0 const ch = chargeSum(line) return t?.isSerNo && isBatchTracked(t) ? Math.max(sn, ch) : sn + ch } export const remainder = (line: any) => Number(line?.qtyEntered ?? 0) - trackedUnits(line) export type TrackingStatus = 'complete' | 'partial' | 'over' | 'none' export const trackingStatus = (line: any): TrackingStatus => { const t: LineTracking | undefined = line?.tracking if (!t || !isTracked(t)) return 'none' const used = trackedUnits(line) const qty = Number(line?.qtyEntered ?? 0) if (used === 0) return 'none' if (used > qty) return 'over' return used === qty ? 'complete' : 'partial' } /** Short chip text, e.g. "SN 3/5", "Charge 12/12", "SN 2 · Ch 3 / 5", "Tracking…". */ export const trackingBadge = (line: any): string => { const t: LineTracking | undefined = line?.tracking if (!t || !isTracked(t)) return 'Tracking…' const qty = Number(line?.qtyEntered ?? 0) const sn = t.serials.length const ch = chargeSum(line) if (t.isSerNo && isBatchTracked(t)) { if (!sn && !ch) return 'SN / Charge' return `SN ${sn}${ch ? ` · Ch ${ch}` : ''} / ${qty}` } if (t.isSerNo) return `SN ${sn}/${qty}` return `${t.isLot ? 'Charge' : 'MHD'} ${ch}/${qty}` } export interface TrackingSummary { incomplete: { productName: string; productValue: string; tracked: number; qty: number }[] over: { productName: string; productValue: string; tracked: number; qty: number }[] errors: string[] totalUnits: number } export const summarizeTracking = (lines: any[]): TrackingSummary => { const s: TrackingSummary = { incomplete: [], over: [], errors: [], totalUnits: 0 } for (const l of lines ?? []) { const t: LineTracking | undefined = l?.tracking if (!t || !isTracked(t)) continue const used = trackedUnits(l) const qty = Number(l?.qtyEntered ?? 0) s.totalUnits += used const row = { productName: l.productName, productValue: l.productValue, tracked: used, qty } if (used > qty) s.over.push(row) else if (used < qty) s.incomplete.push(row) for (const e of chargeErrors(l)) s.errors.push(`${l.productValue}: ${e}`) } return s } /** Server payload for one line — undefined when nothing was captured (plain line). */ export const buildTrackingPayload = (line: any) => { const t: LineTracking | undefined = line?.tracking if (!t || !t.attributeSetId || !isTracked(t)) return undefined const charges = t.charges .filter((c) => Number(c.qty) > 0 && (String(c.lot ?? '').trim() || c.mhd)) .map((c) => ({ lot: String(c.lot ?? '').trim(), mhd: c.mhd || null, qty: Number(c.qty) })) if (!t.serials.length && !charges.length) return undefined // Set "both": each serial belongs to a charge — assign sequentially by group // qty; the charge groups then only carry their non-serialised remainder. if (t.isSerNo && isBatchTracked(t) && charges.length) { const serials: any[] = [] const rest: any[] = [] let i = 0 for (const c of charges) { let left = c.qty while (left > 0 && i < t.serials.length) { serials.push({ serNo: t.serials[i++], lot: c.lot, mhd: c.mhd }); left-- } if (left > 0) rest.push({ ...c, qty: left }) } while (i < t.serials.length) serials.push({ serNo: t.serials[i++] }) return { attributeSetId: t.attributeSetId, serials, charges: rest } } return { attributeSetId: t.attributeSetId, serials: [...t.serials], charges } } // --- dates ------------------------------------------------------------------------ /** dd.mm.yyyy / d.m.yy / yyyy-mm-dd / yyyymmdd / dd/mm/yyyy → ISO, or null when invalid. */ export const parseDateFlexible = (raw: any): string | null => { const s = String(raw ?? '').trim() if (!s) return '' let y: string, mo: string, d: string let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/) if (m) { [, y, mo, d] = m } else if ((m = s.match(/^(\d{4})(\d{2})(\d{2})$/))) { [, y, mo, d] = m } else if ((m = s.match(/^(\d{1,2})[.\/\-\s](\d{1,2})[.\/\-\s](\d{2}|\d{4})$/))) { [, d, mo, y] = m; if (y.length === 2) y = '20' + y } else if ((m = s.match(/^(\d{2})(\d{2})(\d{4})$/))) { [, d, mo, y] = m } else return null const dt = new Date(Date.UTC(+y, +mo - 1, +d)) if (dt.getUTCFullYear() !== +y || dt.getUTCMonth() !== +mo - 1 || dt.getUTCDate() !== +d) return null return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}` } export const formatDateDe = (iso: any) => { const m = String(iso ?? '').match(/^(\d{4})-(\d{2})-(\d{2})/) return m ? `${m[3]}.${m[2]}.${m[1]}` : '' } // --- CSV import ------------------------------------------------------------------- export interface CsvTrackingRow { serial: string; lot: string; mhd: string; qty: number; line: number } export interface CsvParseResult { rows: CsvTrackingRow[]; errors: { line: number; message: string }[]; delimiter: string; hadHeader: boolean } const HEADER_SERIAL = /^(serial|seriennummer|serialnumber|serial[_ ]?no|sn|seriennr)/i const HEADER_LOT = /^(lot|charge|batch|chargennummer|lot[_ ]?no)/i const HEADER_MHD = /^(mhd|bbd|expiry|expires|verfall|verfallsdatum|guarantee|haltbar|best[_ ]?before|ablauf)/i const HEADER_QTY = /^(qty|menge|quantity|anzahl|stk)/i const splitCsvLine = (line: string, delimiter: string): string[] => { const out: string[] = [] let cur = '' let quoted = false for (let i = 0; i < line.length; i++) { const ch = line[i] if (ch === '"') { if (quoted && line[i + 1] === '"') { cur += '"'; i++ } else quoted = !quoted } else if (ch === delimiter && !quoted) { out.push(cur); cur = '' } else cur += ch } out.push(cur) return out.map((c) => c.trim()) } /** * Parse a serial / charge CSV. Delimiter autodetected (; , tab) on the first * non-empty line; header optional; columns positional `serial[;lot[;mhd[;qty]]]` * or by header name. Rows without a serial but with lot/MHD become charge rows. */ export const parseTrackingCsv = (text: string): CsvParseResult => { const res: CsvParseResult = { rows: [], errors: [], delimiter: ';', hadHeader: false } const lines = String(text ?? '').replace(/^/, '').split(/\r?\n/) const first = lines.find((l) => l.trim()) if (!first) return res const counts = { ';': (first.match(/;/g) || []).length, ',': (first.match(/,/g) || []).length, '\t': (first.match(/\t/g) || []).length } res.delimiter = (Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] as string) || ';' if (counts[res.delimiter as keyof typeof counts] === 0) res.delimiter = ';' let col = { serial: 0, lot: 1, mhd: 2, qty: 3 } let startAt = 0 const head = splitCsvLine(first, res.delimiter) const headIdx = lines.indexOf(first) if (head.some((h) => HEADER_SERIAL.test(h) || HEADER_LOT.test(h) || HEADER_MHD.test(h))) { res.hadHeader = true startAt = headIdx + 1 col = { serial: -1, lot: -1, mhd: -1, qty: -1 } head.forEach((h, i) => { if (col.serial < 0 && HEADER_SERIAL.test(h)) col.serial = i else if (col.lot < 0 && HEADER_LOT.test(h)) col.lot = i else if (col.mhd < 0 && HEADER_MHD.test(h)) col.mhd = i else if (col.qty < 0 && HEADER_QTY.test(h)) col.qty = i }) // a trailing unlabelled column after the header is treated as the quantity if (col.qty < 0) col.qty = head.length } else { startAt = headIdx } for (let i = startAt; i < lines.length; i++) { const raw = lines[i] if (!raw.trim()) continue const cells = splitCsvLine(raw, res.delimiter) const pick = (idx: number) => (idx >= 0 && idx < cells.length ? cells[idx] : '') const serial = normalizeSerial(pick(col.serial)) const lot = String(pick(col.lot) ?? '').trim() const mhdRaw = pick(col.mhd) const qtyRaw = pick(col.qty) const mhd = parseDateFlexible(mhdRaw) if (mhd === null) { res.errors.push({ line: i + 1, message: `Ungültiges Datum "${mhdRaw}"` }); continue } const qty = qtyRaw ? Number(String(qtyRaw).replace(',', '.')) : 1 if (!Number.isFinite(qty) || qty <= 0) { res.errors.push({ line: i + 1, message: `Ungültige Menge "${qtyRaw}"` }); continue } if (!serial && !lot && !mhd) { res.errors.push({ line: i + 1, message: 'Leere Zeile (weder Seriennummer noch Lot/MHD)' }); continue } res.rows.push({ serial, lot, mhd: mhd || '', qty, line: i + 1 }) } return res } /** * Merge parsed CSV rows into a line: serial rows → serials (duplicates reported), * serial-less rows → charge groups aggregated by (lot, mhd). */ export const applyCsvRows = (line: any, rows: CsvTrackingRow[], lines: any[] = []) => { const t: LineTracking = line.tracking const added: string[] = [] const duplicates: string[] = [] const charges = new Map() for (const r of rows) { if (r.serial) { if (!t.isSerNo) { duplicates.push(`${r.serial} (Artikel ohne Seriennummern-Tracking)`); continue } const a = addSerial(line, r.serial, lines) if (a.ok) added.push(r.serial); else if (a.reason === 'duplicate') duplicates.push(r.serial) // for set "both" a serial row may also carry its lot/MHD → keep as charge info if (a.ok && isBatchTracked(t) && (r.lot || r.mhd)) { const key = `${r.lot}|${r.mhd}` const g = charges.get(key) ?? { lot: r.lot, mhd: r.mhd, qty: 0 } g.qty += 1 charges.set(key, g) } continue } if (!isBatchTracked(t)) continue const key = `${r.lot}|${r.mhd}` const g = charges.get(key) ?? { lot: r.lot, mhd: r.mhd, qty: 0 } g.qty += r.qty charges.set(key, g) } for (const g of charges.values()) { const existing = t.charges.find((c) => c.lot === g.lot && c.mhd === g.mhd) if (existing) existing.qty += g.qty; else t.charges.push(g) } return { added, duplicates, chargeGroups: charges.size } } /** Pre-flight against existing stock; returns the server's `known` list (fail-soft → []). */ export const checkSerialsOnServer = async (serials: string[], organizationId?: any) => { const list = Array.from(new Set(serials.map(normalizeSerial).filter(Boolean))) if (!list.length) return [] as { serNo: string; asiId: number; inStock: boolean; lot: string; mhd: string }[] try { const res: any = await $fetch('/api/inouts/check-serials', { method: 'POST', headers: useRequestHeaders(['cookie']), body: { organizationId: organizationId || undefined, serials: list } }) return (res?.known ?? []) as { serNo: string; asiId: number; inStock: boolean; lot: string; mhd: string }[] } catch { return [] } } /** Enable / change tracking on a product; returns the server's tracking info or throws with the iDempiere message. */ export const setProductTrackingMode = async (productId: any, mode: TrackingMode) => { const res: any = await $fetch(`/api/materials/products/${productId}/tracking`, { method: 'POST', headers: useRequestHeaders(['cookie']), body: { mode } }) if (Number(res?.status) !== 200) throw new Error(res?.message || 'Tracking konnte nicht gesetzt werden') return { attributeSetId: res.attributeSetId, mode: res.mode, ...(res.flags ?? {}), setName: res.name } } export const useReceiptTracking = () => ({ TRACKING_MODE_LABELS, createTracking, applyTrackingInfo, isTracked, isBatchTracked, normalizeSerial, addSerial, removeSerial, allSerials, addCharge, removeCharge, chargeSum, chargeErrors, trackedUnits, remainder, trackingStatus, trackingBadge, summarizeTracking, buildTrackingPayload, parseDateFlexible, formatDateDe, parseTrackingCsv, applyCsvRows, checkSerialsOnServer, setProductTrackingMode })