application planning
This commit is contained in:
@@ -3,6 +3,7 @@ const pool = require('../config/database');
|
|||||||
const { validateRequest, validateParams } = require('../utils/validation');
|
const { validateRequest, validateParams } = require('../utils/validation');
|
||||||
const { applicationPlanSchema, applicationLogSchema, idParamSchema } = require('../utils/validation');
|
const { applicationPlanSchema, applicationLogSchema, idParamSchema } = require('../utils/validation');
|
||||||
const { AppError } = require('../middleware/errorHandler');
|
const { AppError } = require('../middleware/errorHandler');
|
||||||
|
const { calculateApplication } = require('../utils/applicationCalculations');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -173,7 +174,17 @@ router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) =
|
|||||||
// @access Private
|
// @access Private
|
||||||
router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, next) => {
|
router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { lawnSectionId, equipmentId, plannedDate, notes, products } = req.body;
|
const {
|
||||||
|
lawnSectionId,
|
||||||
|
equipmentId,
|
||||||
|
nozzleId,
|
||||||
|
plannedDate,
|
||||||
|
notes,
|
||||||
|
products,
|
||||||
|
areaSquareFeet,
|
||||||
|
equipment,
|
||||||
|
nozzle
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
// Start transaction
|
// Start transaction
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
@@ -210,39 +221,44 @@ router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, n
|
|||||||
|
|
||||||
// Create application plan
|
// Create application plan
|
||||||
const planResult = await client.query(
|
const planResult = await client.query(
|
||||||
`INSERT INTO application_plans (user_id, lawn_section_id, equipment_id, planned_date, notes)
|
`INSERT INTO application_plans (user_id, lawn_section_id, equipment_id, nozzle_id, planned_date, notes)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[req.user.id, lawnSectionId, equipmentId, plannedDate, notes]
|
[req.user.id, lawnSectionId, equipmentId, nozzleId, plannedDate, notes]
|
||||||
);
|
);
|
||||||
|
|
||||||
const plan = planResult.rows[0];
|
const plan = planResult.rows[0];
|
||||||
|
|
||||||
// Add products to plan with calculations
|
// Add products to plan with calculations
|
||||||
for (const product of products) {
|
for (const product of products) {
|
||||||
const { productId, userProductId, rateAmount, rateUnit } = product;
|
const { productId, userProductId, rateAmount, rateUnit, applicationType } = product;
|
||||||
|
|
||||||
// Calculate application amounts based on area and rate
|
// Use passed area or get from database
|
||||||
const sectionArea = parseFloat(section.area);
|
const sectionArea = areaSquareFeet || parseFloat(section.area);
|
||||||
|
|
||||||
|
// Perform advanced calculations using the calculation engine
|
||||||
|
const calculations = calculateApplication({
|
||||||
|
areaSquareFeet: sectionArea,
|
||||||
|
rateAmount: parseFloat(rateAmount),
|
||||||
|
rateUnit,
|
||||||
|
applicationType,
|
||||||
|
equipment,
|
||||||
|
nozzle
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Plan creation calculations:', calculations);
|
||||||
|
|
||||||
|
// Extract calculated values based on application type
|
||||||
let calculatedProductAmount = 0;
|
let calculatedProductAmount = 0;
|
||||||
let calculatedWaterAmount = 0;
|
let calculatedWaterAmount = 0;
|
||||||
let targetSpeed = 3; // Default 3 MPH
|
let targetSpeed = calculations.applicationSpeedMph || 3;
|
||||||
|
|
||||||
// Basic calculation logic (can be enhanced based on equipment type)
|
if (calculations.type === 'liquid') {
|
||||||
if (rateUnit.includes('1000sqft')) {
|
calculatedProductAmount = calculations.productAmountOunces || 0;
|
||||||
calculatedProductAmount = rateAmount * (sectionArea / 1000);
|
calculatedWaterAmount = calculations.waterAmountGallons || 0;
|
||||||
} else if (rateUnit.includes('acre')) {
|
} else if (calculations.type === 'granular') {
|
||||||
calculatedProductAmount = rateAmount * (sectionArea / 43560);
|
calculatedProductAmount = calculations.productAmountPounds || 0;
|
||||||
} else {
|
calculatedWaterAmount = 0; // No water for granular
|
||||||
calculatedProductAmount = rateAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Water calculation for liquid applications
|
|
||||||
if (rateUnit.includes('gal')) {
|
|
||||||
calculatedWaterAmount = calculatedProductAmount;
|
|
||||||
} else if (rateUnit.includes('oz/gal')) {
|
|
||||||
calculatedWaterAmount = sectionArea / 1000; // 1 gal per 1000 sqft default
|
|
||||||
calculatedProductAmount = rateAmount * calculatedWaterAmount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
198
backend/src/utils/applicationCalculations.js
Normal file
198
backend/src/utils/applicationCalculations.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Application Calculation Engine
|
||||||
|
* Calculates product amounts, water amounts, and application speeds
|
||||||
|
* based on area, product rates, and equipment specifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate liquid application requirements
|
||||||
|
* @param {number} areaSquareFeet - Area to be treated in square feet
|
||||||
|
* @param {number} rateAmount - Product rate amount
|
||||||
|
* @param {string} rateUnit - Product rate unit (oz/gal, oz/1000sqft, etc.)
|
||||||
|
* @param {Object} equipment - Equipment specifications
|
||||||
|
* @param {Object} nozzle - Nozzle specifications (optional)
|
||||||
|
* @returns {Object} Calculation results
|
||||||
|
*/
|
||||||
|
function calculateLiquidApplication(areaSquareFeet, rateAmount, rateUnit, equipment, nozzle) {
|
||||||
|
let productOunces = 0;
|
||||||
|
let waterGallons = 0;
|
||||||
|
let applicationSpeedMph = 3; // Default speed
|
||||||
|
|
||||||
|
const areaAcres = areaSquareFeet / 43560; // Convert to acres
|
||||||
|
const area1000sqft = areaSquareFeet / 1000; // Convert to 1000 sq ft units
|
||||||
|
|
||||||
|
console.log(`Calculating liquid application:
|
||||||
|
Area: ${areaSquareFeet} sq ft (${areaAcres.toFixed(3)} acres, ${area1000sqft.toFixed(1)} x 1000sqft)
|
||||||
|
Rate: ${rateAmount} ${rateUnit}
|
||||||
|
Equipment: ${equipment?.categoryName}
|
||||||
|
Nozzle GPM: ${nozzle?.flowRateGpm || 'N/A'}`);
|
||||||
|
|
||||||
|
// Calculate product amount based on rate unit
|
||||||
|
if (rateUnit.includes('oz/gal')) {
|
||||||
|
// Rate is ounces per gallon of spray solution
|
||||||
|
// Need to determine gallons of water needed first
|
||||||
|
const gallonsPerAcre = 20; // Default 20 GPA for lawn applications
|
||||||
|
waterGallons = areaAcres * gallonsPerAcre;
|
||||||
|
productOunces = waterGallons * rateAmount;
|
||||||
|
} else if (rateUnit.includes('oz/1000sqft') || rateUnit.includes('oz per 1000sqft')) {
|
||||||
|
// Rate is ounces per 1000 square feet
|
||||||
|
productOunces = area1000sqft * rateAmount;
|
||||||
|
// Calculate water needed (typical lawn application is 0.5-1 gallon per 1000 sqft)
|
||||||
|
waterGallons = area1000sqft * 0.75; // 0.75 gal per 1000 sqft default
|
||||||
|
} else if (rateUnit.includes('oz/acre')) {
|
||||||
|
// Rate is ounces per acre
|
||||||
|
productOunces = areaAcres * rateAmount;
|
||||||
|
waterGallons = areaAcres * 20; // 20 GPA default
|
||||||
|
} else if (rateUnit.includes('fl oz/1000sqft') || rateUnit.includes('fl oz per 1000sqft')) {
|
||||||
|
// Rate is fluid ounces per 1000 square feet
|
||||||
|
productOunces = area1000sqft * rateAmount;
|
||||||
|
waterGallons = area1000sqft * 0.75;
|
||||||
|
} else {
|
||||||
|
// Fallback: assume rate is per 1000 sq ft
|
||||||
|
productOunces = area1000sqft * rateAmount;
|
||||||
|
waterGallons = area1000sqft * 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate application speed based on equipment and nozzle
|
||||||
|
if (equipment && nozzle) {
|
||||||
|
const sprayWidthFeet = equipment.sprayWidthFeet || 3; // Default 3 ft width
|
||||||
|
const nozzleGpm = nozzle.flowRateGpm || 0.25; // Default 0.25 GPM
|
||||||
|
const gallonsPerAcre = (waterGallons / areaAcres) || 20;
|
||||||
|
|
||||||
|
// Speed calculation: MPH = (GPM × 5940) / (Width × GPA)
|
||||||
|
// Where 5940 is a conversion constant
|
||||||
|
applicationSpeedMph = (nozzleGpm * 5940) / (sprayWidthFeet * gallonsPerAcre);
|
||||||
|
|
||||||
|
// Limit speed to reasonable range (1-8 MPH for lawn applications)
|
||||||
|
applicationSpeedMph = Math.max(1, Math.min(8, applicationSpeedMph));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
productAmountOunces: Math.round(productOunces * 100) / 100, // Round to 2 decimals
|
||||||
|
waterAmountGallons: Math.round(waterGallons * 100) / 100,
|
||||||
|
applicationSpeedMph: Math.round(applicationSpeedMph * 100) / 100,
|
||||||
|
gallonsPerAcre: Math.round((waterGallons / areaAcres) * 100) / 100,
|
||||||
|
calculationDetails: {
|
||||||
|
areaSquareFeet,
|
||||||
|
areaAcres: Math.round(areaAcres * 1000) / 1000,
|
||||||
|
area1000sqft: Math.round(area1000sqft * 10) / 10,
|
||||||
|
rateAmount,
|
||||||
|
rateUnit
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Liquid calculation result:', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate granular application requirements
|
||||||
|
* @param {number} areaSquareFeet - Area to be treated in square feet
|
||||||
|
* @param {number} rateAmount - Product rate amount
|
||||||
|
* @param {string} rateUnit - Product rate unit (lbs/1000sqft, lbs/acre, etc.)
|
||||||
|
* @param {Object} equipment - Equipment specifications
|
||||||
|
* @returns {Object} Calculation results
|
||||||
|
*/
|
||||||
|
function calculateGranularApplication(areaSquareFeet, rateAmount, rateUnit, equipment) {
|
||||||
|
let productPounds = 0;
|
||||||
|
let applicationSpeedMph = 3; // Default speed
|
||||||
|
|
||||||
|
const areaAcres = areaSquareFeet / 43560;
|
||||||
|
const area1000sqft = areaSquareFeet / 1000;
|
||||||
|
|
||||||
|
console.log(`Calculating granular application:
|
||||||
|
Area: ${areaSquareFeet} sq ft (${areaAcres.toFixed(3)} acres, ${area1000sqft.toFixed(1)} x 1000sqft)
|
||||||
|
Rate: ${rateAmount} ${rateUnit}
|
||||||
|
Equipment: ${equipment?.categoryName}`);
|
||||||
|
|
||||||
|
// Calculate product amount based on rate unit
|
||||||
|
if (rateUnit.includes('lbs/1000sqft') || rateUnit.includes('lbs per 1000sqft') || rateUnit.includes('lb/1000sqft')) {
|
||||||
|
// Rate is pounds per 1000 square feet
|
||||||
|
productPounds = area1000sqft * rateAmount;
|
||||||
|
} else if (rateUnit.includes('lbs/acre') || rateUnit.includes('lb/acre')) {
|
||||||
|
// Rate is pounds per acre
|
||||||
|
productPounds = areaAcres * rateAmount;
|
||||||
|
} else if (rateUnit.includes('oz/1000sqft') || rateUnit.includes('oz per 1000sqft')) {
|
||||||
|
// Rate is ounces per 1000 square feet, convert to pounds
|
||||||
|
productPounds = (area1000sqft * rateAmount) / 16; // 16 oz = 1 lb
|
||||||
|
} else {
|
||||||
|
// Fallback: assume rate is per 1000 sq ft
|
||||||
|
productPounds = area1000sqft * rateAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate application speed for spreaders
|
||||||
|
if (equipment) {
|
||||||
|
const spreadWidthFeet = equipment.spreadWidth || equipment.sprayWidthFeet || 3;
|
||||||
|
// For granular applications, speed is typically 2-4 MPH
|
||||||
|
// This is more dependent on walking/driving speed than flow rate
|
||||||
|
applicationSpeedMph = 3; // Standard walking speed for spreader applications
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
productAmountPounds: Math.round(productPounds * 100) / 100,
|
||||||
|
applicationSpeedMph: Math.round(applicationSpeedMph * 100) / 100,
|
||||||
|
calculationDetails: {
|
||||||
|
areaSquareFeet,
|
||||||
|
areaAcres: Math.round(areaAcres * 1000) / 1000,
|
||||||
|
area1000sqft: Math.round(area1000sqft * 10) / 10,
|
||||||
|
rateAmount,
|
||||||
|
rateUnit
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Granular calculation result:', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main calculation function that determines application type and calls appropriate calculator
|
||||||
|
* @param {Object} params - Calculation parameters
|
||||||
|
* @returns {Object} Calculation results
|
||||||
|
*/
|
||||||
|
function calculateApplication(params) {
|
||||||
|
const {
|
||||||
|
areaSquareFeet,
|
||||||
|
rateAmount,
|
||||||
|
rateUnit,
|
||||||
|
applicationType,
|
||||||
|
equipment,
|
||||||
|
nozzle
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
console.log('Starting application calculation with params:', params);
|
||||||
|
|
||||||
|
if (applicationType === 'liquid') {
|
||||||
|
return {
|
||||||
|
type: 'liquid',
|
||||||
|
...calculateLiquidApplication(areaSquareFeet, rateAmount, rateUnit, equipment, nozzle)
|
||||||
|
};
|
||||||
|
} else if (applicationType === 'granular') {
|
||||||
|
return {
|
||||||
|
type: 'granular',
|
||||||
|
...calculateGranularApplication(areaSquareFeet, rateAmount, rateUnit, equipment)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Auto-detect based on rate unit
|
||||||
|
const isLiquid = rateUnit.toLowerCase().includes('oz/gal') ||
|
||||||
|
rateUnit.toLowerCase().includes('fl oz') ||
|
||||||
|
equipment?.categoryName?.toLowerCase() === 'sprayer';
|
||||||
|
|
||||||
|
if (isLiquid) {
|
||||||
|
return {
|
||||||
|
type: 'liquid',
|
||||||
|
...calculateLiquidApplication(areaSquareFeet, rateAmount, rateUnit, equipment, nozzle)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'granular',
|
||||||
|
...calculateGranularApplication(areaSquareFeet, rateAmount, rateUnit, equipment)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calculateApplication,
|
||||||
|
calculateLiquidApplication,
|
||||||
|
calculateGranularApplication
|
||||||
|
};
|
||||||
@@ -94,13 +94,30 @@ const userProductSchema = Joi.object({
|
|||||||
const applicationPlanSchema = Joi.object({
|
const applicationPlanSchema = Joi.object({
|
||||||
lawnSectionId: Joi.number().integer().positive().required(),
|
lawnSectionId: Joi.number().integer().positive().required(),
|
||||||
equipmentId: Joi.number().integer().positive().required(),
|
equipmentId: Joi.number().integer().positive().required(),
|
||||||
|
nozzleId: Joi.number().integer().positive().optional(),
|
||||||
plannedDate: Joi.date().required(),
|
plannedDate: Joi.date().required(),
|
||||||
notes: Joi.string().allow('').optional(),
|
notes: Joi.string().allow('').optional(),
|
||||||
|
areaSquareFeet: Joi.number().positive().optional(),
|
||||||
|
equipment: Joi.object({
|
||||||
|
id: Joi.number().integer().positive().optional(),
|
||||||
|
categoryName: Joi.string().optional(),
|
||||||
|
tankSizeGallons: Joi.number().positive().allow(null).optional(),
|
||||||
|
pumpGpm: Joi.number().positive().allow(null).optional(),
|
||||||
|
sprayWidthFeet: Joi.number().positive().allow(null).optional(),
|
||||||
|
capacityLbs: Joi.number().positive().allow(null).optional(),
|
||||||
|
spreadWidth: Joi.number().positive().allow(null).optional()
|
||||||
|
}).optional(),
|
||||||
|
nozzle: Joi.object({
|
||||||
|
id: Joi.number().integer().positive().optional(),
|
||||||
|
flowRateGpm: Joi.number().positive().allow(null).optional(),
|
||||||
|
sprayAngle: Joi.number().integer().allow(null).optional()
|
||||||
|
}).allow(null).optional(),
|
||||||
products: Joi.array().items(Joi.object({
|
products: Joi.array().items(Joi.object({
|
||||||
productId: Joi.number().integer().positive().optional(),
|
productId: Joi.number().integer().positive().optional(),
|
||||||
userProductId: Joi.number().integer().positive().optional(),
|
userProductId: Joi.number().integer().positive().optional(),
|
||||||
rateAmount: Joi.number().positive().required(),
|
rateAmount: Joi.number().positive().required(),
|
||||||
rateUnit: Joi.string().max(50).required()
|
rateUnit: Joi.string().max(50).required(),
|
||||||
|
applicationType: Joi.string().valid('liquid', 'granular').optional()
|
||||||
})).min(1).required()
|
})).min(1).required()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
10
database/migrations/add_nozzle_to_application_plans.sql
Normal file
10
database/migrations/add_nozzle_to_application_plans.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Add nozzle_id to application plans
|
||||||
|
-- This allows linking nozzles to liquid application plans
|
||||||
|
|
||||||
|
ALTER TABLE application_plans
|
||||||
|
ADD COLUMN IF NOT EXISTS nozzle_id INTEGER REFERENCES user_equipment(id);
|
||||||
|
|
||||||
|
-- Create index for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_application_plans_nozzle ON application_plans(nozzle_id);
|
||||||
|
|
||||||
|
SELECT 'Added nozzle_id to application_plans table successfully!' as migration_status;
|
||||||
@@ -134,18 +134,45 @@ const Applications = () => {
|
|||||||
try {
|
try {
|
||||||
// Create a plan for each selected area
|
// Create a plan for each selected area
|
||||||
const planPromises = planData.selectedAreas.map(async (areaId) => {
|
const planPromises = planData.selectedAreas.map(async (areaId) => {
|
||||||
|
// Get the area details for calculations
|
||||||
|
const selectedArea = selectedPropertyDetails.sections.find(s => s.id === areaId);
|
||||||
|
const areaSquareFeet = selectedArea?.area || 0;
|
||||||
|
|
||||||
|
// Get equipment details for calculations
|
||||||
|
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
|
||||||
|
|
||||||
|
// Get nozzle details if selected
|
||||||
|
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
|
||||||
|
|
||||||
const planPayload = {
|
const planPayload = {
|
||||||
lawnSectionId: parseInt(areaId),
|
lawnSectionId: parseInt(areaId),
|
||||||
equipmentId: parseInt(planData.equipmentId),
|
equipmentId: parseInt(planData.equipmentId),
|
||||||
|
nozzleId: planData.nozzleId ? parseInt(planData.nozzleId) : null,
|
||||||
plannedDate: new Date().toISOString().split('T')[0], // Default to today
|
plannedDate: new Date().toISOString().split('T')[0], // Default to today
|
||||||
notes: planData.notes || '', // Ensure notes is never null/undefined
|
notes: planData.notes || '',
|
||||||
|
areaSquareFeet: areaSquareFeet, // Pass area for calculations
|
||||||
|
equipment: {
|
||||||
|
id: selectedEquipment?.id,
|
||||||
|
categoryName: selectedEquipment?.categoryName,
|
||||||
|
tankSizeGallons: selectedEquipment?.tankSizeGallons,
|
||||||
|
pumpGpm: selectedEquipment?.pumpGpm,
|
||||||
|
sprayWidthFeet: selectedEquipment?.sprayWidthFeet,
|
||||||
|
capacityLbs: selectedEquipment?.capacityLbs,
|
||||||
|
spreadWidth: selectedEquipment?.spreadWidth
|
||||||
|
},
|
||||||
|
nozzle: selectedNozzle ? {
|
||||||
|
id: selectedNozzle.id,
|
||||||
|
flowRateGpm: selectedNozzle.flowRateGpm,
|
||||||
|
sprayAngle: selectedNozzle.sprayAngle
|
||||||
|
} : null,
|
||||||
products: [{
|
products: [{
|
||||||
...(planData.selectedProduct?.isShared
|
...(planData.selectedProduct?.isShared
|
||||||
? { productId: parseInt(planData.selectedProduct.id) }
|
? { productId: parseInt(planData.selectedProduct.id) }
|
||||||
: { userProductId: parseInt(planData.selectedProduct.id) }
|
: { userProductId: parseInt(planData.selectedProduct.id) }
|
||||||
),
|
),
|
||||||
rateAmount: parseFloat(planData.selectedProduct?.customRateAmount || planData.selectedProduct?.rateAmount || 1),
|
rateAmount: parseFloat(planData.selectedProduct?.customRateAmount || planData.selectedProduct?.rateAmount || 1),
|
||||||
rateUnit: planData.selectedProduct?.customRateUnit || planData.selectedProduct?.rateUnit || 'per 1000sqft'
|
rateUnit: planData.selectedProduct?.customRateUnit || planData.selectedProduct?.rateUnit || 'per 1000sqft',
|
||||||
|
applicationType: planData.applicationType
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user