import { string } from 'alga-js' import refreshTokenHelper from '../../utils/refreshTokenHelper' import errorHandlingHelper from '../../utils/errorHandlingHelper' // Read-only earnings analysis over cust_fulfillmentfeeline for a custom date // range. Mirrors the position grouping of fulfillment/generate-orders.post.ts // (preview mode) but NEVER creates orders and NEVER marks lines as processed. // Aggregated types: fulfillment, return, spacerentqm3, spacerentflat, parcel, // subscription, shippingfee (bucketed by product). request stays per-entry on // the merchant level (ticket description verbatim) and is aggregated by // product in the grand total. Unknown types are aggregated too so nothing is // silently dropped from the analysis. const AGG_TYPE_ORDER = ['fulfillment', 'return', 'spacerentqm3', 'spacerentflat', 'parcel', 'subscription', 'shippingfee', 'request'] const typeSortIndex = (type: string) => { const idx = AGG_TYPE_ORDER.indexOf(type) return idx === -1 ? AGG_TYPE_ORDER.length : idx } const roundToTwo = (num: number): number => { return Math.round((num + Number.EPSILON) * 100) / 100 } // m_product identifiers are "{Value}_{Name}" and Value often ends in a long // numeric segment (e.g. "-1_1000199_Fulfillment Auftrag Gebühr"). Used as a // fallback when the role's token can't read the expanded product Name. const cleanIdentifierName = (identifier: string): string => { return identifier.replace(/^.+?_\d{6,}_/, '') } const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const query = getQuery(event) const dateFrom = String(query.dateFrom || '') const dateTo = String(query.dateTo || '') const statusFilter = String(query.status || 'all') // all | unprocessed | processed const partnerId = query.partnerId ? parseInt(String(query.partnerId)) : null // Types to exclude from ALL aggregations (comma-separated). byType still // reports every type in the range so the UI can offer re-enabling them. const excludeTypes = new Set( String(query.excludeTypes || '').split(',').map(s => s.trim()).filter(Boolean) ) const datePattern = /^\d{4}-\d{2}-\d{2}$/ if (!datePattern.test(dateFrom) || !datePattern.test(dateTo)) { return { status: 400, message: 'dateFrom and dateTo are required (YYYY-MM-DD)' } } const fromDate = new Date(dateFrom + 'T00:00:00Z') const toDate = new Date(dateTo + 'T00:00:00Z') if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime()) || fromDate.getTime() > toDate.getTime()) { return { status: 400, message: 'Invalid date range' } } const rangeDays = Math.round((toDate.getTime() - fromDate.getTime()) / 86400000) + 1 if (rangeDays > 366) { return { status: 400, message: 'Date range too large (max 366 days)' } } // Inclusive dateTo → exclusive upper bound (next day), same date-only literal // style the invoice generator uses for shipping_date filters. const endExclusive = new Date(toDate.getTime() + 86400000).toISOString().split('T')[0] let filter = `shipping_date ge '${dateFrom}' and shipping_date lt '${endExclusive}'` if (statusFilter === 'unprocessed') { filter += ' and A_Processed eq false' } else if (statusFilter === 'processed') { filter += ' and A_Processed eq true' } if (partnerId) { filter += ` and C_BPartner_ID eq ${partnerId}` } // Partner names come from the FK identifier; the product identifier carries // the raw search-key prefix (e.g. "-1_1000199_..."), so expand the clean // product Name only — keeps the payload small (a full month is ~6k lines). const feeLines: any = await event.context.fetch( `models/cust_fulfillmentfeeline?$filter=${string.urlEncode(filter)}&$expand=${string.urlEncode('M_Product_ID($select=Name,Value)')}`, 'GET', token, null ) const records: any[] = feeLines?.records || [] const merchants: any = {} const totalPositions: any = {} const byType: any = {} const byDay: any = {} const summary: any = { totalAmount: 0, totalLines: 0, merchantCount: 0, processedLines: 0, unprocessedLines: 0, processedAmount: 0, unprocessedAmount: 0, skippedLines: 0, excludedLines: 0, excludedAmount: 0 } for (const line of records) { const bpartnerId = line.C_BPartner_ID?.id const bpartnerName = line.C_BPartner_ID?.identifier || (bpartnerId ? `Partner ${bpartnerId}` : 'Unknown') const productId = line.M_Product_ID?.id const productName = line.M_Product_ID?.Name || (line.M_Product_ID?.identifier ? cleanIdentifierName(line.M_Product_ID.identifier) : '') || (productId ? `Product ${productId}` : 'Unknown') const type = line.FulfillTypeAccounting || 'unknown' const amount = parseFloat(line.LineTotalAmt) || 0 const qtyInvoiced = parseFloat(line.QtyInvoiced) || 0 const processed = line.A_Processed === true if (!bpartnerId || !productId) { summary.skippedLines++ continue } // byType always covers every type in the range — the UI needs the full // list (with amounts) to render the deselectable type chips. if (!byType[type]) { byType[type] = { type, amount: 0, lines: 0 } } byType[type].amount += amount byType[type].lines++ if (excludeTypes.has(type)) { summary.excludedLines++ summary.excludedAmount += amount continue } summary.totalLines++ summary.totalAmount += amount if (processed) { summary.processedLines++ summary.processedAmount += amount } else { summary.unprocessedLines++ summary.unprocessedAmount += amount } const dayKey = String(line.shipping_date || '').slice(0, 10) if (dayKey) { if (!byDay[dayKey]) { byDay[dayKey] = { date: dayKey, total: 0, types: {} } } byDay[dayKey].total += amount byDay[dayKey].types[type] = (byDay[dayKey].types[type] || 0) + amount } if (!merchants[bpartnerId]) { merchants[bpartnerId] = { bpartnerId, bpartnerName, totalAmount: 0, linesCount: 0, processedLines: 0, unprocessedLines: 0, processedAmount: 0, unprocessedAmount: 0, positions: {}, requestLines: [] } } const merchant = merchants[bpartnerId] merchant.totalAmount += amount merchant.linesCount++ if (processed) { merchant.processedLines++ merchant.processedAmount += amount } else { merchant.unprocessedLines++ merchant.unprocessedAmount += amount } // Per-merchant positions — same shapes as the generator preview if (type === 'request') { // Per-entry, ticket Description verbatim (no aggregation) merchant.requestLines.push({ id: line.id, productId, productName, description: line.Description || '', qty: parseFloat(line.QtyEntered) || qtyInvoiced || 1, price: parseFloat(line.PriceEntered) || 0, amount, shippingDate: line.shipping_date, processed }) } else { const posKey = `${type}|${productId}` if (!merchant.positions[posKey]) { merchant.positions[posKey] = { type, productId, productName, totalAmt: 0, qtyInvoiced: 0, sourceLines: 0, inoutIds: new Set() } } const pos = merchant.positions[posKey] pos.totalAmt += amount pos.qtyInvoiced += qtyInvoiced pos.sourceLines++ if (line.M_InOut_ID?.id) { pos.inoutIds.add(line.M_InOut_ID.id) } } // Grand total positions — everything aggregated by type + product const totalKey = `${type}|${productId}` if (!totalPositions[totalKey]) { totalPositions[totalKey] = { type, productId, productName, totalAmt: 0, qtyInvoiced: 0, qtyEntered: 0, sourceLines: 0, inoutIds: new Set(), merchantIds: new Set() } } const totalPos = totalPositions[totalKey] totalPos.totalAmt += amount totalPos.qtyInvoiced += qtyInvoiced totalPos.qtyEntered += parseFloat(line.QtyEntered) || 0 totalPos.sourceLines++ if (line.M_InOut_ID?.id) { totalPos.inoutIds.add(line.M_InOut_ID.id) } totalPos.merchantIds.add(bpartnerId) } // qty semantics per type mirror the generator preview: fulfillment/return // count positions (source lines); request uses QtyEntered; everything else // sums QtyInvoiced. const positionQty = (pos: any) => { if (pos.type === 'fulfillment' || pos.type === 'return') return pos.sourceLines if (pos.type === 'request') return roundToTwo(pos.qtyEntered ?? pos.qtyInvoiced) return roundToTwo(pos.qtyInvoiced) } const finalizePosition = (pos: any) => { const qty = positionQty(pos) return { type: pos.type, productId: pos.productId, productName: pos.productName, qty, unitPrice: roundToTwo(qty > 0 ? pos.totalAmt / qty : pos.totalAmt), amount: roundToTwo(pos.totalAmt), sourceLines: pos.sourceLines, shipmentCount: pos.inoutIds ? pos.inoutIds.size : 0, ...(pos.merchantIds ? { merchantCount: pos.merchantIds.size } : {}) } } const sortPositions = (a: any, b: any) => { const t = typeSortIndex(a.type) - typeSortIndex(b.type) if (t !== 0) return t return b.amount - a.amount } const merchantList = Object.values(merchants).map((m: any) => { const positions = Object.values(m.positions).map(finalizePosition) for (const req of m.requestLines) { positions.push({ type: 'request', productId: req.productId, productName: req.productName, description: req.description, qty: req.qty, unitPrice: roundToTwo(req.price), amount: roundToTwo(req.amount), sourceLines: 1, shipmentCount: 0, shippingDate: req.shippingDate, processed: req.processed } as any) } positions.sort(sortPositions) return { bpartnerId: m.bpartnerId, bpartnerName: m.bpartnerName, totalAmount: roundToTwo(m.totalAmount), linesCount: m.linesCount, processedLines: m.processedLines, unprocessedLines: m.unprocessedLines, processedAmount: roundToTwo(m.processedAmount), unprocessedAmount: roundToTwo(m.unprocessedAmount), positions } }) merchantList.sort((a: any, b: any) => b.totalAmount - a.totalAmount) const totalList = Object.values(totalPositions).map(finalizePosition) totalList.sort(sortPositions) const byTypeList = Object.values(byType).map((tp: any) => ({ type: tp.type, amount: roundToTwo(tp.amount), lines: tp.lines, excluded: excludeTypes.has(tp.type) })) byTypeList.sort((a: any, b: any) => typeSortIndex(a.type) - typeSortIndex(b.type)) summary.merchantCount = merchantList.length summary.totalAmount = roundToTwo(summary.totalAmount) summary.processedAmount = roundToTwo(summary.processedAmount) summary.unprocessedAmount = roundToTwo(summary.unprocessedAmount) summary.excludedAmount = roundToTwo(summary.excludedAmount) // Per-day trend — every day of the range present (zero-filled) so charts // show gaps honestly instead of skipping empty days. const byDayList: any[] = [] for (let ts = fromDate.getTime(); ts <= toDate.getTime(); ts += 86400000) { const dayKey = new Date(ts).toISOString().split('T')[0] const day = byDay[dayKey] const types: any = {} if (day) { for (const type in day.types) { types[type] = roundToTwo(day.types[type]) } } byDayList.push({ date: dayKey, total: roundToTwo(day?.total || 0), types }) } return { status: 200, dateFrom, dateTo, statusFilter, excludedTypes: Array.from(excludeTypes), summary, byType: byTypeList, byDay: byDayList, merchants: merchantList, totalPositions: totalList } } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) } } return data })