application planning

This commit is contained in:
Jake Kasper
2025-08-23 13:18:53 -04:00
parent f2f3ee52cf
commit f480fddc2c
5 changed files with 294 additions and 26 deletions

View File

@@ -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(

View 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
};

View File

@@ -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()
});

View 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;

View File

@@ -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
}]
};