1561 lines
58 KiB
JavaScript
1561 lines
58 KiB
JavaScript
'use strict';
|
||
|
||
module.exports = function (locals) {
|
||
const
|
||
async = require('async'),
|
||
assert = require('assert'),
|
||
debug = require('debug')('agm:job'),
|
||
ObjectId = require('mongodb').ObjectId,
|
||
{ Job, JobLog, App, AppFile, AppDetail, Customer, JobAssign, Vehicle, Pilot, RptVar } = require('../model'),
|
||
Currencies = require('../helpers/currencies'),
|
||
path = require('path'),
|
||
fs = require('fs-extra'),
|
||
moment = require('moment'),
|
||
turf = require('@turf/turf'),
|
||
uniqid = require('uniqid'),
|
||
utils = require('../helpers/utils'),
|
||
jobUtil = require('../helpers/job_util'),
|
||
geoUtil = require('../helpers/geo_util'),
|
||
polyUtil = require('../helpers/poly_util'),
|
||
mongoUtil = require('../helpers/mongo'),
|
||
polylabel = require('polylabel'),
|
||
cloneDeep = require('clone-deep'),
|
||
simplify = require('simplify-path'),
|
||
webUtil = require('../helpers/web_util'),
|
||
{ JobStatus, JobInvoiceStatus } = require('../helpers/job_constants'),
|
||
{ Units, Errors, DEFAULT_LANG, CostingItemType } = require('../helpers/constants'),
|
||
{ AppParamError, AppError, AppAuthError, AppInputError } = require('../helpers/app_error'),
|
||
{ getFormattedAddress, getDocumentCountry } = require('../helpers/user_helper'),
|
||
env = require('../helpers/env'),
|
||
Joi = require('joi');
|
||
|
||
Joi.objectId = require('joi-objectid')(Joi);
|
||
|
||
|
||
/**
|
||
* Handles the GET request to retrieve a list of jobs based on the provided filters.
|
||
*
|
||
* @async
|
||
* @function getJobs_get
|
||
* @param {Object} req.query - The query parameters from the request.
|
||
* @param {string} [req.query.clientId] - The ID of the client to filter jobs by.
|
||
* @param {boolean} [req.query.jpo] - Whether to filter jobs by the pilot's ID.
|
||
* @param {string} [req.query.byTime] - A time range to filter jobs by creation date. E.g. '365d' for 365 days or '3m' for 3 months or '2021' for the year 2021
|
||
* or an array of ISO 8601 date strings for a date range or a ISO 8601 date. E.g.: ?byTime[]=2022-01-01&byTime[]=2025-04-02 or ?byTime=2022-01-01
|
||
* @param {number} [req.query.status] - The status of the jobs to filter by.
|
||
* @throws {AppAuthError} If the user information is not provided in the request.
|
||
* @throws {AppError} If the pilot does not exist when filtering by pilot.
|
||
* @returns {Promise<void>} Sends a JSON response containing the list of jobs.
|
||
*
|
||
* @description
|
||
* This function retrieves a list of jobs from the database based on the provided query parameters.
|
||
* It supports filtering by client ID, pilot ID, time range, and job status. The function constructs
|
||
* a MongoDB aggregation pipeline to fetch and process the job data, including client and job log
|
||
* information. The resulting jobs are returned as a JSON response.
|
||
*/
|
||
async function getJobs_get(req, res) {
|
||
const userInfo = req.userInfo;
|
||
if (!userInfo) AppAuthError.throw();
|
||
|
||
const clientId = req.query['clientId'];
|
||
let filter = {
|
||
markedDelete: { $in: [null, false] },
|
||
...(utils.isObjectId(clientId) ? { client: ObjectId(clientId) } : { byPuid: ObjectId(userInfo.puid) })
|
||
};
|
||
const jobsByPilot = utils.stringToBoolean(req.query['jpo']);
|
||
if (jobsByPilot) {
|
||
const pilot = await Pilot.findById(ObjectId(req.uid), '_id', { lean: true });
|
||
if (!pilot) AppError.throw(Errors.PILOT_NOT_EXIST);
|
||
|
||
filter['operator'] = pilot._id;
|
||
}
|
||
if (req.query['byTime']) {
|
||
filter = { ...filter, ... (mongoUtil.getDateFilter(req.query['byTime'], 'createdAt')) };
|
||
}
|
||
const status = Number(req.query['status']);
|
||
if (status && Object.values(JobStatus).includes(status)) {
|
||
filter['status'] = status;
|
||
}
|
||
|
||
const pipeline = [
|
||
{ $match: filter },
|
||
{
|
||
$project: {
|
||
_id: 1, orderNumber: 1, name: 1, createdAt: 1, startDate: 1, endDate: 1, status: 1, client: 1, costings: 1, invoiceStatus: 1, invoiceId: 1
|
||
}
|
||
},
|
||
{
|
||
$lookup: {
|
||
from: 'users',
|
||
let: { client: "$client" },
|
||
pipeline: [
|
||
{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$client"] }] } } },
|
||
{ $project: { name: 1, kind: 1 } }
|
||
],
|
||
as: 'client'
|
||
}
|
||
},
|
||
{ $unwind: "$client" },
|
||
{
|
||
$lookup: {
|
||
from: 'job_logs',
|
||
let: { job_id: "$_id" },
|
||
pipeline: [
|
||
{ $match: { $expr: { $and: [{ $eq: ["$job", "$$job_id"] }, { "$type": 2 }] } } },
|
||
{ $project: { date: 1, user: 1 } },
|
||
{ $group: { _id: "$user", date: { $max: "$date" } } },
|
||
{
|
||
$lookup: {
|
||
from: 'users',
|
||
let: { user_id: "$_id" },
|
||
pipeline: [
|
||
{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$user_id"] }] } } },
|
||
{ $project: { username: 1, _id: 0 } }
|
||
],
|
||
as: 'userD'
|
||
}
|
||
},
|
||
{ $unwind: "$userD" },
|
||
{ $project: { _id: 0, date: 1, "user": "$userD.username" } },
|
||
{ $sort: { date: -1, user: 1 } }
|
||
],
|
||
as: 'by'
|
||
}
|
||
},
|
||
{
|
||
$project: {
|
||
_id: 1, orderNumber: 1, name: 1, createdAt: 1, startDate: 1, endDate: 1, status: 1, client: 1, costings: 1, invoiceId: 1, invoiceStatus: { $ifNull: ['$invoiceStatus', JobInvoiceStatus.NONE] },
|
||
by: {
|
||
$cond: { if: { $and: [{ $gt: [{ $size: "$by" }, 0] }, { $eq: ["$status", JobStatus.DOWNLOWNED] }] }, then: "$by", else: "$$REMOVE" }
|
||
},
|
||
}
|
||
}
|
||
];
|
||
|
||
const jobs = await Job.aggregate(pipeline);
|
||
res.json(jobs);
|
||
}
|
||
|
||
async function createJob_post(req, res) {
|
||
const _job = req.body;
|
||
if (!_job) AppParamError.throw();
|
||
|
||
if (!utils.isEmptyArray(_job?.costings?.items)) {
|
||
_job.costings = handleCostingItems(_job.costings);
|
||
}
|
||
|
||
if (_job._id === 0) {
|
||
_job.cloneId = +_job.cloneId;
|
||
delete _job.rptOp;
|
||
}
|
||
delete _job._id;
|
||
const job = new Job(req.body); // To assign properties quickly
|
||
|
||
// If it is a cloned Job, find the original job then copy items to the new job.
|
||
if (!!(_job.cloneId)) {
|
||
const orgJob = await Job.findById(_job.cloneId, 'sprayAreas excludedAreas bufs waypoints places byPuid').lean();
|
||
if (orgJob) {
|
||
for (let p in orgJob) {
|
||
if (p != "_id")
|
||
job[p] = orgJob[p];
|
||
}
|
||
// Make sure appRate consistent between job and its spray areas
|
||
if (!utils.isEmptyArray(job.sprayAreas)) {
|
||
for (let i = 0; i < job.sprayAreas.length; i++) {
|
||
if (job.sprayAreas[i].properties.appRate != job.appRate) {
|
||
job.sprayAreas[i].properties.appRate = job.appRate;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
job.set('createdAt', undefined, { strict: false });
|
||
job.set('updatedAt', undefined, { strict: false });
|
||
}
|
||
|
||
const userInfo = req.userInfo;
|
||
if (!userInfo) AppAuthError.throw();
|
||
|
||
job.byPuid = userInfo.puid;
|
||
const savedJob = await job.save();
|
||
|
||
const insertedJob = savedJob.toObject();
|
||
insertedJob.client = _job.client;
|
||
insertedJob.product = _job.product;
|
||
insertedJob.operator = _job.operator;
|
||
insertedJob.vehicle = _job.vehicle;
|
||
res.json(insertedJob);
|
||
}
|
||
|
||
async function getJob_get(req, res) {
|
||
const jobId = req.params.job_id;
|
||
const withItems = req.query['withItems'] == "true" ? true : false;
|
||
const withLines = req.query['withLines'] == "true" ? true : false;
|
||
|
||
let exludeFields = withItems ? '' : '-excludedAreas -bufs -waypoints -places -heading -masterPoint';
|
||
if (!withLines) exludeFields += '-lines';
|
||
|
||
if (!Number(jobId) || !utils.isNumber(Number(jobId))) AppParamError.throw();
|
||
|
||
const job = await Job.findById(jobId)
|
||
.select(exludeFields)
|
||
.populate({ path: 'client', select: 'name' })
|
||
.populate({ path: 'operator', select: 'name' })
|
||
.populate({ path: 'vehicle', select: { 'name': 1 } })
|
||
.populate({ path: 'crop', select: 'name', skipInvalidIds: true })
|
||
.populate(withItems ? '' : 'products.product')
|
||
.populate(withItems ? 'sprayAreas.properties.crop' : '', 'name');
|
||
|
||
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
const _job = job.toObject();
|
||
|
||
_job.hasItems = !(utils.isEmptyArray(_job.sprayAreas));
|
||
if (!withItems)
|
||
delete _job.sprayAreas;
|
||
|
||
let jobwSumAreas;
|
||
// Get sum of all spray areas if the area was not set for the job
|
||
if (!withItems && (!_job.loadOp || !_job.loadOp.area)) {
|
||
jobwSumAreas = await Job.aggregate(
|
||
[
|
||
{ $match: { _id: _job._id } },
|
||
{ $unwind: { path: "$sprayAreas" } },
|
||
{
|
||
$group: {
|
||
_id: null,
|
||
totalArea: { $sum: "$sprayAreas.properties.area" },
|
||
}
|
||
}
|
||
]
|
||
);
|
||
}
|
||
|
||
if (!withItems) {
|
||
if (jobwSumAreas && jobwSumAreas.length && jobwSumAreas[0].totalArea) {
|
||
_job.loadOp = jobUtil.defLoadOp(_job.loadOp);
|
||
_job.loadOp.area = Number(utils.toArea(jobwSumAreas[0].totalArea, _job.measureUnit, false).toFixed(1)); // sqm2 to ha or acre
|
||
}
|
||
}
|
||
|
||
res.json(_job);
|
||
}
|
||
|
||
|
||
function handleCostingItems(inputCostings) {
|
||
const costingItemsSchema = Joi.object().keys({
|
||
billableArea: Joi.number().min(0).optional().default(0),
|
||
billableAmount: Joi.number().min(0).required().default(0),
|
||
currency: Joi.string().valid(...Object.keys(Currencies)).optional(),
|
||
items: Joi.array().items({
|
||
item: Joi.objectId().required(),
|
||
name: Joi.string().required(),
|
||
price: Joi.number().min(0).required(),
|
||
quantity: Joi.number().min(0).required(),
|
||
type: Joi.number().valid(...Object.values(CostingItemType)),
|
||
unit: Joi.number().valid(...Object.values(Units)),
|
||
}).required()
|
||
});
|
||
|
||
const { error, value } = costingItemsSchema.options({ stripUnknown: true }).validate(inputCostings);
|
||
if (error) AppInputError.throw(error.details[0].message)
|
||
|
||
const { items, billableArea, billableAmount, currency } = value;
|
||
return {
|
||
currency,
|
||
billableArea,
|
||
billableAmount: utils.toFixedNumber(billableAmount),
|
||
items
|
||
};
|
||
}
|
||
|
||
async function updateJob_put(req, res) {
|
||
/* Param Object:
|
||
{ job: Job,
|
||
updateItems: boolean,
|
||
updateStatus?: boolean,
|
||
delSprItems: any[],
|
||
useDefRate
|
||
}
|
||
*/
|
||
if (!req.params.job_id || !req.body.job) AppParamError.throw();
|
||
|
||
delete req.body.job._id;
|
||
|
||
let upJob = {};
|
||
const updateItems = req.body.updateItems || false;
|
||
const useDefRate = req.body.useDefRate || false;
|
||
const inJob = req.body.job;
|
||
|
||
if (!updateItems) {
|
||
upJob = inJob;
|
||
delete upJob.sprayAreas;
|
||
delete upJob.excludedAreas;
|
||
delete upJob.waypoints;
|
||
delete upJob.bufs;
|
||
delete upJob.places;
|
||
delete upJob.rptOp; // Not overwrite report options, only update after Prreview report
|
||
delete upJob.weatherInfo; // Not overwrite weather info, only update after Preview report
|
||
|
||
delete upJob.invoiceStatus;
|
||
delete upJob.invoiceId;
|
||
|
||
if (upJob.client && upJob.client._id)
|
||
upJob.client = upJob.client._id;
|
||
if (upJob.product && upJob.product._id)
|
||
upJob.product = upJob.product._id;
|
||
if (upJob.operator && upJob.operator._id)
|
||
upJob.operator = upJob.operator._id;
|
||
if (upJob.vehicle && upJob.vehicle._id)
|
||
upJob.vehicle = upJob.vehicle._id;
|
||
} else {
|
||
upJob.ttSprArea = inJob.ttSprArea;
|
||
}
|
||
|
||
if (updateItems) {
|
||
if (req.body.updateStatus)
|
||
upJob.status = inJob.status;
|
||
|
||
const [areas, xcls, waypoints, places] = await Promise.all([
|
||
jobUtil.cleanAreasAsync(inJob.sprayAreas), jobUtil.cleanAreasAsync(inJob.excludedAreas),
|
||
jobUtil.cleanGeoPointsAsync(inJob.waypoints), jobUtil.cleanGeoPointsAsync(inJob.places)
|
||
]);
|
||
upJob.sprayAreas = areas;
|
||
upJob.excludedAreas = xcls;
|
||
upJob.waypoints = waypoints;
|
||
upJob.places = places;
|
||
upJob.bufs = inJob.bufs;
|
||
}
|
||
else if (useDefRate && inJob['appRate']) {
|
||
await Job.updateMany(
|
||
{ _id: req.params.job_id, "sprayAreas.properties": { $exists: true } },
|
||
{ $set: { "sprayAreas.$[].properties.appRate": inJob.appRate } });
|
||
}
|
||
|
||
if (!utils.isEmptyArray(inJob?.costings?.items)) {
|
||
upJob.costings = handleCostingItems(inJob.costings);
|
||
}
|
||
|
||
const job = await Job.findOneAndUpdate({ _id: req.params.job_id }, upJob, { new: true }) // NOTES: disable validation for now
|
||
.populate({ path: 'client', select: 'name' })
|
||
.populate({ path: 'operator', select: 'name' })
|
||
.populate({ path: 'vehicle', select: 'name' })
|
||
.populate('crop', 'name')
|
||
.populate(updateItems ? '' : 'products.product')
|
||
.populate(updateItems ? 'sprayAreas.properties.crop' : '', 'name');
|
||
|
||
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
const retJob = job.toObject();
|
||
if (!updateItems) {
|
||
retJob.hasItems = (retJob.sprayAreas.length > 0);
|
||
delete retJob.sprayAreas;
|
||
delete retJob.excludedAreas;
|
||
delete retJob.bufs;
|
||
delete retJob.waypoints;
|
||
delete retJob.places;
|
||
}
|
||
else {
|
||
// Delete gridlines of deleted spray areas
|
||
const delSprItems = req.body.delSprItems;
|
||
if (!utils.isEmptyArray(delSprItems))
|
||
await jobUtil.deleteAreaLines(delSprItems.map(a => ObjectId(a)));
|
||
}
|
||
|
||
res.json(retJob);
|
||
}
|
||
|
||
async function deleteJob(req, res) {
|
||
const job = await Job.findById(req.params.job_id);
|
||
if (job) await job.removeFull();
|
||
|
||
res.json({ ok: true }).end();
|
||
}
|
||
|
||
/**
|
||
* Apply drift, checking and skipping or splitting into spray segments
|
||
* RULES: If the deposit location is NOT inside any XCLs, and if drift position is on an XCL, don’t plot or paint spray on
|
||
*/
|
||
function setDriftSegs(seg, xclZones, refUTM) {
|
||
if (utils.isEmptyArray(seg)) return seg;
|
||
|
||
const llUTM = new locals.LatLonUTM(0, 0), utmBack = locals.UTM.newInstance(refUTM.zone, refUTM.hemisphere, 0, 0);
|
||
let cur = 0, orgUtmPnt, driftedLL, depLL, segs = [], skip;
|
||
let _seg = cloneDeep(seg); // Clone the seg for adding drifts
|
||
|
||
while (cur <= (_seg.length - 1)) {
|
||
skip = false;
|
||
llUTM.lat = _seg[cur].lat, llUTM.lon = _seg[cur].lon;
|
||
orgUtmPnt = llUTM.toUtm(refUTM.zone, refUTM.hemisphere); // Current ll to UTM
|
||
|
||
if (utils.isNumber(_seg[cur].driftX) && utils.isNumber(_seg[cur].driftY) && (_seg[cur].driftX !== 0.0 || _seg[cur].driftY !== 0.0)) {
|
||
|
||
utmBack.easting = orgUtmPnt.easting + _seg[cur].driftX; utmBack.northing = orgUtmPnt.northing + _seg[cur].driftY;
|
||
driftedLL = utmBack.toLatLon();
|
||
_seg[cur].lat = driftedLL.lat, _seg[cur].lon = driftedLL.lon;
|
||
}
|
||
if (!utils.isEmptyArray(xclZones) && utils.isNumber(_seg[cur].depositX) && utils.isNumber(_seg[cur].depositX)) {
|
||
if (polyUtil.isPointinPolys(_seg[cur].lat, _seg[cur].lon, xclZones)) {
|
||
utmBack.easting = orgUtmPnt.easting + _seg[cur].depositX; utmBack.northing = orgUtmPnt.northing + _seg[cur].depositY;
|
||
depLL = utmBack.toLatLon();
|
||
// Scan until get excluded point => add the previous point to end a segment
|
||
if (!polyUtil.isPointinPolys(depLL.lat, depLL.lng, xclZones)) {
|
||
// Skip the point or split seg here
|
||
if (segs.length && segs[segs.length - 1].length && cur != segs.length - 1)
|
||
segs.push([]);
|
||
skip = true;
|
||
}
|
||
}
|
||
}
|
||
if (!skip) {
|
||
if (!segs.length)
|
||
segs[0] = [_seg[cur]];
|
||
else
|
||
segs[segs.length - 1].push(_seg[cur]);
|
||
}
|
||
|
||
cur++;
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
function applyDrifts(segs, xclZones, refUTM) {
|
||
let _segs = [], appliedSegs;
|
||
for (let i = 0; i < segs.length; i++) {
|
||
appliedSegs = setDriftSegs(segs[i], xclZones, refUTM);
|
||
appliedSegs && (_segs = [..._segs, ...appliedSegs]);
|
||
}
|
||
return _segs;
|
||
}
|
||
|
||
/**
|
||
* Get Job Data by Id
|
||
* @param {*} jobId
|
||
* @param {*} selectFields
|
||
* @param {*} Ops Query Options with
|
||
* {
|
||
* wApps: with application info or not true/false,
|
||
* dataOp 0: spray inside paths only, 1: spray paths, 2: flight paths only, 3: both spray-in and flight paths, 4: spray and flight paths,
|
||
* wFileId: true/false whether to include fileId or not
|
||
* }
|
||
* @returns wApps true ? { jobId: job._id, measureUnit: job.measureUnit, apps: [], fileIds: [], data: [appFileData] } : [appFileData]
|
||
* appFileData { id:(appfile Id), file: filename, data: (arrays of sprayed segments) [[[lat, lon]]] }
|
||
**/
|
||
async function getAppDataByJobId(jobId, selectFields, { wApps = false, dataOp = 0, wFileId = false, withJob = false }) {
|
||
const job = await Job.findById(jobId).lean();
|
||
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
const retData = wApps ?
|
||
withJob ? ({ job: job, apps: [], fileIds: [], data: [] }) : ({ jobId: job._id, measureUnit: job.measureUnit, apps: [], fileIds: [], data: [] })
|
||
: [];
|
||
|
||
const projection = { _id: 1, fileName: 1, totalSprayed: 1, startDateTime: 1, endDateTime: 1, totalFlightTime: 1, totalSprayTime: 1, totalTurnTime: 1 };
|
||
const _apps = await App.find({ 'jobId': jobId, 'status': 3, 'totalSprayed': { $ne: null }, markedDelete: { $ne: true } }, projection).sort({ 'startDateTime': 1 }).lean();
|
||
if (utils.isEmptyArray(_apps)) return retData;
|
||
retData.apps = _apps;
|
||
|
||
let afSels = '_id name totalSprayed'; if (job.status === JobStatus.ARCHIVED) afSels += ' data';
|
||
const appFiles = await AppFile.find({ appId: { $in: _apps.map(it => it._id) } }, afSels).sort("agn").lean();
|
||
|
||
if (job.status === JobStatus.ARCHIVED) {
|
||
appFiles.map(af => {
|
||
const afData = { file: af.name, data: af.data };
|
||
if (wApps) {
|
||
retData.fileIds.push(af._id);
|
||
retData.data.push(afData)
|
||
} else retData.push(afData);
|
||
});
|
||
return retData;
|
||
}
|
||
|
||
if (!utils.isEmptyArray(appFiles)) {
|
||
let refUTM;
|
||
if (dataOp != 2) {
|
||
// Calculate reference UTM Zone on job's areas
|
||
let allAreas = [...job.sprayAreas || [], ...job.excludedAreas || []];
|
||
for (let zone of allAreas)
|
||
zone.type = 'Feature';
|
||
if (!utils.isEmptyArray(allAreas)) {
|
||
const centerP = turf.center({ type: "FeatureCollection", features: allAreas });
|
||
if (centerP) {
|
||
const point = turf.getCoord(centerP);
|
||
refUTM = new locals.LatLonUTM(point[1], point[0]).toUtm();
|
||
}
|
||
}
|
||
}
|
||
|
||
let afDetails;
|
||
for await (const appFile of appFiles) {
|
||
if (wApps) retData.fileIds.push(appFile._id);
|
||
|
||
// .sort('gpsTime') // No need, to avoid pass midnight reset gpsTime data issue
|
||
afDetails = await AppDetail.find({ 'fileId': appFile._id }).select(selectFields + ' driftX driftY depositX depositY').lean();
|
||
if (!utils.isEmptyArray(afDetails)) {
|
||
// Handle the worst case if job has no areas.
|
||
if (!refUTM) refUTM = new locals.LatLonUTM(afDetails[0].lat, afDetails[0].lon).toUtm();
|
||
let segs = [], fsegs = [], sprayOp = dataOp < 2 ? dataOp : (dataOp - 2) - 1, fileData;
|
||
|
||
if (dataOp != 2) {
|
||
segs = !(/^.*.asc$/i.test(appFile.name)) && sprayOp < 1 ? getSprayOnSegments(afDetails, true) : getSprayOnSegments(afDetails, false);
|
||
segs = applyDrifts(segs, job.excludedAreas, refUTM);
|
||
|
||
for (let k = 0; k < segs.length; k++) {
|
||
if (segs[k].length > 2) {
|
||
const lonlats = segs[k].map(it => [it.lon, it.lat]);
|
||
const simplified = simplify(lonlats, 0.00001);
|
||
segs[k] = simplified.map(it => [it[1], it[0]]);
|
||
}
|
||
else
|
||
segs[k] = segs[k].map(it => [it.lat, it.lon]);
|
||
}
|
||
}
|
||
fileData = { file: appFile.name, data: segs };
|
||
if (wFileId) fileData.id = appFile._id;
|
||
|
||
if (dataOp >= 2) {
|
||
fsegs = getFlightSegments(afDetails);
|
||
for (let k = 0; k < fsegs.length; k++) {
|
||
if (!fsegs[k].length) continue;
|
||
|
||
if (fsegs[k].length > 2) {
|
||
const lonlats = fsegs[k].map(it => [it.lon, it.lat]);
|
||
const simplified = simplify(lonlats, 0.000019);
|
||
fsegs[k] = simplified.map(it => [it[1], it[0]]);
|
||
}
|
||
else
|
||
fsegs[k] = fsegs[k].map(it => [it.lat, it.lon]);
|
||
}
|
||
fileData['fdata'] = fsegs;
|
||
}
|
||
if (wApps) retData.data.push(fileData); else retData.push(fileData);
|
||
}
|
||
}
|
||
}
|
||
return retData;
|
||
}
|
||
|
||
function getFlightSegments(data) {
|
||
if (!data || utils.isEmptyArray(data)) return [];
|
||
|
||
let start = 0, cur = 1, seg = [], segs = [];
|
||
const MAX_DIST_MET = 100;
|
||
|
||
while (cur < data.length) {
|
||
if ((geoUtil.distance([data[cur - 1].lat, data[cur - 1].lon], [data[cur].lat, data[cur].lon]) >= MAX_DIST_MET) || cur === data.length - 1) {
|
||
|
||
seg = data.slice(start, (cur + 1));
|
||
if (seg.length > 1) {
|
||
segs.push(seg);
|
||
}
|
||
seg = [];
|
||
start = cur;
|
||
}
|
||
cur++;
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
/**
|
||
* Get Spray-On segments
|
||
* @param {*} data
|
||
* @param {*} sprayInOnly
|
||
*/
|
||
function getSprayOnSegments(data, sprayInOnly) {
|
||
if (!data || utils.isEmptyArray(data)) return [];
|
||
|
||
let llnum = data[0].llnum;
|
||
let start = 0, cur = 1, seg = [], segs = [];
|
||
while (cur < data.length) {
|
||
if (data[cur - 1].sprayStat == 0 && data[cur].sprayStat == 0 || data[cur - 1].sprayStat == 0 && data[cur].sprayStat != 0) {
|
||
llnum = data[cur].llnum;
|
||
start++; cur++;
|
||
continue;
|
||
}
|
||
|
||
if (llnum !== data[cur].llnum || data[cur].sprayStat === 3
|
||
|| endSegChecker(data[cur].sprayStat, data[cur - 1].sprayStat)
|
||
|| (geoUtil.distance([data[cur - 1].lat, data[cur - 1].lon], [data[cur].lat, data[cur].lon]) >= 1)
|
||
|| (sprayInOnly && (data[cur].sprayStat - data[cur - 1].sprayStat) > 99)
|
||
|| cur === data.length - 1) {
|
||
|
||
seg = data.slice(start, cur);
|
||
if (seg.length > 1) {
|
||
if (sprayInOnly) { // Trim ouside points at beginning or end
|
||
while (seg.length && (seg[0].satsIn < 99 || seg[0].sprayStat == 0)) seg.shift();
|
||
let g = seg.length - 1;
|
||
while (g > 0) {
|
||
if (seg[g].satsIn < 99) {
|
||
seg.splice(g, 1);
|
||
g--;
|
||
}
|
||
else break;
|
||
}
|
||
}
|
||
if (seg.length > 1)
|
||
segs.push(seg);
|
||
}
|
||
seg = [];
|
||
start = cur;
|
||
}
|
||
llnum = data[cur].llnum;
|
||
cur++;
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
function endSegChecker(cur, prev) {
|
||
return (cur === 0 && prev === 1) || (cur === 0 && prev === 10) || (cur === 3 && prev === 0) || (cur === 0 && prev === 3) || (cur <= 0 && prev > 0 || (cur === 3 && prev === 1));
|
||
}
|
||
|
||
async function getData_post(req, res) {
|
||
const jobId = req.body.jobId;
|
||
if (!jobId) AppParamError.throw();
|
||
|
||
const wApps = (req.body.inside != undefined);
|
||
let dataOp = req.body.dataOp || 0;
|
||
|
||
if (wApps) dataOp = req.body.inside == 0 ? 1 : 0;
|
||
|
||
// '-_id lat lon head alt grSpeed sprayStat lminApp timeAdv swath gpsTime'
|
||
const jobAppData = await getAppDataByJobId(jobId, '-_id lat lon sprayStat llnum gpsTime satsIn stdHdop', { wApps, dataOp });
|
||
let result = { jobId: jobId, data: jobAppData ? (wApps ? jobAppData['data'] : jobAppData) : [] };
|
||
|
||
// Get the aggregated weather info
|
||
if (wApps && jobAppData && !utils.isEmptyArray(jobAppData.fileIds)) {
|
||
const wi = await jobUtil.getDataWeatherInfo(jobAppData.fileIds);
|
||
if (wi && wi.length)
|
||
result['weatherInfo'] = ({
|
||
windSpd: utils.mpSecToKnot(wi[0]['avgWindSpd']).toFixed(1),
|
||
windDir: utils.deg2Compass(wi[0]['avgWindDir']),
|
||
temp: utils.inCorF(wi[0]['avgTemp'], jobAppData.measureUnit, false),
|
||
humid: utils.truncR(wi[0]['avgHumid'], 0)
|
||
});
|
||
}
|
||
res.json(result ? result : []);
|
||
}
|
||
|
||
/**
|
||
* Get Report Setting values for the job.
|
||
* @returns If both coverage and actualVol values exist, return them to the client's request as is otherwise gather from imported files.
|
||
* Notes: the values's units are based on the job's measurement system
|
||
*/
|
||
async function getReportOps_get(req, res) {
|
||
const jobId = req.body.jobId;
|
||
if (!jobId)
|
||
return res.json(null).end();
|
||
|
||
let cvrVal = 0, actVol = 0;
|
||
|
||
const _job = await Job.findById(jobId, { rptOp: 1, measureUnit: 1, appRate: 1, appRateUnit: 1, swathWidth: 1 }).lean();
|
||
if (!_job) return res.json(null).end(); // Ignore error for now even if the job is being or was just deleted
|
||
|
||
const numOfApps = await App.countDocuments({ jobId: _job._id });
|
||
if (numOfApps) {
|
||
if (_job.rptOp) {
|
||
if (_job.rptOp.coverage)
|
||
cvrVal = utils.toArea(_job.rptOp.coverage, _job.measureUnit, true);
|
||
if (_job.rptOp.actualVol)
|
||
actVol = utils.toVolume(_job.rptOp.actualVol, (_job.appRateUnit !== 2 && _job.appRateUnit !== 4), _job.measureUnit);
|
||
}
|
||
|
||
if (!cvrVal || !actVol) {
|
||
const results = await App.aggregate([
|
||
{
|
||
$match: { jobId: jobId }
|
||
},
|
||
{
|
||
$group: {
|
||
_id: null,
|
||
coverage: { $sum: "$totalSprayed" },
|
||
totalLength: { $sum: "$totalSprLength" },
|
||
actualVol: { $sum: "$totalSprayMat" }
|
||
}
|
||
}]);
|
||
|
||
// Total Coverage = Total Coverage from AgNav data + Total Coverage by Length (Non-AgNav data)
|
||
if (results && results.length > 0) {
|
||
if (!cvrVal) {
|
||
if (results[0].coverage)
|
||
cvrVal = utils.toArea(results[0].coverage, _job.measureUnit, true);
|
||
if (results[0].totalLength && _job.swathWidth)
|
||
cvrVal += utils.toArea((results[0].totalLength * utils.toMeter(_job.swathWidth, _job.measureUnit)) * 1e-4, _job.measureUnit, true);
|
||
}
|
||
if (!actVol) {
|
||
if (results[0].actualVol)
|
||
actVol = utils.toVolume(results[0].actualVol, (_job.appRateUnit !== 2 && _job.appRateUnit !== 4), _job.measureUnit);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const rptOp = {
|
||
areaSize: _job.rptOp && _job.rptOp.areaSize ? utils.toArea(_job.rptOp.areaSize, _job.measureUnit, true) : 0,
|
||
printArea: (_job.rptOp && _job.rptOp['printArea'] !== undefined) ? _job.rptOp['printArea'] : true,
|
||
coverage: cvrVal,
|
||
appRate: _job.appRate,
|
||
useActualVol: (_job.rptOp && _job.rptOp['useActualVol'] !== undefined) ? _job.rptOp['useActualVol'] : false,
|
||
actualVol: actVol
|
||
};
|
||
|
||
res.json(rptOp);
|
||
}
|
||
|
||
async function preAppReport_post(req, res) {
|
||
const input = req.body;
|
||
const jobId = input.jobId;
|
||
let job, sprayData, hasData = false, lang = input.lang || DEFAULT_LANG, rptDS;
|
||
|
||
const theJob = await Job.findById(jobId)
|
||
.populate({
|
||
path: 'client',
|
||
select: '-password',
|
||
populate: {
|
||
path: 'Country', model: 'Country', select: 'code name -_id',
|
||
foreignField: 'code', // Join on the 'code' field in Country model
|
||
localField: 'country' // Match with the 'country' field in client
|
||
}
|
||
})
|
||
.populate({
|
||
path: 'operator',
|
||
select: '-password',
|
||
populate: {
|
||
path: 'Country', model: 'Country', select: 'code name -_id',
|
||
foreignField: 'code', // Join on the 'code' field in Country model
|
||
localField: 'country' // Match with the 'country' field in operator
|
||
}
|
||
})
|
||
.populate({ path: 'vehicle', select: '-password' })
|
||
.populate('products.product', 'name type restricted epaReg')
|
||
.populate('crop', 'name');
|
||
|
||
if (!theJob) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
job = theJob.toObject();
|
||
|
||
// Save report settings to the corresponding job.
|
||
let updateVars = {};
|
||
|
||
if (input.rptOp) {
|
||
const rptOp = Object.assign({}, input.rptOp);
|
||
// Check and convert report settings's values to Metric if the job is in US Measurement
|
||
if (job.measureUnit) {
|
||
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);
|
||
}
|
||
updateVars["rptOp"] = rptOp;
|
||
}
|
||
|
||
updateVars["useCustWI"] = input.useCustWI;
|
||
updateVars["weatherInfo"] = input.weatherInfo;
|
||
|
||
if (Object.keys(updateVars).length) {
|
||
// Update the custom weather info to the job.
|
||
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;
|
||
}
|
||
|
||
const genFolder = uniqid(`app_${jobId}_`);
|
||
const tempFolder = path.join(env.TEMP_DIR, 'report', genFolder);
|
||
const targetFolder = path.join(env.REPORT_DIR, 'dat', genFolder);
|
||
let reportWebTempPath = `${req.protocol}://${req.hostname}/report/${genFolder}/`;
|
||
|
||
job.sprayAreas = await setPolysWCenter(job.sprayAreas);
|
||
job.excludedAreas = await setPolysWCenter(job.excludedAreas);
|
||
|
||
const customer = await Customer.findOne({ '_id': job.byPuid }, '-password', { lean: true })
|
||
.populate({ path: 'Country', select: 'code name -_id', model: 'Country' })
|
||
.lean();
|
||
|
||
let dataOp = 2;
|
||
if (input.showSprayed) {
|
||
if (input.showFlights)
|
||
dataOp = input.sprOp.dataOp == 0 ? 4 : 3;
|
||
else
|
||
dataOp = input.sprOp.dataOp == 0 ? 1 : 0;
|
||
}
|
||
if (input.showSprayed || input.showFlights) {
|
||
sprayData = await getAppDataByJobId(jobId, '-_id lat lon sprayStat llnum gpsTime xTrack satsIn', { wApps: true, dataOp });
|
||
hasData = !!(sprayData && !utils.isEmptyArray(sprayData.data));
|
||
}
|
||
|
||
// Copy template file to temp folder
|
||
await fs.copy(path.join(process.cwd(), 'public/sprayMap.html'), path.join(tempFolder, 'sprayMap.html'));
|
||
|
||
// Create data json file
|
||
const data = {
|
||
premium: (customer && customer.premium || 0),
|
||
job: job,
|
||
params: input.params,
|
||
data: hasData ? sprayData.data : null,
|
||
sprOp: input.sprOp,
|
||
obs: input.obs || [],
|
||
colors: input.colors || { sprayZone: 'blue', fpColor: 'lime' }
|
||
};
|
||
await fs.writeFile(path.join(tempFolder, 'spraydata.js'), 'var req=' + JSON.stringify(data, null, 2) + ';', 'utf-8');
|
||
|
||
await fs.ensureDir(path.join(targetFolder, 'map'));
|
||
// Capture the picture of the area with spray paths in a map webpage
|
||
await webUtil.webShot({
|
||
url: reportWebTempPath + 'sprayMap.html',
|
||
type: 'jpeg', quality: 90, width: input.params.width, height: input.params.height,
|
||
path: path.join(targetFolder, 'map') + '.jpg'
|
||
});
|
||
|
||
// Consolidate the Dataset and related data for the report so the client then can render the report in Report Viewer
|
||
const numOfApps = sprayData && sprayData.apps ? sprayData.apps.length : 0;
|
||
|
||
const startApp = numOfApps ? sprayData.apps[0] : null;
|
||
let endApp;
|
||
if (numOfApps > 0) {
|
||
endApp = numOfApps === 1 ? startApp : sprayData.apps[sprayData.apps.length - 1];
|
||
}
|
||
|
||
// TODO: Handle the case the actVol is preferred or input manually
|
||
const totalSprayedArea = Number(input.rptOp.coverage);
|
||
const appRate = input.rptOp && input.rptOp.appRate ? Number(input.rptOp.appRate) : job.appRate;
|
||
let totalVolume = totalSprayedArea * appRate;
|
||
let totalRateUnit = job.appRateUnit;
|
||
if (job.measureUnit && job.appRateUnit === 0) {
|
||
totalVolume = utils.ozToGal(totalVolume);
|
||
totalRateUnit = 1; // gals
|
||
}
|
||
let actVolAdj = 1; // Actual volume different adjustment factor
|
||
if (input.rptOp.useActualVol && input.rptOp.actualVol > 0 && input.rptOp.actualVol !== totalVolume) {
|
||
actVolAdj += (input.rptOp.actualVol - totalVolume) / totalVolume;
|
||
totalVolume = totalVolume * actVolAdj;
|
||
}
|
||
|
||
moment.locale(lang);
|
||
const planStartDate = moment(job.startDate);
|
||
const planEndDate = moment(job.endDate);
|
||
const actStartDate = moment.utc(startApp ? startApp.startDateTime : null);
|
||
const actEndDate = moment.utc(endApp ? endApp.endDateTime : null);
|
||
// Total area coverage used to calculate the Total Vol used of products
|
||
const totalCoverage = utils.roundTo(!hasData ? input.totalArea : totalSprayedArea, 1);
|
||
|
||
rptDS = makeJobAppDataSource(job, customer, `https://${req.hostname}/reports/dat/${genFolder}/map.jpg`, lang);
|
||
let app = {
|
||
appId: "app1", jobId: jobId, orderNum: job.orderNum || '',
|
||
planStart: planStartDate.isValid() ? planStartDate.format("MMM DD, YYYY") : '',
|
||
planEnd: planEndDate.isValid() ? planEndDate.format("MMM DD, YYYY") : '',
|
||
appRate: '', actStart: '', actEnd: '', actStartTime: '', actEndTime: '', totalArea: '', totalSprayed: '', totalVolume: '',
|
||
dataFile: '', windSpd: "", windDir: "", temp: "", humid: "",
|
||
};
|
||
if (numOfApps) {
|
||
rptDS.reports.type = 1; // Application Report
|
||
app.appRate = utils.toLocaleStr(appRate, 2, lang) + ' ' + utils.rateUnitString(job.appRateUnit, true);
|
||
app.actStart = actStartDate.isValid() ? actStartDate.format("MMM DD, YYYY") : '';
|
||
app.actEnd = actEndDate.isValid() ? actEndDate.format("MMM DD, YYYY") : '';
|
||
app.actStartTime = actStartDate.isValid() ? actStartDate.format('HH:mm:ss') : '';
|
||
app.actEndTime = actEndDate.isValid() ? actEndDate.format('HH:mm:ss') : '';
|
||
app.totalSprayed = totalSprayedArea ? utils.toLocaleStr(totalSprayedArea, 1, lang) + ' ' + utils.areaUnitString(job.measureUnit, true) : '';
|
||
// Total used volume, estimated = total applied area * appRate
|
||
app.totalVolume = totalVolume ? utils.toLocaleStr(totalVolume, 1, lang) + ' ' + utils.rateUnitString(totalRateUnit, true, 1) : '';
|
||
app.dataFile = sprayData.apps.map(i => i.fileName).join(',');
|
||
|
||
let totalFlightTime = 0, totalSprayTime = 0, totalTurnTime = 0;
|
||
for (let i = 0; i < sprayData.apps.length; i++) {
|
||
const app = sprayData.apps[i];
|
||
if (app.totalSprayTime)
|
||
totalSprayTime += app.totalSprayTime;
|
||
if (app.totalTurnTime)
|
||
totalTurnTime += app.totalTurnTime;
|
||
if (app.totalFlightTime)
|
||
totalFlightTime += app.totalFlightTime;
|
||
}
|
||
app.totalFlightTime = utils.secondsToHMS(totalFlightTime, 2);
|
||
app.totalSprayTime = utils.secondsToHMS(totalSprayTime, 2);
|
||
app.totalTurnTime = utils.secondsToHMS(totalTurnTime, 2);
|
||
}
|
||
if (input.rptOp && input.rptOp.printArea && utils.isNumber(input.rptOp.areaSize)) {
|
||
app.totalArea = `${utils.toLocaleStr(Number(input.rptOp.areaSize), 1, lang)} ${utils.areaUnitString(job.measureUnit, true)}`;
|
||
}
|
||
rptDS.apps.push(app);
|
||
|
||
if (!utils.isEmptyArray(job.products)) {
|
||
rptDS.products = [];
|
||
let rate, unit, prod;
|
||
for (let i = 0; i < job.products.length; i++) {
|
||
rate = job.products[i].rate; unit = job.products[i].unit;
|
||
prod = {
|
||
id: job.products[i]._id,
|
||
jobId: jobId,
|
||
name: job.products[i].product.name,
|
||
type: job.products[i].product.type,
|
||
epaReg: job.products[i].product.epaReg || '',
|
||
restricted: job.products[i].product.restricted || false,
|
||
rateStr: utils.toLocaleStr(rate, 2, lang) + ' ' + utils.getProdUnit(unit)
|
||
};
|
||
rate = (rate * totalCoverage) * actVolAdj;
|
||
if (unit === Units.OZ) {
|
||
unit = Units.GAL;
|
||
rate = utils.ozToGal(rate);
|
||
}
|
||
prod["totalRateStr"] = utils.toLocaleStr(rate, 2, lang) + ' ' + utils.getProdUnit(unit);
|
||
rptDS.products.push(prod);
|
||
}
|
||
}
|
||
|
||
if (hasData) {
|
||
// Use custom weather info
|
||
if (job.useCustWI && job.weatherInfo) {
|
||
rptDS.apps[0].windSpd = `${job.weatherInfo.windSpd} kt`;
|
||
rptDS.apps[0].windDir = job.weatherInfo.windDir;
|
||
rptDS.apps[0].temp = `${utils.truncR(job.weatherInfo.temp, 0)} ${(job.measureUnit ? "°F" : "°C")}`;
|
||
rptDS.apps[0].humid = `${utils.truncR(job.weatherInfo.humid, 0)} %`;
|
||
|
||
} else { // Use aggregated weather info from data
|
||
const result = await jobUtil.getDataWeatherInfo(sprayData.fileIds);
|
||
if (result && result.length > 0) {
|
||
const windDir = (result[0].maxWindDir - result[0].minWindDir) > 22.5
|
||
? utils.deg2Compass(result[0].minWindDir) + ' - ' + utils.deg2Compass(result[0].maxWindDir) : utils.deg2Compass(result[0].minWindDir);
|
||
|
||
const windSpd = result[0].maxWindSpd > result[0].minWindSpd ?
|
||
utils.toLocaleStr(utils.mpSecToKnot(result[0].minWindSpd), 1, lang) + ' - ' + utils.toLocaleStr(utils.mpSecToKnot(result[0].maxWindSpd), 1, lang)
|
||
: utils.toLocaleStr(utils.mpSecToKnot(result[0].minWindSpd), 1, lang);
|
||
|
||
rptDS.apps[0].windSpd = `${windSpd} kt`;
|
||
rptDS.apps[0].windDir = windDir;
|
||
rptDS.apps[0].temp = utils.inCorF(result[0].avgTemp, job.measureUnit, true);
|
||
rptDS.apps[0].humid = `${utils.truncR(result[0].avgHumid, 0)} %`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Write the report datasource file to the temp folder
|
||
await fs.writeFile(path.join(targetFolder, 'rptDS.json'), JSON.stringify(rptDS, null, 2), 'utf-8');
|
||
|
||
const sApplicatorId = customer && customer._id.toHexString();
|
||
const existed = await fs.pathExists(path.join(env.REPORT_DIR, `app_${sApplicatorId}.mrt`));
|
||
const reportId = existed ? `app_${sApplicatorId}` : 'app';
|
||
|
||
res.json({
|
||
rid: reportId,
|
||
path: genFolder,
|
||
c: reportId === 'app' ? 0 : 1 // Whether it is a customized report
|
||
});
|
||
/* Notes: BC we want to keep created temporary files for a while so the customer can still view them in the report.
|
||
Thus, they are will be deleted by the maintenainer app periodically */
|
||
}
|
||
|
||
async function getRptVars_post(req, res) {
|
||
const input = req.body;
|
||
const vars = await RptVar.find({ $and: [{ user: ObjectId(req.uid) }, { rpt: input.rpt }] }); // TODO: Check with lean:true ops later
|
||
res.json(!utils.isEmptyArray(vars) ? vars[0].toObject() : null);
|
||
}
|
||
|
||
async function setRptVars_post(req, res) {
|
||
const ops = req.body;
|
||
if (!ops['rpt']) AppParamError.throw();
|
||
|
||
await RptVar.deleteMany({ rpt: ops.rpt, user: ObjectId(req.uid) });
|
||
|
||
if (ops.vars) {
|
||
await RptVar.insertMany({ rpt: String(ops.rpt), user: ObjectId(req.uid), vars: ops.vars });
|
||
}
|
||
res.json({ ok: true });
|
||
}
|
||
|
||
async function saveReport_post(req, res) {
|
||
const ops = req.body;
|
||
await fs.writeFile(path.join(env.REPORT_DIR, ops.rid + '.mrt'), ops.content);
|
||
return res.json({ ok: true });
|
||
}
|
||
|
||
//TODO: Make this as async fnc ???
|
||
function preLoadReport_post(req, res, next) {
|
||
if (!req.body || !req.body.jobId || !req.body.loadOp || !req.body.loadOp.area)
|
||
return next(AppParamError.create());
|
||
|
||
const input = req.body, jobId = input.jobId;
|
||
input.loadOp.area = +input.loadOp.area;
|
||
input.loadOp.loads = +input.loadOp.loads;
|
||
|
||
let _job, rptDS;
|
||
const lang = input.lang || DEFAULT_LANG;
|
||
|
||
async.series([
|
||
function (callback) {
|
||
Job.findById(jobId)
|
||
.populate({ path: 'client', select: '-password' })
|
||
.populate({ path: 'operator', select: '-password' })
|
||
.populate({ path: 'vehicle', select: '-password' })
|
||
.populate('products.product', 'name type rate unit')
|
||
.lean()
|
||
.then(job => {
|
||
if (!job) {
|
||
callback(AppError.create(Errors.JOB_NOT_FOUND));
|
||
return;
|
||
}
|
||
_job = job;
|
||
callback();
|
||
})
|
||
.catch(err => {
|
||
callback(err);
|
||
});
|
||
},
|
||
function (callback) { // Save report settings to the corresponding job.
|
||
Job.findOneAndUpdate({ _id: jobId }, { $set: { "loadOp": Object.assign({}, input.loadOp) } }, { new: true, lean: true }, (err, data) => {
|
||
if (err) throw err;
|
||
if (!data)
|
||
return callback(AppError.create(Errors.JOB_NOT_FOUND));
|
||
callback();
|
||
});
|
||
}
|
||
], function (err) {
|
||
if (err) {
|
||
return next(err);
|
||
}
|
||
const genFolder = uniqid(`loadsheet_${jobId}_`);
|
||
const targetFolder = path.join(env.REPORT_DIR, 'dat', genFolder);
|
||
|
||
rptDS = makeJobAppDataSource(_job, null, '', lang, input.loadOp);
|
||
|
||
async.series([
|
||
function (callback) {
|
||
moment.locale(lang);
|
||
rptDS.reports = {
|
||
date: moment(input.loadOp.date).format("MMM DD, YYYY")
|
||
};
|
||
|
||
if (!utils.isEmptyArray(_job.products)) {
|
||
rptDS.products = [];
|
||
rptDS.loads = [];
|
||
rptDS.load_details = [];
|
||
|
||
let numLoads = input.loadOp.loads, totalLoad = _job.appRate * input.loadOp.area, areaPerLoad = (input.loadOp.area / numLoads);
|
||
if (input.loadOp.loadType != 0 || totalLoad % input.loadOp.capacity == 0 || numLoads == 1) {
|
||
rptDS.loads.push({
|
||
id: 1,
|
||
loads: `1 - ${numLoads}`,
|
||
areaPL: areaPerLoad,
|
||
area: `${utils.toLocaleStr(areaPerLoad, 1, lang)}`,
|
||
totalLoadStr: `${utils.toLocaleStr((totalLoad / numLoads), 1, lang)} ${utils.getProdUnit(_job.appRateUnit)}`
|
||
});
|
||
}
|
||
else {
|
||
areaPerLoad = (input.loadOp.capacity / _job.appRate);
|
||
rptDS.loads.push({
|
||
id: 1,
|
||
loads: `1 - ${numLoads - 1}`,
|
||
areaPL: areaPerLoad,
|
||
area: `${utils.toLocaleStr(areaPerLoad, 1, lang)}`,
|
||
totalLoadStr: `${utils.toLocaleStr((areaPerLoad * _job.appRate), 1, lang)} ${utils.getProdUnit(_job.appRateUnit)}`
|
||
});
|
||
|
||
areaPerLoad = input.loadOp.area - (areaPerLoad * (input.loadOp.loads - 1));
|
||
rptDS.loads.push({
|
||
id: 2,
|
||
loads: numLoads.toString(),
|
||
areaPL: areaPerLoad,
|
||
area: `${utils.toLocaleStr(areaPerLoad, 1, lang)}`,
|
||
totalLoadStr: `${utils.toLocaleStr((areaPerLoad * _job.appRate), 1, lang)} ${utils.getProdUnit(_job.appRateUnit)}`
|
||
});
|
||
}
|
||
|
||
// Products
|
||
let prdRate, totalRate, unit, prod, loadDetail;
|
||
for (let i = 0; i < _job.products.length; i++) {
|
||
prdRate = _job.products[i].rate; unit = _job.products[i].unit;
|
||
totalRate = prdRate * input.loadOp.area;
|
||
|
||
prod = {
|
||
id: _job.products[i].product._id.toHexString(),
|
||
name: _job.products[i].product.name,
|
||
type: _job.products[i].product.type,
|
||
rate: utils.toLocaleStr(prdRate, 2, lang),
|
||
unit: utils.getProdUnit(unit),
|
||
totalStr: `${utils.toLocaleStr(prdRate * input.loadOp.area, 2, lang)} ${utils.getProdUnit(unit)}`
|
||
};
|
||
if (unit === Units.OZ) {
|
||
prod.totalStr += '\n' + `${utils.toLocaleStr(utils.ozToGal(totalRate), 2, lang)} gal`;
|
||
}
|
||
rptDS.products.push(prod);
|
||
|
||
// Load Details
|
||
for (let j = 0; j < rptDS.loads.length; j++) {
|
||
const load = rptDS.loads[j];
|
||
loadDetail = {
|
||
loadId: load.id,
|
||
prodId: prod.id,
|
||
totalProdStr: `${utils.toLocaleStr((load.areaPL * prdRate), 1, lang)} ${prod.unit}`
|
||
};
|
||
if (unit === Units.OZ) {
|
||
loadDetail.totalProdStr += '\n' + `${utils.toLocaleStr(utils.ozToGal((load.areaPL * prdRate)), 1, lang)} gal`;
|
||
}
|
||
rptDS.load_details.push(loadDetail);
|
||
}
|
||
}
|
||
}
|
||
|
||
callback();
|
||
},
|
||
function (callback) {
|
||
fs.ensureDir(targetFolder, err => {
|
||
if (err) return callback(err);
|
||
callback();
|
||
})
|
||
},
|
||
function (callback) { // Write the report datasource file to the temp folder
|
||
fs.writeFile(path.join(targetFolder, 'rptDS.json'), JSON.stringify(rptDS, null, 2), 'utf-8', function (err) {
|
||
if (err) return callback(err);
|
||
callback();
|
||
});
|
||
}
|
||
], function (err) {
|
||
if (err) {
|
||
debug(err);
|
||
return next(err);
|
||
}
|
||
res.json({
|
||
rid: 'loadsheet',
|
||
path: genFolder,
|
||
c: 0
|
||
});
|
||
// Temporary files to be deleted in the maintenainer app periodically
|
||
});
|
||
});
|
||
}
|
||
|
||
function makeJobAppDataSource(job, applicator, genImgPath, lang, loadOp) {
|
||
const appDS = {
|
||
reports: { type: 0 },
|
||
clients: [{ // The applicator is a client of its customers in report model. TODO: Refactor this to a better name later. Will require to update report templates too.
|
||
id: applicator && applicator._id.toHexString(),
|
||
name: utils.getField(applicator, "name"),
|
||
address: getFormattedAddress(applicator),
|
||
}],
|
||
customers: [{
|
||
id: job.client._id.toHexString(),
|
||
name: job.client.name || '',
|
||
address: getFormattedAddress(job.client),
|
||
contact: job.client.contact || '',
|
||
phone: job.client.phone || ''
|
||
}],
|
||
pilots: [
|
||
{
|
||
id: utils.getField(job.operator, "_id", "0"),
|
||
name: utils.getField(job.operator, "name"),
|
||
address: getFormattedAddress(job.operator),
|
||
}],
|
||
products: [{ // default empty product
|
||
id: "0",
|
||
jobId: job._id,
|
||
name: "",
|
||
epaReg: "",
|
||
restricted: null,
|
||
type: 1,
|
||
rate: 0,
|
||
unit: "",
|
||
rateStr: "",
|
||
totalRateStr: ""
|
||
}],
|
||
vehicles: [{
|
||
id: utils.getField(job.vehicle, "_id", "0"),
|
||
name: utils.getField(job.vehicle, "name"),
|
||
model: utils.getField(job.vehicle, "model")
|
||
}],
|
||
apps: []
|
||
};
|
||
|
||
// Add country information for all entities in the report data
|
||
if (applicator) {
|
||
appDS.clients[0].country = getDocumentCountry(applicator, true);
|
||
}
|
||
|
||
if (job.client) {
|
||
appDS.customers[0].country = getDocumentCountry(job.client, true);
|
||
}
|
||
|
||
if (job.operator) {
|
||
appDS.pilots[0].country = getDocumentCountry(job.operator, true);
|
||
}
|
||
|
||
appDS["jobs"] = [{
|
||
id: job._id,
|
||
orderNum: job.orderNumber || "",
|
||
clientId: applicator && applicator._id,
|
||
vehicleId: job.vehicle ? job.vehicle._id.toHexString() : "0",
|
||
customerId: job.client ? job.client._id.toHexString() : "0",
|
||
pilotId: job.operator ? job.operator._id.toHexString() : "0",
|
||
name: job.name || '',
|
||
appRate: `${utils.toLocaleStr(job.appRate, 2, lang)} ${utils.rateUnitString(job.appRateUnit, true)}`,
|
||
flight: job.flightNumber || '',
|
||
farm: job.farm || '',
|
||
crop: ((job.crop && job.crop['_id']) ? job.crop['name'] : job.crop) || '',
|
||
remark: job.remark || '',
|
||
measureUnit: job.measureUnit,
|
||
mapfile: genImgPath,
|
||
sysPsi: job.sysPsi,
|
||
appType: job.appType,
|
||
missionTime: job.missionTime
|
||
}];
|
||
if (loadOp) {
|
||
appDS.jobs[0].areaSize = loadOp.area;
|
||
appDS.jobs[0].capacity = loadOp.capacity;
|
||
appDS.jobs[0].loadType = loadOp.loadType;
|
||
appDS.jobs[0].totalLoads = loadOp.loads;
|
||
}
|
||
|
||
return appDS;
|
||
}
|
||
|
||
// function updatePolyCenter(geoPolys) {
|
||
// if (geoPolys === undefined || utils.isEmptyArray(geoPolys))
|
||
// return false;
|
||
// try {
|
||
// let geoP;
|
||
// for (let i = 0; i < geoPolys.length; i++) {
|
||
// geoP = polylabel(geoPolys[i].geometry.coordinates, 1.0);
|
||
// if (geoP)
|
||
// geoPolys[i].properties["center"] = [geoP[1], geoP[0]];
|
||
// }
|
||
// } catch (error) {
|
||
// debug('updatePolyCenter()', error);
|
||
// return false;
|
||
// }
|
||
// }
|
||
|
||
async function setPolyWCenter(item) {
|
||
return new Promise(resolve => {
|
||
try {
|
||
const itemWPL = polylabel(item.geometry.coordinates, 1.0);
|
||
if (itemWPL)
|
||
item.properties["center"] = [itemWPL[1], itemWPL[0]];
|
||
} catch (error) {
|
||
debug(error);
|
||
} finally {
|
||
resolve(item);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function setPolysWCenter(geoPolys) {
|
||
return Promise.all(geoPolys.map(async item => await setPolyWCenter(item)));
|
||
}
|
||
|
||
async function getUploadedFiles_post(req, res) {
|
||
const jobId = req.body.jobId;
|
||
const job = await Job.findById(jobId, { '_id': 1 });
|
||
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
const apps = await App.find({ 'jobId': job._id, 'status': 3, markedDelete: { $ne: true } })
|
||
.sort('-updateDate')
|
||
.select('_id fileName savedFilename fileSize status proStatus totalSprayed createdDate')
|
||
.lean();
|
||
|
||
return res.json(apps || []);
|
||
}
|
||
|
||
async function importStatus_post(req, res) {
|
||
const appId = req.body.appId;
|
||
if (!utils.isObjectId(appId)) AppParamError.throw();
|
||
|
||
const app = await App.findById(ObjectId(appId))
|
||
.select('_id fileName savedFilename fileSize status proStatus errorMsg warnMsg markedDelete')
|
||
.lean();
|
||
res.json(app);
|
||
}
|
||
|
||
async function importingStatus_post(req, res) {
|
||
let filterOps = {};
|
||
|
||
if (req.body.jobId) {
|
||
filterOps = { jobId: req.body.jobId, $or: [{ status: 1 }, { status: 2 }] };
|
||
}
|
||
else {
|
||
if (!utils.isEmptyArray(req.body.appIds)) {
|
||
const appIds = req.body.appIds.map(it => ObjectId(it));
|
||
filterOps._id = { $in: appIds };
|
||
}
|
||
filterOps['byImport'] = true;
|
||
}
|
||
|
||
const apps = await App.aggregate(
|
||
[
|
||
{ $match: filterOps },
|
||
{
|
||
$lookup:
|
||
{
|
||
from: "jobs",
|
||
let: { jobId: "$jobId" },
|
||
pipeline: [
|
||
{
|
||
$match: {
|
||
$expr: {
|
||
$and: [
|
||
{ $eq: ["$_id", "$$jobId"] },
|
||
{ $eq: ["$byPuid", ObjectId(req.userInfo.puid)] }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
],
|
||
as: "appjob"
|
||
}
|
||
},
|
||
{ $unwind: { path: "$appjob", "preserveNullAndEmptyArrays": true } },
|
||
{
|
||
$lookup:
|
||
{
|
||
from: "users",
|
||
localField: "appjob.client",
|
||
foreignField: "_id",
|
||
as: "jobclient"
|
||
}
|
||
},
|
||
{ $unwind: { path: "$jobclient", "preserveNullAndEmptyArrays": true } },
|
||
{ $project: { fileName: 1, fileSize: 1, status: 1, proStatus: 1, errorMsg: 1, jobId: 1, createdDate: 1, cid: '$appjob.client', cname: '$jobclient.name' } },
|
||
{ $sort: { createdDate: - 1 } },
|
||
{ $limit: 500 }
|
||
]);
|
||
|
||
res.json(apps || []).end();
|
||
}
|
||
|
||
async function deleteAppFile_post(req, res) {
|
||
const _appId = req.body.appId;
|
||
|
||
await jobUtil.deleteAppById(_appId, true);
|
||
res.json({ appId: _appId });
|
||
}
|
||
|
||
async function getJobLogs_post(req, res) {
|
||
const jobId = req.body.jobId;
|
||
const type = req.body.type || 2;
|
||
|
||
const logs = await JobLog.find({ job: jobId, type: type }, 'date user')
|
||
.limit(30)
|
||
.sort('-date')
|
||
.populate('user', '-_id username')
|
||
.lean();
|
||
|
||
res.json(logs ? logs : []);
|
||
}
|
||
|
||
async function assign_post(req, res) {
|
||
const _params = req.body;
|
||
if (!_params || !_params.jobId || !_params.dlOp || !_params.asUsers) AppParamError.throw();
|
||
|
||
let _doneIds = [], _avIds = [];
|
||
|
||
const job = await Job.findById(_params.jobId).select('dlOp');
|
||
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
if (_params.dlOp.type !== job.dlOp.type)
|
||
await Job.updateOne({ _id: _params.jobId }, { $set: { "dlOp.type": _params.dlOp.type } });
|
||
|
||
if (!utils.isEmptyArray(_params.avUsers)) {
|
||
for (const it of _params.avUsers) {
|
||
if (ObjectId.isValid(it.uid))
|
||
_avIds.push(ObjectId(it.uid));
|
||
}
|
||
}
|
||
|
||
if (_avIds.length)
|
||
await JobAssign.deleteMany({ $or: [{ job: _params.jobId, status: 0 }, { job: _params.jobId, user: { $in: _avIds } }] });
|
||
else
|
||
await JobAssign.deleteMany({ job: _params.jobId, status: 0 });
|
||
|
||
let doneJUs;
|
||
if (!utils.isEmptyArray(_params.asUsers)) {
|
||
const asUIds = [];
|
||
for (const it of _params.asUsers) {
|
||
if (ObjectId.isValid(it.uid))
|
||
asUIds.push(ObjectId(it.uid));
|
||
}
|
||
doneJUs = await JobAssign.find({ job: _params.jobId, user: { $in: asUIds }, status: { $gt: 0 } }, 'user').lean();
|
||
}
|
||
|
||
if (!utils.isEmptyArray(_params.asUsers)) {
|
||
if (!utils.isEmptyArray(doneJUs))
|
||
_doneIds = doneJUs.map(it => it.user);
|
||
|
||
const newItems = [];
|
||
for (const it of _params.asUsers) {
|
||
if (!utils.objectIdIn(_doneIds, it.uid))
|
||
newItems.push({ user: it.uid, job: _params.jobId, status: 0 });
|
||
}
|
||
if (newItems.length)
|
||
await JobAssign.insertMany(newItems, { rawResult: true });
|
||
}
|
||
res.json({ ok: true });
|
||
}
|
||
|
||
async function assignments_post(req, res) {
|
||
const _jobId = Number(req.body.jobId);
|
||
if (!_jobId) AppParamError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
let _assignedUserIds;
|
||
const assigment = {
|
||
avUsers: [],
|
||
asUsers: []
|
||
};
|
||
|
||
const job = await Job.findById(_jobId, '_id byPuid', { lean: true });
|
||
if (!job) AppError.throw(Errors.JOB_NOT_FOUND);
|
||
|
||
const assignedUsers = await JobAssign.find({ job: _jobId }, '-_id user', { lean: true });
|
||
if (!utils.isEmptyArray(assignedUsers))
|
||
_assignedUserIds = assignedUsers.map(it => it.user);
|
||
|
||
const _availableUsers = await Vehicle.aggregate([
|
||
{ $match: { markedDelete: { $ne: true }, parent: job.byPuid, username: { $nin: [null, ''] } } },
|
||
{
|
||
$project: {
|
||
_id: 0,
|
||
uid: "$_id",
|
||
name: 1,
|
||
username: 1,
|
||
active: 1,
|
||
pkgActive: 1
|
||
}
|
||
},
|
||
]);
|
||
|
||
let avUsers = [], asUsers = [];
|
||
if (!utils.isEmptyArray(_assignedUserIds)) {
|
||
for (let i in _availableUsers) {
|
||
if (utils.objectIdIn(_assignedUserIds, _availableUsers[i].uid))
|
||
asUsers.push(_availableUsers[i]);
|
||
else if (_availableUsers[i].active === true)
|
||
avUsers.push(_availableUsers[i]);
|
||
}
|
||
}
|
||
else if (!utils.isEmptyArray(_availableUsers)) {
|
||
avUsers = _availableUsers.slice(0);
|
||
}
|
||
|
||
if (!utils.isEmptyArray(avUsers)) assigment.avUsers = avUsers;
|
||
if (!utils.isEmptyArray(asUsers)) assigment.asUsers = asUsers;
|
||
|
||
res.json(assigment);
|
||
}
|
||
|
||
async function countByClient_post(req, res) {
|
||
const clientId = req.body.clientId;
|
||
if (!utils.isObjectId(clientId)) AppParamError.throw();
|
||
|
||
const count = await Job.countDocuments({ client: ObjectId(clientId) });
|
||
res.json(count); // Notes: Can not use res.json(number) or express error and the response will fail.
|
||
}
|
||
|
||
async function saveMapOps_post(req, res) {
|
||
const mapOps = req.body.mapOps;
|
||
if (!req.body.jobId || !mapOps || !mapOps.width || !mapOps.height || !mapOps.center || !mapOps.zoom)
|
||
AppParamError.throw();
|
||
|
||
const data = await Job.findOneAndUpdate({ _id: req.body.jobId }, { $set: { "dlOp.mapOp": mapOps } }, { new: true, lean: true });
|
||
return res.json(data ? data.dlOp.mapOp : null); // Ingore job_not_found
|
||
}
|
||
|
||
async function searchJobs_post(req, res) {
|
||
// { byPuid: this.authSvc.byPUserId, clientId: this.fClient?.value, nameId: this.fNameId?.trim(), status: this.fStatus?.value }
|
||
const params = req.body;
|
||
// console.log('params:', params);
|
||
if (!params || !params.byPuid) AppParamError.throw();
|
||
|
||
const filter = { markedDelete: { $ne: true }, byPuid: ObjectId(params.byPuid) };
|
||
if (params.clientId && ObjectId.isValid(params.clientId))
|
||
filter['client'] = ObjectId(params.clientId);
|
||
if (params.nameId) {
|
||
if (!utils.isNumber(Number(params.nameId)))
|
||
filter['name'] = { $regex: new RegExp(`${params.nameId}`, 'i') };
|
||
else
|
||
filter['_id'] = Number(params.nameId);
|
||
}
|
||
if (utils.isNumber(params.status))
|
||
filter['status'] = params.status;
|
||
|
||
const jobs = await Job.find(filter, '_id name sprayAreas excludedAreas measureUnit appRate appRateUnit status invoiceStatus').sort('_id');
|
||
res.json(jobs);
|
||
}
|
||
|
||
/**
|
||
* Get list of uploaded files given the jobId
|
||
*/
|
||
async function appFiles_post(req, res) {
|
||
const params = req.body;
|
||
if (!params || !params.jobId) AppParamError.throw();
|
||
|
||
const jobs = await App.aggregate([
|
||
{
|
||
$match: { jobId: params.jobId, markedDelete: { $ne: true } }
|
||
},
|
||
{
|
||
$lookup: {
|
||
from: "appfiles",
|
||
localField: "_id",
|
||
foreignField: "appId",
|
||
as: "appfiles"
|
||
}
|
||
},
|
||
{ $unwind: { path: '$appfiles' } },
|
||
{
|
||
$project: {
|
||
fid: '$appfiles._id',
|
||
name: '$appfiles.name',
|
||
agn: '$appfiles.agn',
|
||
meta: '$appfiles.meta'
|
||
}
|
||
},
|
||
{
|
||
$sort: {
|
||
agn: 1
|
||
}
|
||
}]);
|
||
|
||
res.json(jobs);
|
||
}
|
||
|
||
/**
|
||
* Get spray data records given the fileIds list
|
||
*/
|
||
async function filesdata_post(req, res) {
|
||
const params = req.body;
|
||
if (!params || !params.fileIds) AppParamError.throw();
|
||
|
||
let filesData = [];
|
||
for (let i = 0; i < params.fileIds.length; i++) {
|
||
const filewData = { fid: params.fileIds[i] };
|
||
filewData.data = await AppDetail.find({ fileId: params.fileIds[i] }, null, { lean: true });
|
||
filesData.push(filewData);
|
||
}
|
||
res.json(filesData);
|
||
}
|
||
|
||
async function fetchInvReadyJobs_post(req, res) {
|
||
/*
|
||
params { excludeIds:[], currency: <3-char ISO currency> }
|
||
*/
|
||
const input = req.body;
|
||
assert(input, AppParamError.create());
|
||
const puid = req.userInfo?.puid;
|
||
if (!puid || !ObjectId.isValid(puid)) AppParamError.throw(Errors.INVALID_PUID);
|
||
|
||
const filter = {
|
||
...(!utils.isEmptyArray(input.excludeIds) && { _id: { $nin: input.excludeIds } }),
|
||
markedDelete: { $ne: true },
|
||
invoiceStatus: { $in: [null, JobInvoiceStatus.NONE] },
|
||
status: { $gte: JobStatus.READY }, status: { $ne: 9 },
|
||
costings: { $ne: null }, "costings.billableAmount": { $gt: 0 },
|
||
...(input.currency && { "costings.currency": input.currency }),
|
||
|
||
byPuid: ObjectId(puid)
|
||
};
|
||
|
||
const jobs = await Job.find(filter, '_id name measureUnit status costings invoiceStatus').populate('client', '_id name');
|
||
res.json(jobs);
|
||
}
|
||
|
||
return {
|
||
getJobs_get, createJob_post, getJob_get, updateJob_put, deleteJob, getData_post, getReportOps_get, preAppReport_post,
|
||
getRptVars_post, setRptVars_post, saveReport_post, preLoadReport_post, getUploadedFiles_post, importStatus_post, importingStatus_post,
|
||
deleteAppFile_post, getJobLogs_post, assign_post, assignments_post, countByClient_post, saveMapOps_post, searchJobs_post, appFiles_post,
|
||
filesdata_post, getAppDataByJobId, fetchInvReadyJobs_post
|
||
}
|
||
}
|