From 7395d4d290e6e72eb97d83445eab1d41dd805f5a Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Sun, 5 Apr 2026 19:47:47 +0000 Subject: [PATCH] fix: stabilize ordrestyring sync layers --- .../src/services/ordrestyringSyncService.js | 1328 ++++++++++++++++- 1 file changed, 1326 insertions(+), 2 deletions(-) diff --git a/backend/src/services/ordrestyringSyncService.js b/backend/src/services/ordrestyringSyncService.js index a65faeb..e9f0990 100644 --- a/backend/src/services/ordrestyringSyncService.js +++ b/backend/src/services/ordrestyringSyncService.js @@ -19,7 +19,9 @@ class OrdrestyringSyncService { this.apiToken = process.env.ORDRESTYRING_API_TOKEN; this.apiBaseUrl = 'https://v2.api.ordrestyring.dk'; this.lastSyncTime = null; + this.lastPriceListSync = null; this.isRunning = false; + this.caseFeatureBatchSize = parseInt(process.env.ORDRESTYRING_CASE_FEATURE_BATCH_SIZE || '200', 10); // Database connection for Ordrestyring data this.dbConfig = { @@ -29,6 +31,14 @@ class OrdrestyringSyncService { password: process.env.DB_PASSWORD, // No fallback - must be configured database: 'ordrestyring_local' }; + + this.appDbConfig = { + host: process.env.DB_HOST || '127.0.0.1', + port: parseInt(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'tilbudgivern_service', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'tilbudgivern' + }; } /** @@ -93,12 +103,15 @@ class OrdrestyringSyncService { this.syncHours(), this.syncMaterials(), this.syncDebtors(), + this.syncOfferSnapshots(), + this.syncCaseMaterialSnapshots(), + this.syncCaseFeatures(), this.syncDashboardMetrics() // Cache dashboard metrics hourly ]); // Process results syncResults.forEach((result, index) => { - const dataType = ['cases', 'users', 'hours', 'materials', 'debtors', 'dashboard_metrics'][index]; + const dataType = ['cases', 'users', 'hours', 'materials', 'debtors', 'offer_snapshots', 'case_material_snapshots', 'case_features', 'dashboard_metrics'][index]; if (result.status === 'fulfilled') { if (result.value.hasChanges) { changesDetected = true; @@ -644,6 +657,1315 @@ class OrdrestyringSyncService { await this.insertDebtor(connection, debtorData); // Uses ON DUPLICATE KEY UPDATE } + parseOrdrestyringDate(value) { + if (!value && value !== 0) { + return null; + } + + if (typeof value === 'number') { + return new Date(value < 100000000000 ? value * 1000 : value); + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + normalizeText(value) { + return (value || '').toString().trim().toLowerCase(); + } + + parseOrdrestyringDecimal(value) { + if (value === null || value === undefined || value === '') { + return 0; + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0; + } + + const normalized = String(value) + .trim() + .replace(/\s+/g, '') + .replace(/\.(?=\d{3}(?:\D|$))/g, '') + .replace(',', '.'); + + const parsed = parseFloat(normalized); + return Number.isFinite(parsed) ? parsed : 0; + } + + async upsertSyncMetadataRow(connection, data = {}) { + const fields = [ + 'project_id', + 'case_number', + 'offer_id', + 'offer_number', + 'last_status', + 'last_status_at', + 'last_price_list_synced_at', + 'last_material_sync_at', + 'notes' + ]; + + const values = fields.map(field => (field in data ? data[field] : null)); + const placeholders = fields.map(() => '?').join(', '); + + await connection.execute( + ` + INSERT INTO ordrestyring_sync_metadata (${fields.join(', ')}) + VALUES (${placeholders}) + ON DUPLICATE KEY UPDATE + project_id = COALESCE(VALUES(project_id), project_id), + case_number = COALESCE(VALUES(case_number), case_number), + offer_id = COALESCE(VALUES(offer_id), offer_id), + offer_number = COALESCE(VALUES(offer_number), offer_number), + last_status = VALUES(last_status), + last_status_at = VALUES(last_status_at), + last_price_list_synced_at = VALUES(last_price_list_synced_at), + last_material_sync_at = VALUES(last_material_sync_at), + notes = VALUES(notes), + updated_at = CURRENT_TIMESTAMP + `, + values + ); + } + + async getLatestPriceListTimestamp(connection) { + const queries = [ + ` + SELECT COALESCE(completed_at, started_at) AS ts + FROM bygma_import_log + ORDER BY COALESCE(completed_at, started_at) DESC + LIMIT 1 + `, + ` + SELECT COALESCE(completed_at, started_at) AS ts + FROM import_logs + WHERE source_system = 'Stark' + ORDER BY COALESCE(completed_at, started_at) DESC + LIMIT 1 + `, + ` + SELECT COALESCE(import_completed_at, import_started_at) AS ts + FROM vendor_import_log + WHERE import_status IN ('completed', 'processing') + ORDER BY COALESCE(import_completed_at, import_started_at) DESC + LIMIT 1 + ` + ]; + + const timestamps = []; + + for (const sql of queries) { + try { + const [rows] = await connection.execute(sql); + const ts = rows?.[0]?.ts; + if (!ts) { + continue; + } + + const numericTs = new Date(ts).getTime(); + if (!Number.isNaN(numericTs)) { + timestamps.push(numericTs); + } + } catch (error) { + const msg = (error?.message || '').toLowerCase(); + if ( + msg.includes('doesn\'t exist') || + msg.includes('unknown table') || + msg.includes('unknown column') + ) { + continue; + } + logger.warn('Failed to query price list history for metadata', { error: error.message }); + return null; + } + } + + if (timestamps.length === 0) { + return null; + } + + const latest = Math.max(...timestamps); + return this.formatOrdrestyringTimestamp(latest); + } + + inferRoofTypeFromText(text) { + const normalized = this.normalizeText(text) + .replace(/æ/g, 'ae') + .replace(/ø/g, 'oe') + .replace(/å/g, 'aa'); + + if (!normalized) { + return null; + } + + if (normalized.includes('velux') || normalized.includes('tagvindue') || normalized.includes('ovenlys')) { + return 'velux'; + } + + if (normalized.includes('tegl') || normalized.includes('vingetegl') || normalized.includes('betontegl')) { + return 'tegl'; + } + + if (normalized.includes('tagpap') || normalized.includes('flad')) { + return 'tagpap'; + } + + if (normalized.includes('skorsten') || normalized.includes('inddaekning')) { + return 'skorsten'; + } + + if (normalized.includes('b7') || normalized.includes('eternit')) { + return 'b7'; + } + + return null; + } + + inferPackageIdFromText(text) { + const roofType = this.inferRoofTypeFromText(text); + const packageMap = { + velux: 'velux_tagvindue', + tegl: 'tegl_tag_renovering', + tagpap: 'tagpap_renovering', + skorsten: 'tagreparation_skorsten', + b7: 'b7_tag_udskiftning' + }; + + return packageMap[roofType] || null; + } + + async ensureIndex(connection, schemaName, tableName, indexName, createSql) { + const [rows] = await connection.execute( + ` + SELECT INDEX_NAME + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND INDEX_NAME = ? + `, + [schemaName, tableName, indexName] + ); + + if (rows.length === 0) { + await connection.query(createSql); + } + } + + async ensureLocalSearchIndexes(connection) { + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'cases', + 'idx_cases_case_number', + 'CREATE INDEX idx_cases_case_number ON ordrestyring_local.cases (case_number)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'cases', + 'idx_cases_case_number_id', + 'CREATE INDEX idx_cases_case_number_id ON ordrestyring_local.cases (case_number, id)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'cases', + 'idx_cases_customer_number', + 'CREATE INDEX idx_cases_customer_number ON ordrestyring_local.cases (customer_number)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'cases', + 'idx_cases_customer_number_id', + 'CREATE INDEX idx_cases_customer_number_id ON ordrestyring_local.cases (customer_number, id)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'cases', + 'idx_cases_customer_number_creation_date_id', + 'CREATE INDEX idx_cases_customer_number_creation_date_id ON ordrestyring_local.cases (customer_number, creation_date, id)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'hours', + 'idx_hours_new_case_number', + 'CREATE INDEX idx_hours_new_case_number ON ordrestyring_local.hours (new_case_number)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'hours', + 'idx_hours_new_case_number_id', + 'CREATE INDEX idx_hours_new_case_number_id ON ordrestyring_local.hours (new_case_number, id)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'hours', + 'idx_hours_new_case_number_updated_at_id', + 'CREATE INDEX idx_hours_new_case_number_updated_at_id ON ordrestyring_local.hours (new_case_number, updated_at, id)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'case_materials', + 'idx_case_materials_case_number', + 'CREATE INDEX idx_case_materials_case_number ON ordrestyring_local.case_materials (case_number)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'case_materials', + 'idx_case_materials_case_number_id', + 'CREATE INDEX idx_case_materials_case_number_id ON ordrestyring_local.case_materials (case_number, id)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'case_materials', + 'idx_case_materials_case_number_added_time_id', + 'CREATE INDEX idx_case_materials_case_number_added_time_id ON ordrestyring_local.case_materials (case_number, added_time, id)' + ); + await this.ensureIndex( + connection, + 'ordrestyring_local', + 'debtors', + 'idx_debtors_customer_number', + 'CREATE INDEX idx_debtors_customer_number ON ordrestyring_local.debtors (customer_number)' + ); + } + + async getActiveOrdrestyringCaseCount(appConnection) { + const [rows] = await appConnection.execute( + ` + SELECT COUNT(*) AS active_case_count + FROM ( + SELECT case_number + FROM ordrestyring_local.case_materials + WHERE case_number IS NOT NULL AND case_number <> '' + UNION + SELECT new_case_number AS case_number + FROM ordrestyring_local.hours + WHERE new_case_number IS NOT NULL AND new_case_number <> '' + ) active_cases + ` + ); + + return parseInt(rows[0]?.active_case_count, 10) || 0; + } + + async getActiveOrdrestyringCaseNumbers(appConnection) { + const [rows] = await appConnection.execute( + ` + SELECT active_cases.case_number + FROM ( + SELECT case_number + FROM ordrestyring_local.case_materials + WHERE case_number IS NOT NULL AND case_number <> '' + UNION + SELECT new_case_number AS case_number + FROM ordrestyring_local.hours + WHERE new_case_number IS NOT NULL AND new_case_number <> '' + ) active_cases + ORDER BY active_cases.case_number ASC + ` + ); + + return rows + .map(row => row.case_number) + .filter(Boolean); + } + + chunkItems(items, chunkSize) { + const chunks = []; + for (let index = 0; index < items.length; index += chunkSize) { + chunks.push(items.slice(index, index + chunkSize)); + } + return chunks; + } + + formatOrdrestyringTimestamp(value) { + const numericValue = Number(value); + + if (!Number.isFinite(numericValue) || numericValue <= 0) { + return null; + } + + const normalizedMs = numericValue > 100000000000 ? numericValue : numericValue * 1000; + const date = new Date(normalizedMs); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return date.toISOString().slice(0, 19).replace('T', ' '); + } + + buildCaseFeatureSearchText(parts = []) { + return parts + .map(part => (typeof part === 'string' ? part.trim() : '')) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + } + + async fetchLatestOrdrestyringCases(appConnection, caseNumbers = []) { + if (!Array.isArray(caseNumbers) || caseNumbers.length === 0) { + return []; + } + + const placeholders = caseNumbers.map(() => '?').join(', '); + const [rows] = await appConnection.execute( + ` + SELECT + derived.case_number, + derived.customer_number, + derived.description, + derived.remarks, + COALESCE(derived.creation_date, derived.created_at, 0) AS creation_date, + COALESCE(derived.updated_at, derived.creation_date, derived.created_at, 0) AS updated_at + FROM ( + SELECT + c.case_number, + c.customer_number, + c.description, + c.remarks, + c.creation_date, + c.created_at, + c.updated_at, + ROW_NUMBER() OVER ( + PARTITION BY c.case_number + ORDER BY GREATEST( + COALESCE(c.updated_at, 0), + COALESCE(c.creation_date, 0), + COALESCE(c.created_at, 0) + ) DESC, + c.id DESC + ) AS row_rank + FROM ordrestyring_local.cases c + WHERE c.case_number IN (${placeholders}) + AND c.case_number <> '' + ) derived + WHERE derived.row_rank = 1 + `, + caseNumbers + ); + + return rows; + } + + async fetchOrdrestyringHourSummaries(appConnection, caseNumbers = []) { + if (!Array.isArray(caseNumbers) || caseNumbers.length === 0) { + return new Map(); + } + + const placeholders = caseNumbers.map(() => '?').join(', '); + const [rows] = await appConnection.execute( + ` + SELECT + h.new_case_number AS case_number, + COUNT(*) AS hour_entries, + COALESCE(SUM( + CASE + WHEN h.start_time IS NOT NULL + AND h.stop_time IS NOT NULL + AND h.stop_time > h.start_time + THEN (h.stop_time - h.start_time) / 3600 + ELSE 0 + END + ), 0) AS total_hours, + MAX(COALESCE(h.updated_at, h.stop_time, h.start_time, h.created_at, 0)) AS last_hour_ts + FROM ordrestyring_local.hours h + WHERE h.new_case_number IN (${placeholders}) + GROUP BY h.new_case_number + `, + caseNumbers + ); + + return new Map(rows.map(row => [row.case_number, row])); + } + + async fetchOrdrestyringMaterialSummaries(appConnection, caseNumbers = []) { + if (!Array.isArray(caseNumbers) || caseNumbers.length === 0) { + return new Map(); + } + + const placeholders = caseNumbers.map(() => '?').join(', '); + const [rows] = await appConnection.execute( + ` + SELECT + case_number, + COUNT(*) AS material_lines_count, + COALESCE(SUM( + CAST(COALESCE(quantity, 0) AS DECIMAL(12,3)) + * COALESCE(CAST(NULLIF(REPLACE(TRIM(sales_price), ',', '.'), '') AS DECIMAL(12,2)), 0) + ), 0) AS material_total, + MAX(COALESCE(added_time, updated_at, created_at, 0)) AS last_material_ts + FROM ordrestyring_local.case_materials + WHERE case_number IN (${placeholders}) + GROUP BY case_number + `, + caseNumbers + ); + + return new Map(rows.map(row => [row.case_number, row])); + } + + async fetchOrdrestyringMaterialRows(appConnection, caseNumbers = []) { + if (!Array.isArray(caseNumbers) || caseNumbers.length === 0) { + return new Map(); + } + + const placeholders = caseNumbers.map(() => '?').join(', '); + const [rows] = await appConnection.execute( + ` + SELECT + case_number, + product_number, + product_text, + supplier, + quantity, + sales_price, + added_time, + created_at, + updated_at + FROM ordrestyring_local.case_materials + WHERE case_number IN (${placeholders}) + AND case_number <> '' + ORDER BY case_number ASC, COALESCE(added_time, updated_at, created_at, 0) ASC, id ASC + `, + caseNumbers + ); + + const grouped = new Map(); + rows.forEach(row => { + if (!grouped.has(row.case_number)) { + grouped.set(row.case_number, []); + } + grouped.get(row.case_number).push(row); + }); + + return grouped; + } + + async fetchOrdrestyringMaterialSamples(appConnection, caseNumbers = [], maxSamplesPerCase = 12) { + if (!Array.isArray(caseNumbers) || caseNumbers.length === 0) { + return new Map(); + } + + const placeholders = caseNumbers.map(() => '?').join(', '); + const [rows] = await appConnection.execute( + ` + SELECT + case_number, + COALESCE(NULLIF(TRIM(product_text), ''), NULLIF(TRIM(product_number), '')) AS material_label + FROM ordrestyring_local.case_materials + WHERE case_number IN (${placeholders}) + AND COALESCE(NULLIF(TRIM(product_text), ''), NULLIF(TRIM(product_number), '')) IS NOT NULL + ORDER BY case_number ASC, COALESCE(added_time, updated_at, created_at, 0) DESC, id DESC + `, + caseNumbers + ); + + const samplesByCase = new Map(); + + rows.forEach(row => { + const caseNumber = row.case_number; + const materialLabel = row.material_label; + + if (!caseNumber || !materialLabel) { + return; + } + + const existing = samplesByCase.get(caseNumber) || []; + if (existing.length >= maxSamplesPerCase || existing.includes(materialLabel)) { + return; + } + + existing.push(materialLabel); + samplesByCase.set(caseNumber, existing); + }); + + return samplesByCase; + } + + async fetchOrdrestyringHourEntries(appConnection, caseNumbers = []) { + if (!Array.isArray(caseNumbers) || caseNumbers.length === 0) { + return new Map(); + } + + const placeholders = caseNumbers.map(() => '?').join(', '); + const [rows] = await appConnection.execute( + ` + SELECT + new_case_number AS case_number, + id, + emp_id, + start_time, + stop_time, + remark, + hour_type, + approval_status, + costprice, + updated_at, + created_at + FROM ordrestyring_local.hours + WHERE new_case_number IN (${placeholders}) + AND new_case_number <> '' + ORDER BY new_case_number ASC, COALESCE(start_time, created_at, updated_at, 0) ASC, id ASC + `, + caseNumbers + ); + + const grouped = new Map(); + rows.forEach(row => { + if (!grouped.has(row.case_number)) { + grouped.set(row.case_number, []); + } + grouped.get(row.case_number).push(row); + }); + + return grouped; + } + + async fetchOrdrestyringDebtors(appConnection, customerNumbers = []) { + if (!Array.isArray(customerNumbers) || customerNumbers.length === 0) { + return new Map(); + } + + const placeholders = customerNumbers.map(() => '?').join(', '); + const [rows] = await appConnection.execute( + ` + SELECT customer_number, customer_name + FROM ordrestyring_local.debtors + WHERE customer_number IN (${placeholders}) + `, + customerNumbers + ); + + return new Map( + rows.map(row => [row.customer_number, row.customer_name || '']) + ); + } + + buildCaseFeatureRecords(caseRows = [], hoursByCase = new Map(), materialsByCase = new Map(), materialSamplesByCase = new Map(), debtorsByCustomerNumber = new Map()) { + return caseRows + .filter(row => row.case_number) + .map(row => { + const materialSummary = materialsByCase.get(row.case_number); + const hourSummary = hoursByCase.get(row.case_number); + const materialSamples = materialSamplesByCase.get(row.case_number) || []; + const customerName = (debtorsByCustomerNumber.get(row.customer_number) || '').slice(0, 255); + const searchText = this.buildCaseFeatureSearchText([ + customerName, + row.description || '', + row.remarks || '', + materialSamples.join(' ') + ]); + const inferredRoofType = this.inferRoofTypeFromText(searchText); + const inferredPackageId = this.inferPackageIdFromText(searchText); + const createdAt = this.formatOrdrestyringTimestamp(row.creation_date); + const lastActivitySource = Math.max( + Number(row.updated_at) || 0, + Number(row.creation_date) || 0, + Number(materialSummary?.last_material_ts) || 0, + Number(hourSummary?.last_hour_ts) || 0 + ); + + return [ + row.case_number, + row.customer_number || null, + customerName || null, + row.description || null, + row.remarks || null, + searchText || null, + inferredRoofType, + inferredPackageId, + parseFloat(hourSummary?.total_hours) || 0, + parseInt(hourSummary?.hour_entries, 10) || 0, + parseInt(materialSummary?.material_lines_count, 10) || 0, + parseFloat(materialSummary?.material_total) || 0, + createdAt, + this.formatOrdrestyringTimestamp(lastActivitySource) + ]; + }); + } + + async insertCaseFeatureBatch(appConnection, stagingTable, featureRecords = []) { + if (!Array.isArray(featureRecords) || featureRecords.length === 0) { + return 0; + } + + const rowPlaceholders = featureRecords + .map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .join(', '); + + const flattenedValues = featureRecords.flat(); + + const [result] = await appConnection.execute( + ` + INSERT INTO ${stagingTable} ( + case_number, + customer_number, + customer_name, + description, + remarks, + search_text, + inferred_roof_type, + inferred_package_id, + total_hours, + hour_entries, + material_lines_count, + material_total, + created_at_ordrestyring, + last_activity_at_ordrestyring + ) + VALUES ${rowPlaceholders} + `, + flattenedValues + ); + + return result.affectedRows || 0; + } + + async rebuildCaseFeaturesTable(appConnection) { + const stagingTable = 'ordrestyring_case_features_rebuild'; + const backupTable = 'ordrestyring_case_features_previous'; + + await appConnection.execute(`DROP TABLE IF EXISTS ${stagingTable}`); + await appConnection.execute(`CREATE TABLE ${stagingTable} LIKE ordrestyring_case_features`); + + const activeCaseNumbers = await this.getActiveOrdrestyringCaseNumbers(appConnection); + const caseNumberChunks = this.chunkItems(activeCaseNumbers, this.caseFeatureBatchSize); + let insertedRows = 0; + + for (const caseNumberChunk of caseNumberChunks) { + const caseRows = await this.fetchLatestOrdrestyringCases(appConnection, caseNumberChunk); + if (caseRows.length === 0) { + continue; + } + + const customerNumbers = [...new Set( + caseRows + .map(row => row.customer_number) + .filter(Boolean) + )]; + + const [hoursByCase, materialsByCase, materialSamplesByCase, debtorsByCustomerNumber, materialRowsByCase, hourEntriesByCase] = await Promise.all([ + this.fetchOrdrestyringHourSummaries(appConnection, caseNumberChunk), + this.fetchOrdrestyringMaterialSummaries(appConnection, caseNumberChunk), + this.fetchOrdrestyringMaterialSamples(appConnection, caseNumberChunk), + this.fetchOrdrestyringDebtors(appConnection, customerNumbers), + this.fetchOrdrestyringMaterialRows(appConnection, caseNumberChunk), + this.fetchOrdrestyringHourEntries(appConnection, caseNumberChunk) + ]); + + await this.persistReferenceCases(appConnection, caseRows); + await this.persistReferenceMaterials(appConnection, materialRowsByCase); + await this.persistReferenceLaborEntries(appConnection, hourEntriesByCase); + + const materialSummaries = materialsByCase; + const hourSummaries = hoursByCase; + + const featureRecords = this.buildCaseFeatureRecords( + caseRows, + hourSummaries, + materialSummaries, + materialSamplesByCase, + debtorsByCustomerNumber + ); + insertedRows += await this.insertCaseFeatureBatch(appConnection, stagingTable, featureRecords); + } + + const activeCaseCount = activeCaseNumbers.length; + if (activeCaseCount > 0 && insertedRows === 0) { + throw new Error(`Rebuild of ordrestyring_case_features produced 0 rows from ${activeCaseCount} active Ordrestyring cases`); + } + + await appConnection.execute(`DROP TABLE IF EXISTS ${backupTable}`); + await appConnection.execute(` + RENAME TABLE + ordrestyring_case_features TO ${backupTable}, + ${stagingTable} TO ordrestyring_case_features + `); + await appConnection.execute(`DROP TABLE ${backupTable}`); + + return insertedRows; + + // existing code no longer reachable + } + + async persistReferenceCases(appConnection, caseRows = []) { + if (!Array.isArray(caseRows) || caseRows.length === 0) { + return; + } + + const rowPlaceholders = caseRows + .map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .join(', '); + + const values = caseRows.flatMap(row => [ + row.case_number, + row.customer_number || null, + row.customer_name || null, + row.description || null, + row.remarks || null, + row.additional_technicians || null, + row.case_type || null, + row.status || null, + this.formatOrdrestyringTimestamp(row.creation_date || row.created_at), + this.formatOrdrestyringTimestamp(row.updated_at || row.creation_date || row.created_at) + ]); + + await appConnection.execute( + ` + INSERT INTO ordrestyring_reference_cases ( + case_number, + customer_number, + customer_name, + description, + remarks, + additional_technicians, + case_type, + status, + created_at_ordrestyring, + updated_at_ordrestyring + ) VALUES ${rowPlaceholders} + ON DUPLICATE KEY UPDATE + customer_number = VALUES(customer_number), + customer_name = VALUES(customer_name), + description = VALUES(description), + remarks = VALUES(remarks), + additional_technicians = VALUES(additional_technicians), + case_type = VALUES(case_type), + status = VALUES(status), + created_at_ordrestyring = VALUES(created_at_ordrestyring), + updated_at_ordrestyring = VALUES(updated_at_ordrestyring) + `, + values + ); + } + + async persistReferenceMaterials(appConnection, materialsByCase = new Map()) { + const caseNumbers = [...materialsByCase.keys()]; + if (caseNumbers.length === 0) { + return; + } + + const placeholders = caseNumbers.map(() => '?').join(', '); + await appConnection.execute( + `DELETE FROM ordrestyring_reference_materials WHERE case_number IN (${placeholders})`, + caseNumbers + ); + + const rows = []; + materialsByCase.forEach(list => { + rows.push(...list); + }); + + if (rows.length === 0) { + return; + } + + const rowPlaceholders = rows + .map(() => '(?, ?, ?, ?, ?, ?, ?)') + .join(', '); + + const values = rows.flatMap(row => [ + row.case_number, + row.product_number || null, + row.product_text || null, + row.supplier || null, + parseFloat(row.quantity) || 0, + parseFloat(row.sales_price) || 0, + this.formatOrdrestyringTimestamp(row.added_time || row.created_at || row.updated_at) + ]); + + await appConnection.execute( + ` + INSERT INTO ordrestyring_reference_materials ( + case_number, + product_number, + product_text, + supplier, + quantity, + sales_price, + created_at_ordrestyring + ) VALUES ${rowPlaceholders} + `, + values + ); + } + + async persistReferenceLaborEntries(appConnection, hourEntriesByCase = new Map()) { + const caseNumbers = [...hourEntriesByCase.keys()]; + if (caseNumbers.length === 0) { + return; + } + + const placeholders = caseNumbers.map(() => '?').join(', '); + await appConnection.execute( + `DELETE FROM ordrestyring_reference_labor_entries WHERE case_number IN (${placeholders})`, + caseNumbers + ); + + const rows = []; + hourEntriesByCase.forEach(list => { + rows.push(...list); + }); + + if (rows.length === 0) { + return; + } + + const rowPlaceholders = rows + .map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .join(', '); + + const values = rows.flatMap(row => [ + row.case_number, + row.emp_id || null, + this.formatOrdrestyringTimestamp(row.start_time), + this.formatOrdrestyringTimestamp(row.stop_time), + row.remark || null, + row.hour_type || null, + row.approval_status || null, + row.costprice || null, + this.formatOrdrestyringTimestamp(row.created_at), + this.formatOrdrestyringTimestamp(row.updated_at) + ]); + + await appConnection.execute( + ` + INSERT INTO ordrestyring_reference_labor_entries ( + case_number, + emp_id, + start_at, + stop_at, + remark, + hour_type, + approval_status, + costprice, + created_at_ordrestyring + ) VALUES ${rowPlaceholders} + `, + values + ); + } + } + + async syncCaseMaterialSnapshots(appConnection) { + await appConnection.execute('DELETE FROM ordrestyring_case_material_snapshots'); + + const [result] = await appConnection.execute( + ` + INSERT INTO ordrestyring_case_material_snapshots ( + case_number, + line_id, + product_number, + product_text, + supplier, + quantity, + cost_price, + sales_price, + created_at_ordrestyring + ) + SELECT + cm.case_number, + cm.id, + NULLIF(TRIM(cm.product_number), ''), + NULLIF(TRIM(cm.product_text), ''), + NULLIF(TRIM(cm.supplier), ''), + CAST(COALESCE(cm.quantity, 0) AS DECIMAL(12,3)), + COALESCE(CAST(NULLIF(REPLACE(TRIM(cm.cost_price), ',', '.'), '') AS DECIMAL(12,2)), 0), + COALESCE(CAST(NULLIF(REPLACE(TRIM(cm.sales_price), ',', '.'), '') AS DECIMAL(12,2)), 0), + FROM_UNIXTIME( + CASE + WHEN COALESCE(cm.added_time, cm.updated_at, cm.created_at, 0) > 100000000000 + THEN FLOOR(COALESCE(cm.added_time, cm.updated_at, cm.created_at, 0) / 1000) + ELSE COALESCE(cm.added_time, cm.updated_at, cm.created_at, 0) + END + ) + FROM ordrestyring_local.case_materials cm + WHERE cm.case_number IS NOT NULL + AND cm.case_number <> '' + ` + ); + + return result.affectedRows || 0; + } + + async fetchOfferSnapshots(limit = 50) { + const query = ` + query SyncOfferSnapshots($limit: Int!) { + offers( + pagination: { cursor: null, limit: $limit }, + orderBy: { field: "createdAt", direction: DESC } + ) { + items { + id + number + } + } + } + `; + + try { + const response = await axios.post( + 'https://graphql.ordrestyring.dk/graphql', + { + query, + variables: { limit } + }, + { + headers: { + Authorization: `Bearer ${this.apiToken}`, + 'Content-Type': 'application/json' + }, + timeout: 30000 + } + ); + + const offers = response.data?.data?.offers?.items; + return Array.isArray(offers) ? offers : []; + } catch (error) { + logger.warn('Failed to load offer list from Ordrestyring GraphQL', { + message: error.message + }); + return []; + } + } + + async fetchOfferDetails(offerId) { + const query = ` + query SyncOffer($id: Int!) { + offer(id: $id) { + id + number + description + createdAt + customer { + name + email + } + totals { + salesPrice + salesPriceWithVat + } + status { + text + } + lines { + id + description + quantity + unit + unitPrice + total + } + } + } + `; + + try { + const response = await axios.post( + 'https://graphql.ordrestyring.dk/graphql', + { + query, + variables: { id: parseInt(offerId, 10) } + }, + { + headers: { + Authorization: `Bearer ${this.apiToken}`, + 'Content-Type': 'application/json' + }, + timeout: 30000 + } + ); + + return response.data?.data?.offer || null; + } catch (error) { + logger.warn('Failed to load offer details from Ordrestyring GraphQL', { + offerId, + message: error.message + }); + return null; + } + } + + async syncOfferSnapshots() { + const offers = await this.fetchOfferSnapshots(50); + if (!offers.length) { + logger.info('Offer snapshot sync skipped; no offers returned from Ordrestyring GraphQL'); + return { hasChanges: false, changes: 0 }; + } + + const appConnection = await mysql.createConnection(this.appDbConfig); + let changes = 0; + + for (const offer of offers) { + try { + const details = await this.fetchOfferDetails(offer.id); + if (!details) { + continue; + } + + const createdAt = this.parseOrdrestyringDate(details.createdAt); + const customerName = details.customer?.name || ''; + const description = details.description || ''; + const searchText = [customerName, description] + .filter(Boolean) + .join(' ') + .trim(); + + await appConnection.execute( + ` + INSERT INTO ordrestyring_offer_snapshots ( + offer_id, + offer_number, + customer_name, + customer_email, + description, + status_text, + search_text, + total_sales_price, + total_sales_price_with_vat, + created_at_ordrestyring + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + offer_number = VALUES(offer_number), + customer_name = VALUES(customer_name), + customer_email = VALUES(customer_email), + description = VALUES(description), + status_text = VALUES(status_text), + search_text = VALUES(search_text), + total_sales_price = VALUES(total_sales_price), + total_sales_price_with_vat = VALUES(total_sales_price_with_vat), + created_at_ordrestyring = VALUES(created_at_ordrestyring) + `, + [ + details.id, + details.number || null, + customerName || null, + details.customer?.email || null, + description || null, + details.status?.text || null, + searchText || null, + parseFloat(details.totals?.salesPrice) || 0, + parseFloat(details.totals?.salesPriceWithVat) || 0, + createdAt + ] + ); + + await appConnection.execute( + 'DELETE FROM ordrestyring_offer_line_snapshots WHERE offer_id = ?', + [details.id] + ); + + for (const line of details.lines || []) { + await appConnection.execute( + ` + INSERT INTO ordrestyring_offer_line_snapshots ( + offer_id, + line_id, + description, + quantity, + unit, + unit_price, + line_total + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + [ + details.id, + line.id, + line.description || null, + parseFloat(line.quantity) || 0, + line.unit || null, + parseFloat(line.unitPrice) || 0, + parseFloat(line.total) || 0 + ] + ); + } + + await this.upsertSyncMetadataRow(appConnection, { + offer_id: details.id, + offer_number: details.number || null, + last_status: details.status?.text || null, + last_status_at: createdAt, + notes: 'offer sync' + }); + + changes++; + } catch (error) { + logger.warn('Skipping offer sync due to error for offer', { + offerId: offer.id, + message: error.message + }); + } + } + + await appConnection.end(); + return { hasChanges: changes > 0, changes }; + } + + async syncCaseMaterialSnapshots() { + try { + const appConnection = await mysql.createConnection(this.appDbConfig); + await this.ensureLocalSearchIndexes(appConnection); + await appConnection.execute('DELETE FROM ordrestyring_case_material_snapshots'); + + const [result] = await appConnection.execute( + ` + INSERT INTO ordrestyring_case_material_snapshots ( + case_number, + line_id, + product_number, + product_text, + supplier, + quantity, + cost_price, + sales_price, + created_at_ordrestyring + ) + SELECT + cm.case_number, + cm.id, + NULLIF(TRIM(cm.product_number), ''), + NULLIF(TRIM(cm.product_text), ''), + NULLIF(TRIM(cm.supplier), ''), + CAST(COALESCE(cm.quantity, 0) AS DECIMAL(12,3)), + COALESCE(CAST(NULLIF(REPLACE(TRIM(cm.cost_price), ',', '.'), '') AS DECIMAL(12,2)), 0), + COALESCE(CAST(NULLIF(REPLACE(TRIM(cm.sales_price), ',', '.'), '') AS DECIMAL(12,2)), 0), + FROM_UNIXTIME( + CASE + WHEN COALESCE(cm.added_time, cm.updated_at, cm.created_at, 0) > 100000000000 + THEN FLOOR(COALESCE(cm.added_time, cm.updated_at, cm.created_at, 0) / 1000) + ELSE COALESCE(cm.added_time, cm.updated_at, cm.created_at, 0) + END + ) + FROM ordrestyring_local.case_materials cm + WHERE cm.case_number IS NOT NULL + AND cm.case_number <> '' + ` + ); + + const latestPriceListAt = await this.getLatestPriceListTimestamp(appConnection); + const priceListSyncAt = latestPriceListAt || this.formatOrdrestyringTimestamp(Date.now()); + this.lastPriceListSync = priceListSyncAt + ? new Date(`${priceListSyncAt.replace(' ', 'T')}Z`).toISOString() + : null; + try { + await appConnection.execute( + ` + INSERT INTO ordrestyring_sync_metadata ( + case_number, + last_material_sync_at, + last_price_list_synced_at, + notes + ) + SELECT + case_number, + NOW(), + ?, + 'material sync' + FROM ordrestyring_local.case_materials + WHERE case_number IS NOT NULL + AND case_number <> '' + GROUP BY case_number + ON DUPLICATE KEY UPDATE + last_material_sync_at = VALUES(last_material_sync_at), + last_price_list_synced_at = COALESCE(VALUES(last_price_list_synced_at), last_price_list_synced_at), + notes = VALUES(notes), + updated_at = CURRENT_TIMESTAMP + `, + [priceListSyncAt] + ); + } catch (metadataError) { + logger.warn('Failed to update metadata after material sync', { + error: metadataError.message + }); + } + + await appConnection.end(); + return { hasChanges: (result.affectedRows || 0) > 0, changes: result.affectedRows || 0 }; + } catch (error) { + logger.error('Error syncing case material snapshots:', error.message); + return { hasChanges: false, changes: 0 }; + } + } + + async syncCaseFeatures() { + try { + const appConnection = await mysql.createConnection(this.appDbConfig); + await this.ensureLocalSearchIndexes(appConnection); + const materialSnapshotResult = await this.syncCaseMaterialSnapshots(); + const insertedRows = await this.rebuildCaseFeaturesTable(appConnection); + + try { + await appConnection.execute( + ` + INSERT INTO ordrestyring_sync_metadata ( + case_number, + last_status, + last_status_at, + notes + ) + SELECT + derived.case_number, + derived.status, + derived.last_status_at, + 'case status sync' + FROM ( + SELECT + c.case_number, + c.status, + FROM_UNIXTIME( + COALESCE(c.updated_at, c.creation_date, c.created_at, 0) + ) AS last_status_at + FROM ordrestyring_local.cases c + INNER JOIN ( + SELECT + case_number, + MAX(id) AS latest_id + FROM ordrestyring_local.cases + WHERE case_number IS NOT NULL + AND case_number <> '' + GROUP BY case_number + ) latest_case_ids + ON latest_case_ids.latest_id = c.id + ) derived + WHERE derived.case_number IS NOT NULL + AND derived.case_number <> '' + ON DUPLICATE KEY UPDATE + last_status = VALUES(last_status), + last_status_at = VALUES(last_status_at), + notes = VALUES(notes), + updated_at = CURRENT_TIMESTAMP + ` + ); + } catch (metadataError) { + logger.warn('Failed to update metadata after case sync', { + error: metadataError.message + }); + } + + await appConnection.end(); + const changes = insertedRows + (materialSnapshotResult.changes || 0); + return { hasChanges: changes > 0, changes }; + } catch (error) { + logger.error('Error syncing case features:', { + message: error.message, + stack: error.stack + }); + throw error; + } + } + /** * Log sync activity for monitoring */ @@ -690,6 +2012,7 @@ class OrdrestyringSyncService { return { isRunning: this.isRunning, lastSync: this.lastSyncTime, + priceListLastSyncedAt: this.lastPriceListSync, recentLogs: logs, statistics: stats[0] }; @@ -699,6 +2022,7 @@ class OrdrestyringSyncService { return { isRunning: this.isRunning, lastSync: this.lastSyncTime, + priceListLastSyncedAt: this.lastPriceListSync, error: error.message }; } @@ -761,4 +2085,4 @@ class OrdrestyringSyncService { } } -module.exports = OrdrestyringSyncService; \ No newline at end of file +module.exports = OrdrestyringSyncService;