diff --git a/backend/src/services/__tests__/ordrestyringPreflight.test.js b/backend/src/services/__tests__/ordrestyringPreflight.test.js index 8a6b25d..ff7b9da 100644 --- a/backend/src/services/__tests__/ordrestyringPreflight.test.js +++ b/backend/src/services/__tests__/ordrestyringPreflight.test.js @@ -1,12 +1,13 @@ const fs = require('fs'); const path = require('path'); const { assertOrdrestyringReadiness } = require('../ordrestyringPreflight'); -const tables = ['ordrestyring_cases_current', 'ordrestyring_debtors_current', 'ordrestyring_material_semantics', 'ordrestyring_projection_state']; +const tables = ['ordrestyring_cases_current', 'ordrestyring_debtors_current', 'ordrestyring_material_semantics', 'ordrestyring_projection_state', 'ordrestyring_hour_mirror_ids']; function fixture() { const states = ['cases', 'debtors', 'materials'].map(projection_kind => ({ projection_kind, source_account: 'account', row_count: 2, sync_run_id: 'run', mirrored_at: '2026-10-04' })); const counts = { actual_count: 2, account_count: 2 }; const db = { execute: jest.fn(async sql => { if (sql.includes('INFORMATION_SCHEMA')) return [tables.map(table_name => ({ table_name }))]; + if (sql.includes('ordrestyring_hour_mirror_ids')) return [[{ tracked_count: 2 }]]; if (sql.includes('FROM ordrestyring_local.ordrestyring_projection_state')) return [states]; return [[counts]]; }) }; diff --git a/backend/src/services/__tests__/ordrestyringReleaseReadiness.test.js b/backend/src/services/__tests__/ordrestyringReleaseReadiness.test.js index f65e6ef..eefb355 100644 --- a/backend/src/services/__tests__/ordrestyringReleaseReadiness.test.js +++ b/backend/src/services/__tests__/ordrestyringReleaseReadiness.test.js @@ -124,7 +124,7 @@ test.each([ test('benchmark SQL requires both export state and tracked upstream identity', () => { - expect(hoursRelation).toMatch(/exported\s*=\s*0/); + expect(hoursRelation).toMatch(/exported\s+IS\s+NOT\s+NULL[\s\S]*exported\s*<>\s*-1/); expect(hoursRelation).toMatch(/EXISTS\s*\(SELECT.*ordrestyring_hour_mirror_ids.*hour_id\s*=\s*\w+\.id/s); expect(planningHoursRelation).not.toMatch(/exported|ordrestyring_hour_mirror_ids/); }); @@ -148,8 +148,8 @@ test('benchmark hours exclude local and untracked rows while planning retains lo INSERT INTO ordrestyring_local.ordrestyring_hour_mirror_ids VALUES (1), (2), (5), (6), (7);`); const model = new OrdrestyringReadModel({ query: async (sql, params) => db.prepare(sql).all(...params) }); const hours = await model.hours(['A']); - expect(hours.get('A').map(row => row.id)).toEqual([1, 6]); - expect(db.prepare(`SELECT SUM((stop_time - start_time) / 3600) AS total FROM ${hoursRelation}`).get().total).toBe(2); + expect(hours.get('A').map(row => row.id)).toEqual([1, 2, 6]); + expect(db.prepare(`SELECT SUM((stop_time - start_time) / 3600) AS total FROM ${hoursRelation}`).get().total).toBe(4); expect(db.prepare(`SELECT id FROM ${planningHoursRelation} ORDER BY id`).all().map(row => row.id)).toEqual([1, 2, 3, 4, 6, 7]); } finally { db.close(); diff --git a/backend/src/services/ordrestyringPreflight.js b/backend/src/services/ordrestyringPreflight.js index ecbe43a..4358300 100644 --- a/backend/src/services/ordrestyringPreflight.js +++ b/backend/src/services/ordrestyringPreflight.js @@ -16,10 +16,15 @@ async function assertOrdrestyringReadiness(connection, env = process.env) { throw new Error('Ordrestyring preflight requires ORDRESTYRING_SOURCE_ACCOUNT'); } const fail = reason => { throw new Error(`Ordrestyring canonical preflight failed: ${reason}. Run the explicit canonical migration before starting.`); }; - const tables = [...Object.values(projections), 'ordrestyring_projection_state']; + const tables = [...Object.values(projections), 'ordrestyring_projection_state', 'ordrestyring_hour_mirror_ids']; const rows = await model.rows(`SELECT TABLE_NAME AS table_name FROM INFORMATION_SCHEMA.TABLES - WHERE TABLE_SCHEMA = 'ordrestyring_local' AND TABLE_NAME IN (?, ?, ?, ?)`, tables); + WHERE TABLE_SCHEMA = 'ordrestyring_local' AND TABLE_NAME IN (?, ?, ?, ?, ?)`, tables); if (!tables.every(table => rows.some(row => row.table_name === table))) fail('missing canonical tables'); + const [trackedHours] = await model.rows(`SELECT COUNT(*) AS tracked_count + FROM ordrestyring_local.ordrestyring_hour_mirror_ids`); + if (!Number.isSafeInteger(Number(trackedHours?.tracked_count)) || Number(trackedHours.tracked_count) <= 0) { + fail('hours provenance is not ready'); + } const states = await model.rows(`SELECT projection_kind, source_account, row_count, sync_run_id, mirrored_at FROM ordrestyring_local.ordrestyring_projection_state`); for (const [kind, table] of Object.entries(projections)) { diff --git a/backend/src/services/ordrestyringReadModel.js b/backend/src/services/ordrestyringReadModel.js index 32ea600..1274214 100644 --- a/backend/src/services/ordrestyringReadModel.js +++ b/backend/src/services/ordrestyringReadModel.js @@ -18,7 +18,7 @@ const fields = (names) => names.map(name => `NULLIF(JSON_UNQUOTE(JSON_EXTRACT(so const casesRelation = `(SELECT case_number, case_number AS id, ${fields(['customer_number', 'description', 'remarks', 'creation_date', 'created_at', 'updated_at', 'status', 'case_type', 'main_technician', 'additional_technicians', 'contact', 'delivery_address', 'yourref', 'work_done', 'offer_number'])} FROM ordrestyring_local.ordrestyring_cases_current)`; const debtorsRelation = `(SELECT customer_number, ${fields(['customer_name', 'customer_address', 'customer_telephone', 'customer_email'])} FROM ordrestyring_local.ordrestyring_debtors_current)`; const seconds = name => `(CASE WHEN ${name} > 100000000000 THEN ${name} / 1000 ELSE ${name} END)`; -const hoursRelation = `(SELECT id, emp_id, new_case_number, remark, hour_type, ${seconds('start_time')} AS start_time, ${seconds('stop_time')} AS stop_time FROM ordrestyring_local.hours h WHERE exported = 0 AND EXISTS (SELECT 1 FROM ordrestyring_local.ordrestyring_hour_mirror_ids mirror WHERE mirror.hour_id = h.id) AND start_time > 0 AND ${seconds('stop_time')} > ${seconds('start_time')})`; +const hoursRelation = `(SELECT id, emp_id, new_case_number, remark, hour_type, ${seconds('start_time')} AS start_time, ${seconds('stop_time')} AS stop_time FROM ordrestyring_local.hours h WHERE exported IS NOT NULL AND exported <> -1 AND EXISTS (SELECT 1 FROM ordrestyring_local.ordrestyring_hour_mirror_ids mirror WHERE mirror.hour_id = h.id) AND start_time > 0 AND ${seconds('stop_time')} > ${seconds('start_time')})`; // Planning also represents local allocations whose end is not known yet. const planningHoursRelation = `(SELECT id, emp_id, new_case_number, remark, hour_type, ${seconds('start_time')} AS start_time, ${seconds('stop_time')} AS stop_time FROM ordrestyring_local.hours WHERE start_time > 0 AND (stop_time = 0 OR ${seconds('stop_time')} > ${seconds('start_time')}))`; const parse = value => typeof value === 'string' ? JSON.parse(value) : value;