const path = require('path'); const fs = require('fs/promises'); const mysql = require('mysql2/promise'); require('dotenv').config({ path: path.join(__dirname, '..', 'backend', '.env') }); const REPORT_PATH = path.join( __dirname, '..', 'docs', 'status-reports', 'ORDRESTYRING_FULL_DATA_QUALITY_2026-04-06.md' ); async function main() { if (!process.env.DB_PASSWORD) { throw new Error('DB_PASSWORD is required'); } const conn = await mysql.createConnection({ host: process.env.DB_HOST || '127.0.0.1', port: parseInt(process.env.DB_PORT, 10) || 3306, user: process.env.DB_USER || 'tilbudgivern_service', password: process.env.DB_PASSWORD, database: process.env.DB_NAME || 'tilbudgivern' }); try { await conn.execute( ` CREATE TABLE IF NOT EXISTS ordrestyring_data_quality_issues ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, issue_type VARCHAR(64) NOT NULL, severity VARCHAR(16) NOT NULL, entity_type VARCHAR(32) NOT NULL, entity_key VARCHAR(255) NULL, issue_details_json JSON NULL, detected_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, resolved_at DATETIME NULL, INDEX idx_quality_issue_type (issue_type), INDEX idx_quality_entity (entity_type, entity_key), INDEX idx_quality_detected (detected_at) ) ` ); await conn.execute('DELETE FROM ordrestyring_data_quality_issues WHERE resolved_at IS NULL'); const [[latestCaseTableStats]] = await conn.execute( ` SELECT COUNT(*) AS table_count FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ordrestyring_case_latest' ` ); const hasCaseLatestTable = Number(latestCaseTableStats.table_count || 0) > 0; let latestCaseRows = 0; if (hasCaseLatestTable) { const [[latestCaseRowsStats]] = await conn.execute( ` SELECT COUNT(*) AS total_rows FROM ordrestyring_case_latest ` ); latestCaseRows = Number(latestCaseRowsStats.total_rows || 0); } const [[caseStats]] = await conn.execute( ` SELECT COUNT(*) AS total_case_rows, COUNT(DISTINCT case_number) AS distinct_case_numbers FROM ordrestyring_local.cases WHERE case_number IS NOT NULL AND case_number <> '' ` ); const [[hourStats]] = await conn.execute( ` SELECT COUNT(*) AS total_hours, SUM(CASE WHEN new_case_number IS NULL OR new_case_number = '' THEN 1 ELSE 0 END) AS hours_without_case, SUM(CASE WHEN remark IS NULL OR TRIM(remark) = '' THEN 1 ELSE 0 END) AS hours_without_remark FROM ordrestyring_local.hours ` ); const [[hourLinkStats]] = await conn.execute( ` SELECT SUM( CASE WHEN (h.new_case_number IS NULL OR h.new_case_number = '') AND h.hour_type IN ( SELECT DISTINCT hour_type FROM ordrestyring_local.hours WHERE new_case_number IS NOT NULL AND new_case_number <> '' AND hour_type IS NOT NULL ) THEN 1 ELSE 0 END ) AS hours_without_case_case_related FROM ordrestyring_local.hours h ` ); const [[materialStats]] = await conn.execute( ` SELECT COUNT(*) AS total_materials, SUM(CASE WHEN case_number IS NULL OR case_number = '' THEN 1 ELSE 0 END) AS materials_without_case, SUM(CASE WHEN product_text IS NULL OR TRIM(product_text) = '' THEN 1 ELSE 0 END) AS materials_without_text, SUM(CASE WHEN CAST(COALESCE(NULLIF(sales_price, ''), '0') AS DECIMAL(14,2)) = 0 THEN 1 ELSE 0 END) AS materials_zero_price FROM ordrestyring_local.case_materials ` ); const [[normMatStats]] = await conn.execute( ` SELECT COUNT(*) AS rows_total, SUM(CASE WHEN normalized_unit = 'm2' THEN 1 ELSE 0 END) AS rows_m2, SUM(CASE WHEN normalized_unit = 'lbm' THEN 1 ELSE 0 END) AS rows_lbm, SUM(CASE WHEN normalized_unit = 'stk' THEN 1 ELSE 0 END) AS rows_stk FROM ordrestyring_materials_normalized ` ); const [[normHourStats]] = await conn.execute( ` SELECT COUNT(*) AS rows_total, SUM(CASE WHEN duration_hours > 0 THEN 1 ELSE 0 END) AS rows_with_duration, SUM(CASE WHEN remark IS NOT NULL AND TRIM(remark) <> '' THEN 1 ELSE 0 END) AS rows_with_remark FROM ordrestyring_hours_normalized ` ); const duplicateRatio = caseStats.distinct_case_numbers > 0 ? (caseStats.total_case_rows / caseStats.distinct_case_numbers) : 0; const issues = []; if (latestCaseRows === 0) { issues.push({ issueType: 'CASE_DUPLICATION', severity: duplicateRatio > 20 ? 'high' : (duplicateRatio > 5 ? 'medium' : 'low'), entityType: 'cases', entityKey: 'global', details: { totalCaseRows: Number(caseStats.total_case_rows || 0), distinctCaseNumbers: Number(caseStats.distinct_case_numbers || 0), avgRowsPerCase: Number(duplicateRatio.toFixed(2)), note: 'ordrestyring_case_latest mangler data' } }); } if (Number(hourLinkStats.hours_without_case_case_related || 0) > 0) { issues.push({ issueType: 'HOURS_WITHOUT_CASE', severity: 'high', entityType: 'hours', entityKey: 'global', details: { hoursWithoutCaseCaseRelated: Number(hourLinkStats.hours_without_case_case_related || 0), hoursWithoutCaseAll: Number(hourStats.hours_without_case || 0), totalHours: Number(hourStats.total_hours || 0) } }); } if (Number(materialStats.materials_without_case || 0) > 0) { issues.push({ issueType: 'MATERIALS_WITHOUT_CASE', severity: 'high', entityType: 'case_materials', entityKey: 'global', details: { materialsWithoutCase: Number(materialStats.materials_without_case || 0), totalMaterials: Number(materialStats.total_materials || 0) } }); } const zeroPricePct = Number(materialStats.total_materials || 0) > 0 ? (Number(materialStats.materials_zero_price || 0) / Number(materialStats.total_materials || 1)) * 100 : 0; if (zeroPricePct > 10) { issues.push({ issueType: 'MATERIAL_ZERO_PRICE_RATIO', severity: zeroPricePct > 30 ? 'high' : 'medium', entityType: 'case_materials', entityKey: 'global', details: { zeroPriceRows: Number(materialStats.materials_zero_price || 0), totalRows: Number(materialStats.total_materials || 0), percent: Number(zeroPricePct.toFixed(2)) } }); } for (const issue of issues) { await conn.execute( ` INSERT INTO ordrestyring_data_quality_issues ( issue_type, severity, entity_type, entity_key, issue_details_json ) VALUES (?, ?, ?, ?, ?) `, [ issue.issueType, issue.severity, issue.entityType, issue.entityKey, JSON.stringify(issue.details) ] ); } const lines = []; lines.push('# Ordrestyring Full Data Quality (Sprint 1)'); lines.push(''); lines.push(`Generated: ${new Date().toISOString()}`); lines.push(''); lines.push('## Source tables'); lines.push(`- cases rows: ${Number(caseStats.total_case_rows || 0).toLocaleString('da-DK')}`); lines.push(`- distinct cases: ${Number(caseStats.distinct_case_numbers || 0).toLocaleString('da-DK')}`); lines.push(`- avg rows per case: ${duplicateRatio.toFixed(2)}`); lines.push(`- case_latest cache table: ${hasCaseLatestTable ? 'ja' : 'nej'}`); lines.push(`- case_latest rows: ${latestCaseRows.toLocaleString('da-DK')}`); lines.push(`- hours rows: ${Number(hourStats.total_hours || 0).toLocaleString('da-DK')}`); lines.push(`- hours without case (all): ${Number(hourStats.hours_without_case || 0).toLocaleString('da-DK')}`); lines.push(`- hours without case (case-related hour_type): ${Number(hourLinkStats.hours_without_case_case_related || 0).toLocaleString('da-DK')}`); lines.push(`- hours without remark: ${Number(hourStats.hours_without_remark || 0).toLocaleString('da-DK')}`); lines.push(`- material rows: ${Number(materialStats.total_materials || 0).toLocaleString('da-DK')}`); lines.push(`- material rows without text: ${Number(materialStats.materials_without_text || 0).toLocaleString('da-DK')}`); lines.push(`- material zero-price rows: ${Number(materialStats.materials_zero_price || 0).toLocaleString('da-DK')}`); lines.push(''); lines.push('## Normalized tables'); lines.push(`- ordrestyring_case_latest rows: ${latestCaseRows.toLocaleString('da-DK')}`); lines.push(`- ordrestyring_materials_normalized rows: ${Number(normMatStats.rows_total || 0).toLocaleString('da-DK')}`); lines.push(` - m2: ${Number(normMatStats.rows_m2 || 0).toLocaleString('da-DK')}`); lines.push(` - lbm: ${Number(normMatStats.rows_lbm || 0).toLocaleString('da-DK')}`); lines.push(` - stk: ${Number(normMatStats.rows_stk || 0).toLocaleString('da-DK')}`); lines.push(`- ordrestyring_hours_normalized rows: ${Number(normHourStats.rows_total || 0).toLocaleString('da-DK')}`); lines.push(` - with duration > 0: ${Number(normHourStats.rows_with_duration || 0).toLocaleString('da-DK')}`); lines.push(` - with remark: ${Number(normHourStats.rows_with_remark || 0).toLocaleString('da-DK')}`); lines.push(''); lines.push('## Open quality issues'); for (const issue of issues) { lines.push(`- [${issue.severity}] ${issue.issueType}: ${JSON.stringify(issue.details)}`); } lines.push(''); await fs.mkdir(path.dirname(REPORT_PATH), { recursive: true }); await fs.writeFile(REPORT_PATH, `${lines.join('\n')}\n`, 'utf8'); console.log(JSON.stringify({ reportPath: REPORT_PATH, openIssues: issues.length, topIssue: issues[0] || null }, null, 2)); } finally { await conn.end(); } } main().catch((error) => { console.error(error); process.exit(1); });