import errorHandlingHelper from "../../../../utils/errorHandlingHelper" import fetchHelper from "../../../../utils/fetchHelper" import getTokenHelper from "../../../../utils/getTokenHelper" import { dpdGetAuth, dpdStoreOrders, extractOrderSourceDpdCreds, xmlEscape, kgTo10GramUnits, toCm, buildVolumeString, iso2ToCustomsOrigin, type DpdCredentials } from "../../../../utils/dpdHelper" /** * Manual DPD label — create a label without a M_Inout reference. * Mirrors the DHL manual endpoint. */ export default defineEventHandler(async (event) => { const data: any = {} const body: any = await readBody(event) const country = String(body.country ?? '').toUpperCase().trim() // Normalize parcels: accept either body.parcels (array) for multi-parcel mode, // or fall back to the flat weight/length/width/height for single-parcel mode. const rawParcels: any[] = Array.isArray(body.parcels) && body.parcels.length > 0 ? body.parcels : [{ weight: body.weight, length: body.length, width: body.width, height: body.height }] const parcels = rawParcels .map(p => ({ weightKg: parseFloat(p.weight ?? 0) || 0, length: p.length, width: p.width, height: p.height })) .filter(p => p.weightKg > 0) if (!body.name || !body.address || !body.postalCode || !body.city || !country || parcels.length === 0) { return { status: 400, message: 'Missing required fields: name, address, postalCode, city, country, and at least one parcel with weight > 0' } } // If an OrderSource was picked, fetch it and extract DPD credentials. // Falls back to .env defaults when no OrderSource is provided or the // chosen one doesn't have DPD_USE_CUSTOM enabled. let customCreds: Partial | null = null if (body.orderSourceId) { try { const token = await getTokenHelper(event) const orderSource: any = await fetchHelper(event, `models/c_ordersource/${body.orderSourceId}`, 'GET', token, null) const extracted = extractOrderSourceDpdCreds(orderSource) if (!extracted) { return { status: 400, message: `OrderSource ${body.orderSourceId} (${orderSource?.Name || '?'}) does not have DPD_USE_CUSTOM enabled.` } } if (extracted.missing.length > 0) { // Log the raw row so we can spot e.g. encryption-masked passwords coming back null. console.warn('[DPD Manual] OrderSource missing fields:', extracted.missing, { id: orderSource?.id, Name: orderSource?.Name, DPD_USE_CUSTOM: orderSource?.DPD_USE_CUSTOM, DPDURL_set: !!orderSource?.DPDURL, DPDDELISID_set: !!orderSource?.DPDDELISID, DPDPASSWORD_set: !!orderSource?.DPDPASSWORD, DPDCUSTOMERNUMBER_set: !!orderSource?.DPDCUSTOMERNUMBER }) return { status: 400, message: `OrderSource ${body.orderSourceId} (${orderSource?.Name || '?'}) is missing required DPD field(s): ${extracted.missing.join(', ')}. Open Settings → Order Sources → ${orderSource?.Name || body.orderSourceId} and fill them in. If a value IS set in iDempiere but appears missing here, the column may be encrypted and not exposed via OData — check the AD_Column IsEncrypted flag for that field.` } } customCreds = extracted.creds } catch(err: any) { return { status: 500, message: `Failed to load OrderSource ${body.orderSourceId}: ${err?.message || err}` } } } let auth, creds try { const r = await dpdGetAuth(event, customCreds) auth = r.auth creds = r.creds } catch(err: any) { return { status: 500, message: err?.message || 'DPD authentication failed' } } const refNo = `MANUAL-${Date.now()}` // Build one element per parcel (DPD's "MPS" multi-parcel format). // Each parcel gets +0.25kg packaging weight to match DHL's behavior. const parcelsXml = parcels.map(p => { const totalWeightKg = p.weightKg + 0.25 const lengthCm = toCm(p.length) const widthCm = toCm(p.width) const heightCm = toCm(p.height) const hasDims = lengthCm > 0 && widthCm > 0 && heightCm > 0 return ` ${kgTo10GramUnits(totalWeightKg)} ${hasDims ? ` ${buildVolumeString(lengthCm, widthCm, heightCm)}\n` : ''} ` }).join('\n') const isMulti = parcels.length > 1 // ----- Customs / international block ----- // Emitted only for non-EU destinations when the user supplied per-parcel // customs data. paymentTerm derived from the user's chosen incoterm: // DDP → B (sender pays duties + taxes — no surprise fees for recipient) // DAP → A (recipient pays duties + taxes) // EXW → A (recipient pays everything) const EU_COUNTRIES = ['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES','SE'] const isNonEU = !EU_COUNTRIES.includes(country) const customsInput = body.customs || {} const customsItems = rawParcels .map((p: any) => ({ description: String(p.description || '').trim(), hsCode: String(p.hsCode || '').trim(), countryOfOrigin: String(p.countryOfOrigin || '').trim().toUpperCase().substring(0, 2), quantity: Math.max(1, parseInt(p.quantity || 1) || 1), unitValue: parseFloat(p.value) || 0, weightKg: parseFloat(p.weight ?? 0) || 0 })) .filter((it: any) => it.description && it.hsCode && it.countryOfOrigin && it.unitValue > 0) const totalCustomsValue = customsItems.reduce((s: number, it: any) => s + it.unitValue * it.quantity, 0) const customsCurrency = String(customsInput.currency || 'EUR').toUpperCase().substring(0, 3) const incoterm = ['EXW', 'DAP', 'DDP'].includes(customsInput.incoterm) ? customsInput.incoterm : 'DDP' const paymentTerm = incoterm === 'DDP' ? 'B' : 'A' const reasonForExport = ['01', '02', '03'].includes(customsInput.reasonForExport) ? customsInput.reasonForExport : '01' const internationalXml = (isNonEU && customsItems.length > 0) ? ` false ${Math.round(totalCustomsValue * 100)} ${xmlEscape(customsCurrency)} ${Math.round(totalCustomsValue * 100)} ${xmlEscape(customsCurrency)} ${incoterm === 'DDP' ? '02' : '01'} A true ${Math.min(99, customsItems.length)} ${body.shipperEori ? ` ${xmlEscape(String(body.shipperEori).substring(0, 20))} LogYou GmbH Mühlenweg 4 DE 35510 Butzbach ${xmlEscape(creds.customerNumber.substring(0, 17))} \n` : ''}${customsItems.slice(0, 99).map((it: any, i: number) => { // V4_4 has no dedicated HS code field — embed in description text const desc = it.hsCode ? `${it.description} [HS: ${it.hsCode}]` : it.description return ` ${i + 1} ${it.quantity} ${xmlEscape(String(desc).substring(0, 200))} ${Math.round(it.unitValue * it.quantity * 100)} ${iso2ToCustomsOrigin(it.countryOfOrigin)} ${kgTo10GramUnits(it.weightKg)} ${kgTo10GramUnits(it.weightKg)} ` }).join('\n')} ` : '' const orderInner = ` ${xmlEscape(refNo.substring(0, 35))} ${xmlEscape(auth.depot)} CL LogYou GmbH Mühlenweg 4 DE 35510 Butzbach ${xmlEscape(creds.customerNumber.substring(0, 17))} +4960339160570 info@logyou.de ${xmlEscape(String(body.name).substring(0, 35))} ${xmlEscape(String(body.address).substring(0, 35))} ${body.houseNumber ? `${xmlEscape(String(body.houseNumber).substring(0, 8))}` : ''} ${xmlEscape(country.substring(0, 2))} ${xmlEscape(String(body.postalCode).substring(0, 9))} ${xmlEscape(String(body.city).substring(0, 35))} ${body.phone ? `${xmlEscape(String(body.phone).substring(0, 30))}` : ''} ${body.email ? `${xmlEscape(body.email)}` : ''} ${parcelsXml} consignment ${body.email && /@/.test(body.email) ? `1${xmlEscape(body.email)}DE` : ''} ${internationalXml} ` // splitByParcel=true gives one PDF per parcel; we want a single combined PDF for the response const storeOrdersInner = ` PDF A6 ${isMulti ? 'true' : 'false'} ${orderInner} ` try { const { result } = await dpdStoreOrders(event, customCreds, storeOrdersInner) if (result.faults && result.faults.length > 0) { const detail = result.faults.map(f => `${f.code}: ${f.message}`).join(' | ') return { status: 400, message: detail, dpd_errors: result.faults.map(f => `${f.code}: ${f.message}`) } } if (!result.parcelLabelNumbers.length || !result.labelPdfBase64) { // Help diagnose response-shape mismatches by surfacing what DPD actually sent. const rawSnippet = (result.raw || '').substring(0, 4000) console.error('[DPD Manual] Response missing tracking/label. Raw XML (first 4000 chars):', rawSnippet) return { status: 500, message: 'DPD response missing tracking number or label', debug: { parsedMpsId: result.mpsId, parcelCount: result.parcelLabelNumbers.length, hasLabel: !!result.labelPdfBase64, faults: result.faults, rawSnippet } } } data['status'] = 200 data['message'] = '' data['label_b64'] = result.labelPdfBase64 data['tracking_number'] = result.parcelLabelNumbers[0] data['tracking_numbers'] = result.parcelLabelNumbers // all parcels data['parcel_count'] = result.parcelLabelNumbers.length data['shipment_code'] = result.mpsId || result.parcelLabelNumbers[0] // Per-parcel labels for the frontend's preview navigation if (result.parcelLabels && result.parcelLabels.length > 0) { data['parcels'] = result.parcelLabels.map(p => ({ tracking_number: p.trackingNumber, label_b64: p.pdfBase64 })) } data['mpsId'] = result.mpsId data['product'] = 'CL' return data } catch(err: any) { const detail = err?.dpdFault ? `${err.dpdFault.code}: ${err.dpdFault.message}` : (err?.message || 'DPD API Error') console.error('[DPD Manual] DPD API rejected shipment:', detail) return { status: 400, message: detail, dpd_errors: [detail] } } })