'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) }; };