665 lines
24 KiB
JavaScript
665 lines
24 KiB
JavaScript
'use strict';
|
||
|
||
const debug = require('debug')('agm:dashboard');
|
||
const ObjectId = require('mongodb').ObjectId;
|
||
const { Job, App, AppFile, AppDetail } = require('../model');
|
||
const { JobStatus } = require('../helpers/job_constants');
|
||
const { Errors } = require('../helpers/constants');
|
||
const { AppAuthError, AppParamError, AppError } = require('../helpers/app_error');
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
/** Job statuses shown in the Active Jobs panel. INVOICED (5) and ARCHIVED (9) are excluded. */
|
||
const ACTIVE_JOB_STATUSES = [
|
||
JobStatus.NEW,
|
||
JobStatus.READY,
|
||
JobStatus.DOWNLOADED,
|
||
JobStatus.SPRAYED,
|
||
JobStatus.COMPLETED
|
||
];
|
||
|
||
/** XT cross-track error thresholds (meters). */
|
||
const XT_GOOD = 1.0;
|
||
const XT_MONITOR = 3.0;
|
||
|
||
/** Altitude thresholds (meters). Target ~3.7 m. */
|
||
const ALT_TARGET = 3.7;
|
||
const ALT_GOOD = 0.15; // ±0.15 m of target
|
||
const ALT_MONITOR = 0.46; // ±0.46 m of target
|
||
|
||
// ─── Timezone helpers ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Validate an IANA timezone string. Returns 'UTC' if invalid or missing.
|
||
*/
|
||
function validateTz(tz) {
|
||
if (!tz || typeof tz !== 'string') return 'UTC';
|
||
try {
|
||
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||
return tz;
|
||
} catch {
|
||
return 'UTC';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Return a 'YYYY-MM-DD' date label for a Date in the given timezone.
|
||
* Uses 'en-CA' locale which formats as YYYY-MM-DD natively.
|
||
*/
|
||
function toDateLabel(date, tz) {
|
||
return new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: tz,
|
||
year: 'numeric', month: '2-digit', day: '2-digit'
|
||
}).format(date);
|
||
}
|
||
|
||
/**
|
||
* Return the UTC Date corresponding to midnight 00:00:00 of dateLabel in tz.
|
||
*
|
||
* Strategy: format noon-UTC as local time in tz (via Intl), parse that back as
|
||
* UTC to measure the timezone offset, then apply that offset to naive midnight.
|
||
* This handles DST correctly because noon on most days is not a DST boundary.
|
||
*/
|
||
function midnightUTC(dateLabel, tz) {
|
||
const noonUTC = new Date(`${dateLabel}T12:00:00.000Z`);
|
||
const localStr = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: tz,
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||
hour12: false
|
||
}).format(noonUTC);
|
||
// en-CA produces "YYYY-MM-DD, HH:mm:ss"
|
||
const [datePart, timePart] = localStr.split(', ');
|
||
const localNoon = new Date(`${datePart}T${timePart}Z`); // parse as UTC to get naive value
|
||
const offsetMs = noonUTC.getTime() - localNoon.getTime();
|
||
const naiveUTC = new Date(`${dateLabel}T00:00:00.000Z`);
|
||
return new Date(naiveUTC.getTime() + offsetMs);
|
||
}
|
||
|
||
/** { start, end } UTC range for a day that is daysAgo before today in tz (0 = today). */
|
||
function dayWindow(daysAgo, tz) {
|
||
const now = new Date();
|
||
const todayLabel = toDateLabel(now, tz);
|
||
const pivot = new Date(`${todayLabel}T12:00:00.000Z`);
|
||
pivot.setUTCDate(pivot.getUTCDate() - daysAgo);
|
||
const targetLabel = toDateLabel(pivot, tz);
|
||
const start = midnightUTC(targetLabel, tz);
|
||
const end = new Date(start.getTime() + 86400000);
|
||
return { start, end };
|
||
}
|
||
|
||
/** { start, end } UTC range for the current Mon–Sun calendar week in tz. */
|
||
function weekWindow(tz) {
|
||
const now = new Date();
|
||
const todayLabel = toDateLabel(now, tz);
|
||
const pivot = new Date(`${todayLabel}T12:00:00.000Z`);
|
||
const sinceMonday = (pivot.getUTCDay() + 6) % 7; // Mon=0 … Sun=6
|
||
const monPivot = new Date(pivot);
|
||
monPivot.setUTCDate(monPivot.getUTCDate() - sinceMonday);
|
||
const sunPivot = new Date(monPivot);
|
||
sunPivot.setUTCDate(sunPivot.getUTCDate() + 6);
|
||
const start = midnightUTC(toDateLabel(monPivot, tz), tz);
|
||
const end = new Date(midnightUTC(toDateLabel(sunPivot, tz), tz).getTime() + 86400000);
|
||
return { start, end };
|
||
}
|
||
|
||
/** { start, end } UTC range for the current calendar month in tz. */
|
||
function monthWindow(tz) {
|
||
const now = new Date();
|
||
const [year, month] = toDateLabel(now, tz).split('-');
|
||
const start = midnightUTC(`${year}-${month}-01`, tz);
|
||
const end = new Date(start);
|
||
end.setUTCMonth(end.getUTCMonth() + 1);
|
||
return { start, end };
|
||
}
|
||
|
||
/** { start, end } UTC range for the current calendar year in tz. */
|
||
function yearWindow(tz) {
|
||
const now = new Date();
|
||
const [year] = toDateLabel(now, tz).split('-');
|
||
const start = midnightUTC(`${year}-01-01`, tz);
|
||
const end = new Date(start);
|
||
end.setUTCFullYear(end.getUTCFullYear() + 1);
|
||
return { start, end };
|
||
}
|
||
|
||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||
|
||
function round2(n) {
|
||
return Math.round((n || 0) * 100) / 100;
|
||
}
|
||
|
||
function safePct(current, previous) {
|
||
if (!previous) return null;
|
||
return Math.round(((current - previous) / previous) * 100);
|
||
}
|
||
|
||
/** Build Application base match for a set of jobIds (processed apps only). */
|
||
function appMatch(jobIds) {
|
||
return { jobId: { $in: jobIds }, status: 3, markedDelete: { $ne: true } };
|
||
}
|
||
|
||
/** Fetch all pilot job documents (light projection). */
|
||
async function fetchPilotJobs(pilotId) {
|
||
return Job.find(
|
||
{ operator: ObjectId(pilotId), markedDelete: { $ne: true } },
|
||
{ _id: 1, ttSprArea: 1, status: 1, byPuid: 1, name: 1, client: 1, vehicle: 1, endDate: 1, createdAt: 1 }
|
||
).lean();
|
||
}
|
||
|
||
// ─── GET /api/dashboard/pilot/kpi ────────────────────────────────────────────
|
||
|
||
/**
|
||
* @api {get} /api/dashboard/pilot/kpi Pilot KPI Cards
|
||
* @apiName GetPilotKpi
|
||
* @apiGroup PilotDashboard
|
||
* @apiDescription Returns KPI card data for the authenticated pilot.
|
||
* All Application metrics are based on createdDate (upload time) since
|
||
* Application.endDateTime is a raw device string with no guaranteed format.
|
||
*
|
||
* @apiQuery {String} [tz=UTC] IANA timezone string for today/week/month/year boundaries.
|
||
*
|
||
* @apiSuccess {Number} assignedJobs All-time total jobs assigned to this pilot.
|
||
* @apiSuccess {Number} assignedHectares Sum of ttSprArea across all assigned jobs.
|
||
* @apiSuccess {Number} sprayedToday Hectares sprayed today (by upload time).
|
||
* @apiSuccess {Number} flightHoursToday Flight hours recorded today.
|
||
* @apiSuccess {Object} operations distanceKm and sprayVolumeLiters for today.
|
||
* @apiSuccess {Object} historical Breakdown of jobs/hectares/flightHours by period.
|
||
*/
|
||
async function getKpi(req, res) {
|
||
if (!req.uid) AppAuthError.throw();
|
||
|
||
const tz = validateTz(req.query.tz);
|
||
const todayW = dayWindow(0, tz);
|
||
const weekW = weekWindow(tz);
|
||
const monthW = monthWindow(tz);
|
||
const yearW = yearWindow(tz);
|
||
|
||
const jobs = await fetchPilotJobs(req.uid);
|
||
const jobIds = jobs.map(j => j._id);
|
||
const assignedJobs = jobs.length;
|
||
const assignedHectares = round2(jobs.reduce((s, j) => s + (j.ttSprArea || 0), 0));
|
||
|
||
if (!jobIds.length) {
|
||
return res.json({
|
||
assignedJobs,
|
||
assignedHectares,
|
||
sprayedToday: 0,
|
||
flightHoursToday: 0,
|
||
operations: { distanceKm: 0, sprayVolumeLiters: 0 },
|
||
historical: {
|
||
jobs: { year: 0, month: 0, week: 0, day: 0 },
|
||
hectares: { year: 0, month: 0, week: 0, day: 0 },
|
||
flightHours: { year: 0, month: 0, week: 0, day: 0 }
|
||
}
|
||
});
|
||
}
|
||
|
||
const base = appMatch(jobIds);
|
||
|
||
// Run all aggregations in parallel
|
||
const [todayR, weekR, monthR, yearR, wJobs, mJobs, yJobs] = await Promise.all([
|
||
App.aggregate([
|
||
{ $match: { ...base, createdDate: { $gte: todayW.start, $lt: todayW.end } } },
|
||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, distance: { $sum: '$totalSprLength' }, volume: { $sum: '$totalSprayMat' } } }
|
||
]),
|
||
App.aggregate([
|
||
{ $match: { ...base, createdDate: { $gte: weekW.start, $lt: weekW.end } } },
|
||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
|
||
]),
|
||
App.aggregate([
|
||
{ $match: { ...base, createdDate: { $gte: monthW.start, $lt: monthW.end } } },
|
||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
|
||
]),
|
||
App.aggregate([
|
||
{ $match: { ...base, createdDate: { $gte: yearW.start, $lt: yearW.end } } },
|
||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } }
|
||
]),
|
||
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: weekW.start, $lt: weekW.end } }),
|
||
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: monthW.start, $lt: monthW.end } }),
|
||
Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: yearW.start, $lt: yearW.end } })
|
||
]);
|
||
|
||
const t = todayR[0] || {}, w = weekR[0] || {}, m = monthR[0] || {}, y = yearR[0] || {};
|
||
|
||
res.json({
|
||
assignedJobs,
|
||
assignedHectares,
|
||
sprayedToday: round2(t.sprayed || 0),
|
||
flightHoursToday: round2((t.flightTime || 0) / 3600),
|
||
operations: {
|
||
distanceKm: round2((t.distance || 0) / 1000),
|
||
sprayVolumeLiters: round2(t.volume || 0)
|
||
},
|
||
historical: {
|
||
jobs: { year: yJobs, month: mJobs, week: wJobs, day: assignedJobs },
|
||
hectares: { year: round2(y.sprayed || 0), month: round2(m.sprayed || 0), week: round2(w.sprayed || 0), day: round2(t.sprayed || 0) },
|
||
flightHours: { year: round2((y.flightTime || 0) / 3600), month: round2((m.flightTime || 0) / 3600), week: round2((w.flightTime || 0) / 3600), day: round2((t.flightTime || 0) / 3600) }
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── GET /api/dashboard/pilot/summary ────────────────────────────────────────
|
||
|
||
/**
|
||
* @api {get} /api/dashboard/pilot/summary Pilot Daily Summary
|
||
* @apiName GetPilotSummary
|
||
* @apiGroup PilotDashboard
|
||
* @apiDescription Returns today vs yesterday operational metrics with percentage deltas.
|
||
*
|
||
* @apiQuery {String} [tz=UTC] IANA timezone string.
|
||
*/
|
||
async function getSummary(req, res) {
|
||
if (!req.uid) AppAuthError.throw();
|
||
|
||
const tz = validateTz(req.query.tz);
|
||
const todayW = dayWindow(0, tz);
|
||
const yesterW = dayWindow(1, tz);
|
||
|
||
const jobs = await fetchPilotJobs(req.uid);
|
||
const jobIds = jobs.map(j => j._id);
|
||
|
||
const empty = { hectares: 0, flightHours: 0, haPerHour: 0, avgSpeedKmh: 0, sprayVolumeLiters: 0 };
|
||
|
||
if (!jobIds.length) {
|
||
return res.json({ today: empty, yesterday: empty, deltas: { hectaresPct: null, flightHoursPct: null, haPerHourPct: null, avgSpeedPct: null, sprayVolumePct: null } });
|
||
}
|
||
|
||
const base = appMatch(jobIds);
|
||
|
||
const [todayR, yesterR] = await Promise.all([
|
||
App.aggregate([
|
||
{ $match: { ...base, createdDate: { $gte: todayW.start, $lt: todayW.end } } },
|
||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } }
|
||
]),
|
||
App.aggregate([
|
||
{ $match: { ...base, createdDate: { $gte: yesterW.start, $lt: yesterW.end } } },
|
||
{ $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } }
|
||
])
|
||
]);
|
||
|
||
function buildDay(r) {
|
||
const raw = r[0] || {};
|
||
const hours = round2((raw.flightTime || 0) / 3600);
|
||
const ha = round2(raw.sprayed || 0);
|
||
const haPerHour = hours > 0 ? round2(ha / hours) : 0;
|
||
return {
|
||
hectares: ha,
|
||
flightHours: hours,
|
||
haPerHour,
|
||
avgSpeedKmh: round2((raw.avgSpeed || 0) * 3.6),
|
||
sprayVolumeLiters: round2(raw.volume || 0)
|
||
};
|
||
}
|
||
|
||
const today = buildDay(todayR);
|
||
const yesterday = buildDay(yesterR);
|
||
|
||
res.json({
|
||
today,
|
||
yesterday,
|
||
deltas: {
|
||
hectaresPct: safePct(today.hectares, yesterday.hectares),
|
||
flightHoursPct: safePct(today.flightHours, yesterday.flightHours),
|
||
haPerHourPct: safePct(today.haPerHour, yesterday.haPerHour),
|
||
avgSpeedPct: safePct(today.avgSpeedKmh, yesterday.avgSpeedKmh),
|
||
sprayVolumePct: safePct(today.sprayVolumeLiters, yesterday.sprayVolumeLiters)
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── GET /api/dashboard/pilot/trend ──────────────────────────────────────────
|
||
|
||
/**
|
||
* @api {get} /api/dashboard/pilot/trend Pilot Trend Charts Data
|
||
* @apiName GetPilotTrend
|
||
* @apiGroup PilotDashboard
|
||
* @apiDescription Returns daily hours flown and hectares sprayed for a date range.
|
||
* Defaults to the current calendar week (Mon–Sun). Missing days are filled with 0.
|
||
*
|
||
* @apiQuery {String} [tz=UTC] IANA timezone string.
|
||
* @apiQuery {String} [startDate] YYYY-MM-DD start date (inclusive). Defaults to Monday of current week.
|
||
* @apiQuery {String} [endDate] YYYY-MM-DD end date (inclusive). Defaults to Sunday of current week.
|
||
*/
|
||
async function getTrend(req, res) {
|
||
if (!req.uid) AppAuthError.throw();
|
||
|
||
const tz = validateTz(req.query.tz);
|
||
|
||
let startDate = req.query.startDate;
|
||
let endDate = req.query.endDate;
|
||
|
||
if (!startDate || !endDate) {
|
||
const ww = weekWindow(tz);
|
||
startDate = toDateLabel(ww.start, tz);
|
||
// ww.end is exclusive (start of next Mon), so subtract 1 ms to get Sun
|
||
endDate = toDateLabel(new Date(ww.end.getTime() - 1), tz);
|
||
}
|
||
|
||
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
|
||
if (!dateRe.test(startDate) || !dateRe.test(endDate)) AppParamError.throw();
|
||
|
||
const startUTC = midnightUTC(startDate, tz);
|
||
const endExcl = new Date(midnightUTC(endDate, tz).getTime() + 86400000);
|
||
const diffDays = Math.round((endExcl - startUTC) / 86400000);
|
||
if (diffDays < 1 || diffDays > 90) AppParamError.throw();
|
||
|
||
const jobs = await fetchPilotJobs(req.uid);
|
||
const jobIds = jobs.map(j => j._id);
|
||
|
||
// Generate all date labels for the range
|
||
const labels = [];
|
||
const pivot = new Date(`${startDate}T12:00:00.000Z`);
|
||
while (true) {
|
||
const label = toDateLabel(pivot, tz);
|
||
labels.push(label);
|
||
if (label === endDate) break;
|
||
pivot.setUTCDate(pivot.getUTCDate() + 1);
|
||
if (labels.length > 91) break; // safety cap
|
||
}
|
||
|
||
if (!jobIds.length) {
|
||
return res.json({ labels, hoursFlown: labels.map(() => 0), hectaresPerDay: labels.map(() => 0) });
|
||
}
|
||
|
||
const grouped = await App.aggregate([
|
||
{
|
||
$match: {
|
||
...appMatch(jobIds),
|
||
createdDate: { $gte: startUTC, $lt: endExcl }
|
||
}
|
||
},
|
||
{
|
||
$group: {
|
||
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdDate', timezone: tz } },
|
||
hoursFlown: { $sum: '$totalFlightTime' },
|
||
hectaresPerDay: { $sum: '$totalSprayed' }
|
||
}
|
||
}
|
||
]);
|
||
|
||
const dataMap = {};
|
||
for (const r of grouped) {
|
||
dataMap[r._id] = { hoursFlown: round2(r.hoursFlown / 3600), hectaresPerDay: round2(r.hectaresPerDay) };
|
||
}
|
||
|
||
const hoursFlown = labels.map(l => (dataMap[l] || {}).hoursFlown || 0);
|
||
const hectaresPerDay = labels.map(l => (dataMap[l] || {}).hectaresPerDay || 0);
|
||
|
||
res.json({ labels, hoursFlown, hectaresPerDay });
|
||
}
|
||
|
||
// ─── GET /api/dashboard/pilot/activeJobs ─────────────────────────────────────
|
||
|
||
/**
|
||
* Display status groupings used by the frontend color/badge system.
|
||
* NEW (0) → 'NEW'
|
||
* READY/DOWNLOADED/SPRAYED → 'IN_PROGRESS'
|
||
* COMPLETED (4) → 'COMPLETED'
|
||
*/
|
||
function toDisplayStatus(status) {
|
||
if (status === JobStatus.NEW) return 'NEW';
|
||
if (status === JobStatus.COMPLETED) return 'COMPLETED';
|
||
return 'IN_PROGRESS';
|
||
}
|
||
|
||
/**
|
||
* @api {get} /api/dashboard/pilot/activeJobs Pilot Active Jobs Panel
|
||
* @apiName GetPilotActiveJobs
|
||
* @apiGroup PilotDashboard
|
||
* @apiDescription Returns the pilot's assigned jobs (active statuses only) with per-job
|
||
* sprayed hectares and applied volume aggregated from uploaded Application files.
|
||
* INVOICED (5) and ARCHIVED (9) jobs are excluded.
|
||
*/
|
||
async function getActiveJobs(req, res) {
|
||
if (!req.uid) AppAuthError.throw();
|
||
|
||
const jobs = await Job.aggregate([
|
||
{
|
||
$match: {
|
||
operator: ObjectId(req.uid),
|
||
markedDelete: { $ne: true },
|
||
status: { $in: ACTIVE_JOB_STATUSES }
|
||
}
|
||
},
|
||
{ $sort: { createdAt: -1 } },
|
||
{ $limit: 50 },
|
||
// Client name (stored in users collection)
|
||
{
|
||
$lookup: {
|
||
from: 'users',
|
||
localField: 'client',
|
||
foreignField: '_id',
|
||
as: 'clientDoc'
|
||
}
|
||
},
|
||
{ $unwind: { path: '$clientDoc', preserveNullAndEmptyArrays: true } },
|
||
// Aircraft registration (Vehicle is a User discriminator stored in users collection)
|
||
{
|
||
$lookup: {
|
||
from: 'users',
|
||
localField: 'vehicle',
|
||
foreignField: '_id',
|
||
as: 'vehicleDoc'
|
||
}
|
||
},
|
||
{ $unwind: { path: '$vehicleDoc', preserveNullAndEmptyArrays: true } },
|
||
// Per-job aggregated Application totals
|
||
{
|
||
$lookup: {
|
||
from: 'applications',
|
||
let: { jobId: '$_id' },
|
||
pipeline: [
|
||
{
|
||
$match: {
|
||
$expr: {
|
||
$and: [
|
||
{ $eq: ['$jobId', '$$jobId'] },
|
||
{ $eq: ['$status', 3] },
|
||
{ $ne: ['$markedDelete', true] }
|
||
]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
$group: {
|
||
_id: null,
|
||
haSprayed: { $sum: '$totalSprayed' },
|
||
volumeApplied: { $sum: '$totalSprayMat' }
|
||
}
|
||
}
|
||
],
|
||
as: 'appTotals'
|
||
}
|
||
},
|
||
{ $unwind: { path: '$appTotals', preserveNullAndEmptyArrays: true } },
|
||
{
|
||
$project: {
|
||
_id: 1,
|
||
name: 1,
|
||
status: 1,
|
||
haTotal: '$ttSprArea',
|
||
clientName: '$clientDoc.name',
|
||
aircraftReg: { $ifNull: ['$vehicleDoc.tailNumber', '$vehicleDoc.unitId'] },
|
||
haSprayed: { $ifNull: ['$appTotals.haSprayed', 0] },
|
||
volumeAppliedLiters: { $ifNull: ['$appTotals.volumeApplied', 0] }
|
||
}
|
||
}
|
||
]);
|
||
|
||
const result = jobs.map(j => {
|
||
const haSprayed = round2(j.haSprayed);
|
||
const haTotal = round2(j.haTotal || 0);
|
||
const progressPct = haTotal > 0 ? Math.min(100, Math.max(0, Math.round((haSprayed / haTotal) * 100))) : 0;
|
||
return {
|
||
jobId: j._id,
|
||
name: j.name || '',
|
||
clientName: j.clientName || '',
|
||
aircraftReg: j.aircraftReg || '',
|
||
status: j.status,
|
||
displayStatus: toDisplayStatus(j.status),
|
||
haTotal,
|
||
haSprayed,
|
||
progressPct,
|
||
volumeAppliedLiters: round2(j.volumeAppliedLiters)
|
||
};
|
||
});
|
||
|
||
res.json({ jobs: result });
|
||
}
|
||
|
||
// ─── GET /api/dashboard/pilot/performance ────────────────────────────────────
|
||
|
||
/**
|
||
* @api {get} /api/dashboard/pilot/performance Pilot Performance Gauges
|
||
* @apiName GetPilotPerformance
|
||
* @apiGroup PilotDashboard
|
||
* @apiDescription Returns average XT cross-track error and spray altitude gauges.
|
||
* Aggregates over ApplicationDetail records for the pilot's last 10 processed
|
||
* application files. Altitude source priority: sprayHeight (FM) → radarAlt (AGL).
|
||
* Returns hasXtData / hasAltitudeData = false when no sensor data exists.
|
||
*
|
||
* NOTE: ApplicationDetail is a billion-document collection.
|
||
* All queries are scoped by fileId to use the fileId index and avoid collection scans.
|
||
*/
|
||
async function getPerformance(req, res) {
|
||
if (!req.uid) AppAuthError.throw();
|
||
|
||
const jobs = await fetchPilotJobs(req.uid);
|
||
const jobIds = jobs.map(j => j._id);
|
||
|
||
const noData = {
|
||
avgXtErrorMeters: null,
|
||
xtThreshold: { good: XT_GOOD, monitor: XT_MONITOR },
|
||
hasXtData: false,
|
||
avgSprayAltitudeMeters: null,
|
||
altitudeSource: null,
|
||
altThreshold: { target: ALT_TARGET, goodBand: ALT_GOOD, monitorBand: ALT_MONITOR },
|
||
hasAltitudeData: false,
|
||
sampleSize: 0
|
||
};
|
||
|
||
if (!jobIds.length) return res.json(noData);
|
||
|
||
// Step 1: Get the 10 most recently uploaded processed Application records
|
||
const recentApps = await App.find(appMatch(jobIds)).sort({ createdDate: -1 }).limit(10).select('_id').lean();
|
||
if (!recentApps.length) return res.json(noData);
|
||
|
||
// Step 2: Get all AppFiles for those applications
|
||
const appIds = recentApps.map(a => a._id);
|
||
const appFiles = await AppFile.find(
|
||
{ appId: { $in: appIds }, markedDelete: { $ne: true } },
|
||
{ _id: 1 }
|
||
).lean();
|
||
|
||
if (!appFiles.length) return res.json(noData);
|
||
|
||
const fileIds = appFiles.map(f => f._id);
|
||
|
||
// Step 3: Aggregate ApplicationDetail — uses fileId index only (no collection scan)
|
||
const agg = await AppDetail.aggregate([
|
||
{ $match: { fileId: { $in: fileIds } } },
|
||
{
|
||
$group: {
|
||
_id: null,
|
||
// XT error: average of abs(xTrack) excluding zero (zero means "no reading")
|
||
avgXtError: { $avg: { $cond: [{ $ne: ['$xTrack', 0] }, { $abs: '$xTrack' }, null] } },
|
||
xtCount: { $sum: { $cond: [{ $ne: ['$xTrack', 0] }, 1, 0] } },
|
||
// sprayHeight: Flight Master dedicated sensor
|
||
avgSprayHeight: { $avg: { $cond: [{ $gt: ['$sprayHeight', 0] }, '$sprayHeight', null] } },
|
||
sprayHeightCount: { $sum: { $cond: [{ $gt: ['$sprayHeight', 0] }, 1, 0] } },
|
||
// radarAlt: fallback AGL source
|
||
avgRadarAlt: { $avg: { $cond: [{ $gt: ['$radarAlt', 0] }, '$radarAlt', null] } },
|
||
radarAltCount: { $sum: { $cond: [{ $gt: ['$radarAlt', 0] }, 1, 0] } }
|
||
}
|
||
}
|
||
]);
|
||
|
||
const r = agg[0];
|
||
if (!r) return res.json({ ...noData, sampleSize: fileIds.length });
|
||
|
||
// Altitude: prefer sprayHeight over radarAlt
|
||
let avgSprayAltitudeMeters = null;
|
||
let altitudeSource = null;
|
||
let hasAltitudeData = false;
|
||
|
||
if (r.sprayHeightCount > 0 && r.avgSprayHeight != null) {
|
||
avgSprayAltitudeMeters = round2(r.avgSprayHeight);
|
||
altitudeSource = 'sprayHeight';
|
||
hasAltitudeData = true;
|
||
} else if (r.radarAltCount > 0 && r.avgRadarAlt != null) {
|
||
avgSprayAltitudeMeters = round2(r.avgRadarAlt);
|
||
altitudeSource = 'radarAlt';
|
||
hasAltitudeData = true;
|
||
}
|
||
|
||
const hasXtData = r.xtCount > 0 && r.avgXtError != null;
|
||
const avgXtErrorMeters = hasXtData ? round2(r.avgXtError) : null;
|
||
|
||
res.json({
|
||
avgXtErrorMeters,
|
||
xtThreshold: { good: XT_GOOD, monitor: XT_MONITOR },
|
||
hasXtData,
|
||
avgSprayAltitudeMeters,
|
||
altitudeSource,
|
||
altThreshold: { target: ALT_TARGET, goodBand: ALT_GOOD, monitorBand: ALT_MONITOR },
|
||
hasAltitudeData,
|
||
sampleSize: fileIds.length
|
||
});
|
||
}
|
||
|
||
// ─── PATCH /api/jobs/:job_id/complete ────────────────────────────────────────
|
||
|
||
/**
|
||
* @api {patch} /api/jobs/:job_id/complete Mark Job as Completed
|
||
* @apiName CompleteJob
|
||
* @apiGroup Jobs
|
||
* @apiDescription Transitions a job from SPRAYED (3) to COMPLETED (4).
|
||
* Only the Applicator who owns the job (Job.byPuid) may perform this action.
|
||
* Returns 409 if the job is not in SPRAYED status. Returns 401 if the caller
|
||
* is not the job owner.
|
||
*
|
||
* @apiParam {Number} job_id The numeric job ID.
|
||
*
|
||
* @apiSuccess {Object} job The updated job document.
|
||
*
|
||
* @apiError (409) {Object} error Status is not SPRAYED, or job does not exist.
|
||
* @apiError (401) {Object} error Caller is not the job owner.
|
||
*/
|
||
async function completeJob(req, res) {
|
||
const jobId = Number(req.params.job_id);
|
||
if (!Number.isFinite(jobId) || jobId <= 0) AppParamError.throw();
|
||
|
||
const job = await Job.findById(jobId, { status: 1, byPuid: 1 }).lean();
|
||
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
// Only the Applicator who owns the job may complete it
|
||
if (!job.byPuid || job.byPuid.toString() !== req.uid) AppAuthError.throw();
|
||
|
||
// Transition is only valid from SPRAYED (3)
|
||
if (job.status !== JobStatus.SPRAYED) AppParamError.throw(Errors.STATUS_JOB_INVALID);
|
||
|
||
const updated = await Job.findByIdAndUpdate(
|
||
jobId,
|
||
{ $set: { status: JobStatus.COMPLETED } },
|
||
{ new: true, lean: true }
|
||
)
|
||
.populate({ path: 'client', select: 'name' })
|
||
.populate({ path: 'operator', select: 'name' })
|
||
.populate({ path: 'vehicle', select: 'name tailNumber unitId' });
|
||
|
||
if (!updated) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
debug('Job %d marked COMPLETED by %s', jobId, req.uid);
|
||
res.json(updated);
|
||
}
|
||
|
||
module.exports = {
|
||
getKpi,
|
||
getSummary,
|
||
getTrend,
|
||
getActiveJobs,
|
||
getPerformance,
|
||
completeJob
|
||
};
|