agmission/server/controllers/advanced_report.js

795 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* Advanced Application Report — endpoint + datasource builder (D2).
* Contract: docs/ADVANCED_REPORTS_API.md; plan: docs/ADVANCED_REPORTS_IMPLEMENTATION_PLAN.md §4.
*
* Mirrors the legacy preAppReport_post flow (controllers/job.js): load job with
* populated refs → persist report settings → read ApplicationDetail ONCE (streaming
* cursor, projected fields — NFR-1.2) through the D1 analytics engine → capture all
* maps in one Chromium instance (NFR-1.3) → write rptDS.json → select template.
* The legacy report pipeline is untouched (NFR-5.1).
*/
// In-process generation counter — module scope so every controller instance shares it (NFR-2.2)
let activeGenerations = 0;
const MAX_CONCURRENT_GENERATIONS = 2;
// Mission limits (NFR-2.1)
const MAX_ZONES = 50;
const MAX_LINES = 2000;
module.exports = function (locals) {
const
path = require('path'),
crypto = require('crypto'),
fs = require('fs-extra'),
moment = require('moment'),
uniqid = require('uniqid'),
polylabel = require('polylabel'),
turf = require('@turf/turf'),
{ Job, App, AppFile, AppDetail, Customer } = require('../model'),
utils = require('../helpers/utils'),
jobUtil = require('../helpers/job_util'),
reportUtil = require('../helpers/report_util'),
webUtil = require('../helpers/web_util'),
{ JobStatus } = require('../helpers/job_constants'),
{ Units, Errors, DEFAULT_LANG, RateUnits, HttpStatus, APTypes, flightPathViewRoles } = require('../helpers/constants'),
{ AppParamError, AppError } = require('../helpers/app_error'),
{ getFormattedAddress } = require('../helpers/user_helper'),
env = require('../helpers/env'),
logger = require('../helpers/logger').child('advanced_report');
const DASH = ''; // missing/unavailable value convention (API doc §6) — renders as empty space
const SUPPORTED_LANGS = ['en', 'pt', 'es'];
const COMPACT_ZONE_THRESHOLD = 12; // FR-3.5 — F-OQ-2 assumed "more than 12" until the PO decides
const ZONE_STROKE_WEIGHT = 4; // matches Mission Overview's polygon boundary weight
const THUMB_STROKE_WEIGHT = 12; // ~3x — offsets the Mission Coverage card's much smaller mm embed (see window.setZoneStrokeWeight)
const CONTENT_DEFAULTS = { includeZoneDetail: true, sprayedZonesOnly: false, includeFlightLineStats: true, hideMapBackground: false };
// AppDetail fields the analytics engine consumes — nothing else leaves Mongo (NFR-1.2).
// driftX/driftY are read here only to feed applyDrift() below — never handed to the engine.
const DETAIL_PROJECTION = '-_id lat lon gpsTime llnum sprayStat grSpeed xTrack sprayHeight lminApp swath driftX driftY';
/**
* Shifts a point's lat/lon by its recorded driftX/driftY (meters, UTM easting/northing) —
* mirrors controllers/job.js setDriftSegs and the client's job-map-edit createPoint, both
* of which apply this same correction before drawing spray. Without it, the report draws
* raw, uncorrected GPS positions — visibly offset from the live map and legacy report
* wherever a point's drift vector is large enough to matter (e.g. a track drawn sliding
* past the edge of a zone that was itself mapped against the corrected track).
*/
function applyDrift(p, refUTM, LatLonUTM, UTM) {
if (!refUTM || !(utils.isNumber(p.driftX) && utils.isNumber(p.driftY) && (p.driftX !== 0 || p.driftY !== 0))) return;
const orgUtm = new LatLonUTM(p.lat, p.lon).toUtm(refUTM.zone, refUTM.hemisphere);
const shifted = UTM.newInstance(refUTM.zone, refUTM.hemisphere, orgUtm.easting + p.driftX, orgUtm.northing + p.driftY).toLatLon();
p.lat = shifted.lat; p.lon = shifted.lon;
}
/**
* Key-sorted JSON so equivalent objects hash the same regardless of property insertion order.
* Date/ObjectId (and anything else with a custom toJSON) are delegated to JSON.stringify —
* Object.keys() on those sees no own enumerable properties, which would otherwise collapse
* every date/id to the same '{}' and drop it from the hash entirely.
*/
function stableStringify(v) {
if (v === null || typeof v !== 'object') return JSON.stringify(v);
if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
if (typeof v.toJSON === 'function') return JSON.stringify(v.toJSON());
return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
}
/** Cache key for generateAdvancedReport's reuse check — everything that feeds captureMaps/buildDatasource */
function hashReportInputs(o) {
return crypto.createHash('sha256').update(stableStringify(o)).digest('hex');
}
/**
* Which .mrt template to render with — a per-applicator override if one exists on disk,
* else the shared default (FR-1.4). Computed fresh every call (cheap fs check) regardless
* of whether the images/datasource behind it came from cache or a fresh generation, since a
* template file can be added/removed independently of anything that would invalidate the cache.
*/
async function selectReportTemplate(applicator) {
const sApplicatorId = applicator && applicator._id.toHexString();
let reportId = 'app_advanced';
if (sApplicatorId && /^[0-9a-f]{24}$/i.test(sApplicatorId)
&& await fs.pathExists(path.join(env.REPORT_DIR, `app_advanced_${sApplicatorId}.mrt`)))
reportId = `app_advanced_${sApplicatorId}`;
return { rid: reportId, c: reportId === 'app_advanced' ? 0 : 1 };
}
/** POST /api/jobs/preAdvancedReport */
async function preAdvancedReport_post(req, res) {
const input = req.body;
if (!utils.isNumber(Number(input.jobId))) AppParamError.throw();
const lang = input.lang || DEFAULT_LANG;
if (!SUPPORTED_LANGS.includes(lang)) AppParamError.throw(Errors.INVALID_PARAM, `unknown lang '${input.lang}'`);
if (activeGenerations >= MAX_CONCURRENT_GENERATIONS) {
const busy = AppError.create(Errors.REPORT_BUSY);
busy.statusCode = HttpStatus.TOO_MANY_REQUESTS;
throw busy;
}
activeGenerations++;
try {
const result = await generateAdvancedReport(input, lang, {
protocol: req.protocol, hostname: req.hostname, userType: req.ut
});
res.json(result);
} finally {
activeGenerations--;
}
}
/**
* The generation itself — isolated from the HTTP layer so the existing worker
* framework can call it directly later (NFR-2.3).
* @returns {{ rid, path, c }}
*/
async function generateAdvancedReport(input, lang, { protocol, hostname, userType }) {
const t0 = Date.now();
const jobId = Number(input.jobId);
const phase = (name, extra) => logger.info({ jobId, phase: name, ms: Date.now() - t0, ...extra }, 'advanced report');
// Same gate as the client's AuthService.isPlanner — only these roles see flight paths
// on the Job Map (client/src/app/job/job-map-edit/job-map-edit.component.ts preInitMap);
// Pilot, Client, Inspector, Admin, and every other role are excluded there too
const canViewFlightPath = flightPathViewRoles.includes(userType);
// ---- 1. Job + populated refs (legacy populate block) ---------------------
const theJob = await Job.findById(jobId)
.populate({
path: 'client',
select: '-password',
populate: {
path: 'Country', model: 'Country', select: 'code name -_id',
foreignField: 'code', localField: 'country'
}
})
.populate({
path: 'operator',
select: '-password',
populate: {
path: 'Country', model: 'Country', select: 'code name -_id',
foreignField: 'code', localField: 'country'
}
})
.populate({ path: 'vehicle', select: '-password' })
.populate('products.product', 'name type restricted epaReg')
.populate('crop', 'name');
if (!theJob) AppError.throw(Errors.JOB_NOT_FOUND);
const job = theJob.toObject();
const zones = job.sprayAreas || [];
if (zones.length > MAX_ZONES)
AppError.throw(Errors.REPORT_LIMITS_EXCEEDED, `${zones.length} zones exceeds the ${MAX_ZONES}-zone limit`);
// ---- 2. Persist report settings (rptOp incl. reportContents, FR-7.5) -----
const contents = Object.assign({}, CONTENT_DEFAULTS, input.reportContents);
const updateVars = {};
if (input.rptOp) {
const rptOp = Object.assign({}, input.rptOp);
if (job.measureUnit) { // store metric, same as legacy
rptOp.coverage = utils.acreToHa(rptOp.coverage);
rptOp.areaSize = utils.acreToHa(rptOp.areaSize);
rptOp.actualVol = utils.toMetricVolume(rptOp.actualVol, (job.appRateUnit !== Units.LB && job.appRateUnit !== Units.KG), job.measureUnit);
}
rptOp.reportContents = contents;
updateVars.rptOp = rptOp;
} else {
updateVars['rptOp.reportContents'] = contents;
}
updateVars.useCustWI = input.useCustWI;
updateVars.weatherInfo = input.weatherInfo;
const updatedJob = await Job.findOneAndUpdate({ _id: jobId }, { $set: updateVars }, { new: true, lean: true });
if (!updatedJob) AppError.throw(Errors.JOB_NOT_FOUND);
job.useCustWI = updatedJob.useCustWI;
job.rptOp = updatedJob.rptOp;
job.weatherInfo = updatedJob.weatherInfo;
phase('settings-saved');
// ---- 3. Cheap lookups the cache decision (and, on a miss, the pipeline below) both need --
const apps = await App.find(
{ jobId: jobId, status: 3, totalSprayed: { $ne: null }, markedDelete: { $ne: true } },
{ _id: 1, fileName: 1, startDateTime: 1, endDateTime: 1, totalSprayMat: 1, updateDate: 1 }
).sort({ startDateTime: 1 }).lean();
const appFiles = apps.length
? await AppFile.find({ appId: { $in: apps.map(a => a._id) } }, '_id name').sort('agn').lean()
: [];
const fileIds = appFiles.map(f => f._id);
// applicator (report header + per-customer template) — legacy pattern
const applicator = await Customer.findOne({ _id: job.byPuid }, '-password', { lean: true })
.populate({ path: 'Country', select: 'code name -_id', model: 'Country' })
.lean();
// ---- 3b. Cache check — reuse the previous generation's images/datasource when nothing
// that feeds them has changed (zone geometry/settings, applicator, imported data, request
// options, viewer role), skipping the ApplicationDetail stream + analytics engine below
// AND the Chromium map-capture batch (captureMaps) — by far the two most expensive steps.
// Hash inputs are everything captureMaps/buildDatasource actually read; App.updateDate
// (only bumped when a file is (re)processed — model/application.js) stands in for the
// ApplicationDetail rows themselves, which carry no timestamp of their own.
const cacheHash = hashReportInputs({
job: {
sprayAreas: zones, excludedAreas: job.excludedAreas || [], swathWidth: job.swathWidth,
measureUnit: job.measureUnit, appRate: job.appRate, appRateUnit: job.appRateUnit,
crop: job.crop, name: job.name, appType: job.appType, startDate: job.startDate, endDate: job.endDate,
client: job.client, operator: job.operator, vehicle: job.vehicle, flightNumber: job.flightNumber,
products: job.products, byPuid: job.byPuid,
rptOp: job.rptOp, useCustWI: job.useCustWI, weatherInfo: job.weatherInfo
},
applicator,
apps: apps.map(a => ({ id: a._id, updateDate: a.updateDate, totalSprayMat: a.totalSprayMat, startDateTime: a.startDateTime, endDateTime: a.endDateTime })),
contents, dataOp: input.dataOp, lang, canViewFlightPath, params: input.params || null
});
const cached = job.advRptCache;
if (cached && cached.hash === cacheHash
&& await fs.pathExists(path.join(env.REPORT_DIR, 'dat', cached.genFolder, 'rptDS.json'))) {
const { rid, c } = await selectReportTemplate(applicator);
phase('done', { rid, cache: 'hit' });
return { rid, path: cached.genFolder, c };
}
// ---- 4. Stream ApplicationDetail once through the analytics engine -------
const isUS = !!job.measureUnit;
// mirrors the "Spray Coverage: All/Inside" preference (Setting.sprayPath.dataOp:
// 0=All/1=Inside — client sends the same field name/values as job.js's getData_post);
// defaults to "All" (matching that setting's own default) when the caller doesn't send one
const engine = reportUtil.createMissionAnalytics({
zones,
swathWidthM: utils.toMeter(job.swathWidth || 0, isUS),
collectDraw: true,
excludedAreas: job.excludedAreas || [],
includeOutOfZoneSpray: input.dataOp !== 1
});
// Reference UTM zone for applyDrift() — same convention as controllers/job.js
// getAppDataByJobId: centered on the job's own mapped areas so the drift's
// easting/northing offset lands correctly regardless of which UTM zone the
// job happens to sit in. Falls back to the first real point when the job has
// no zones/excluded areas to center on.
let refUTM;
const allAreaFeatures = [...zones, ...(job.excludedAreas || [])].map(z => ({ type: 'Feature', properties: {}, geometry: z.geometry }));
if (allAreaFeatures.length) {
const centerP = turf.getCoord(turf.center({ type: 'FeatureCollection', features: allAreaFeatures }));
refUTM = new locals.LatLonUTM(centerP[1], centerP[0]).toUtm();
}
for (const appFile of appFiles) {
// stored order preserved — gpsTime is seconds-of-day and wraps past midnight
const cursor = AppDetail.find({ fileId: appFile._id }).select(DETAIL_PROJECTION).lean().cursor();
for await (const point of cursor) {
if (!refUTM) refUTM = new locals.LatLonUTM(point.lat, point.lon).toUtm();
applyDrift(point, refUTM, locals.LatLonUTM, locals.UTM);
engine.push(point);
if (engine.lineCount() > MAX_LINES)
AppError.throw(Errors.REPORT_LIMITS_EXCEEDED, `flight lines exceed the ${MAX_LINES}-line limit`);
}
engine.fileBreak();
}
const analytics = engine.finish();
const hasData = analytics.lines.length > 0;
phase('analytics', { lines: analytics.lines.length, zones: zones.length, files: fileIds.length });
// ---- 5. Map captures — one Chromium for every image (NFR-1.3) ------------
const genFolder = uniqid(`appadv_${jobId}_`);
const targetFolder = path.join(env.REPORT_DIR, 'dat', genFolder);
await fs.ensureDir(targetFolder);
const imgBase = `https://${hostname}/reports/dat/${genFolder}`;
let missionInfo = { dispersed: false };
let failedZones = new Set();
let failedThumbs = new Set();
try {
const captured = await captureMaps({ job, zones, analytics, contents, canViewFlightPath, genFolder, targetFolder, protocol, hostname, input, applicator });
missionInfo = captured.missionInfo;
failedZones = captured.failedZones;
failedThumbs = captured.failedThumbs;
} catch (err) {
// mission map failure fails the request (NFR-3.1)
logger.error({ jobId, err: err.message }, 'mission map capture failed');
AppError.throw(Errors.REPORT_GENERATION_FAILED, err.message);
}
phase('maps-captured', { dispersed: missionInfo.dispersed, failedZones: failedZones.size });
// ---- 5. Datasource ---------------------------------------------------------
const rptDS = buildDatasource({
job, zones, analytics, contents, lang, imgBase, missionInfo,
failedZones, failedThumbs, hasData, apps, input, applicator
});
// weather is async (aggregation) — resolved here, suppressed via empty dataset
rptDS.weather = await buildWeather(job, fileIds, hasData, lang, apps);
try {
await fs.writeFile(path.join(targetFolder, 'rptDS.json'), JSON.stringify(rptDS, null, 2), 'utf-8');
} catch (err) {
logger.error({ jobId, err: err.message }, 'datasource write failed');
AppError.throw(Errors.REPORT_GENERATION_FAILED, err.message);
}
phase('datasource-written');
// Remember this generation so an identical follow-up request (§3b) can skip straight to it
await Job.findOneAndUpdate({ _id: jobId }, { $set: { advRptCache: { hash: cacheHash, genFolder, generatedAt: new Date() } } });
// ---- 6. Template selection (FR-1.4, applicator id sanitized — NFR-4.2) ----
const { rid: reportId, c } = await selectReportTemplate(applicator);
phase('done', { rid: reportId, cache: 'miss' });
return { rid: reportId, path: genFolder, c };
// Generated artifacts are cleaned up periodically by the maintainer app (legacy pattern)
}
/**
* All report captures from a single sprayMapAdvanced.html page (plan §5):
* mission map -> thumbnails (cropped clips when single-viewport) -> zone maps
* via window.focusZone. Zone-map failures degrade (optional shots); the mission
* capture failure propagates.
*/
async function captureMaps({ job, zones, analytics, contents, canViewFlightPath, genFolder, targetFolder, protocol, hostname, input, applicator }) {
const tempFolder = path.join(env.TEMP_DIR, 'report', genFolder);
const reportWebTempPath = `${protocol}://${hostname}/report/${genFolder}/`;
// polygon label anchors (legacy pattern — polylabel centers). Subtract any excluded
// area that actually overlaps this zone before finding the anchor, so the badge/name/
// area label never lands inside a no-spray exclusion hole — otherwise it reads as if
// the label belongs to the exclusion rather than the zone it's actually describing.
// A zero-margin subtraction still isn't enough on its own: it only keeps the anchor
// POINT outside the exclusion, but the badge's own rendered circle (and the acreage
// chip below it) has real screen size, which becomes a large real-world distance once
// the mission map has to zoom out far to fit several widely-separated zones into one
// capture (Job #108) — a ~87m point-clearance measured fine geometrically but the
// badge still visually reached the exclusion at that zoom. Buffer the exclusion
// outward by a fixed safety margin first so the anchor keeps real clearance; if that
// leaves nothing to anchor on for a small zone, fall back to a zero-margin subtraction
// (still correct, just tighter) rather than the raw unexcluded zone.
const LABEL_EXCLUSION_MARGIN_KM = 0.15; // ~150m — comfortably covers the badge + chip's rendered footprint at typical mission-overview zoom levels
for (const area of zones) {
try {
let labelGeom = area.geometry;
for (const excl of (job.excludedAreas || [])) {
if (!excl.geometry) continue;
try {
if (!turf.booleanIntersects(labelGeom, excl.geometry)) continue;
let exclGeom = excl.geometry;
try {
const buffered = turf.buffer(excl.geometry, LABEL_EXCLUSION_MARGIN_KM, { units: 'kilometers' });
if (buffered) exclGeom = buffered.geometry;
} catch (e) { /* fall back to the un-buffered exclusion below */ }
let diff = turf.difference(labelGeom, exclGeom);
if (!diff && exclGeom !== excl.geometry) {
// the buffered exclusion swallowed the whole zone — retry without the margin
diff = turf.difference(labelGeom, excl.geometry);
}
if (diff) labelGeom = diff.geometry;
} catch (e) { /* keep prior labelGeom, skip this one exclusion */ }
}
let coords = labelGeom.coordinates;
if (labelGeom.type === 'MultiPolygon') {
// an exclusion can split a zone into disjoint pieces — anchor on the largest one
coords = coords.reduce((a, b) =>
turf.area({ type: 'Polygon', coordinates: a }) >= turf.area({ type: 'Polygon', coordinates: b }) ? a : b);
}
const c = polylabel(coords, 1.0);
if (c) area.properties['center'] = [c[1], c[0]];
} catch (err) { /* keep bounds-center fallback in the map page */ }
}
const params = Object.assign(
{ width: 1843, height: 1153, base: 'satellite' }, // 190×96 mm mission map block @ ~236 dpi
input.params || {}
);
await fs.copy(path.join(process.cwd(), 'public/sprayMapAdvanced.html'), path.join(tempFolder, 'sprayMapAdvanced.html'));
const pageData = {
premium: (applicator && applicator.premium) || 0,
variant: 'mission',
hideBg: !!contents.hideMapBackground, // FR-7.4
job: {
measureUnit: job.measureUnit, swathWidth: job.swathWidth,
sprayAreas: zones, excludedAreas: job.excludedAreas || []
},
params,
// ferry/flight-path lines omitted for roles that can't see them on the Job Map either
// (canViewFlightPath) — spray lines (data) always show, that's a separate permission
data: analytics.draw ? [{ file: 'mission', data: analytics.draw.spray, fdata: canViewFlightPath ? analytics.draw.flight : [] }] : null,
sprOp: { overlap: 100 },
obs: [],
colors: { sprayZone: 'blue', fpColor: 'lime' }
};
await fs.writeFile(path.join(tempFolder, 'spraydata.js'), 'var req=' + JSON.stringify(pageData) + ';', 'utf-8');
// which zones get detail captures (FR-7.4 filters)
const zoneIncluded = zones.map((z, idx) => contents.includeZoneDetail
&& (!contents.sprayedZonesOnly || analytics.zones[idx].lineCount > 0));
const withThumbs = zones.length <= COMPACT_ZONE_THRESHOLD; // FR-3.5
// ONE batch on ONE page (NFR-1.3), in capture order: missionInfo -> mission map ->
// per-zone focusZone captures. Each zone gets its own independent fit/refit (plan
// D3.4+ revision) instead of a rect cropped out of the shared mission-wide view, whose
// native resolution and framing were both at the mercy of whatever zoom that shared
// view had to use to fit every zone at once (confirmed inconsistent across jobs — Job
// #105's very small zones, Job #108's widely-separated ones). Zone Detail (zone_N.jpg)
// and the Mission Coverage thumbnail (zone_thumb_N.jpg) are now two separate shots of
// that same fit/refit, not one shared file — the thumbnail's much smaller embed size
// needs a heavier boundary stroke to print at the same visual thickness (see
// window.setZoneStrokeWeight), which would over-thicken the Zone Detail page if shared.
// Zone shots are optional: failures degrade to placeholders (NFR-3.1).
const shots = [
{ extract: 'window.missionInfo' },
{ path: path.join(targetFolder, 'map.jpg'), type: 'jpeg', quality: 90 }
];
const zoneAt = {};
const thumbAt = {};
zones.forEach((z, idx) => {
if (!zoneIncluded[idx] && !withThumbs) return;
if (zoneIncluded[idx]) {
zoneAt[idx] = shots.length;
shots.push({
path: path.join(targetFolder, `zone_${idx + 1}.jpg`), type: 'jpeg', quality: 85,
evaluate: `window.setZoneStrokeWeight(${ZONE_STROKE_WEIGHT}); window.focusZone(${idx})`,
waitFor: 'window.loaded == true', optional: true
});
}
if (withThumbs) {
// dedicated capture for the Mission Coverage thumbnail card: same view, heavier
// boundary stroke (THUMB_STROKE_WEIGHT) to compensate for that card's much smaller
// embed size (~57x36mm vs this zone_N.jpg's ~190x135mm on Zone Detail) — see
// window.setZoneStrokeWeight for why a shared raster can't serve both as-is.
thumbAt[idx] = shots.length;
shots.push({
path: path.join(targetFolder, `zone_thumb_${idx + 1}.jpg`), type: 'jpeg', quality: 85,
evaluate: `window.setZoneStrokeWeight(${THUMB_STROKE_WEIGHT}); window.focusZone(${idx})`,
waitFor: 'window.loaded == true', optional: true
});
}
});
const results = await webUtil.webShotBatch(
{ url: reportWebTempPath + 'sprayMapAdvanced.html', width: params.width, height: params.height },
shots, { timeout: 60000 });
const missionInfo = results[0] || { dispersed: false };
const failedZones = new Set();
const failedThumbs = new Set();
zones.forEach((z, idx) => {
if (zoneAt[idx] !== undefined && results[zoneAt[idx]] === null) failedZones.add(idx);
if (thumbAt[idx] !== undefined && results[thumbAt[idx]] === null) failedThumbs.add(idx);
});
return { missionInfo, failedZones, failedThumbs };
}
/** Assemble rptDS.json exactly per API doc §6 — every display value pre-localized (FR-1.3) */
function buildDatasource({ job, zones, analytics, contents, lang, imgBase, missionInfo, failedZones, failedThumbs, hasData, apps, input, applicator }) {
moment.locale(lang);
const isUS = !!job.measureUnit;
const isLiquid = (job.appRateUnit !== RateUnits.LBS_PER_ACRE && job.appRateUnit !== RateUnits.KG_PER_HA);
const loc = (v, d) => utils.toLocaleStr(v, d, lang);
// ---- localized unit formatters (null-in → dash-out) -----------------------
const fmt = (v, f) => (v === null || v === undefined) ? DASH : f(v);
const areaStr = m2 => fmt(m2, v => `${loc(utils.toArea(v, isUS), 1)} ${utils.areaUnitString(isUS, true)}`);
const speedStr = mps => fmt(mps, v => `${loc(isUS ? v * 2.23694 : v * 3.6, 1)} ${isUS ? 'mph' : 'km/h'}`);
const lenStr = m => fmt(m, v => `${loc(isUS ? v * 3.28084 : v, 0)} ${isUS ? 'ft' : 'm'}`);
const shortLenStr = m => fmt(m, v => `${loc(isUS ? v * 3.28084 : v, 2)} ${isUS ? 'ft' : 'm'}`);
const distStr = m => fmt(m, v => `${loc(isUS ? v / 1609.344 : v / 1000, 1)} ${isUS ? 'mi' : 'km'}`);
const volStr = l => fmt(l, v => `${loc(utils.toVolume(v, isLiquid, isUS), 0)} ${isLiquid ? (isUS ? 'gal' : 'L') : (isUS ? 'lb' : 'kg')}`);
const flowStr = lmin => fmt(lmin, v => `${loc(isUS && isLiquid ? v * 0.264172 : v, 1)} ${isUS && isLiquid ? 'GPM' : 'L/min'}`);
const pctStr = p => fmt(p, v => `${loc(v, 1)}%`);
const secStr = s => fmt(s, v => `${loc(v, 1)} s`);
const hm = s => {
if (s === null || s === undefined || s <= 0) return DASH;
if (s < 60) return `${Math.round(s)}s`;
let h = Math.floor(s / 3600), m = Math.round((s - h * 3600) / 60);
if (m === 60) { h += 1; m = 0; } // carry a minute-rounding overflow into the hour
if (h === 0) return `${m}m`;
return m > 0 ? `${h}h ${String(m).padStart(2, '0')}m` : `${h}h`;
};
const rateStr = (volL, areaM2) => {
if (volL === null || !areaM2) return DASH;
const v = utils.toVolume(volL, isLiquid, isUS) / utils.toArea(areaM2, isUS);
return `${loc(v, 2)} ${utils.rateUnitString(job.appRateUnit, true)}`;
};
const todStr = s => fmt(s, v => utils.secondsToHMS(Math.round(v) % 86400, 1));
const m = analytics.mission;
// planned/sprayed areas: manual Report Settings values override data (legacy behaviour)
const rptOp = input.rptOp || {};
const plannedM2 = rptOp.printArea && utils.isNumber(rptOp.areaSize) && Number(rptOp.areaSize) > 0
? Number(rptOp.areaSize) * (isUS ? 4046.86 : 10000) : m.plannedAreaM2;
const sprayedM2 = utils.isNumber(rptOp.coverage) && Number(rptOp.coverage) > 0
? Number(rptOp.coverage) * (isUS ? 4046.86 : 10000) : m.sprayedAreaM2;
// no manual override active -> reuse the analytics engine's per-zone-capped coveragePct
// (an overlapped zone must not numerically stand in for an untouched one); a manual
// override has no per-zone breakdown to cap against, so it falls back to a plain ratio —
// uncapped, same reasoning as a zone's own coveragePct: if the user's own entered numbers
// imply more was sprayed than planned, showing that honestly beats hiding it behind "100%"
const coveragePct = (plannedM2 === m.plannedAreaM2 && sprayedM2 === m.sprayedAreaM2)
? m.coveragePct
: (plannedM2 > 0 ? (sprayedM2 / plannedM2) * 100 : null);
// actual spray volume: sum(Application.totalSprayMat) — same source/method as the legacy
// "Actual Spray Volume" (controllers/job.js getReportOps_get) and the job-map-edit "Mat
// Sprayed" playback total, kept consistent across the app rather than using this report's
// own flow-integration figure (report_util.js mission.volumeL, still used for Avg Flow Rate).
// Manual actual-volume override still applies.
const totalSprayMatSum = apps.reduce((sum, a) => sum + (a.totalSprayMat || 0), 0);
let volumeL = totalSprayMatSum > 0 ? totalSprayMatSum : null;
if (rptOp.useActualVol && rptOp.actualVol > 0)
volumeL = utils.toMetricVolume(Number(rptOp.actualVol), isLiquid, isUS);
// planned Application "Rate" + "Total Volume Used" — same fields/formula as legacy's
// Application table row (controllers/job.js preAppReport_post: appRate/totalVolume),
// not the flow-measured avgAppRate this report already derives from volumeL above.
const coverageJobUnits = utils.toArea(sprayedM2, isUS);
const appRate = rptOp.appRate ? Number(rptOp.appRate) : job.appRate;
let appTotalVol = coverageJobUnits * appRate;
let appTotalVolUnit = job.appRateUnit;
if (isUS && job.appRateUnit === RateUnits.OZ_PER_ACRE) {
appTotalVol = utils.ozToGal(appTotalVol);
appTotalVolUnit = RateUnits.GAL_PER_ACRE;
}
if (rptOp.useActualVol && rptOp.actualVol > 0 && appTotalVol && Number(rptOp.actualVol) !== appTotalVol)
appTotalVol = Number(rptOp.actualVol);
// ---- mission (page 1) ------------------------------------------------------
const planStart = moment(job.startDate), planEnd = moment(job.endDate);
const createdDate = moment().format('MMM DD, YYYY');
// actual application window from the imported data files (legacy pattern)
let actualDates = DASH;
if (!utils.isEmptyArray(apps)) {
const actStart = moment.utc(apps[0].startDateTime);
const actEnd = moment.utc(apps[apps.length - 1].endDateTime);
if (actStart.isValid() && actEnd.isValid())
actualDates = actStart.isSame(actEnd, 'day')
? `${actStart.format('MMM DD, YYYY, h:mm A')} - ${actEnd.format('h:mm A')}`
: `${actStart.format('MMM DD, YYYY, h:mm A')} - ${actEnd.format('MMM DD, YYYY, h:mm A')}`;
}
const mission = {
jobId: job._id,
name: job.name || '',
jobType: job.appType || DASH,
farm: job.farm || DASH, // same field/label as legacy (controllers/job.js:1221 "Farm:") — Advanced Report never surfaced it until now
crop: ((job.crop && job.crop['_id']) ? job.crop['name'] : job.crop) || DASH,
planDates: planStart.isValid()
? `${planStart.format('MMM DD, YYYY')} - ${planEnd.isValid() ? planEnd.format('MMM DD, YYYY') : DASH}` : DASH,
actualDates,
duration: hasData ? hm(m.totalFlightS) : DASH,
customer: (job.client && job.client.name) || '',
customerAddress: getFormattedAddress(job.client) || '',
pilot: (job.operator && job.operator.name) || DASH,
licence: (job.operator && job.operator.licence) || DASH,
aircraft: job.vehicle ? [job.vehicle.name, job.vehicle.model].filter(Boolean).join(' ') : DASH,
flightNumber: job.flightNumber || DASH, // matches legacy (controllers/job.js:1229) — no tailNumber fallback; a tail number identifies the aircraft, not this flight
applicator: (applicator && applicator.name) || '',
applicatorAddress: getFormattedAddress(applicator) || '',
mapfile: `${imgBase}/map.jpg`,
coveragePct: hasData ? pctStr(coveragePct) : DASH,
avgSpeed: hasData ? speedStr(m.avgSpeedMps) : DASH,
avgHeight: hasData ? shortLenStr(m.avgHeightM) : DASH,
avgXtError: hasData ? shortLenStr(m.avgXtM) : DASH,
totalVolume: hasData ? volStr(volumeL) : DASH,
zonesSprayed: `${m.zonesSprayed} / ${m.zonesTotal}`,
plannedArea: areaStr(plannedM2),
sprayedArea: hasData ? areaStr(sprayedM2) : DASH,
totalFlightTime: hasData ? hm(m.totalFlightS) : DASH,
totalSprayTime: hasData ? hm(m.sprayTimeS) : DASH,
ferryTime: hasData ? hm(m.ferryTimeS) : DASH,
totalDistance: hasData ? distStr(m.totalDistanceM) : DASH,
sprayDistance: hasData ? distStr(m.sprayDistanceM) : DASH,
ferryDistance: hasData ? distStr(m.ferryDistanceM) : DASH,
avgAppRate: hasData ? rateStr(volumeL, sprayedM2) : DASH,
avgFlowRate: hasData ? flowStr(m.avgFlowLmin) : DASH,
swathWidth: hasData ? shortLenStr(m.avgSwathM) : DASH,
appRate: hasData ? `${loc(appRate, 2)} ${utils.rateUnitString(job.appRateUnit, true)}` : DASH,
appTotalVolume: hasData ? `${loc(appTotalVol, 1)} ${utils.rateUnitString(appTotalVolUnit, true, 1)}` : DASH,
remark: job.remark || DASH, // FR-2.10
createdDate
};
// ---- coverageCards: ALL zones, always (§6) ----------------------------------
// Whether the Mission Coverage page itself is worth showing for a single-zone
// mission is a display decision, made client-side against the page template —
// not something this dataset should encode by omitting the zone (§6 requires
// coverageCards to always list every zone, regardless of any filtering).
const withThumbs = zones.length <= COMPACT_ZONE_THRESHOLD;
const coverageCards = zones.map((z, idx) => {
const zs = analytics.zones[idx];
// '' -> template renders the compact text layout (FR-3.5). zone_thumb_N.jpg — its own
// independent per-zone capture (focusZone), not a rect cropped out of the shared
// mission-wide view (see captureMaps for why) — captured with a heavier boundary
// stroke than zone_N.jpg since this card embeds at a much smaller mm size (see
// window.setZoneStrokeWeight)
const thumbFile = (withThumbs && !failedThumbs.has(idx)) ? `${imgBase}/zone_thumb_${idx + 1}.jpg` : '';
return {
zoneNum: idx + 1,
name: zs.name || `Zone ${idx + 1}`,
sprayedPlanned: zs.lineCount
? `${loc(utils.toArea(zs.sprayedAreaM2, isUS), 1)} / ${loc(utils.toArea(zs.plannedAreaM2, isUS), 1)} ${utils.areaUnitString(isUS, true)}`
: `${DASH} / ${loc(utils.toArea(zs.plannedAreaM2, isUS), 1)} ${utils.areaUnitString(isUS, true)}`,
coveragePct: zs.lineCount ? pctStr(zs.coveragePct) : DASH,
thumbFile
};
});
// ---- zones: filtered per Report Contents (FR-7.4); dashes for unsprayed (FR-4.6)
const includedZoneIdx = zones.map((z, idx) => idx).filter(idx => contents.includeZoneDetail
&& (!contents.sprayedZonesOnly || analytics.zones[idx].lineCount > 0));
// Scale each zone's flow-integration volume (zs.volumeL) proportionally against the
// mission's Application.totalSprayMat total, so the zone breakdown always sums exactly
// to the mission "Actual Volume" KPI instead of two independently-computed figures that
// only agree to within a few percent. m.volumeL (the flow-integration mission total,
// still computed by report_util.js for avgFlowRate) is the same unit/method as each
// zone's own volumeL, so it's the right denominator for redistributing totalSprayMatSum
// by each zone's relative share of measured flow. Only valid for liquid jobs with real
// flow data; dry/granular jobs (KG, no meaningful lminApp) fall back to the raw,
// unscaled per-zone figure.
const volumeScale = (isLiquid && m.volumeL > 0 && totalSprayMatSum > 0) ? totalSprayMatSum / m.volumeL : null;
// Comma-joined names of the job's own active-ingredient products (excludes carriers
// like water/diluent, matching the Products table's own Active/Carrier distinction) —
// mission-wide, same value repeated on every zone, same as `crop: mission.crop` above.
const productNames = !utils.isEmptyArray(job.products)
? job.products
.filter(jp => jp.product && jp.product.type !== APTypes.CARRIER)
.map(jp => jp.product.name)
.join(', ') || DASH
: DASH;
const zonesDS = includedZoneIdx.map(idx => {
const zs = analytics.zones[idx];
const sprayed = zs.lineCount > 0;
const zoneImgOk = !failedZones.has(idx);
const zoneVolumeL = (volumeScale !== null && zs.volumeL !== null) ? zs.volumeL * volumeScale : zs.volumeL;
return {
zoneNum: idx + 1,
name: zs.name || `Zone ${idx + 1}`,
farm: mission.farm, // mission-wide, same value on every zone (same pattern as crop/product below)
crop: mission.crop,
product: productNames,
plannedArea: areaStr(zs.plannedAreaM2),
sprayedArea: sprayed ? areaStr(zs.sprayedAreaM2) : DASH,
coveragePct: sprayed ? pctStr(zs.coveragePct) : DASH,
volumeApplied: sprayed ? volStr(zoneVolumeL) : DASH,
avgAppRate: sprayed ? rateStr(zoneVolumeL, zs.sprayedAreaM2) : DASH,
startTime: sprayed ? todStr(zs.startTimeS) : DASH,
endTime: sprayed ? todStr(zs.endTimeS) : DASH,
flightTime: sprayed ? hm(zs.flightTimeS) : DASH,
sprayTime: sprayed ? hm(zs.sprayTimeS) : DASH,
avgTurnTime: sprayed ? secStr(zs.avgTurnTimeS) : DASH,
avgSpeed: sprayed ? speedStr(zs.avgSpeedMps) : DASH,
avgHeight: sprayed ? shortLenStr(zs.avgHeightM) : DASH,
avgFlowRate: sprayed ? flowStr(zs.avgFlowLmin) : DASH,
avgXtError: sprayed ? shortLenStr(zs.avgXtM) : DASH,
mapfile: zoneImgOk ? `${imgBase}/zone_${idx + 1}.jpg` : `${imgBase}/map.jpg`, // placeholder on capture failure (NFR-3.1)
zoneIndexLabel: `Zone ${idx + 1} of ${zones.length}`
};
});
// ---- lines: nested per zone via zoneNum; omitted entirely when the option is off
let linesDS = [];
if (contents.includeFlightLineStats) {
const included = new Set(includedZoneIdx);
linesDS = analytics.lines
.filter(l => included.has(l.zoneIdx))
.map((l, i) => ({
zoneNum: l.zoneIdx + 1,
lineNum: l.llnum,
startTime: todStr(l.startTimeS),
sprayTime: secStr(l.sprayTimeS),
sprayLength: lenStr(l.lengthM),
avgSpeed: speedStr(l.avgSpeedMps),
areaCovered: `${loc(utils.toArea(l.areaM2, isUS), 2)} ${utils.areaUnitString(isUS, true)}`,
// same global scale factor as the zone-level volumeApplied/avgAppRate above, so a
// zone's own total stays consistent with the sum of its own flight lines
appRate: rateStr((volumeScale !== null && l.volumeL !== null) ? l.volumeL * volumeScale : l.volumeL, l.areaM2),
avgXtError: shortLenStr(l.avgXtM),
turnTime: secStr(l.turnTimeS)
}));
// unsprayed zones keep the full table layout with a single dash row (FR-4.6)
for (const idx of includedZoneIdx) {
if (analytics.zones[idx].lineCount === 0)
linesDS.push({
zoneNum: idx + 1, lineNum: DASH, startTime: DASH, sprayTime: DASH, sprayLength: DASH,
avgSpeed: DASH, areaCovered: DASH, appRate: DASH, avgXtError: DASH, turnTime: DASH
});
}
}
// ---- products (legacy rate math, §6 shape) -----------------------------------
const products = [];
if (!utils.isEmptyArray(job.products)) {
for (const jp of job.products) {
let rate = jp.rate, unit = jp.unit;
const p = {
name: jp.product ? jp.product.name : '',
type: jp.product && jp.product.type === APTypes.CARRIER ? 'Carrier' : 'Active',
restricted: jp.product && jp.product.restricted ? 'Yes' : 'No',
epaReg: (jp.product && jp.product.epaReg) || DASH,
rateStr: `${loc(rate, 2)} ${utils.getProdUnit(unit)}`
};
rate = rate * coverageJobUnits;
if (unit === Units.OZ) { unit = Units.GAL; rate = utils.ozToGal(rate); }
p.totalRateStr = hasData || coverageJobUnits ? `${loc(rate, 2)} ${utils.getProdUnit(unit)}` : DASH;
products.push(p);
}
}
// A long product list AND a long remark both push content past Mission Overview's
// fixed page budget — a product-count-only check missed this: job 108 with a
// (data-level) duplicated 3-line remark overflowed at only 5 products, a count
// otherwise safe for the usual 2-line remark. Modeled as a shared "growth budget":
// each product row beyond the first, and each wrapped remark line beyond the first,
// costs about one unit; calibrated against three real/verified render-harness data
// points — (5 products, 2 lines)=safe, (6, 2)=overflow, (5, 3)=overflow — all land
// exactly on a budget of 5 units. REMARK_CHARS_PER_LINE=100 is deliberately on the
// low side (safe-but-approximate: 147 chars -> 2 lines, ~296 -> 3 lines are the only
// real calibration points; Stimulsoft's actual text layout isn't reproduced here),
// so it's biased toward over-estimating lines rather than under-estimating them —
// relocating a little earlier than strictly necessary costs nothing, unlike the
// under-relocation this replaces.
const REMARK_CHARS_PER_LINE = 100;
const OVERFLOW_BUDGET_UNITS = 5;
const remarkLines = Math.max(1, Math.ceil((mission.remark || '').length / REMARK_CHARS_PER_LINE));
mission.remarkOnCoverage = (products.length - 1) + (remarkLines - 1) > OVERFLOW_BUDGET_UNITS;
return {
reports: { type: 2 }, // 2 = Advanced Report (§6 field notes)
mission: [mission],
coverageCards,
zones: zonesDS,
lines: linesDS,
products,
weather: [] // filled by buildWeather (suppressed = stays empty)
};
}
/** Weather dataset — manual override or aggregated from the data; empty when neither (FR-2.7) */
async function buildWeather(job, fileIds, hasData, lang, apps) {
const isUS = !!job.measureUnit;
// Same source/join as legacy's Application.dataFile (controllers/job.js) — the list of
// imported flight files, independent of whether the weather values themselves are manual
// or aggregated.
const dataFile = !utils.isEmptyArray(apps) ? apps.map(a => a.fileName).filter(Boolean).join(', ') : DASH;
if (job.useCustWI && job.weatherInfo) {
const wi = job.weatherInfo;
// wi.temp is already entered in the job's own display unit (°F for US, °C otherwise —
// same field the Report Settings dialog labels), so it's shown as-is, not re-converted.
return [{
windSpd: utils.isNumber(wi.windSpd) ? `${utils.toLocaleStr(wi.windSpd, 1, lang)} kt` : DASH,
windDir: wi.windDir || DASH,
temp: utils.isNumber(wi.temp) ? `${utils.toLocaleStr(wi.temp, 1, lang)}${isUS ? '°F' : '°C'}` : DASH,
humid: utils.isNumber(wi.humid) ? `${utils.truncR(wi.humid, 0)}%` : DASH,
dataFile
}];
}
if (!hasData || utils.isEmptyArray(fileIds)) return [];
// Per-field validity (job_util.getDataWeatherInfoPerField): unlike the legacy query, one
// implausible field (e.g. a stuck temp sensor) no longer blanks the other three — each
// field is dashed independently based on its own average, not a shared all-or-nothing filter.
const result = await jobUtil.getDataWeatherInfoPerField(fileIds);
if (utils.isEmptyArray(result)) return [];
const w = result[0];
if (![w.avgWindSpd, w.avgWindDir, w.avgTemp, w.avgHumid].some(utils.isNumber)) return [];
return [{
windSpd: utils.isNumber(w.avgWindSpd) ? `${utils.toLocaleStr(utils.mpSecToKnot(w.avgWindSpd), 1, lang)} kt` : DASH,
windDir: utils.isNumber(w.avgWindDir) ? `${Math.round(w.avgWindDir)}° ${utils.deg2Compass(w.avgWindDir)}` : DASH, // degrees + cardinal (FR-2.7)
temp: utils.isNumber(w.avgTemp) ? utils.inCorF(w.avgTemp, isUS, true) : DASH, // avgTemp is stored in °C; same conversion legacy uses (job.js)
humid: utils.isNumber(w.avgHumid) ? `${utils.truncR(w.avgHumid, 0)}%` : DASH,
dataFile
}];
}
return {
preAdvancedReport_post,
generateAdvancedReport, // worker-callable without the HTTP layer (NFR-2.3)
};
};