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();
|
||||
|
||||
Reference in New Issue
Block a user