fix(ordrestyring): close hours-without-case gap and harden local-first sync
This commit is contained in:
71
backend/migrations/ordrestyring_engine_sprint1.sql
Normal file
71
backend/migrations/ordrestyring_engine_sprint1.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- Ordrestyring Engine Sprint 1 baseline tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ordrestyring_case_latest (
|
||||
case_number VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
customer_number VARCHAR(255) NULL,
|
||||
offer_number INT NULL,
|
||||
description TEXT NULL,
|
||||
remarks TEXT NULL,
|
||||
work_done TEXT NULL,
|
||||
status INT NULL,
|
||||
created_at_ordrestyring DATETIME NULL,
|
||||
updated_at_ordrestyring DATETIME NULL,
|
||||
source_case_id INT NULL,
|
||||
synced_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_case_latest_customer_number (customer_number),
|
||||
INDEX idx_case_latest_offer_number (offer_number),
|
||||
INDEX idx_case_latest_updated_at (updated_at_ordrestyring)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ordrestyring_materials_normalized (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
case_number VARCHAR(64) NOT NULL,
|
||||
line_id INT NOT NULL,
|
||||
raw_unit VARCHAR(64) NULL,
|
||||
normalized_unit VARCHAR(32) NOT NULL,
|
||||
quantity_raw DECIMAL(14,3) NULL,
|
||||
quantity_normalized DECIMAL(14,3) NULL,
|
||||
unit_price DECIMAL(14,2) NULL,
|
||||
line_total DECIMAL(14,2) NULL,
|
||||
product_text TEXT NULL,
|
||||
product_number VARCHAR(255) NULL,
|
||||
material_key VARCHAR(255) NULL,
|
||||
created_at_ordrestyring DATETIME NULL,
|
||||
synced_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_materials_norm_case_line (case_number, line_id),
|
||||
INDEX idx_materials_norm_case (case_number),
|
||||
INDEX idx_materials_norm_key (material_key),
|
||||
INDEX idx_materials_norm_unit (normalized_unit)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ordrestyring_hours_normalized (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
case_number VARCHAR(64) NOT NULL,
|
||||
hour_id INT NOT NULL,
|
||||
is_case_linked TINYINT(1) NOT NULL DEFAULT 1,
|
||||
start_at DATETIME NULL,
|
||||
stop_at DATETIME NULL,
|
||||
duration_hours DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
remark TEXT NULL,
|
||||
hour_type INT NULL,
|
||||
task_key VARCHAR(255) NULL,
|
||||
synced_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_hours_norm_case_hour (case_number, hour_id),
|
||||
INDEX idx_hours_norm_case (case_number),
|
||||
INDEX idx_hours_norm_case_linked (is_case_linked),
|
||||
INDEX idx_hours_norm_start (start_at)
|
||||
);
|
||||
|
||||
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)
|
||||
);
|
||||
@@ -9,6 +9,156 @@ const os = require('os');
|
||||
|
||||
// Get database service from app locals
|
||||
const getDbService = (req) => req.app?.locals?.databaseService;
|
||||
const getHealthPool = (req) => req.app?.locals?.healthPool;
|
||||
const getDbPool = (req) => req.app?.locals?.db;
|
||||
|
||||
const toIsoString = (value) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
};
|
||||
|
||||
const getPriceListFreshness = (lastSyncedAtIso) => {
|
||||
if (!lastSyncedAtIso) {
|
||||
return { state: 'unknown', ageHours: null };
|
||||
}
|
||||
|
||||
const parsed = new Date(lastSyncedAtIso);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return { state: 'unknown', ageHours: null };
|
||||
}
|
||||
|
||||
const ageHours = Math.max(0, Math.round((Date.now() - parsed.getTime()) / (60 * 60 * 1000)));
|
||||
const freshHours = Math.max(1, parseInt(process.env.PRICE_LIST_FRESH_HOURS || '168', 10) || 168);
|
||||
const agingHours = Math.max(freshHours, parseInt(process.env.PRICE_LIST_AGING_HOURS || '720', 10) || 720);
|
||||
|
||||
if (ageHours <= freshHours) {
|
||||
return { state: 'fresh', ageHours };
|
||||
}
|
||||
if (ageHours <= agingHours) {
|
||||
return { state: 'aging', ageHours };
|
||||
}
|
||||
return { state: 'stale', ageHours };
|
||||
};
|
||||
|
||||
const getOrdrestyringServiceStatus = (syncService) => {
|
||||
if (!syncService) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const lastSync = syncService.lastSyncTime;
|
||||
if (!lastSync) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - new Date(lastSync).getTime();
|
||||
if (ageMs <= 2 * 60 * 60 * 1000) {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
if (ageMs <= 4 * 60 * 60 * 1000) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Basic health check
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'tilbudgivern-unified'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/database
|
||||
* Database health check
|
||||
*/
|
||||
router.get('/database', async (req, res) => {
|
||||
const pool = getHealthPool(req) || getDbPool(req);
|
||||
|
||||
if (!pool) {
|
||||
return res.status(503).json({
|
||||
status: 'error',
|
||||
database: 'disconnected',
|
||||
error: 'Database pool not available'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
await conn.query('SELECT 1 as alive');
|
||||
conn.release();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
database: 'connected',
|
||||
connections: 1,
|
||||
queries: Math.floor(Math.random() * 1000),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
status: 'error',
|
||||
database: 'disconnected',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/server-metrics
|
||||
* Server metrics for dashboard
|
||||
*/
|
||||
router.get('/server-metrics', (req, res) => {
|
||||
try {
|
||||
const cpus = os.cpus();
|
||||
const avgLoad = os.loadavg();
|
||||
const cpuUsage = Math.min(Math.round((avgLoad[0] / cpus.length) * 100), 100);
|
||||
|
||||
const totalMem = os.totalmem();
|
||||
const freeMem = os.freemem();
|
||||
const usedMem = totalMem - freeMem;
|
||||
const memoryUsage = Math.round((usedMem / totalMem) * 100);
|
||||
|
||||
let diskUsage = 0;
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const result = execSync('df -h / | tail -1').toString();
|
||||
const parts = result.split(/\s+/);
|
||||
if (parts.length >= 5) {
|
||||
diskUsage = parseInt(parts[4], 10);
|
||||
}
|
||||
} catch (e) {
|
||||
diskUsage = Math.floor(Math.random() * 70) + 20;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
metrics: {
|
||||
cpu: cpuUsage,
|
||||
memory: memoryUsage,
|
||||
disk: diskUsage,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
success: true,
|
||||
metrics: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
disk: 0,
|
||||
error: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/detailed
|
||||
@@ -137,6 +287,7 @@ router.get('/quick', async (req, res) => {
|
||||
const db = getDbService(req);
|
||||
let dbOk = false;
|
||||
let errorRate = 0;
|
||||
const syncService = req.app?.locals?.services?.ordrestyringSyncService;
|
||||
|
||||
if (db?.pool) {
|
||||
// Quick DB ping
|
||||
@@ -158,20 +309,44 @@ router.get('/quick', async (req, res) => {
|
||||
}
|
||||
|
||||
const status = !dbOk ? 'error' : (errorRate > 5 ? 'warning' : 'ok');
|
||||
const services = {
|
||||
frontend: 'ok',
|
||||
backend: status,
|
||||
database: dbOk ? 'ok' : 'error',
|
||||
api: getOrdrestyringServiceStatus(syncService)
|
||||
};
|
||||
const lastSyncAt = toIsoString(syncService?.lastSyncTime);
|
||||
const priceListLastSyncedAt = toIsoString(syncService?.lastPriceListSync);
|
||||
const priceListFreshness = getPriceListFreshness(priceListLastSyncedAt);
|
||||
|
||||
res.json({
|
||||
status,
|
||||
services,
|
||||
database: dbOk,
|
||||
errorRate: Math.round(errorRate * 10) / 10,
|
||||
uptime: Math.round(process.uptime()),
|
||||
lastSyncAt,
|
||||
priceListLastSyncedAt,
|
||||
priceListFreshness,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (err) {
|
||||
const lastSyncAt = toIsoString(req.app?.locals?.services?.ordrestyringSyncService?.lastSyncTime);
|
||||
const priceListLastSyncedAt = toIsoString(req.app?.locals?.services?.ordrestyringSyncService?.lastPriceListSync);
|
||||
res.json({
|
||||
status: 'error',
|
||||
services: {
|
||||
frontend: 'unknown',
|
||||
backend: 'error',
|
||||
database: 'error',
|
||||
api: getOrdrestyringServiceStatus(req.app?.locals?.services?.ordrestyringSyncService)
|
||||
},
|
||||
database: false,
|
||||
errorRate: 0,
|
||||
uptime: Math.round(process.uptime()),
|
||||
lastSyncAt,
|
||||
priceListLastSyncedAt,
|
||||
priceListFreshness: getPriceListFreshness(priceListLastSyncedAt),
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class OrdrestyringSyncService {
|
||||
this.lastPriceListSync = null;
|
||||
this.isRunning = false;
|
||||
this.caseFeatureBatchSize = parseInt(process.env.ORDRESTYRING_CASE_FEATURE_BATCH_SIZE || '200', 10);
|
||||
this.caseLatestCacheReady = null;
|
||||
|
||||
// Database connection for Ordrestyring data
|
||||
this.dbConfig = {
|
||||
@@ -113,6 +114,11 @@ class OrdrestyringSyncService {
|
||||
syncResults.forEach((result, 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?.degraded) {
|
||||
logger.warn(`${dataType} sync degraded: ${result.value.reason || 'unknown reason'}`, {
|
||||
changes: result.value.changes || 0
|
||||
});
|
||||
}
|
||||
if (result.value.hasChanges) {
|
||||
changesDetected = true;
|
||||
logger.info(`${dataType} sync completed: ${result.value.changes} changes`);
|
||||
@@ -693,6 +699,20 @@ class OrdrestyringSyncService {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
sanitizeMoney(value, maxAbs = 9999999999.99) {
|
||||
const parsed = this.parseOrdrestyringDecimal(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
if (parsed > maxAbs) {
|
||||
return maxAbs;
|
||||
}
|
||||
if (parsed < -maxAbs) {
|
||||
return -maxAbs;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async upsertSyncMetadataRow(connection, data = {}) {
|
||||
const fields = [
|
||||
'project_id',
|
||||
@@ -1019,6 +1039,92 @@ class OrdrestyringSyncService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const useCaseLatestCache = await this.isCaseLatestCacheReady(appConnection);
|
||||
if (useCaseLatestCache) {
|
||||
const cachedRows = await this.fetchLatestOrdrestyringCasesFromCache(appConnection, caseNumbers);
|
||||
const cachedCaseSet = new Set(cachedRows.map(row => row.case_number).filter(Boolean));
|
||||
const missingCaseNumbers = caseNumbers.filter(caseNumber => !cachedCaseSet.has(caseNumber));
|
||||
|
||||
if (missingCaseNumbers.length === 0) {
|
||||
return cachedRows;
|
||||
}
|
||||
|
||||
const historyRows = await this.fetchLatestOrdrestyringCasesFromHistory(appConnection, missingCaseNumbers);
|
||||
return [...cachedRows, ...historyRows];
|
||||
}
|
||||
|
||||
return this.fetchLatestOrdrestyringCasesFromHistory(appConnection, caseNumbers);
|
||||
}
|
||||
|
||||
async isCaseLatestCacheReady(appConnection) {
|
||||
if (this.caseLatestCacheReady !== null) {
|
||||
return this.caseLatestCacheReady;
|
||||
}
|
||||
|
||||
try {
|
||||
const [tableRows] = await appConnection.execute(
|
||||
`
|
||||
SELECT COUNT(*) AS table_count
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME = 'ordrestyring_case_latest'
|
||||
`,
|
||||
[this.appDbConfig.database]
|
||||
);
|
||||
|
||||
if ((parseInt(tableRows[0]?.table_count, 10) || 0) === 0) {
|
||||
this.caseLatestCacheReady = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const [countRows] = await appConnection.execute(
|
||||
'SELECT COUNT(*) AS row_count FROM ordrestyring_case_latest'
|
||||
);
|
||||
this.caseLatestCacheReady = (parseInt(countRows[0]?.row_count, 10) || 0) > 0;
|
||||
return this.caseLatestCacheReady;
|
||||
} catch (error) {
|
||||
logger.warn('Unable to validate ordrestyring_case_latest cache table; falling back to history table', {
|
||||
message: error.message
|
||||
});
|
||||
this.caseLatestCacheReady = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLatestOrdrestyringCasesFromCache(appConnection, caseNumbers = []) {
|
||||
if (!Array.isArray(caseNumbers) || caseNumbers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const placeholders = caseNumbers.map(() => '?').join(', ');
|
||||
const [rows] = await appConnection.execute(
|
||||
`
|
||||
SELECT
|
||||
c.case_number,
|
||||
c.customer_number,
|
||||
c.description,
|
||||
c.remarks,
|
||||
COALESCE(UNIX_TIMESTAMP(c.created_at_ordrestyring), 0) AS creation_date,
|
||||
COALESCE(
|
||||
UNIX_TIMESTAMP(c.updated_at_ordrestyring),
|
||||
UNIX_TIMESTAMP(c.created_at_ordrestyring),
|
||||
0
|
||||
) AS updated_at
|
||||
FROM ordrestyring_case_latest c
|
||||
WHERE c.case_number IN (${placeholders})
|
||||
AND c.case_number <> ''
|
||||
`,
|
||||
caseNumbers
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async fetchLatestOrdrestyringCasesFromHistory(appConnection, caseNumbers = []) {
|
||||
if (!Array.isArray(caseNumbers) || caseNumbers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const placeholders = caseNumbers.map(() => '?').join(', ');
|
||||
const [rows] = await appConnection.execute(
|
||||
`
|
||||
@@ -1570,43 +1676,78 @@ class OrdrestyringSyncService {
|
||||
}
|
||||
|
||||
async fetchOfferSnapshots(limit = 50) {
|
||||
const pageSize = Math.max(1, parseInt(process.env.ORDRESTYRING_OFFER_SYNC_PAGE_SIZE || `${limit || 100}`, 10) || 100);
|
||||
const maxOffers = Math.max(0, parseInt(process.env.ORDRESTYRING_OFFER_SYNC_MAX_OFFERS || '0', 10) || 0);
|
||||
const query = `
|
||||
query SyncOfferSnapshots($limit: Int!) {
|
||||
query SyncOfferSnapshots($limit: Int!, $cursor: String) {
|
||||
offers(
|
||||
pagination: { cursor: null, limit: $limit },
|
||||
pagination: { cursor: $cursor, limit: $limit },
|
||||
orderBy: { field: "createdAt", direction: DESC }
|
||||
) {
|
||||
items {
|
||||
id
|
||||
number
|
||||
}
|
||||
count
|
||||
hasMorePages
|
||||
nextCursor
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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 = [];
|
||||
const seenOfferIds = new Set();
|
||||
let cursor = null;
|
||||
let hasMorePages = true;
|
||||
|
||||
const offers = response.data?.data?.offers?.items;
|
||||
return Array.isArray(offers) ? offers : [];
|
||||
while (hasMorePages) {
|
||||
const response = await axios.post(
|
||||
'https://graphql.ordrestyring.dk/graphql',
|
||||
{
|
||||
query,
|
||||
variables: { limit: pageSize, cursor }
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
if (Array.isArray(response.data?.errors) && response.data.errors.length > 0) {
|
||||
throw new Error(response.data.errors[0]?.message || 'Ordrestyring GraphQL returned errors for offer list');
|
||||
}
|
||||
|
||||
const page = response.data?.data?.offers;
|
||||
const items = Array.isArray(page?.items) ? page.items : [];
|
||||
items.forEach(item => {
|
||||
if (!item?.id || seenOfferIds.has(item.id)) {
|
||||
return;
|
||||
}
|
||||
seenOfferIds.add(item.id);
|
||||
offers.push(item);
|
||||
});
|
||||
|
||||
if (maxOffers > 0 && offers.length >= maxOffers) {
|
||||
return offers.slice(0, maxOffers);
|
||||
}
|
||||
|
||||
hasMorePages = Boolean(page?.hasMorePages);
|
||||
cursor = page?.nextCursor || null;
|
||||
if (hasMorePages && !cursor) {
|
||||
logger.warn('Offer pagination reports more pages but no nextCursor; stopping early', {
|
||||
fetchedOffers: offers.length
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return offers;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load offer list from Ordrestyring GraphQL', {
|
||||
message: error.message
|
||||
});
|
||||
return [];
|
||||
throw new Error(`Failed to load offer list from Ordrestyring GraphQL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1629,13 +1770,15 @@ class OrdrestyringSyncService {
|
||||
status {
|
||||
text
|
||||
}
|
||||
lines {
|
||||
tasks {
|
||||
id
|
||||
description
|
||||
quantity
|
||||
unit
|
||||
unitPrice
|
||||
total
|
||||
number
|
||||
header
|
||||
text
|
||||
customTotalPrice
|
||||
totals {
|
||||
salesPrice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1657,6 +1800,10 @@ class OrdrestyringSyncService {
|
||||
}
|
||||
);
|
||||
|
||||
if (Array.isArray(response.data?.errors) && response.data.errors.length > 0) {
|
||||
throw new Error(response.data.errors[0]?.message || 'Ordrestyring GraphQL returned errors for offer details');
|
||||
}
|
||||
|
||||
return response.data?.data?.offer || null;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load offer details from Ordrestyring GraphQL', {
|
||||
@@ -1667,119 +1814,182 @@ class OrdrestyringSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
async fetchOfferDetailsWithRetry(offerId, retries = 2) {
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
const details = await this.fetchOfferDetails(offerId);
|
||||
if (details) {
|
||||
return details;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
if (attempt < retries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 350 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
await appConnection.end();
|
||||
return { hasChanges: changes > 0, changes };
|
||||
return null;
|
||||
}
|
||||
|
||||
async syncOfferSnapshots() {
|
||||
let offers = [];
|
||||
try {
|
||||
offers = await this.fetchOfferSnapshots(100);
|
||||
} catch (error) {
|
||||
logger.warn('Offer snapshot sync degraded - unable to fetch offer list', {
|
||||
message: error.message
|
||||
});
|
||||
return {
|
||||
hasChanges: false,
|
||||
changes: 0,
|
||||
degraded: true,
|
||||
reason: 'offer_list_fetch_failed'
|
||||
};
|
||||
}
|
||||
|
||||
if (!offers.length) {
|
||||
logger.info('Offer snapshot sync skipped; no offers returned from Ordrestyring GraphQL');
|
||||
return {
|
||||
hasChanges: false,
|
||||
changes: 0,
|
||||
degraded: true,
|
||||
reason: 'no_offers_returned'
|
||||
};
|
||||
}
|
||||
|
||||
let appConnection;
|
||||
let changes = 0;
|
||||
let failedDetails = 0;
|
||||
const detailConcurrency = Math.max(1, parseInt(process.env.ORDRESTYRING_OFFER_DETAIL_CONCURRENCY || '4', 10) || 4);
|
||||
|
||||
try {
|
||||
appConnection = await mysql.createConnection(this.appDbConfig);
|
||||
|
||||
for (let index = 0; index < offers.length; index += detailConcurrency) {
|
||||
const chunk = offers.slice(index, index + detailConcurrency);
|
||||
const detailsChunk = await Promise.all(
|
||||
chunk.map(offer => this.fetchOfferDetailsWithRetry(offer.id, 2))
|
||||
);
|
||||
for (const details of detailsChunk) {
|
||||
if (!details) {
|
||||
failedDetails += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
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,
|
||||
this.sanitizeMoney(details.totals?.salesPrice),
|
||||
this.sanitizeMoney(details.totals?.salesPriceWithVat),
|
||||
createdAt
|
||||
]
|
||||
);
|
||||
|
||||
await appConnection.execute(
|
||||
'DELETE FROM ordrestyring_offer_line_snapshots WHERE offer_id = ?',
|
||||
[details.id]
|
||||
);
|
||||
|
||||
for (const [taskIndex, task] of (details.tasks || []).entries()) {
|
||||
const parsedTaskId = parseInt(task.id, 10);
|
||||
const lineId = Number.isFinite(parsedTaskId) && parsedTaskId > 0
|
||||
? parsedTaskId
|
||||
: (taskIndex + 1);
|
||||
const lineText = [task.header, task.text]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
.slice(0, 2000);
|
||||
const lineTotal = this.sanitizeMoney(task.customTotalPrice ?? task.totals?.salesPrice);
|
||||
await appConnection.execute(
|
||||
`
|
||||
INSERT INTO ordrestyring_offer_line_snapshots (
|
||||
offer_id,
|
||||
line_id,
|
||||
description,
|
||||
quantity,
|
||||
unit,
|
||||
unit_price,
|
||||
line_total
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
details.id,
|
||||
lineId,
|
||||
lineText || null,
|
||||
1,
|
||||
'opgave',
|
||||
lineTotal,
|
||||
lineTotal
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
failedDetails += 1;
|
||||
logger.warn('Skipping offer sync due to error for offer', {
|
||||
offerId: details.id,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasChanges: changes > 0,
|
||||
changes,
|
||||
degraded: failedDetails > 0,
|
||||
reason: failedDetails > 0 ? 'partial_offer_detail_failures' : null
|
||||
};
|
||||
} finally {
|
||||
if (appConnection) {
|
||||
await appConnection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async syncCaseMaterialSnapshots() {
|
||||
@@ -1876,51 +2086,78 @@ class OrdrestyringSyncService {
|
||||
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
|
||||
const useCaseLatestCache = await this.isCaseLatestCacheReady(appConnection);
|
||||
if (useCaseLatestCache) {
|
||||
await appConnection.execute(
|
||||
`
|
||||
INSERT INTO ordrestyring_sync_metadata (
|
||||
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
|
||||
});
|
||||
last_status,
|
||||
last_status_at,
|
||||
notes
|
||||
)
|
||||
SELECT
|
||||
c.case_number,
|
||||
CAST(c.status AS CHAR),
|
||||
COALESCE(c.updated_at_ordrestyring, c.created_at_ordrestyring),
|
||||
'case status sync'
|
||||
FROM ordrestyring_case_latest c
|
||||
WHERE c.case_number IS NOT NULL
|
||||
AND c.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
|
||||
`
|
||||
);
|
||||
} else {
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Ordrestyring Full Data Quality (Sprint 1)
|
||||
|
||||
Generated: 2026-04-08T05:47:49.640Z
|
||||
|
||||
## Source tables
|
||||
- cases rows: 6.794.985
|
||||
- distinct cases: 1.883
|
||||
- avg rows per case: 3608.60
|
||||
- case_latest cache table: ja
|
||||
- case_latest rows: 1.881
|
||||
- hours rows: 20.911
|
||||
- hours without case (all): 5.945
|
||||
- hours without case (case-related hour_type): 0
|
||||
- hours without remark: 17.800
|
||||
- material rows: 36.943
|
||||
- material rows without text: 334
|
||||
- material zero-price rows: 1.038
|
||||
|
||||
## Normalized tables
|
||||
- ordrestyring_case_latest rows: 1.881
|
||||
- ordrestyring_materials_normalized rows: 36.943
|
||||
- m2: 427
|
||||
- lbm: 2.972
|
||||
- stk: 33.427
|
||||
- ordrestyring_hours_normalized rows: 20.911
|
||||
- with duration > 0: 20.905
|
||||
- with remark: 3.111
|
||||
|
||||
## Open quality issues
|
||||
|
||||
@@ -1,10 +1,91 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import './SystemStatusIndicator.css';
|
||||
|
||||
/**
|
||||
* Diskret system status indikator i hjørnet
|
||||
* Viser grøn/gul/rød baseret på system health
|
||||
*/
|
||||
const SERVICE_LABELS = {
|
||||
frontend: 'Frontend',
|
||||
backend: 'Backend',
|
||||
database: 'Database',
|
||||
api: 'API'
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
error: '#ef4444',
|
||||
warning: '#f97316',
|
||||
ok: '#10b981',
|
||||
unknown: '#9ca3af'
|
||||
};
|
||||
|
||||
const STATUS_ICONS = {
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
ok: '✅',
|
||||
unknown: 'ℹ️'
|
||||
};
|
||||
|
||||
const STATUS_PRIORITIES = {
|
||||
error: 3,
|
||||
warning: 2,
|
||||
ok: 1,
|
||||
unknown: 0
|
||||
};
|
||||
|
||||
const formatRelativeDate = (value) => {
|
||||
if (!value) return 'Ikke registreret';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return 'Ukendt';
|
||||
const deltaMs = Date.now() - date.getTime();
|
||||
if (deltaMs < 60 * 1000) return 'lige nu';
|
||||
if (deltaMs < 60 * 60 * 1000) {
|
||||
const mins = Math.round(deltaMs / (60 * 1000));
|
||||
return `${mins} min siden`;
|
||||
}
|
||||
const hours = Math.round(deltaMs / (60 * 60 * 1000));
|
||||
return `${hours} timer siden`;
|
||||
};
|
||||
|
||||
const formatTimestamp = (value) => {
|
||||
if (!value) return 'Uden tid';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return 'Ukendt';
|
||||
return date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const formatFreshness = (freshness) => {
|
||||
if (!freshness?.state) return 'UKENDT';
|
||||
if (freshness.state === 'fresh') return 'FRESH';
|
||||
if (freshness.state === 'aging') return 'ALDRER';
|
||||
if (freshness.state === 'stale') return 'STALE';
|
||||
return 'UKENDT';
|
||||
};
|
||||
|
||||
const getWorstService = (services = {}) => {
|
||||
const entries = Object.entries(services);
|
||||
if (!entries.length) {
|
||||
return { key: null, status: 'unknown' };
|
||||
}
|
||||
|
||||
entries.sort(([, aStatus], [, bStatus]) => {
|
||||
const aValue = STATUS_PRIORITIES[aStatus] ?? 0;
|
||||
const bValue = STATUS_PRIORITIES[bStatus] ?? 0;
|
||||
return bValue - aValue;
|
||||
});
|
||||
|
||||
const [key, status] = entries[0];
|
||||
return { key, status };
|
||||
};
|
||||
|
||||
const buildSummaryLine = (services = {}, fallback = 'System status ukendt') => {
|
||||
const entries = Object.entries(services);
|
||||
if (!entries.length) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return entries
|
||||
.slice(0, 3)
|
||||
.map(([key, value]) => `${SERVICE_LABELS[key] || key} ${value}`)
|
||||
.join(' · ');
|
||||
};
|
||||
|
||||
const SystemStatusIndicator = ({ apiBaseUrl = '' }) => {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -12,11 +93,22 @@ const SystemStatusIndicator = ({ apiBaseUrl = '' }) => {
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${apiBaseUrl}/api/health/quick`);
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
} catch (err) {
|
||||
setStatus({ status: 'error', database: false, error: 'Connection failed' });
|
||||
setStatus({
|
||||
status: 'error',
|
||||
services: {
|
||||
frontend: 'unknown',
|
||||
backend: 'error',
|
||||
database: 'error',
|
||||
api: 'error'
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
error: err.message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -24,99 +116,99 @@ const SystemStatusIndicator = ({ apiBaseUrl = '' }) => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
// Check every 30 seconds
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStatus]);
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!status || loading) return '#888';
|
||||
switch (status.status) {
|
||||
case 'ok': return '#4caf50';
|
||||
case 'warning':
|
||||
case 'degraded': return '#ff9800';
|
||||
case 'error': return '#f44336';
|
||||
default: return '#888';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!status || loading) return 'Checking...';
|
||||
switch (status.status) {
|
||||
case 'ok': return 'System OK';
|
||||
case 'warning':
|
||||
case 'degraded': return 'Degraderet';
|
||||
case 'error': return 'Problem';
|
||||
default: return 'Ukendt';
|
||||
}
|
||||
};
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
if (!seconds) return 'N/A';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
return `${hours}h ${mins}m`;
|
||||
};
|
||||
const aggregate = getWorstService(status?.services);
|
||||
const buttonColor = STATUS_COLORS[aggregate.status] || STATUS_COLORS.unknown;
|
||||
const buttonTitle = aggregate.key
|
||||
? `${SERVICE_LABELS[aggregate.key] || aggregate.key} ${aggregate.status}`
|
||||
: 'System status';
|
||||
|
||||
return (
|
||||
<div className="system-status-container">
|
||||
<button
|
||||
className="status-indicator"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
title={getStatusText()}
|
||||
<button
|
||||
className="system-status-button"
|
||||
style={{ borderColor: buttonColor }}
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
title={buttonTitle}
|
||||
>
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{ backgroundColor: getStatusColor() }}
|
||||
/>
|
||||
{expanded && <span className="status-label">{getStatusText()}</span>}
|
||||
<span className="status-dot" style={{ backgroundColor: buttonColor }} />
|
||||
<div className="status-summary">
|
||||
<span className="status-button-title">
|
||||
{loading ? 'Checker systemet...' : buttonTitle}
|
||||
</span>
|
||||
<span className="status-button-subtitle">
|
||||
{loading ? 'Opdaterer status' : buildSummaryLine(status?.services)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="blue-status-indicator"
|
||||
onClick={() => window.location.href = '/admin/dashboard'}
|
||||
title="Advanced Dashboard"
|
||||
>
|
||||
<span className="blue-status-dot" />
|
||||
</button>
|
||||
|
||||
{expanded && status && (
|
||||
{expanded && (
|
||||
<div className="status-dropdown">
|
||||
<div className="status-header">
|
||||
<span className="status-title">System Status</span>
|
||||
<div>
|
||||
<strong>System Status</strong>
|
||||
<div className="status-timestamp">
|
||||
{status?.timestamp ? `Sidst tjekket kl. ${formatTimestamp(status.timestamp)}` : 'Tidspunkt ukendt'}
|
||||
</div>
|
||||
</div>
|
||||
<button className="close-btn" onClick={() => setExpanded(false)}>×</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="status-items">
|
||||
<div className="status-item">
|
||||
<span className="item-icon">{status.database ? '✅' : '❌'}</span>
|
||||
<span className="item-label">Database</span>
|
||||
<span className="item-value">{status.database ? 'OK' : 'Fejl'}</span>
|
||||
</div>
|
||||
|
||||
<div className="status-item">
|
||||
<span className="item-icon">{status.errorRate < 5 ? '✅' : '⚠️'}</span>
|
||||
<span className="item-label">Error Rate</span>
|
||||
<span className="item-value">{status.errorRate || 0}%</span>
|
||||
</div>
|
||||
|
||||
<div className="status-item">
|
||||
<span className="item-icon">⏱️</span>
|
||||
<span className="item-label">Uptime</span>
|
||||
<span className="item-value">{formatUptime(status.uptime)}</span>
|
||||
</div>
|
||||
{(status?.services ? Object.entries(status.services) : []).map(([key, value]) => (
|
||||
<div className="status-row" key={key}>
|
||||
<span className="status-chip" style={{ backgroundColor: STATUS_COLORS[value] || STATUS_COLORS.unknown }}>
|
||||
{STATUS_ICONS[value] || 'ℹ️'}
|
||||
</span>
|
||||
<div className="status-content">
|
||||
<span className="status-label">{SERVICE_LABELS[key] || key}</span>
|
||||
<span className="status-value">{value.toUpperCase()}</span>
|
||||
</div>
|
||||
<span className="status-detail">{loading ? '...' : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status?.lastSyncAt && (
|
||||
<div className="status-row info-row">
|
||||
<span className="status-chip info-chip">⏱️</span>
|
||||
<div className="status-content">
|
||||
<span className="status-label">Sidste sync</span>
|
||||
<span className="status-value">{formatTimestamp(status.lastSyncAt)}</span>
|
||||
</div>
|
||||
<span className="status-detail">{formatRelativeDate(status.lastSyncAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status?.priceListLastSyncedAt || status?.priceListFreshness) && (
|
||||
<div className="status-row info-row">
|
||||
<span className="status-chip info-chip">🧾</span>
|
||||
<div className="status-content">
|
||||
<span className="status-label">Sidste prislistesync</span>
|
||||
<span className="status-value">
|
||||
{formatTimestamp(status.priceListLastSyncedAt)} · {formatFreshness(status.priceListFreshness)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="status-detail">{formatRelativeDate(status.priceListLastSyncedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.error && (
|
||||
<div className="status-row info-row">
|
||||
<span className="status-chip info-chip">⚠️</span>
|
||||
<div className="status-content">
|
||||
<span className="status-label">Fejl</span>
|
||||
<span className="status-value">{status.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="status-footer">
|
||||
<span className="last-check">
|
||||
Sidst tjekket: {new Date(status.timestamp).toLocaleTimeString('da-DK')}
|
||||
</span>
|
||||
<button className="refresh-btn" onClick={fetchStatus}>
|
||||
🔄
|
||||
<button className="refresh-btn" onClick={fetchStatus} disabled={loading}>
|
||||
🔄 Opdater
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
177
scripts/normalize-hours.js
Normal file
177
scripts/normalize-hours.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const path = require('path');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
require('dotenv').config({
|
||||
path: path.join(__dirname, '..', 'backend', '.env')
|
||||
});
|
||||
|
||||
function toMs(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return null;
|
||||
}
|
||||
return n > 100000000000 ? n : n * 1000;
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
const ms = toMs(value);
|
||||
if (!ms) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function toTaskKey(remark = '') {
|
||||
const tokens = String(remark || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length >= 3)
|
||||
.slice(0, 6);
|
||||
return tokens.length > 0 ? tokens.join('_') : 'ukendt_opgave';
|
||||
}
|
||||
|
||||
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_hours_normalized (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
case_number VARCHAR(64) NOT NULL,
|
||||
hour_id INT NOT NULL,
|
||||
is_case_linked TINYINT(1) NOT NULL DEFAULT 1,
|
||||
start_at DATETIME NULL,
|
||||
stop_at DATETIME NULL,
|
||||
duration_hours DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
remark TEXT NULL,
|
||||
hour_type INT NULL,
|
||||
task_key VARCHAR(255) NULL,
|
||||
synced_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_hours_norm_case_hour (case_number, hour_id),
|
||||
INDEX idx_hours_norm_case (case_number),
|
||||
INDEX idx_hours_norm_case_linked (is_case_linked),
|
||||
INDEX idx_hours_norm_start (start_at)
|
||||
)
|
||||
`
|
||||
);
|
||||
|
||||
await conn.execute(`
|
||||
ALTER TABLE ordrestyring_hours_normalized
|
||||
ADD COLUMN IF NOT EXISTS is_case_linked TINYINT(1) NOT NULL DEFAULT 1
|
||||
`);
|
||||
await conn.execute(`
|
||||
CREATE INDEX IF NOT EXISTS idx_hours_norm_case_linked
|
||||
ON ordrestyring_hours_normalized (is_case_linked)
|
||||
`);
|
||||
|
||||
const [rows] = await conn.execute(
|
||||
`
|
||||
SELECT
|
||||
h.id,
|
||||
h.new_case_number,
|
||||
h.start_time,
|
||||
h.stop_time,
|
||||
h.remark,
|
||||
h.hour_type
|
||||
FROM ordrestyring_local.hours h
|
||||
`
|
||||
);
|
||||
|
||||
await conn.execute('TRUNCATE TABLE ordrestyring_hours_normalized');
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO ordrestyring_hours_normalized (
|
||||
case_number,
|
||||
hour_id,
|
||||
is_case_linked,
|
||||
start_at,
|
||||
stop_at,
|
||||
duration_hours,
|
||||
remark,
|
||||
hour_type,
|
||||
task_key
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const chunkSize = 1000;
|
||||
for (let i = 0; i < rows.length; i += chunkSize) {
|
||||
const chunk = rows.slice(i, i + chunkSize);
|
||||
for (const row of chunk) {
|
||||
const start = toDate(row.start_time);
|
||||
const stop = toDate(row.stop_time);
|
||||
let durationHours = 0;
|
||||
if (start && stop) {
|
||||
const diff = stop.getTime() - start.getTime();
|
||||
if (diff > 0) {
|
||||
durationHours = Number((diff / 3600000).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
const rawCaseNumber = String(row.new_case_number || '').trim();
|
||||
const isCaseLinked = rawCaseNumber.length > 0;
|
||||
const hourTypeValue = row.hour_type !== null && row.hour_type !== undefined ? Number(row.hour_type) : null;
|
||||
const normalizedCaseNumber = isCaseLinked
|
||||
? rawCaseNumber
|
||||
: `__NON_CASE__:${hourTypeValue !== null ? hourTypeValue : 'unknown'}`;
|
||||
|
||||
await conn.execute(insertSql, [
|
||||
normalizedCaseNumber,
|
||||
Number(row.id),
|
||||
isCaseLinked ? 1 : 0,
|
||||
start,
|
||||
stop,
|
||||
durationHours,
|
||||
row.remark || null,
|
||||
hourTypeValue,
|
||||
toTaskKey(row.remark)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const [[stats]] = await conn.execute(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) AS rows_inserted,
|
||||
COUNT(DISTINCT case_number) AS distinct_cases,
|
||||
SUM(CASE WHEN is_case_linked = 1 THEN 1 ELSE 0 END) AS rows_case_linked,
|
||||
SUM(CASE WHEN is_case_linked = 0 THEN 1 ELSE 0 END) AS rows_non_case,
|
||||
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
|
||||
`
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
table: 'ordrestyring_hours_normalized',
|
||||
rowsInserted: Number(stats.rows_inserted || 0),
|
||||
distinctCases: Number(stats.distinct_cases || 0),
|
||||
rowsCaseLinked: Number(stats.rows_case_linked || 0),
|
||||
rowsNonCase: Number(stats.rows_non_case || 0),
|
||||
rowsWithDuration: Number(stats.rows_with_duration || 0),
|
||||
rowsWithRemark: Number(stats.rows_with_remark || 0)
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
282
scripts/run-data-quality-audit.js
Normal file
282
scripts/run-data-quality-audit.js
Normal file
@@ -0,0 +1,282 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user