application planning
This commit is contained in:
@@ -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(
|
||||
|
||||
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({
|
||||
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()
|
||||
});
|
||||
|
||||
|
||||
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 {
|
||||
// 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
|
||||
}]
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user