import * as XLSX from 'xlsx'; export function getBcConfig(env = process.env) { const legacyWriteMethod = String(env.BC_WRITE_METHOD || 'PATCH').toUpperCase(); const legacyWriteUrlTemplate = env.BC_WRITE_URL_TEMPLATE || `{{itemsUrl}}('{{itemNo}}')`; const legacyWriteBodyTemplate = env.BC_WRITE_BODY_TEMPLATE || JSON.stringify({ cpnpNo: '{{cpnpNo}}' }); return { tenantId: env.BC_TENANT_ID || 'fab724f7-6b6d-4e3b-86e3-8c1e05e36b2a', clientId: env.BC_CLIENT_ID || '6f832138-cb48-43e7-8601-efca120b45dc', clientSecret: env.BC_CLIENT_SECRET, companyId: env.BC_COMPANY_ID || '2acec35c-7d06-ed11-82f8-0022485ceea3', writeMethod: legacyWriteMethod, writeUrlTemplate: legacyWriteUrlTemplate, writeBodyTemplate: legacyWriteBodyTemplate, itemsWriteMethod: String(env.BC_ITEMS_WRITE_METHOD || legacyWriteMethod).toUpperCase(), itemsWriteUrlTemplate: env.BC_ITEMS_WRITE_URL_TEMPLATE || legacyWriteUrlTemplate, itemsWriteBodyTemplate: env.BC_ITEMS_WRITE_BODY_TEMPLATE || null, itemUnitsWriteMethod: env.BC_UOM_WRITE_METHOD ? String(env.BC_UOM_WRITE_METHOD).toUpperCase() : 'PATCH', itemUnitsWriteUrlTemplate: env.BC_UOM_WRITE_URL_TEMPLATE || `{{itemUnitsOfMeasureUrl}}('{{itemNo}}')`, itemUnitsWriteBodyTemplate: env.BC_UOM_WRITE_BODY_TEMPLATE || null, }; } export function getTokenUrl(config) { return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`; } export function getItemsUrl(config) { return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/items`; } let tokenCache = { token: null, expiresAt: 0 }; export async function getBCToken(config) { const now = Date.now(); if (tokenCache.token && now < tokenCache.expiresAt) { return tokenCache.token; } if (!config.clientSecret) { throw new Error('BC_CLIENT_SECRET env var not set'); } const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: config.clientId, client_secret: config.clientSecret, scope: 'https://api.businesscentral.dynamics.com/.default', }); const res = await fetch(getTokenUrl(config), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); const data = await res.json(); if (!data.access_token) { throw new Error('BC token error: ' + JSON.stringify(data)); } tokenCache = { token: data.access_token, expiresAt: now + 55 * 60 * 1000 }; return data.access_token; } export async function fetchAllItems(config, token) { const items = []; let url = `${getItemsUrl(config)}?$top=1000`; while (url) { const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, }); if (!res.ok) { const txt = await res.text(); throw new Error(`BC GET items failed (${res.status}): ${txt}`); } const json = await res.json(); if (Array.isArray(json.value)) { items.push(...json.value); } url = json['@odata.nextLink'] || null; } return items; } export async function findItem(config, token, articleNo) { const filter = encodeURIComponent(`no eq '${articleNo}'`); const url = `${getItemsUrl(config)}?$filter=${filter}&$select=systemId,no,cpnpNo`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, }); if (!res.ok) { const txt = await res.text(); throw new Error(`BC GET items failed (${res.status}): ${txt}`); } const json = await res.json(); const items = json.value || []; console.log('[bc-proxy] GET items result:', JSON.stringify(items)); if (items.length === 0) throw new Error(`Item not found in BC: ${articleNo}`); return items[0]; } export async function patchItemCpnpNo(config, token, item, cpnpNo) { const etag = item['@odata.etag'] || '*'; const itemsUrl = getItemsUrl(config); const url = config.writeUrlTemplate .replaceAll('{{itemsUrl}}', itemsUrl) .replaceAll('{{tenantId}}', config.tenantId) .replaceAll('{{companyId}}', config.companyId) .replaceAll('{{itemNo}}', encodeURIComponent(String(item.no))) .replaceAll('{{systemId}}', encodeURIComponent(String(item.systemId || ''))) .replaceAll('{{cpnpNo}}', String(cpnpNo)); const bodyText = config.writeBodyTemplate .replaceAll('{{itemsUrl}}', itemsUrl) .replaceAll('{{tenantId}}', config.tenantId) .replaceAll('{{companyId}}', config.companyId) .replaceAll('{{itemNo}}', String(item.no)) .replaceAll('{{systemId}}', String(item.systemId || '')) .replaceAll('{{cpnpNo}}', String(cpnpNo)); console.log('[bc-proxy] WRITE url:', url); const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }; if ((config.writeMethod || 'PATCH') === 'PATCH') { headers['If-Match'] = etag; } const res = await fetch(url, { method: config.writeMethod || 'PATCH', headers, body: bodyText, }); if (!res.ok) { const txt = await res.text(); throw new Error(`BC write failed (${res.status}): ${txt}`); } return true; } export function normalizeValue(value) { if (value === null || value === undefined) return ''; if (value instanceof Date) return value.toISOString(); if (typeof value === 'object') return JSON.stringify(value); return value; } export function buildWorkbook(items) { const headers = []; const seen = new Set(); items.forEach(item => { Object.keys(item || {}).forEach(key => { if (key.startsWith('@odata.')) return; if (seen.has(key)) return; seen.add(key); headers.push(key); }); }); const rows = items.map(item => { const row = {}; headers.forEach(key => { row[key] = normalizeValue(item?.[key]); }); return row; }); const ws = XLSX.utils.json_to_sheet(rows, { header: headers }); ws['!autofilter'] = { ref: XLSX.utils.encode_range({ s: { c: 0, r: 0 }, e: { c: Math.max(headers.length - 1, 0), r: Math.max(rows.length, 0) }, }), }; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Items'); return wb; }