795 lines
43 KiB
JavaScript
795 lines
43 KiB
JavaScript
'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)
|
||
};
|
||
};
|