Password Policy/App rename/PM Scheduling Improvements
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
|
||||
const { computeNextDue, projectUsageDue } = require('./pmScheduling');
|
||||
|
||||
const FREQUENCY_UNITS = ['days', 'weeks', 'months', 'quarters', 'semiannual', 'years'];
|
||||
|
||||
@@ -39,6 +40,34 @@ function planComponents(planId) {
|
||||
return all('SELECT id, sort_order, part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
|
||||
}
|
||||
|
||||
// Usage meters declared on a plan template (e.g. Operating Hours / hrs / due every 500).
|
||||
function planMeters(planId) {
|
||||
return all('SELECT id, sort_order, label, unit, usage_interval FROM pm_plan_meters WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
|
||||
}
|
||||
|
||||
// Per-schedule meters with their reading log, used by the engine and surfaced to the UI.
|
||||
function assetPmMeters(apId) {
|
||||
return all('SELECT * FROM asset_pm_meters WHERE asset_pm_id = ? ORDER BY sort_order, id', [apId]).map((m) => {
|
||||
const readings = all(
|
||||
'SELECT reading, reading_date FROM pm_meter_readings WHERE asset_pm_meter_id = ? ORDER BY reading_date, id',
|
||||
[m.id]
|
||||
);
|
||||
return { ...m, readings, projected_usage_due_date: projectUsageDue(m, readings, todayStr()) };
|
||||
});
|
||||
}
|
||||
|
||||
function writePlanMeters(planId, meters) {
|
||||
run('DELETE FROM pm_plan_meters WHERE plan_id = ?', [planId]);
|
||||
(meters || []).forEach((meter, index) => {
|
||||
const label = (meter.label || '').trim();
|
||||
if (!label) return;
|
||||
run(
|
||||
'INSERT INTO pm_plan_meters (plan_id, sort_order, label, unit, usage_interval) VALUES (?, ?, ?, ?, ?)',
|
||||
[planId, index, label, (meter.unit || '').trim() || null, Number(meter.usage_interval) || 0]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Guidance photos for a plan. `withData` is false for list views (omits the base64 blob).
|
||||
function planPhotos(planId, withData = true) {
|
||||
const columns = withData ? 'id, name, mime_type, caption, data_base64, sort_order' : 'id, name, mime_type, caption, sort_order';
|
||||
@@ -58,7 +87,16 @@ function planFromRow(row, { withPhotoData = true } = {}) {
|
||||
if (!row) return null;
|
||||
const components = planComponents(row.id);
|
||||
const photos = planPhotos(row.id, withPhotoData);
|
||||
return { ...row, steps: planSteps(row.id), components, components_total: componentsTotal(components), photos, photo_count: photos.length };
|
||||
return {
|
||||
...row,
|
||||
lifespan_adjust: Boolean(row.lifespan_adjust),
|
||||
steps: planSteps(row.id),
|
||||
components,
|
||||
components_total: componentsTotal(components),
|
||||
meters: planMeters(row.id),
|
||||
photos,
|
||||
photo_count: photos.length
|
||||
};
|
||||
}
|
||||
|
||||
function listPlans() {
|
||||
@@ -128,17 +166,18 @@ function createPlan(body, userId) {
|
||||
const ts = now();
|
||||
return tx(() => {
|
||||
const result = run(
|
||||
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
body.name || 'PM plan', body.description || null, body.category || null,
|
||||
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
|
||||
resolveEstimatedMinutes(body, null), body.instructions || null,
|
||||
resolveEstimatedMinutes(body, null), body.instructions || null, body.lifespan_adjust ? 1 : 0,
|
||||
userId, ts, ts
|
||||
]
|
||||
);
|
||||
writeSteps(result.lastInsertRowid, body.steps);
|
||||
writeComponents(result.lastInsertRowid, body.components);
|
||||
writePlanMeters(result.lastInsertRowid, body.meters);
|
||||
writePhotos(result.lastInsertRowid, body.photos);
|
||||
audit(userId, 'create', 'pm_plan', result.lastInsertRowid, null, { name: body.name });
|
||||
return getPlan(result.lastInsertRowid);
|
||||
@@ -151,17 +190,20 @@ function updatePlan(id, body, userId) {
|
||||
return tx(() => {
|
||||
run(
|
||||
`UPDATE pm_plans SET name = ?, description = ?, category = ?, frequency_value = ?, frequency_unit = ?,
|
||||
estimated_minutes = ?, instructions = ?, updated_at = ? WHERE id = ?`,
|
||||
estimated_minutes = ?, instructions = ?, lifespan_adjust = ?, updated_at = ? WHERE id = ?`,
|
||||
[
|
||||
body.name ?? before.name, body.description ?? before.description, body.category ?? before.category,
|
||||
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
|
||||
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
|
||||
resolveEstimatedMinutes(body, before.estimated_minutes),
|
||||
body.instructions ?? before.instructions, now(), id
|
||||
body.instructions ?? before.instructions,
|
||||
body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : before.lifespan_adjust,
|
||||
now(), id
|
||||
]
|
||||
);
|
||||
if (Array.isArray(body.steps)) writeSteps(id, body.steps);
|
||||
if (Array.isArray(body.components)) writeComponents(id, body.components);
|
||||
if (Array.isArray(body.meters)) writePlanMeters(id, body.meters);
|
||||
if (Array.isArray(body.photos)) writePhotos(id, body.photos);
|
||||
audit(userId, 'update', 'pm_plan', id, before, { name: body.name });
|
||||
return getPlan(id);
|
||||
@@ -179,12 +221,14 @@ function deletePlan(id, userId) {
|
||||
// ---- Per-asset PM schedules ------------------------------------------------
|
||||
|
||||
function assetPmRows(assetId) {
|
||||
const settings = engineSettings();
|
||||
return all(
|
||||
`SELECT ap.*, p.name AS plan_name, p.description AS plan_description, p.instructions AS plan_instructions,
|
||||
u.name AS assigned_to_name
|
||||
u.name AS assigned_to_name, a.in_service_date AS asset_in_service_date, a.useful_life_months AS asset_useful_life_months
|
||||
FROM asset_pm_plans ap
|
||||
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||
LEFT JOIN users u ON u.id = ap.assigned_to
|
||||
LEFT JOIN assets a ON a.id = ap.asset_id
|
||||
WHERE ap.asset_id = ?
|
||||
ORDER BY ap.active DESC, ap.next_due_date IS NULL, ap.next_due_date`,
|
||||
[assetId]
|
||||
@@ -208,12 +252,25 @@ function assetPmRows(assetId) {
|
||||
ratings: { safety: c.rating_safety, physical: c.rating_physical, operating: c.rating_operating }
|
||||
}));
|
||||
const totalCost = round2(completions.reduce((sum, c) => sum + Number(c.cost || 0), 0));
|
||||
const meters = assetPmMeters(row.id);
|
||||
// Re-derive the per-signal breakdown for display (the persisted next_due_date stays authoritative).
|
||||
const projection = computeNextDue(
|
||||
{ lastDate: row.last_completed_date || row.start_date, frequency_value: row.frequency_value, frequency_unit: row.frequency_unit, lifespan_adjust: Boolean(row.lifespan_adjust) },
|
||||
{ in_service_date: row.asset_in_service_date, useful_life_months: row.asset_useful_life_months },
|
||||
meters,
|
||||
settings,
|
||||
todayStr()
|
||||
);
|
||||
return {
|
||||
...row,
|
||||
active: Boolean(row.active),
|
||||
lifespan_adjust: Boolean(row.lifespan_adjust),
|
||||
steps: row.plan_id ? planSteps(row.plan_id) : [],
|
||||
plan_photos: row.plan_id ? planPhotos(row.plan_id) : [],
|
||||
components,
|
||||
meters,
|
||||
signals: projection.signals,
|
||||
due_driver: projection.driver,
|
||||
expected_cost: componentsTotal(components),
|
||||
total_cost: totalCost,
|
||||
last_cost: completions[0] ? round2(completions[0].cost) : 0,
|
||||
@@ -229,11 +286,24 @@ function attachPlan(assetId, body, userId) {
|
||||
const unit = FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : (plan ? plan.frequency_unit : 'months');
|
||||
const startDate = body.start_date || todayStr();
|
||||
const nextDue = body.next_due_date || startDate;
|
||||
const lifespanAdjust = body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : (plan && plan.lifespan_adjust ? 1 : 0);
|
||||
const result = run(
|
||||
`INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
|
||||
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, ts, ts]
|
||||
`INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, lifespan_adjust, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`,
|
||||
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, lifespanAdjust, ts, ts]
|
||||
);
|
||||
// Copy the plan's meter definitions onto the schedule; the first threshold is one interval out.
|
||||
const meters = Array.isArray(body.meters) ? body.meters : (body.plan_id ? planMeters(body.plan_id) : []);
|
||||
meters.forEach((meter, index) => {
|
||||
const label = (meter.label || '').trim();
|
||||
if (!label) return;
|
||||
const interval = Number(meter.usage_interval) || 0;
|
||||
run(
|
||||
`INSERT INTO asset_pm_meters (asset_pm_id, sort_order, label, unit, usage_interval, baseline_reading, due_at_reading)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
||||
[result.lastInsertRowid, index, label, (meter.unit || '').trim() || null, interval, interval > 0 ? interval : null]
|
||||
);
|
||||
});
|
||||
audit(userId, 'attach', 'asset_pm', result.lastInsertRowid, null, { asset_id: assetId, plan_id: body.plan_id });
|
||||
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [result.lastInsertRowid]);
|
||||
}
|
||||
@@ -243,7 +313,7 @@ function updateAssetPm(assetId, apId, body, userId) {
|
||||
if (!before) return null;
|
||||
run(
|
||||
`UPDATE asset_pm_plans SET frequency_value = ?, frequency_unit = ?, start_date = ?, end_date = ?,
|
||||
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, updated_at = ? WHERE id = ?`,
|
||||
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, lifespan_adjust = ?, updated_at = ? WHERE id = ?`,
|
||||
[
|
||||
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
|
||||
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
|
||||
@@ -253,6 +323,7 @@ function updateAssetPm(assetId, apId, body, userId) {
|
||||
body.assigned_to !== undefined ? body.assigned_to : before.assigned_to,
|
||||
body.active === undefined ? before.active : (body.active ? 1 : 0),
|
||||
body.notes ?? before.notes,
|
||||
body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : before.lifespan_adjust,
|
||||
now(), apId
|
||||
]
|
||||
);
|
||||
@@ -272,6 +343,44 @@ function clampRating(value) {
|
||||
return Math.max(1, Math.min(5, Number(value)));
|
||||
}
|
||||
|
||||
// Composite next-due for a schedule given the date it was last serviced. Loads the schedule's meters
|
||||
// (with their reading history) and the owning asset's life, runs the earliest-wins engine, and applies
|
||||
// the asset's maintenance end-date cutoff. Shared by completion and standalone reading logs.
|
||||
function scheduleNextDue(ap, lastDate) {
|
||||
const asset = one('SELECT in_service_date, useful_life_months FROM assets WHERE id = ?', [ap.asset_id]) || {};
|
||||
const projection = computeNextDue(
|
||||
{ lastDate, frequency_value: ap.frequency_value, frequency_unit: ap.frequency_unit, lifespan_adjust: Boolean(ap.lifespan_adjust) },
|
||||
asset,
|
||||
assetPmMeters(ap.id),
|
||||
engineSettings(),
|
||||
todayStr()
|
||||
);
|
||||
let nextDue = projection.next_due_date;
|
||||
let active = ap.active;
|
||||
if (ap.end_date && nextDue && nextDue > ap.end_date) {
|
||||
nextDue = null;
|
||||
active = 0; // schedule complete — past the asset's maintenance end date
|
||||
}
|
||||
return { nextDue, active };
|
||||
}
|
||||
|
||||
// Record a meter reading against a schedule's meter (resets its baseline/threshold when this is a
|
||||
// service completion). Insert into the log so the usage-rate projection stays current.
|
||||
function recordMeterReading(meter, reading, readingDate, source, completionId, userId) {
|
||||
const value = Number(reading);
|
||||
if (!Number.isFinite(value)) return;
|
||||
if (source === 'completion') {
|
||||
// Completing service resets the cycle: the next threshold is one interval past this reading.
|
||||
const dueAt = Number(meter.usage_interval) > 0 ? value + Number(meter.usage_interval) : null;
|
||||
run('UPDATE asset_pm_meters SET baseline_reading = ?, due_at_reading = ?, last_reading = ?, last_reading_date = ? WHERE id = ?',
|
||||
[value, dueAt, value, readingDate, meter.id]);
|
||||
} else {
|
||||
run('UPDATE asset_pm_meters SET last_reading = ?, last_reading_date = ? WHERE id = ?', [value, readingDate, meter.id]);
|
||||
}
|
||||
run('INSERT INTO pm_meter_readings (asset_pm_meter_id, reading, reading_date, source, completion_id, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[meter.id, value, readingDate, source, completionId || null, userId || null, now()]);
|
||||
}
|
||||
|
||||
function completePm(assetId, apId, body, userId) {
|
||||
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
|
||||
if (!ap) return null;
|
||||
@@ -279,18 +388,13 @@ function completePm(assetId, apId, body, userId) {
|
||||
|
||||
const ts = now();
|
||||
const completedDate = (body.completed_at || ts).slice(0, 10);
|
||||
let nextDue = addInterval(completedDate, ap.frequency_value, ap.frequency_unit);
|
||||
let active = ap.active;
|
||||
if (ap.end_date && nextDue > ap.end_date) {
|
||||
nextDue = null;
|
||||
active = 0; // schedule complete — past the asset's maintenance end date
|
||||
}
|
||||
const planComps = ap.plan_id ? planComponents(ap.plan_id) : [];
|
||||
const components = Array.isArray(body.components) ? body.components : planComps;
|
||||
const cost = body.cost != null && body.cost !== '' ? round2(body.cost) : componentsTotal(planComps);
|
||||
const notesList = Array.isArray(body.notes_list) ? body.notes_list.filter((n) => String(n || '').trim()) : (body.notes ? [body.notes] : []);
|
||||
const ratings = body.ratings || {};
|
||||
const photos = Array.isArray(body.photos) ? body.photos.filter((p) => p && p.data) : [];
|
||||
const meterReadings = Array.isArray(body.meter_readings) ? body.meter_readings : [];
|
||||
|
||||
return tx(() => {
|
||||
const inserted = run(
|
||||
@@ -299,7 +403,7 @@ function completePm(assetId, apId, body, userId) {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
apId, ts, userId, notesList.join('\n') || null, json(notesList), json(body.steps || []), cost, json(components),
|
||||
body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), nextDue, ts
|
||||
body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), null, ts
|
||||
]
|
||||
);
|
||||
for (const photo of photos) {
|
||||
@@ -307,12 +411,44 @@ function completePm(assetId, apId, body, userId) {
|
||||
inserted.lastInsertRowid, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.data, ts
|
||||
]);
|
||||
}
|
||||
// Reset each meter's cycle from the reading captured during service.
|
||||
for (const entry of meterReadings) {
|
||||
const meter = one('SELECT * FROM asset_pm_meters WHERE id = ? AND asset_pm_id = ?', [entry.asset_pm_meter_id, apId]);
|
||||
if (meter && entry.reading != null && entry.reading !== '') {
|
||||
recordMeterReading(meter, entry.reading, completedDate, 'completion', inserted.lastInsertRowid, userId);
|
||||
}
|
||||
}
|
||||
// Earliest-wins next-due now reflects the reset meters and the lifespan curve.
|
||||
const { nextDue, active } = scheduleNextDue(ap, completedDate);
|
||||
run('UPDATE pm_completions SET next_due_date = ? WHERE id = ?', [nextDue, inserted.lastInsertRowid]);
|
||||
run('UPDATE asset_pm_plans SET last_completed_date = ?, next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [completedDate, nextDue, active, ts, apId]);
|
||||
audit(userId, 'complete', 'asset_pm', apId, null, { next_due_date: nextDue, completion_id: inserted.lastInsertRowid });
|
||||
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
|
||||
});
|
||||
}
|
||||
|
||||
// Standalone usage reading (quick-log / inspection) — updates the meter's current reading and
|
||||
// recomputes the schedule's next-due so a faster-than-expected usage rate pulls service in sooner.
|
||||
function logReading(assetId, apId, body, userId) {
|
||||
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
|
||||
if (!ap) return null;
|
||||
const meter = one('SELECT * FROM asset_pm_meters WHERE id = ? AND asset_pm_id = ?', [body.asset_pm_meter_id, apId]);
|
||||
if (!meter) throw Object.assign(new Error('Meter not found on this schedule'), { status: 404 });
|
||||
if (body.reading == null || body.reading === '' || !Number.isFinite(Number(body.reading))) {
|
||||
throw Object.assign(new Error('A numeric reading is required'), { status: 400 });
|
||||
}
|
||||
const readingDate = (body.reading_date || now()).slice(0, 10);
|
||||
const source = body.source === 'inspection' ? 'inspection' : 'manual';
|
||||
return tx(() => {
|
||||
recordMeterReading(meter, body.reading, readingDate, source, null, userId);
|
||||
const lastDate = ap.last_completed_date || ap.start_date || readingDate;
|
||||
const { nextDue, active } = scheduleNextDue(ap, lastDate);
|
||||
run('UPDATE asset_pm_plans SET next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [nextDue, active, now(), apId]);
|
||||
audit(userId, 'reading', 'asset_pm', apId, null, { meter: meter.label, reading: Number(body.reading), next_due_date: nextDue });
|
||||
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
|
||||
});
|
||||
}
|
||||
|
||||
function listCompletions(query = {}) {
|
||||
return all(
|
||||
`SELECT c.id, c.completed_at, c.cost, c.notes_json, c.rating_safety, c.rating_physical, c.rating_operating,
|
||||
@@ -369,7 +505,16 @@ function getSettings() {
|
||||
return {
|
||||
pm_default_frequency_value: Number(map.pm_default_frequency_value || 3),
|
||||
pm_default_frequency_unit: map.pm_default_frequency_unit || 'months',
|
||||
pm_lead_days: Number(map.pm_lead_days || 7)
|
||||
pm_lead_days: Number(map.pm_lead_days || 7),
|
||||
pm_usage_tracking_enabled: map.pm_usage_tracking_enabled === '1',
|
||||
pm_lifespan_curve_enabled: map.pm_lifespan_curve_enabled === '1',
|
||||
pm_lifespan_min_factor: map.pm_lifespan_min_factor != null ? Number(map.pm_lifespan_min_factor) : 0.5,
|
||||
pm_lifespan_curve_exponent: map.pm_lifespan_curve_exponent != null ? Number(map.pm_lifespan_curve_exponent) : 2,
|
||||
// Nightly recompute job: refreshes projected due-dates so dynamic schedules don't drift between
|
||||
// completions/readings. pm_last_recompute_at is job-managed (read-only to clients).
|
||||
pm_recompute_enabled: map.pm_recompute_enabled === '1',
|
||||
pm_recompute_hour: map.pm_recompute_hour != null ? Number(map.pm_recompute_hour) : 2,
|
||||
pm_last_recompute_at: map.pm_last_recompute_at || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -378,10 +523,27 @@ function saveSettings(body, userId) {
|
||||
if (body.pm_default_frequency_value !== undefined) set('pm_default_frequency_value', Number(body.pm_default_frequency_value) || 3);
|
||||
if (body.pm_default_frequency_unit !== undefined) set('pm_default_frequency_unit', FREQUENCY_UNITS.includes(body.pm_default_frequency_unit) ? body.pm_default_frequency_unit : 'months');
|
||||
if (body.pm_lead_days !== undefined) set('pm_lead_days', Number(body.pm_lead_days) || 7);
|
||||
if (body.pm_usage_tracking_enabled !== undefined) set('pm_usage_tracking_enabled', body.pm_usage_tracking_enabled ? 1 : 0);
|
||||
if (body.pm_lifespan_curve_enabled !== undefined) set('pm_lifespan_curve_enabled', body.pm_lifespan_curve_enabled ? 1 : 0);
|
||||
// Floor multiplier is clamped to a sane 0.1–1.0; exponent to 0.5–6.
|
||||
if (body.pm_lifespan_min_factor !== undefined) set('pm_lifespan_min_factor', Math.min(1, Math.max(0.1, Number(body.pm_lifespan_min_factor) || 0.5)));
|
||||
if (body.pm_lifespan_curve_exponent !== undefined) set('pm_lifespan_curve_exponent', Math.min(6, Math.max(0.5, Number(body.pm_lifespan_curve_exponent) || 2)));
|
||||
if (body.pm_recompute_enabled !== undefined) set('pm_recompute_enabled', body.pm_recompute_enabled ? 1 : 0);
|
||||
if (body.pm_recompute_hour !== undefined) set('pm_recompute_hour', Math.min(23, Math.max(0, Math.round(Number(body.pm_recompute_hour)) || 0)));
|
||||
audit(userId, 'update', 'pm_settings', null, null, body);
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
// Map persisted settings into the shape pmScheduling.computeNextDue expects.
|
||||
function engineSettings(settings = getSettings()) {
|
||||
return {
|
||||
usage_enabled: settings.pm_usage_tracking_enabled,
|
||||
curve_enabled: settings.pm_lifespan_curve_enabled,
|
||||
min_factor: settings.pm_lifespan_min_factor,
|
||||
curve_exponent: settings.pm_lifespan_curve_exponent
|
||||
};
|
||||
}
|
||||
|
||||
// Alert candidates for PM schedules that are due/overdue.
|
||||
function pmAlertCandidates() {
|
||||
const lead = getSettings().pm_lead_days;
|
||||
@@ -398,15 +560,54 @@ function pmAlertCandidates() {
|
||||
for (const ap of rows) {
|
||||
const name = ap.plan_name || 'Preventative maintenance';
|
||||
const where = ap.asset_code ? ` on ${ap.asset_code}` : '';
|
||||
if (ap.next_due_date < today) {
|
||||
list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.`, due_date: ap.next_due_date });
|
||||
// Meters at/over their threshold are reported in the alert text (e.g. "Operating Hours 512/500").
|
||||
const crossed = assetPmMeters(ap.id)
|
||||
.filter((m) => m.last_reading != null && m.due_at_reading != null && Number(m.last_reading) >= Number(m.due_at_reading))
|
||||
.map((m) => `${m.label} ${Math.round(m.last_reading)}/${Math.round(m.due_at_reading)}${m.unit ? ' ' + m.unit : ''}`);
|
||||
const usageNote = crossed.length ? ` Usage threshold reached: ${crossed.join('; ')}.` : '';
|
||||
if (ap.next_due_date < today || crossed.length) {
|
||||
list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.${usageNote}`, due_date: ap.next_due_date });
|
||||
} else if (ap.next_due_date <= horizon) {
|
||||
list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.`, due_date: ap.next_due_date });
|
||||
list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.${usageNote}`, due_date: ap.next_due_date });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ---- Nightly recompute -----------------------------------------------------
|
||||
|
||||
// Re-derive next_due_date for every active schedule. Usage projections shift as time passes (a meter
|
||||
// that has crossed its threshold becomes due "today"), so without a periodic sweep a schedule that
|
||||
// isn't completed or read keeps a stale projected date. Cheap: one engine pass per active schedule.
|
||||
function recomputeAllSchedules(userId) {
|
||||
const rows = all('SELECT * FROM asset_pm_plans WHERE active = 1');
|
||||
let updated = 0;
|
||||
for (const ap of rows) {
|
||||
const lastDate = ap.last_completed_date || ap.start_date || todayStr();
|
||||
const { nextDue, active } = scheduleNextDue(ap, lastDate);
|
||||
if (nextDue !== ap.next_due_date || (active ? 1 : 0) !== (ap.active ? 1 : 0)) {
|
||||
run('UPDATE asset_pm_plans SET next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [nextDue, active, now(), ap.id]);
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
const ranAt = now();
|
||||
run(
|
||||
"INSERT INTO app_settings (key, value, updated_at) VALUES ('pm_last_recompute_at', ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||
[ranAt, ranAt]
|
||||
);
|
||||
if (userId) audit(userId, 'recompute', 'asset_pm', null, null, { scanned: rows.length, updated });
|
||||
return { scanned: rows.length, updated, ran_at: ranAt };
|
||||
}
|
||||
|
||||
// Self-gating entry point for the scheduler: runs once per day at the configured hour when enabled.
|
||||
function runScheduledRecompute() {
|
||||
const s = getSettings();
|
||||
if (!s.pm_recompute_enabled) return { skipped: 'disabled' };
|
||||
if (new Date().getHours() !== s.pm_recompute_hour) return { skipped: 'off-hour' };
|
||||
if (s.pm_last_recompute_at && String(s.pm_last_recompute_at).slice(0, 10) === todayStr()) return { skipped: 'already-ran-today' };
|
||||
return recomputeAllSchedules(null);
|
||||
}
|
||||
|
||||
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
|
||||
function pmBookLedger(year) {
|
||||
const rows = all(
|
||||
@@ -460,7 +661,10 @@ module.exports = {
|
||||
listCompletions,
|
||||
getSettings,
|
||||
listPlans,
|
||||
logReading,
|
||||
pmAlertCandidates,
|
||||
recomputeAllSchedules,
|
||||
runScheduledRecompute,
|
||||
saveSettings,
|
||||
updateAssetPm,
|
||||
updatePlan
|
||||
|
||||
Reference in New Issue
Block a user