[verified] fix(ordrestyring): mirror hours through GraphQL
This commit is contained in:
@@ -72,6 +72,29 @@ const GET_HOURS_BY_DATE_RANGE_QUERY = `
|
||||
}
|
||||
`;
|
||||
|
||||
// Minimal PII-safe shape used by the durable local hours mirror.
|
||||
const GET_HOURS_SYNC_QUERY = `
|
||||
query GetHoursForSync($pagination: Pagination) {
|
||||
hours(pagination: $pagination) {
|
||||
items {
|
||||
id
|
||||
startTime
|
||||
stopTime
|
||||
description
|
||||
costPrice
|
||||
createdAt
|
||||
updatedAt
|
||||
user { id }
|
||||
case { caseNumber }
|
||||
type { id }
|
||||
}
|
||||
count
|
||||
hasMorePages
|
||||
nextCursor
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GET_HOUR_TYPES_QUERY = `
|
||||
query GetHourTypes($pagination: Pagination) {
|
||||
hourTypes(pagination: $pagination) {
|
||||
@@ -105,6 +128,7 @@ const GET_BUSINESS_HOURS_QUERY = `
|
||||
module.exports = {
|
||||
GET_HOURS_QUERY,
|
||||
GET_HOURS_BY_DATE_RANGE_QUERY,
|
||||
GET_HOURS_SYNC_QUERY,
|
||||
GET_HOUR_TYPES_QUERY,
|
||||
GET_BUSINESS_HOURS_QUERY
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ process.env.DB_PASSWORD = 'test-password';
|
||||
|
||||
jest.mock('mysql2/promise', () => ({ createConnection: jest.fn() }));
|
||||
jest.mock('axios', () => ({ get: jest.fn() }));
|
||||
jest.mock('../graphqlClient', () => ({ request: jest.fn() }));
|
||||
jest.mock('../ordrestyringService', () => ({ calculateDashboardMetrics: jest.fn() }));
|
||||
jest.mock('../../utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
@@ -12,6 +13,7 @@ jest.mock('../../utils/logger', () => ({
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const axios = require('axios');
|
||||
const graphqlClient = require('../graphqlClient');
|
||||
const ordrestyringService = require('../ordrestyringService');
|
||||
const OrdrestyringSyncService = require('../ordrestyringSyncService');
|
||||
|
||||
@@ -126,7 +128,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each(['syncCases', 'syncUsers', 'syncHours', 'syncDebtors'])(
|
||||
test.each(['syncCases', 'syncUsers', 'syncDebtors'])(
|
||||
'%s contains connection-close failures after successful core work',
|
||||
async method => {
|
||||
axios.get.mockResolvedValueOnce({ data: [] });
|
||||
@@ -142,6 +144,277 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
}
|
||||
);
|
||||
|
||||
test('syncHours mirrors every GraphQL page into the local hours schema', async () => {
|
||||
graphqlClient.request
|
||||
.mockResolvedValueOnce({ hours: { items: [{
|
||||
id: 101,
|
||||
startTime: 1758268800,
|
||||
stopTime: 1758272400,
|
||||
description: 'Arbejde',
|
||||
costPrice: 5000.5,
|
||||
createdAt: 1758268700,
|
||||
updatedAt: 1758272500,
|
||||
user: { id: 7 },
|
||||
case: { caseNumber: '5001' },
|
||||
type: { id: 2 }
|
||||
}], count: 1, hasMorePages: true, nextCursor: 'page-2' } })
|
||||
.mockResolvedValueOnce({ hours: { items: [{
|
||||
id: 102,
|
||||
startTime: 1758276000,
|
||||
stopTime: 1758279600,
|
||||
description: 'Kontrol',
|
||||
costPrice: 6000,
|
||||
createdAt: 1758275900,
|
||||
updatedAt: 1758279700,
|
||||
user: { id: 8 },
|
||||
case: { caseNumber: '5002' },
|
||||
type: { id: 3 }
|
||||
}], count: 1, hasMorePages: false, nextCursor: null } });
|
||||
|
||||
const connection = {
|
||||
beginTransaction: jest.fn().mockResolvedValue(),
|
||||
execute: jest.fn(async sql => {
|
||||
if (sql.includes('SELECT id, emp_id')) return [[]];
|
||||
return [{ affectedRows: 1 }];
|
||||
}),
|
||||
commit: jest.fn().mockResolvedValue(),
|
||||
rollback: jest.fn().mockResolvedValue(),
|
||||
end: jest.fn().mockRejectedValue(new Error('private close failure'))
|
||||
};
|
||||
mysql.createConnection.mockResolvedValueOnce(connection);
|
||||
const service = new OrdrestyringSyncService();
|
||||
|
||||
await expect(service.syncHours()).resolves.toEqual({ hasChanges: true, changes: 2 });
|
||||
expect(graphqlClient.request).toHaveBeenNthCalledWith(1, expect.any(String), {
|
||||
pagination: { cursor: null, limit: 200 }
|
||||
});
|
||||
expect(graphqlClient.request).toHaveBeenNthCalledWith(2, expect.any(String), {
|
||||
pagination: { cursor: 'page-2', limit: 200 }
|
||||
});
|
||||
const syncQuery = graphqlClient.request.mock.calls[0][0];
|
||||
expect(syncQuery).not.toMatch(/email|fullName|customer\s*\{/);
|
||||
const insertCalls = connection.execute.mock.calls.filter(([sql]) => sql.includes('INSERT INTO hours'));
|
||||
expect(insertCalls).toHaveLength(2);
|
||||
expect(insertCalls[0][1]).toEqual([
|
||||
101, 7, 1758268800, 1758272400, 'Arbejde', 2, 1758272500, 1758268700, 5001, '5001'
|
||||
]);
|
||||
expect(connection.commit).toHaveBeenCalledTimes(1);
|
||||
expect(connection.rollback).not.toHaveBeenCalled();
|
||||
expect(connection.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('syncHours reconciles deleted rows without rewriting unchanged rows', async () => {
|
||||
const remoteRows = [101, 102, 103, 104].map((id, index) => ({
|
||||
id,
|
||||
startTime: 1758268800 + (index * 7200),
|
||||
stopTime: 1758272400 + (index * 7200),
|
||||
description: `Arbejde ${index}`,
|
||||
costPrice: 5000 + index,
|
||||
createdAt: 1758268700 + (index * 7200),
|
||||
updatedAt: 1758272500 + (index * 7200),
|
||||
user: { id: 7 + index },
|
||||
case: { caseNumber: `500${index + 1}` },
|
||||
type: { id: 2 }
|
||||
}));
|
||||
graphqlClient.request.mockResolvedValueOnce({
|
||||
hours: { items: remoteRows, count: 4, hasMorePages: false, nextCursor: null }
|
||||
});
|
||||
const service = new OrdrestyringSyncService();
|
||||
const localRows = remoteRows.map(row => service.normalizeGraphqlHour(row));
|
||||
localRows.push({
|
||||
id: 999, emp_id: 7, start_time: 1, stop_time: 2, remark: null,
|
||||
hour_type: 2, updated_at: 1, created_at: 1, costprice: 1, new_case_number: 'old'
|
||||
});
|
||||
const connection = {
|
||||
beginTransaction: jest.fn().mockResolvedValue(),
|
||||
execute: jest.fn(async (sql, params) => {
|
||||
if (sql.includes('SELECT id, emp_id')) return [localRows];
|
||||
if (sql.includes('DELETE FROM hours')) return [{ affectedRows: 1 }];
|
||||
throw new Error(`Unexpected write: ${sql} ${params}`);
|
||||
}),
|
||||
commit: jest.fn().mockResolvedValue(),
|
||||
rollback: jest.fn().mockResolvedValue(),
|
||||
end: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mysql.createConnection.mockResolvedValueOnce(connection);
|
||||
|
||||
await expect(service.syncHours()).resolves.toEqual({ hasChanges: true, changes: 1 });
|
||||
expect(connection.execute.mock.calls.some(([sql]) => sql.includes('INSERT INTO hours'))).toBe(false);
|
||||
expect(connection.execute).toHaveBeenCalledWith('DELETE FROM hours WHERE id = ?', [999]);
|
||||
expect(connection.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('syncHours preserves locally created planning allocations', async () => {
|
||||
const remoteRows = [101, 102, 103, 104].map((id, index) => ({
|
||||
id, startTime: 1758268800 + index, stopTime: 1758272400 + index,
|
||||
description: `Arbejde ${index}`, costPrice: 5000, createdAt: 1758268700,
|
||||
updatedAt: 1758272500, user: { id: 7 }, case: { caseNumber: '5001' }, type: { id: 2 }
|
||||
}));
|
||||
graphqlClient.request.mockResolvedValueOnce({
|
||||
hours: { items: remoteRows, count: 4, hasMorePages: false, nextCursor: null }
|
||||
});
|
||||
const service = new OrdrestyringSyncService();
|
||||
const localRows = remoteRows.map(row => ({ ...service.normalizeGraphqlHour(row), exported: null }));
|
||||
localRows.push({
|
||||
id: 999, emp_id: 7, start_time: 1, stop_time: 2, remark: 'Planlagt',
|
||||
hour_type: 1, updated_at: 1, created_at: 1, costprice: null,
|
||||
new_case_number: '5001', exported: -1
|
||||
});
|
||||
const connection = {
|
||||
beginTransaction: jest.fn().mockResolvedValue(),
|
||||
execute: jest.fn(async sql => {
|
||||
if (sql.includes('SELECT id, emp_id')) return [localRows];
|
||||
throw new Error(`Unexpected write: ${sql}`);
|
||||
}),
|
||||
commit: jest.fn().mockResolvedValue(), rollback: jest.fn().mockResolvedValue(),
|
||||
end: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mysql.createConnection.mockResolvedValueOnce(connection);
|
||||
|
||||
await expect(service.syncHours()).resolves.toEqual({ hasChanges: false, changes: 0 });
|
||||
expect(connection.execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM hours'))).toBe(false);
|
||||
expect(connection.commit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('syncHours fails closed if an upstream id collides with a local planning allocation', async () => {
|
||||
const remote = {
|
||||
id: 101, startTime: 1758268800, stopTime: 1758272400, description: 'Arbejde',
|
||||
costPrice: 5000, createdAt: 1758268700, updatedAt: 1758272500,
|
||||
user: { id: 7 }, case: { caseNumber: '5001' }, type: { id: 2 }
|
||||
};
|
||||
graphqlClient.request.mockResolvedValueOnce({
|
||||
hours: { items: [remote], count: 1, hasMorePages: false, nextCursor: null }
|
||||
});
|
||||
const service = new OrdrestyringSyncService();
|
||||
const connection = {
|
||||
beginTransaction: jest.fn().mockResolvedValue(),
|
||||
execute: jest.fn().mockResolvedValueOnce([[{
|
||||
...service.normalizeGraphqlHour(remote), exported: -1
|
||||
}]]),
|
||||
commit: jest.fn().mockResolvedValue(), rollback: jest.fn().mockResolvedValue(),
|
||||
end: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mysql.createConnection.mockResolvedValueOnce(connection);
|
||||
|
||||
await expect(service.syncHours()).rejects.toThrow('collides with a local planning allocation');
|
||||
expect(connection.rollback).toHaveBeenCalledTimes(1);
|
||||
expect(connection.commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('syncHours refuses an unexpectedly empty source before deleting a populated mirror', async () => {
|
||||
graphqlClient.request.mockResolvedValueOnce({
|
||||
hours: { items: [], count: 0, hasMorePages: false, nextCursor: null }
|
||||
});
|
||||
const connection = {
|
||||
beginTransaction: jest.fn().mockResolvedValue(),
|
||||
execute: jest.fn().mockResolvedValueOnce([[{ id: 101 }]]),
|
||||
commit: jest.fn().mockResolvedValue(),
|
||||
rollback: jest.fn().mockResolvedValue(),
|
||||
end: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mysql.createConnection.mockResolvedValueOnce(connection);
|
||||
const service = new OrdrestyringSyncService();
|
||||
|
||||
await expect(service.syncHours()).rejects.toThrow('unexpectedly empty');
|
||||
expect(connection.commit).not.toHaveBeenCalled();
|
||||
expect(connection.rollback).toHaveBeenCalledTimes(1);
|
||||
expect(connection.execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM hours'))).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['id', { id: '101' }],
|
||||
['startTime', { startTime: true }],
|
||||
['stopTime', { stopTime: 'bad' }],
|
||||
['empty stopTime', { stopTime: '' }],
|
||||
['user.id', { user: { id: 0 } }],
|
||||
['costPrice', { costPrice: -1 }],
|
||||
['description', { description: 17 }],
|
||||
['case.caseNumber', { case: { caseNumber: 'x'.repeat(256) } }]
|
||||
])('syncHours rejects malformed non-null %s before database writes', (_field, override) => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
const valid = {
|
||||
id: 101,
|
||||
startTime: 1758268800,
|
||||
stopTime: 1758272400,
|
||||
description: 'Arbejde',
|
||||
costPrice: 5000,
|
||||
createdAt: 1758268700,
|
||||
updatedAt: 1758272500,
|
||||
user: { id: 7 },
|
||||
case: { caseNumber: '5001' },
|
||||
type: { id: 2 }
|
||||
};
|
||||
expect(() => service.normalizeGraphqlHour({ ...valid, ...override })).toThrow('Invalid GraphQL hour');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['stopTime', hour => { delete hour.stopTime; }],
|
||||
['description', hour => { delete hour.description; }],
|
||||
['costPrice', hour => { delete hour.costPrice; }],
|
||||
['user.id', hour => { hour.user = {}; }],
|
||||
['case relation', hour => { delete hour.case; }]
|
||||
])('syncHours rejects a missing %s field before database writes', (_field, removeField) => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
const hour = {
|
||||
id: 101, startTime: 1758268800, stopTime: null, description: null,
|
||||
costPrice: null, createdAt: null, updatedAt: null,
|
||||
user: null, case: null, type: null
|
||||
};
|
||||
removeField(hour);
|
||||
expect(() => service.normalizeGraphqlHour(hour)).toThrow('Missing GraphQL hour');
|
||||
});
|
||||
|
||||
test('syncHours accepts explicit nulls for nullable GraphQL fields', () => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
expect(service.normalizeGraphqlHour({
|
||||
id: 101, startTime: 1758268800, stopTime: null, description: null,
|
||||
costPrice: null, createdAt: null, updatedAt: null,
|
||||
user: null, case: null, type: null
|
||||
})).toEqual({
|
||||
id: 101, emp_id: null, start_time: 1758268800, stop_time: null,
|
||||
remark: null, hour_type: null, updated_at: null, created_at: null,
|
||||
costprice: null, new_case_number: null
|
||||
});
|
||||
});
|
||||
|
||||
test('syncHours fails closed on an invalid source-ratio configuration', async () => {
|
||||
const remote = {
|
||||
id: 101, startTime: 1758268800, stopTime: 1758272400,
|
||||
description: 'Arbejde', costPrice: 5000, createdAt: 1758268700,
|
||||
updatedAt: 1758272500, user: { id: 7 }, case: { caseNumber: '5001' }, type: { id: 2 }
|
||||
};
|
||||
graphqlClient.request.mockResolvedValueOnce({
|
||||
hours: { items: [remote], count: 1, hasMorePages: false, nextCursor: null }
|
||||
});
|
||||
const service = new OrdrestyringSyncService();
|
||||
const connection = {
|
||||
beginTransaction: jest.fn().mockResolvedValue(),
|
||||
execute: jest.fn().mockResolvedValueOnce([[service.normalizeGraphqlHour(remote)]]),
|
||||
commit: jest.fn().mockResolvedValue(),
|
||||
rollback: jest.fn().mockResolvedValue(),
|
||||
end: jest.fn().mockResolvedValue()
|
||||
};
|
||||
mysql.createConnection.mockResolvedValueOnce(connection);
|
||||
const previousRatio = process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO;
|
||||
process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO = 'not-a-number';
|
||||
try {
|
||||
await expect(service.syncHours()).rejects.toThrow('Invalid hours mirror safety threshold');
|
||||
expect(connection.rollback).toHaveBeenCalledTimes(1);
|
||||
expect(connection.commit).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousRatio === undefined) delete process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO;
|
||||
else process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO = previousRatio;
|
||||
}
|
||||
});
|
||||
|
||||
test('syncHours fails before local writes when GraphQL pagination fails', async () => {
|
||||
graphqlClient.request.mockRejectedValueOnce(new Error('private upstream failure'));
|
||||
const service = new OrdrestyringSyncService();
|
||||
|
||||
await expect(service.syncHours()).rejects.toThrow('private upstream failure');
|
||||
expect(mysql.createConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('offer snapshot sync contains connection-close failures after successful core work', async () => {
|
||||
const connection = {
|
||||
end: jest.fn().mockRejectedValue(new Error('private close failure'))
|
||||
@@ -223,11 +496,54 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
expect(appConnection.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('waits for the hours mirror before rebuilding dependent case features', async () => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
let releaseHours;
|
||||
service.syncCases = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 });
|
||||
service.syncUsers = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 });
|
||||
service.syncHours = jest.fn().mockImplementation(() => new Promise(resolve => { releaseHours = resolve; }));
|
||||
service.syncDebtors = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 });
|
||||
service.syncOfferSnapshots = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 });
|
||||
service.syncCaseFeatures = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 });
|
||||
service.syncDashboardMetrics = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 });
|
||||
service.logSyncActivity = jest.fn().mockResolvedValue();
|
||||
|
||||
const run = service.performSync();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(service.syncCaseFeatures).not.toHaveBeenCalled();
|
||||
releaseHours({ hasChanges: true, changes: 1 });
|
||||
await run;
|
||||
|
||||
expect(service.syncCaseFeatures).toHaveBeenCalledTimes(1);
|
||||
expect(service.logSyncActivity).toHaveBeenCalledWith(
|
||||
'success', 'Data updated', expect.any(Number), 1
|
||||
);
|
||||
});
|
||||
|
||||
test('does not rebuild case features when the hours mirror fails', async () => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
for (const name of ['syncCases', 'syncUsers', 'syncDebtors', 'syncOfferSnapshots']) {
|
||||
service[name] = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 });
|
||||
}
|
||||
service.syncHours = jest.fn().mockRejectedValue(new Error('private upstream failure'));
|
||||
service.syncCaseFeatures = jest.fn().mockResolvedValue({ hasChanges: true, changes: 99 });
|
||||
service.syncDashboardMetrics = jest.fn().mockResolvedValue({ hasChanges: false, changes: 0 });
|
||||
service.logSyncActivity = jest.fn().mockResolvedValue();
|
||||
|
||||
await service.performSync();
|
||||
|
||||
expect(service.syncCaseFeatures).not.toHaveBeenCalled();
|
||||
expect(service.logSyncActivity).toHaveBeenCalledWith(
|
||||
'error', 'Synchronization incomplete', expect.any(Number), 0
|
||||
);
|
||||
});
|
||||
|
||||
test('records the total changed rows from all successful sync stages', async () => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
const stages = [
|
||||
['syncCases', 1],
|
||||
['syncUsers', 2],
|
||||
['syncHours', 100],
|
||||
['syncDebtors', 5],
|
||||
['syncOfferSnapshots', 6],
|
||||
['syncCaseFeatures', 8],
|
||||
@@ -236,7 +552,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
stages.forEach(([name, changes]) => {
|
||||
service[name] = jest.fn().mockResolvedValue({ hasChanges: changes > 0, changes });
|
||||
});
|
||||
service.syncHours = jest.fn().mockResolvedValue({ hasChanges: true, changes: 100 });
|
||||
|
||||
service.syncMaterials = jest.fn().mockResolvedValue({ hasChanges: true, changes: 100 });
|
||||
service.syncCaseMaterialSnapshots = jest.fn().mockResolvedValue({ hasChanges: true, changes: 100 });
|
||||
service.logSyncActivity = jest.fn().mockResolvedValue();
|
||||
@@ -247,9 +563,8 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
'success',
|
||||
'Data updated',
|
||||
expect.any(Number),
|
||||
22
|
||||
122
|
||||
);
|
||||
expect(service.syncHours).not.toHaveBeenCalled();
|
||||
expect(service.syncMaterials).not.toHaveBeenCalled();
|
||||
expect(service.syncCaseMaterialSnapshots).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -257,7 +572,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
test('records a failed stage as an incomplete run instead of a false success', async () => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
const stages = [
|
||||
'syncCases', 'syncUsers', 'syncDebtors',
|
||||
'syncCases', 'syncUsers', 'syncHours', 'syncDebtors',
|
||||
'syncOfferSnapshots', 'syncCaseFeatures', 'syncDashboardMetrics'
|
||||
];
|
||||
stages.forEach(name => {
|
||||
@@ -272,7 +587,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
'error',
|
||||
'Synchronization incomplete',
|
||||
expect.any(Number),
|
||||
4
|
||||
5
|
||||
);
|
||||
});
|
||||
|
||||
@@ -282,7 +597,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
])('records a %s stage as incomplete instead of no_changes', async (_label, stageResult) => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
const stages = [
|
||||
'syncCases', 'syncUsers', 'syncDebtors',
|
||||
'syncCases', 'syncUsers', 'syncHours', 'syncDebtors',
|
||||
'syncOfferSnapshots', 'syncCaseFeatures', 'syncDashboardMetrics'
|
||||
];
|
||||
stages.forEach(name => {
|
||||
@@ -308,7 +623,7 @@ describe('OrdrestyringSyncService.getSyncStatus', () => {
|
||||
])('keeps the authoritative run healthy when optional dashboard metrics %s', async (_label, dashboardResult, rejects) => {
|
||||
const service = new OrdrestyringSyncService();
|
||||
const coreStages = [
|
||||
'syncCases', 'syncUsers', 'syncDebtors',
|
||||
'syncCases', 'syncUsers', 'syncHours', 'syncDebtors',
|
||||
'syncOfferSnapshots', 'syncCaseFeatures'
|
||||
];
|
||||
coreStages.forEach(name => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const PlanningService = require('../planningService');
|
||||
|
||||
describe('PlanningService local allocation provenance', () => {
|
||||
test('marks locally created hours so the GraphQL mirror preserves them', async () => {
|
||||
const db = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce([{ id: 7 }])
|
||||
.mockResolvedValueOnce([{ case_number: '5001' }])
|
||||
.mockResolvedValueOnce({ insertId: 9001 })
|
||||
};
|
||||
const service = new PlanningService(db);
|
||||
|
||||
await expect(service.createAllocation({
|
||||
employee_id: 7,
|
||||
case_number: '5001',
|
||||
start_time: '2026-10-04T08:00:00Z',
|
||||
end_time: '2026-10-04T09:00:00Z',
|
||||
remark: 'Planlagt arbejde',
|
||||
hour_type: 1
|
||||
})).resolves.toMatchObject({ success: true, allocation_id: 9001 });
|
||||
|
||||
const [insertSql, params] = db.query.mock.calls[2];
|
||||
expect(insertSql).toContain('exported');
|
||||
expect(insertSql).toContain('VALUES (?, ?, ?, ?, ?, ?, -1, ?, ?)');
|
||||
expect(params).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
const logger = require('../utils/logger');
|
||||
const axios = require('axios');
|
||||
const mysql = require('mysql2/promise');
|
||||
const graphqlClient = require('./graphqlClient');
|
||||
const { GET_HOURS_SYNC_QUERY } = require('../graphql/queries/hours');
|
||||
|
||||
/**
|
||||
* OrdrestyringSyncService - Håndterer automatisk synkronisering af data fra Ordrestyring API
|
||||
@@ -126,20 +128,29 @@ class OrdrestyringSyncService {
|
||||
let recordsProcessed = 0;
|
||||
let syncIncomplete = false;
|
||||
|
||||
// The legacy REST /hours endpoint is documented as unavailable and is not an active writer.
|
||||
// Historical hours remain read-only until a dedicated GraphQL mirror is implemented.
|
||||
const syncResults = await Promise.allSettled([
|
||||
// Refresh authoritative mirrors first. Case features consume both cases and hours,
|
||||
// so they must never rebuild concurrently from an old or partially replaced mirror.
|
||||
const coreResults = await Promise.allSettled([
|
||||
this.syncCases(),
|
||||
this.syncUsers(),
|
||||
this.syncHours(),
|
||||
this.syncDebtors(),
|
||||
this.syncOfferSnapshots(),
|
||||
this.syncCaseFeatures(),
|
||||
this.syncDashboardMetrics() // Cache dashboard metrics hourly
|
||||
this.syncOfferSnapshots()
|
||||
]);
|
||||
const stageIsHealthy = result => result.status === 'fulfilled'
|
||||
&& !result.value?.degraded && !result.value?.skipped;
|
||||
const caseFeaturesResult = stageIsHealthy(coreResults[0]) && stageIsHealthy(coreResults[2])
|
||||
? (await Promise.allSettled([this.syncCaseFeatures()]))[0]
|
||||
: {
|
||||
status: 'fulfilled',
|
||||
value: { hasChanges: false, changes: 0, skipped: true, reason: 'dependency_unavailable' }
|
||||
};
|
||||
const dashboardResult = (await Promise.allSettled([this.syncDashboardMetrics()]))[0];
|
||||
const syncResults = [...coreResults, caseFeaturesResult, dashboardResult];
|
||||
|
||||
// Process results
|
||||
syncResults.forEach((result, index) => {
|
||||
const dataType = ['cases', 'users', 'debtors', 'offer_snapshots', 'case_features', 'dashboard_metrics'][index];
|
||||
const dataType = ['cases', 'users', 'hours', 'debtors', 'offer_snapshots', 'case_features', 'dashboard_metrics'][index];
|
||||
const isAuthoritativeStage = dataType !== 'dashboard_metrics';
|
||||
if (result.status === 'fulfilled') {
|
||||
const stageChanges = Number(result.value?.changes);
|
||||
@@ -301,73 +312,207 @@ class OrdrestyringSyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync hours (timeregistreringer) from API
|
||||
* Fetch the complete Ordrestyring hours dataset through bounded cursor pagination.
|
||||
*/
|
||||
async fetchAllGraphqlHours() {
|
||||
const pageSize = Math.min(200, Math.max(1,
|
||||
parseInt(process.env.ORDRESTYRING_HOURS_SYNC_PAGE_SIZE || '200', 10) || 200));
|
||||
const maxRows = Math.min(50000, Math.max(1,
|
||||
parseInt(process.env.ORDRESTYRING_HOURS_SYNC_MAX_ROWS || '50000', 10) || 50000));
|
||||
const rows = [];
|
||||
const seenCursors = new Set();
|
||||
let cursor = null;
|
||||
let pages = 0;
|
||||
|
||||
do {
|
||||
pages += 1;
|
||||
if (pages > 500) throw new Error('GraphQL hours page ceiling exceeded');
|
||||
const result = await graphqlClient.request(GET_HOURS_SYNC_QUERY, {
|
||||
pagination: { cursor, limit: pageSize }
|
||||
});
|
||||
const page = result?.hours;
|
||||
if (!page || !Array.isArray(page.items)) throw new Error('Invalid GraphQL hours response');
|
||||
if (!Number.isInteger(page.count) || page.count !== page.items.length) {
|
||||
throw new Error('Invalid GraphQL hours page count');
|
||||
}
|
||||
if (typeof page.hasMorePages !== 'boolean') throw new Error('Invalid GraphQL hours pagination state');
|
||||
if (rows.length + page.items.length > maxRows) throw new Error('GraphQL hours row ceiling exceeded');
|
||||
rows.push(...page.items);
|
||||
|
||||
const nextCursor = page.nextCursor || null;
|
||||
if (page.hasMorePages && (page.items.length === 0 || !nextCursor)) {
|
||||
throw new Error('Invalid GraphQL hours next cursor');
|
||||
}
|
||||
if (nextCursor && seenCursors.has(nextCursor)) throw new Error('GraphQL hours cursor repeated');
|
||||
if (nextCursor) seenCursors.add(nextCursor);
|
||||
cursor = page.hasMorePages ? nextCursor : null;
|
||||
} while (cursor);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
normalizeGraphqlHour(hour) {
|
||||
if (!hour || typeof hour !== 'object' || Array.isArray(hour)) {
|
||||
throw new Error('Invalid GraphQL hour item');
|
||||
}
|
||||
const own = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
|
||||
const sourceField = field => {
|
||||
if (!own(hour, field)) throw new Error(`Missing GraphQL hour ${field}`);
|
||||
return hour[field];
|
||||
};
|
||||
const relationField = (relation, field) => {
|
||||
const value = sourceField(relation);
|
||||
if (value === null) return null;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || !own(value, field)) {
|
||||
throw new Error(`Missing GraphQL hour ${relation}.${field}`);
|
||||
}
|
||||
return value[field];
|
||||
};
|
||||
const requiredInteger = (value, field) => {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`Invalid GraphQL hour ${field}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const optionalInteger = (value, field, { positive = false } = {}) => {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || (positive && value <= 0)) {
|
||||
throw new Error(`Invalid GraphQL hour ${field}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const optionalString = (value, field, maxLength = Infinity) => {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string' || value.length > maxLength) {
|
||||
throw new Error(`Invalid GraphQL hour ${field}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const optionalMoneyMinor = (value, field) => {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0
|
||||
|| value > Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error(`Invalid GraphQL hour ${field}`);
|
||||
}
|
||||
return Math.round(value);
|
||||
};
|
||||
|
||||
const startTime = requiredInteger(sourceField('startTime'), 'startTime');
|
||||
const stopTime = optionalInteger(sourceField('stopTime'), 'stopTime', { positive: true });
|
||||
if (stopTime !== null && stopTime < startTime) throw new Error('Invalid GraphQL hour stopTime');
|
||||
|
||||
return {
|
||||
id: requiredInteger(sourceField('id'), 'id'),
|
||||
emp_id: optionalInteger(relationField('user', 'id'), 'user.id', { positive: true }),
|
||||
start_time: startTime,
|
||||
stop_time: stopTime,
|
||||
remark: optionalString(sourceField('description'), 'description'),
|
||||
hour_type: optionalInteger(relationField('type', 'id'), 'type.id', { positive: true }),
|
||||
updated_at: optionalInteger(sourceField('updatedAt'), 'updatedAt', { positive: true }),
|
||||
created_at: optionalInteger(sourceField('createdAt'), 'createdAt', { positive: true }),
|
||||
costprice: optionalMoneyMinor(sourceField('costPrice'), 'costPrice'),
|
||||
new_case_number: optionalString(relationField('case', 'caseNumber'), 'case.caseNumber', 255)
|
||||
};
|
||||
}
|
||||
|
||||
hourRowsEqual(local, incoming) {
|
||||
const nullableNumber = value => value === null || value === undefined ? null : Number(value);
|
||||
const nullableString = value => value === null || value === undefined ? null : String(value);
|
||||
return Number(local.id) === incoming.id
|
||||
&& nullableNumber(local.emp_id) === incoming.emp_id
|
||||
&& nullableNumber(local.start_time) === incoming.start_time
|
||||
&& nullableNumber(local.stop_time) === incoming.stop_time
|
||||
&& nullableString(local.remark) === incoming.remark
|
||||
&& nullableNumber(local.hour_type) === incoming.hour_type
|
||||
&& nullableNumber(local.updated_at) === incoming.updated_at
|
||||
&& nullableNumber(local.created_at) === incoming.created_at
|
||||
&& nullableNumber(local.costprice) === incoming.costprice
|
||||
&& nullableString(local.new_case_number) === incoming.new_case_number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the durable local hours mirror from the working Ordrestyring GraphQL API.
|
||||
*/
|
||||
async syncHours() {
|
||||
let connection;
|
||||
try {
|
||||
// Get hours from last 7 days to catch recent updates
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const response = await axios.get(`${this.apiBaseUrl}/hours`, {
|
||||
auth: {
|
||||
username: this.apiToken,
|
||||
password: 'x'
|
||||
},
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
params: {
|
||||
from_date: sevenDaysAgo.toISOString().split('T')[0]
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
const rawHours = await this.fetchAllGraphqlHours();
|
||||
const incomingById = new Map();
|
||||
for (const rawHour of rawHours) {
|
||||
const hour = this.normalizeGraphqlHour(rawHour);
|
||||
if (incomingById.has(hour.id)) throw new Error('Duplicate GraphQL hour id');
|
||||
incomingById.set(hour.id, hour);
|
||||
}
|
||||
|
||||
const apiHours = response.data;
|
||||
if (!Array.isArray(apiHours)) {
|
||||
throw new Error('Invalid API response for hours');
|
||||
let connection;
|
||||
let transactionStarted = false;
|
||||
try {
|
||||
connection = await mysql.createConnection(this.dbConfig);
|
||||
await connection.beginTransaction();
|
||||
transactionStarted = true;
|
||||
|
||||
const [localHours] = await connection.execute(`
|
||||
SELECT id, emp_id, start_time, stop_time, remark, hour_type,
|
||||
updated_at, created_at, costprice, new_case_number, exported
|
||||
FROM hours
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (incomingById.size === 0 && localHours.length > 0) {
|
||||
throw new Error('GraphQL hours response unexpectedly empty');
|
||||
}
|
||||
const minimumSourceRatio = Number(process.env.ORDRESTYRING_HOURS_SYNC_MIN_SOURCE_RATIO || 0.8);
|
||||
if (!Number.isFinite(minimumSourceRatio) || minimumSourceRatio < 0.8 || minimumSourceRatio > 1) {
|
||||
throw new Error('Invalid hours mirror safety threshold');
|
||||
}
|
||||
if (localHours.length > 0 && incomingById.size / localHours.length < minimumSourceRatio) {
|
||||
throw new Error('GraphQL hours response below mirror safety threshold');
|
||||
}
|
||||
|
||||
connection = await mysql.createConnection(this.dbConfig);
|
||||
|
||||
// Get recent hours from local database
|
||||
const [localHours] = await connection.execute(
|
||||
'SELECT id, case_id, user_id, date, hours FROM hours WHERE date >= DATE_SUB(NOW(), INTERVAL 7 DAY)'
|
||||
);
|
||||
|
||||
const localHoursMap = new Map(localHours.map(h => [h.id, h]));
|
||||
const localById = new Map(localHours.map(hour => [Number(hour.id), hour]));
|
||||
let changes = 0;
|
||||
const upsertSql = `
|
||||
INSERT INTO hours (
|
||||
id, emp_id, start_time, stop_time, remark, hour_type,
|
||||
updated_at, created_at, costprice, new_case_number
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
emp_id = VALUES(emp_id),
|
||||
start_time = VALUES(start_time),
|
||||
stop_time = VALUES(stop_time),
|
||||
remark = VALUES(remark),
|
||||
hour_type = VALUES(hour_type),
|
||||
updated_at = VALUES(updated_at),
|
||||
created_at = VALUES(created_at),
|
||||
costprice = VALUES(costprice),
|
||||
new_case_number = VALUES(new_case_number)
|
||||
`;
|
||||
|
||||
for (const apiHour of apiHours) {
|
||||
const localHour = localHoursMap.get(apiHour.id);
|
||||
|
||||
if (!localHour) {
|
||||
await this.insertHour(connection, apiHour);
|
||||
changes++;
|
||||
} else if (this.hourNeedsUpdate(localHour, apiHour)) {
|
||||
await this.updateHour(connection, apiHour);
|
||||
changes++;
|
||||
for (const hour of incomingById.values()) {
|
||||
const local = localById.get(hour.id);
|
||||
if (Number(local?.exported) === -1) {
|
||||
throw new Error('GraphQL hour id collides with a local planning allocation');
|
||||
}
|
||||
if (!local || !this.hourRowsEqual(local, hour)) {
|
||||
await connection.execute(upsertSql, [
|
||||
hour.id, hour.emp_id, hour.start_time, hour.stop_time, hour.remark,
|
||||
hour.hour_type, hour.updated_at, hour.created_at, hour.costprice, hour.new_case_number
|
||||
]);
|
||||
changes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { hasChanges: changes > 0, changes };
|
||||
for (const local of localHours) {
|
||||
const id = Number(local.id);
|
||||
if (Number(local.exported) !== -1 && !incomingById.has(id)) {
|
||||
await connection.execute('DELETE FROM hours WHERE id = ?', [id]);
|
||||
changes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
transactionStarted = false;
|
||||
return { hasChanges: changes > 0, changes };
|
||||
} catch (error) {
|
||||
// Handle API errors gracefully - don't crash sync
|
||||
if (error.response?.status === 500) {
|
||||
logger.info('hours sync completed: no changes', {
|
||||
note: 'Ordrestyring API /hours endpoint temporarily unavailable (500) - skipped'
|
||||
});
|
||||
return { hasChanges: false, changes: 0, skipped: true };
|
||||
}
|
||||
if (error.response?.status === 404) {
|
||||
logger.info('hours sync completed: no changes', {
|
||||
note: 'Ordrestyring API /hours endpoint not found (404) - skipped'
|
||||
});
|
||||
return { hasChanges: false, changes: 0, skipped: true };
|
||||
}
|
||||
logger.error('Error syncing hours:', error.message);
|
||||
if (connection && transactionStarted) await connection.rollback().catch(() => {});
|
||||
logger.error('GraphQL hours sync failed');
|
||||
throw error;
|
||||
} finally {
|
||||
await this.closeConnectionSafely(connection, 'Hours sync');
|
||||
|
||||
@@ -333,9 +333,10 @@ class PlanningService {
|
||||
remark,
|
||||
hour_type,
|
||||
new_case_number,
|
||||
exported,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, -1, ?, ?)
|
||||
`;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
Reference in New Issue
Block a user