From f480fddc2c6985d0bfbaaa4aed87f196f45a4d88 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Sat, 23 Aug 2025 13:18:53 -0400 Subject: [PATCH] application planning --- backend/src/routes/applications.js | 62 ++++-- backend/src/utils/applicationCalculations.js | 198 ++++++++++++++++++ backend/src/utils/validation.js | 19 +- .../add_nozzle_to_application_plans.sql | 10 + .../src/pages/Applications/Applications.js | 31 ++- 5 files changed, 294 insertions(+), 26 deletions(-) create mode 100644 backend/src/utils/applicationCalculations.js create mode 100644 database/migrations/add_nozzle_to_application_plans.sql diff --git a/backend/src/routes/applications.js b/backend/src/routes/applications.js index 091bfd0..2a72745 100644 --- a/backend/src/routes/applications.js +++ b/backend/src/routes/applications.js @@ -3,6 +3,7 @@ const pool = require('../config/database'); const { validateRequest, validateParams } = require('../utils/validation'); const { applicationPlanSchema, applicationLogSchema, idParamSchema } = require('../utils/validation'); const { AppError } = require('../middleware/errorHandler'); +const { calculateApplication } = require('../utils/applicationCalculations'); const router = express.Router(); @@ -173,7 +174,17 @@ router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) = // @access Private router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, next) => { try { - const { lawnSectionId, equipmentId, plannedDate, notes, products } = req.body; + const { + lawnSectionId, + equipmentId, + nozzleId, + plannedDate, + notes, + products, + areaSquareFeet, + equipment, + nozzle + } = req.body; // Start transaction const client = await pool.connect(); @@ -210,39 +221,44 @@ router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, n // Create application plan const planResult = await client.query( - `INSERT INTO application_plans (user_id, lawn_section_id, equipment_id, planned_date, notes) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO application_plans (user_id, lawn_section_id, equipment_id, nozzle_id, planned_date, notes) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, - [req.user.id, lawnSectionId, equipmentId, plannedDate, notes] + [req.user.id, lawnSectionId, equipmentId, nozzleId, plannedDate, notes] ); const plan = planResult.rows[0]; // Add products to plan with calculations 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 - const sectionArea = parseFloat(section.area); + // Use passed area or get from database + 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 calculatedWaterAmount = 0; - let targetSpeed = 3; // Default 3 MPH + let targetSpeed = calculations.applicationSpeedMph || 3; - // Basic calculation logic (can be enhanced based on equipment type) - if (rateUnit.includes('1000sqft')) { - calculatedProductAmount = rateAmount * (sectionArea / 1000); - } else if (rateUnit.includes('acre')) { - calculatedProductAmount = rateAmount * (sectionArea / 43560); - } else { - 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; + if (calculations.type === 'liquid') { + calculatedProductAmount = calculations.productAmountOunces || 0; + calculatedWaterAmount = calculations.waterAmountGallons || 0; + } else if (calculations.type === 'granular') { + calculatedProductAmount = calculations.productAmountPounds || 0; + calculatedWaterAmount = 0; // No water for granular } await client.query( diff --git a/backend/src/utils/applicationCalculations.js b/backend/src/utils/applicationCalculations.js new file mode 100644 index 0000000..d260e1e --- /dev/null +++ b/backend/src/utils/applicationCalculations.js @@ -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 +}; \ No newline at end of file diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js index 3a3e456..372adb3 100644 --- a/backend/src/utils/validation.js +++ b/backend/src/utils/validation.js @@ -94,13 +94,30 @@ const userProductSchema = Joi.object({ const applicationPlanSchema = Joi.object({ lawnSectionId: Joi.number().integer().positive().required(), equipmentId: Joi.number().integer().positive().required(), + nozzleId: Joi.number().integer().positive().optional(), plannedDate: Joi.date().required(), 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({ productId: Joi.number().integer().positive().optional(), userProductId: Joi.number().integer().positive().optional(), 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() }); diff --git a/database/migrations/add_nozzle_to_application_plans.sql b/database/migrations/add_nozzle_to_application_plans.sql new file mode 100644 index 0000000..829e4b7 --- /dev/null +++ b/database/migrations/add_nozzle_to_application_plans.sql @@ -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; \ No newline at end of file diff --git a/frontend/src/pages/Applications/Applications.js b/frontend/src/pages/Applications/Applications.js index 401a0fe..62a11c5 100644 --- a/frontend/src/pages/Applications/Applications.js +++ b/frontend/src/pages/Applications/Applications.js @@ -134,18 +134,45 @@ const Applications = () => { try { // Create a plan for each selected area 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 = { lawnSectionId: parseInt(areaId), equipmentId: parseInt(planData.equipmentId), + nozzleId: planData.nozzleId ? parseInt(planData.nozzleId) : null, 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: [{ ...(planData.selectedProduct?.isShared ? { productId: parseInt(planData.selectedProduct.id) } : { userProductId: parseInt(planData.selectedProduct.id) } ), 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 }] };