'use strict'; /* Database structure for jobs: User/Applicator and Client ├── Job Job ├── JobLog ├── JobAssign └── Application └── AppFile └── AppDetail */ const debug = require('debug')('agm:job-model'), mongoose = require('mongoose'), Schema = mongoose.Schema, App = require('./application'), JobLog = require('./job_log'), JobAssign = require('./job_assign'), utils = require('../helpers/utils'), { Fields, UserTypes, CostingItemType, Units } = require('../helpers/constants'), { JobInvoiceStatus } = require('../helpers/job_constants'), mongoUtil = require('../helpers/mongo'), Currencies = require('../helpers/currencies'), AutoIncrement = require('mongoose-sequence')(mongoose); const loadOpSchema = new Schema({ date: { type: Date, default: Date.now }, area: { type: Number, default: 0 }, // in ha or acre capacity: { type: Number, default: 0 }, // depends on measureUnit and appRateUnit loadType: { type: Number, default: 0 }, loads: { type: Number, default: 0 } }, { _id: false, toJSON: { getters: true } }); const itemsSchema = new Schema({ item: { type: Schema.Types.ObjectId, ref: 'costing_items', require: true }, name: { type: String, require: true }, price: { type: Number, min: 0, require: true }, quantity: { type: Number, min: 0, require: true }, type: { type: Number, enum: Object.values(CostingItemType) }, unit: { type: Number, enum: Object.values(Units) }, }, { _id: false }); const schema = new Schema({ _id: Schema.Types.Number, name: { type: String, required: false }, orderNumber: { type: String, required: false }, measureUnit: { type: Boolean, required: true, default: false }, swathWidth: { type: Number, required: true }, products: [ { product: { type: Schema.Types.ObjectId, ref: 'Product' }, rate: Number, unit: Number } ], appRate: { type: Number, required: false }, /** * US: 0: oz/ac, 1: gal/ac, 2: lb/ac * Metric: 3: l/ha, kg/ ha * @type {Number} */ appRateUnit: { type: Number, required: false }, startDate: { type: Date, required: false, default: Date.now }, endDate: { type: Date, required: false, default: Date.now }, operator: { type: Schema.Types.ObjectId, ref: UserTypes.PILOT }, client: { type: Schema.Types.ObjectId, ref: UserTypes.CLIENT, required: true, index: true }, vehicle: { type: Schema.Types.ObjectId, ref: UserTypes.DEVICE }, flightNumber: { type: String, required: false }, crop: { type: Schema.Types.ObjectId, ref: 'Crop', required: false }, farm: { type: String, required: false }, remark: { type: String, required: false }, sysPsi: { type: Number, required: false }, // Spray system pressure (in psi) missionTime: { type: Number, required: false }, // Total mission time (in hours) appType: { type: String, required: false }, // Application type sprayAreas: [{ properties: { name: { type: String }, type: { type: Number, default: 0 }, appRate: { type: Number }, color: { type: String }, area: { type: Number }, crop: { type: Schema.Types.ObjectId, ref: 'Crop', required: false }, radius: { type: Number, required: false }, // in meters, store the original radius of the pivot area }, geometry: { type: { type: String, required: true }, coordinates: { type: [[[Number]]], required: true } } }], excludedAreas: [{ properties: { type: Schema.Types.Mixed }, geometry: { type: { type: String, required: true }, coordinates: { type: [[[Number]]], required: true }, } }], bufs: [{ properties: { type: Schema.Types.Mixed }, geometry: { type: { type: String, required: true }, coordinates: { type: [[Number]], required: true } } }], waypoints: [{ properties: { type: Schema.Types.Mixed }, geometry: { type: { type: String, required: true }, coordinates: { type: [Number], required: true } } }], places: [{ properties: { type: Schema.Types.Mixed }, geometry: { type: { type: String, required: true }, coordinates: { type: [Number], required: true } } }], status: { type: Number, default: 0 }, dlOp: { type: { type: Number, default: 0 }, mapOp: { type: Schema.Types.Mixed } }, rptOp: { printArea: { type: Boolean }, // Whether to also print AreaSize within the report areaSize: { type: Number }, // in ha coverage: { type: Number }, // in ha appRate: { type: Number }, // Manual entered actual applied appRate useActualVol: { type: Boolean, default: false }, actualVol: { type: Number } // Manual entered total actual applied material volume. Unit is in LPH or KGPH }, loadOp: { type: loadOpSchema, get: (loadOp) => { const jobUtil = require('../helpers/job_util'); return jobUtil.defLoadOp(loadOp); } }, useCustWI: { type: Boolean, default: false }, weatherInfo: { windSpd: { type: Number }, // knots windDir: { type: String }, // one of the 4-direction-each-quarter bearing compass string temp: { type: Number }, // temperature C or F degree depend on the UM humid: { type: Number } // humidity in percents }, // Applicator userId. This is for performance optimization when querying jobs byPuid: { type: Schema.Types.ObjectId, ref: 'User', index: true }, ttSprArea: { type: Number, default: 0 }, // Total sprayable (excluded the non-spray) areas in ha markedDelete: { type: Boolean, default: false }, invoiceStatus: { type: String, enum: Object.values(JobInvoiceStatus), require: true, default: JobInvoiceStatus.NONE, get: (value) => value ?? JobInvoiceStatus.NONE }, invoiceId: { type: Schema.Types.ObjectId, ref: 'Invoice', require: false }, costings: { billableArea: { type: Number, min: 0 }, billableAmount: { type: Number, min: 0, require: true }, currency: { type: String, enum: Object.keys(Currencies), require: false }, items: [itemsSchema, { _id: false, id: false }], } }, { timestamps: true, toJSON: { getters: true }, toObject: { getters: true } }); schema.plugin(AutoIncrement, { inc_field: '_id' }); /* // NOTE: Enabling 2dsphere indexed for the benefits of powerfull geo queries for these types of data will REQUIRE standardized polygon (CCW for boundaries, CW for holes or xcls) compliant to GeoJSON. // and also no connected multi-polygons. These are required to ensure the data is valid for the 2dsphere index. LATER. schema.index({ 'sprayAreas.geometry': '2dsphere' }); schema.index({ 'excludedAreas.geometry': '2dsphere' }); */ schema.index({ 'bufs.geometry': '2dsphere' }); schema.index({ 'waypoints.geometry': '2dsphere' }); schema.index({ 'places.geometry': '2dsphere' }); async function markDeleteJob(session) { session && (session.startTransaction(mongoUtil.getTranOps())); try { this.markedDelete = true; await this.save({ session }); } catch (error) { session && (session.abortTransaction()); throw error; } await mongoUtil.commitWithRetry(session); } async function deleteRelatedData(session) { session && (session.startTransaction(mongoUtil.getTranOps())); try { const sprIds = !utils.isEmptyArray(this.sprayAreas) ? this.sprayAreas.map(a => a._id) : null; if (!utils.isEmptyArray(sprIds)) { const jobUtil = require('../helpers/job_util'); await jobUtil.deleteAreaLines(sprIds, session); } await JobLog.deleteMany({ job: this._id }).session(session); await JobAssign.deleteMany({ job: this._id }).session(session); } catch (error) { session && (session.abortTransaction()); throw error; } await mongoUtil.commitWithRetry(session); } async function deleteApps(session, markDeletedOnly) { const apps = await App.find({ jobId: this._id }); for (let i = 0; i < apps.length; i++) { await apps[i].removeFull(session, markDeletedOnly); } } async function deleteJob(session) { session && (session.startTransaction(mongoUtil.getTranOps())); try { await this.remove({ session }); } catch (error) { session && (session.abortTransaction()); throw error; } await mongoUtil.commitWithRetry(session); } /** * Remove the job with all related data in atomic db session's transactions * @param {ClientSession} ses the db session. If ses is null, create a session and end when done * @param {boolean} markDeletedOnly Determine whether to just mark the item as deleted only. Default is true. * @param {boolean} endSesDone whether to end the session. */ schema.methods.removeFull = async function (ses = null, markDeletedOnly = true, endSesDone = false) { let session = ses; if (!ses) { session = await this.db.startSession({ readPreference: { mode: "primary" } }); endSesDone = true; } try { // 1. Mark the job as deleted if (!this[Fields.MARKED_DELETE]) await mongoUtil.runTransactionWithRetry(markDeleteJob.bind(this), session); if (!markDeletedOnly) { // 2. Delete related non application data await mongoUtil.runTransactionWithRetry(deleteRelatedData.bind(this), session); // 3. Delete apps and records await deleteApps.call(this, session, markDeletedOnly); // 4. Delete the job await mongoUtil.runTransactionWithRetry(deleteJob.bind(this), session); } } catch (error) { throw error; } finally { if (endSesDone && session) await session.endSession(); } } module.exports = mongoose.model('Job', schema);