606 lines
19 KiB
JavaScript
606 lines
19 KiB
JavaScript
const express = require('express');
|
|
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();
|
|
|
|
// @route GET /api/applications/plans
|
|
// @desc Get all application plans for current user
|
|
// @access Private
|
|
router.get('/plans', async (req, res, next) => {
|
|
try {
|
|
const { status, upcoming, property_id } = req.query;
|
|
|
|
let whereConditions = ['ap.user_id = $1'];
|
|
let queryParams = [req.user.id];
|
|
let paramCount = 1;
|
|
|
|
if (status) {
|
|
paramCount++;
|
|
whereConditions.push(`ap.status = $${paramCount}`);
|
|
queryParams.push(status);
|
|
}
|
|
|
|
if (upcoming === 'true') {
|
|
paramCount++;
|
|
whereConditions.push(`ap.planned_date >= $${paramCount}`);
|
|
queryParams.push(new Date().toISOString().split('T')[0]);
|
|
}
|
|
|
|
if (property_id) {
|
|
paramCount++;
|
|
whereConditions.push(`p.id = $${paramCount}`);
|
|
queryParams.push(property_id);
|
|
}
|
|
|
|
const whereClause = whereConditions.join(' AND ');
|
|
|
|
const result = await pool.query(
|
|
`SELECT ap.*, ls.name as section_name, ls.area as section_area,
|
|
p.name as property_name, p.address as property_address,
|
|
ue.custom_name as equipment_name, et.name as equipment_type,
|
|
COUNT(app.id) as product_count
|
|
FROM application_plans ap
|
|
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
|
|
JOIN properties p ON ls.property_id = p.id
|
|
LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id
|
|
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
|
LEFT JOIN application_plan_products app ON ap.id = app.plan_id
|
|
WHERE ${whereClause}
|
|
GROUP BY ap.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name
|
|
ORDER BY ap.planned_date DESC, ap.created_at DESC`,
|
|
queryParams
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
plans: result.rows.map(plan => ({
|
|
id: plan.id,
|
|
status: plan.status,
|
|
plannedDate: plan.planned_date,
|
|
notes: plan.notes,
|
|
sectionName: plan.section_name,
|
|
sectionArea: parseFloat(plan.section_area),
|
|
propertyName: plan.property_name,
|
|
propertyAddress: plan.property_address,
|
|
equipmentName: plan.equipment_name || plan.equipment_type,
|
|
productCount: parseInt(plan.product_count),
|
|
createdAt: plan.created_at,
|
|
updatedAt: plan.updated_at
|
|
}))
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route GET /api/applications/plans/:id
|
|
// @desc Get single application plan with products
|
|
// @access Private
|
|
router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) => {
|
|
try {
|
|
const planId = req.params.id;
|
|
|
|
// Get plan details
|
|
const planResult = await pool.query(
|
|
`SELECT ap.*, ls.name as section_name, ls.area as section_area, ls.polygon_data,
|
|
p.id as property_id, p.name as property_name, p.address as property_address,
|
|
ue.id as equipment_id, ue.custom_name as equipment_name,
|
|
et.name as equipment_type, et.category as equipment_category
|
|
FROM application_plans ap
|
|
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
|
|
JOIN properties p ON ls.property_id = p.id
|
|
LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id
|
|
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
|
WHERE ap.id = $1 AND ap.user_id = $2`,
|
|
[planId, req.user.id]
|
|
);
|
|
|
|
if (planResult.rows.length === 0) {
|
|
throw new AppError('Application plan not found', 404);
|
|
}
|
|
|
|
const plan = planResult.rows[0];
|
|
|
|
// Get plan products
|
|
const productsResult = await pool.query(
|
|
`SELECT app.*,
|
|
COALESCE(up.custom_name, p.name) as product_name,
|
|
COALESCE(p.brand, '') as product_brand,
|
|
COALESCE(p.product_type, 'unknown') as product_type
|
|
FROM application_plan_products app
|
|
LEFT JOIN products p ON app.product_id = p.id
|
|
LEFT JOIN user_products up ON app.user_product_id = up.id
|
|
WHERE app.plan_id = $1
|
|
ORDER BY app.id`,
|
|
[planId]
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
plan: {
|
|
id: plan.id,
|
|
status: plan.status,
|
|
plannedDate: plan.planned_date,
|
|
notes: plan.notes,
|
|
section: {
|
|
id: plan.lawn_section_id,
|
|
name: plan.section_name,
|
|
area: parseFloat(plan.section_area),
|
|
polygonData: plan.polygon_data
|
|
},
|
|
property: {
|
|
id: plan.property_id,
|
|
name: plan.property_name,
|
|
address: plan.property_address
|
|
},
|
|
equipment: {
|
|
id: plan.equipment_id,
|
|
name: plan.equipment_name || plan.equipment_type,
|
|
type: plan.equipment_type,
|
|
category: plan.equipment_category
|
|
},
|
|
products: productsResult.rows.map(product => ({
|
|
id: product.id,
|
|
productId: product.product_id,
|
|
userProductId: product.user_product_id,
|
|
productName: product.product_name,
|
|
productBrand: product.product_brand,
|
|
productType: product.product_type,
|
|
rateAmount: parseFloat(product.rate_amount),
|
|
rateUnit: product.rate_unit,
|
|
calculatedProductAmount: parseFloat(product.calculated_product_amount),
|
|
calculatedWaterAmount: parseFloat(product.calculated_water_amount),
|
|
targetSpeedMph: parseFloat(product.target_speed_mph)
|
|
})),
|
|
createdAt: plan.created_at,
|
|
updatedAt: plan.updated_at
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route POST /api/applications/plans
|
|
// @desc Create new application plan
|
|
// @access Private
|
|
router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, next) => {
|
|
try {
|
|
const {
|
|
lawnSectionId,
|
|
equipmentId,
|
|
nozzleId,
|
|
plannedDate,
|
|
notes,
|
|
products,
|
|
areaSquareFeet,
|
|
equipment,
|
|
nozzle
|
|
} = req.body;
|
|
|
|
// Start transaction
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify lawn section belongs to user
|
|
const sectionCheck = await client.query(
|
|
`SELECT ls.id, ls.area, p.user_id
|
|
FROM lawn_sections ls
|
|
JOIN properties p ON ls.property_id = p.id
|
|
WHERE ls.id = $1 AND p.user_id = $2`,
|
|
[lawnSectionId, req.user.id]
|
|
);
|
|
|
|
if (sectionCheck.rows.length === 0) {
|
|
throw new AppError('Lawn section not found', 404);
|
|
}
|
|
|
|
const section = sectionCheck.rows[0];
|
|
|
|
// Verify equipment belongs to user
|
|
const equipmentCheck = await client.query(
|
|
'SELECT id, tank_size, pump_gpm, nozzle_gpm, nozzle_count FROM user_equipment WHERE id = $1 AND user_id = $2',
|
|
[equipmentId, req.user.id]
|
|
);
|
|
|
|
if (equipmentCheck.rows.length === 0) {
|
|
throw new AppError('Equipment not found', 404);
|
|
}
|
|
|
|
const equipment = equipmentCheck.rows[0];
|
|
|
|
// Create application plan
|
|
const planResult = await client.query(
|
|
`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, nozzleId, plannedDate, notes]
|
|
);
|
|
|
|
const plan = planResult.rows[0];
|
|
|
|
// Add products to plan with calculations
|
|
for (const product of products) {
|
|
const { productId, userProductId, rateAmount, rateUnit, applicationType } = product;
|
|
|
|
// 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 = calculations.applicationSpeedMph || 3;
|
|
|
|
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(
|
|
`INSERT INTO application_plan_products
|
|
(plan_id, product_id, user_product_id, rate_amount, rate_unit,
|
|
calculated_product_amount, calculated_water_amount, target_speed_mph)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
[plan.id, productId, userProductId, rateAmount, rateUnit,
|
|
calculatedProductAmount, calculatedWaterAmount, targetSpeed]
|
|
);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
message: 'Application plan created successfully',
|
|
data: {
|
|
plan: {
|
|
id: plan.id,
|
|
status: plan.status,
|
|
plannedDate: plan.planned_date,
|
|
notes: plan.notes,
|
|
createdAt: plan.created_at
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route PUT /api/applications/plans/:id/status
|
|
// @desc Update application plan status
|
|
// @access Private
|
|
router.put('/plans/:id/status', validateParams(idParamSchema), async (req, res, next) => {
|
|
try {
|
|
const planId = req.params.id;
|
|
const { status } = req.body;
|
|
|
|
if (!['planned', 'in_progress', 'completed', 'cancelled'].includes(status)) {
|
|
throw new AppError('Invalid status', 400);
|
|
}
|
|
|
|
// Check if plan belongs to user
|
|
const checkResult = await pool.query(
|
|
'SELECT id, status FROM application_plans WHERE id = $1 AND user_id = $2',
|
|
[planId, req.user.id]
|
|
);
|
|
|
|
if (checkResult.rows.length === 0) {
|
|
throw new AppError('Application plan not found', 404);
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`UPDATE application_plans
|
|
SET status = $1, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $2
|
|
RETURNING *`,
|
|
[status, planId]
|
|
);
|
|
|
|
const plan = result.rows[0];
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Plan status updated successfully',
|
|
data: {
|
|
plan: {
|
|
id: plan.id,
|
|
status: plan.status,
|
|
updatedAt: plan.updated_at
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route GET /api/applications/logs
|
|
// @desc Get application logs for current user
|
|
// @access Private
|
|
router.get('/logs', async (req, res, next) => {
|
|
try {
|
|
const { property_id, start_date, end_date, limit = 50 } = req.query;
|
|
|
|
let whereConditions = ['al.user_id = $1'];
|
|
let queryParams = [req.user.id];
|
|
let paramCount = 1;
|
|
|
|
if (property_id) {
|
|
paramCount++;
|
|
whereConditions.push(`p.id = $${paramCount}`);
|
|
queryParams.push(property_id);
|
|
}
|
|
|
|
if (start_date) {
|
|
paramCount++;
|
|
whereConditions.push(`al.application_date >= $${paramCount}`);
|
|
queryParams.push(start_date);
|
|
}
|
|
|
|
if (end_date) {
|
|
paramCount++;
|
|
whereConditions.push(`al.application_date <= $${paramCount}`);
|
|
queryParams.push(end_date);
|
|
}
|
|
|
|
const whereClause = whereConditions.join(' AND ');
|
|
|
|
paramCount++;
|
|
queryParams.push(limit);
|
|
|
|
const result = await pool.query(
|
|
`SELECT al.*, ls.name as section_name, ls.area as section_area,
|
|
p.name as property_name, p.address as property_address,
|
|
ue.custom_name as equipment_name, et.name as equipment_type,
|
|
COUNT(alp.id) as product_count
|
|
FROM application_logs al
|
|
JOIN lawn_sections ls ON al.lawn_section_id = ls.id
|
|
JOIN properties p ON ls.property_id = p.id
|
|
LEFT JOIN user_equipment ue ON al.equipment_id = ue.id
|
|
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
|
LEFT JOIN application_log_products alp ON al.id = alp.log_id
|
|
WHERE ${whereClause}
|
|
GROUP BY al.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name
|
|
ORDER BY al.application_date DESC
|
|
LIMIT $${paramCount}`,
|
|
queryParams
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
logs: result.rows.map(log => ({
|
|
id: log.id,
|
|
planId: log.plan_id,
|
|
applicationDate: log.application_date,
|
|
weatherConditions: log.weather_conditions,
|
|
averageSpeed: parseFloat(log.average_speed),
|
|
areaCovered: parseFloat(log.area_covered),
|
|
notes: log.notes,
|
|
sectionName: log.section_name,
|
|
sectionArea: parseFloat(log.section_area),
|
|
propertyName: log.property_name,
|
|
propertyAddress: log.property_address,
|
|
equipmentName: log.equipment_name || log.equipment_type,
|
|
productCount: parseInt(log.product_count),
|
|
createdAt: log.created_at
|
|
}))
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route POST /api/applications/logs
|
|
// @desc Create application log
|
|
// @access Private
|
|
router.post('/logs', validateRequest(applicationLogSchema), async (req, res, next) => {
|
|
try {
|
|
const {
|
|
planId,
|
|
lawnSectionId,
|
|
equipmentId,
|
|
weatherConditions,
|
|
gpsTrack,
|
|
averageSpeed,
|
|
areaCovered,
|
|
notes,
|
|
products
|
|
} = req.body;
|
|
|
|
// Start transaction
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify lawn section belongs to user
|
|
const sectionCheck = await client.query(
|
|
`SELECT ls.id, p.user_id
|
|
FROM lawn_sections ls
|
|
JOIN properties p ON ls.property_id = p.id
|
|
WHERE ls.id = $1 AND p.user_id = $2`,
|
|
[lawnSectionId, req.user.id]
|
|
);
|
|
|
|
if (sectionCheck.rows.length === 0) {
|
|
throw new AppError('Lawn section not found', 404);
|
|
}
|
|
|
|
// Verify equipment belongs to user (if provided)
|
|
if (equipmentId) {
|
|
const equipmentCheck = await client.query(
|
|
'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2',
|
|
[equipmentId, req.user.id]
|
|
);
|
|
|
|
if (equipmentCheck.rows.length === 0) {
|
|
throw new AppError('Equipment not found', 404);
|
|
}
|
|
}
|
|
|
|
// Create application log
|
|
const logResult = await client.query(
|
|
`INSERT INTO application_logs
|
|
(plan_id, user_id, lawn_section_id, equipment_id, weather_conditions,
|
|
gps_track, average_speed, area_covered, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *`,
|
|
[planId, req.user.id, lawnSectionId, equipmentId,
|
|
JSON.stringify(weatherConditions), JSON.stringify(gpsTrack),
|
|
averageSpeed, areaCovered, notes]
|
|
);
|
|
|
|
const log = logResult.rows[0];
|
|
|
|
// Add products to log
|
|
for (const product of products) {
|
|
const {
|
|
productId,
|
|
userProductId,
|
|
rateAmount,
|
|
rateUnit,
|
|
actualProductAmount,
|
|
actualWaterAmount,
|
|
actualSpeedMph
|
|
} = product;
|
|
|
|
await client.query(
|
|
`INSERT INTO application_log_products
|
|
(log_id, product_id, user_product_id, rate_amount, rate_unit,
|
|
actual_product_amount, actual_water_amount, actual_speed_mph)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
[log.id, productId, userProductId, rateAmount, rateUnit,
|
|
actualProductAmount, actualWaterAmount, actualSpeedMph]
|
|
);
|
|
}
|
|
|
|
// If this was from a plan, mark the plan as completed
|
|
if (planId) {
|
|
await client.query(
|
|
'UPDATE application_plans SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
|
['completed', planId]
|
|
);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
message: 'Application logged successfully',
|
|
data: {
|
|
log: {
|
|
id: log.id,
|
|
applicationDate: log.application_date,
|
|
createdAt: log.created_at
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route GET /api/applications/stats
|
|
// @desc Get application statistics
|
|
// @access Private
|
|
router.get('/stats', async (req, res, next) => {
|
|
try {
|
|
const { year = new Date().getFullYear() } = req.query;
|
|
|
|
const statsQuery = `
|
|
SELECT
|
|
COUNT(DISTINCT al.id) as total_applications,
|
|
COUNT(DISTINCT ap.id) as total_plans,
|
|
COUNT(DISTINCT CASE WHEN ap.status = 'completed' THEN ap.id END) as completed_plans,
|
|
COUNT(DISTINCT CASE WHEN ap.status = 'planned' THEN ap.id END) as planned_applications,
|
|
COALESCE(SUM(al.area_covered), 0) as total_area_treated,
|
|
COALESCE(AVG(al.average_speed), 0) as avg_application_speed
|
|
FROM application_logs al
|
|
FULL OUTER JOIN application_plans ap ON al.plan_id = ap.id OR ap.user_id = $1
|
|
WHERE EXTRACT(YEAR FROM COALESCE(al.application_date, ap.planned_date)) = $2
|
|
AND (al.user_id = $1 OR ap.user_id = $1)
|
|
`;
|
|
|
|
const statsResult = await pool.query(statsQuery, [req.user.id, year]);
|
|
const stats = statsResult.rows[0];
|
|
|
|
// Get monthly breakdown
|
|
const monthlyQuery = `
|
|
SELECT
|
|
EXTRACT(MONTH FROM al.application_date) as month,
|
|
COUNT(*) as applications,
|
|
COALESCE(SUM(al.area_covered), 0) as area_covered
|
|
FROM application_logs al
|
|
WHERE al.user_id = $1
|
|
AND EXTRACT(YEAR FROM al.application_date) = $2
|
|
GROUP BY EXTRACT(MONTH FROM al.application_date)
|
|
ORDER BY month
|
|
`;
|
|
|
|
const monthlyResult = await pool.query(monthlyQuery, [req.user.id, year]);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
stats: {
|
|
totalApplications: parseInt(stats.total_applications) || 0,
|
|
totalPlans: parseInt(stats.total_plans) || 0,
|
|
completedPlans: parseInt(stats.completed_plans) || 0,
|
|
plannedApplications: parseInt(stats.planned_applications) || 0,
|
|
totalAreaTreated: parseFloat(stats.total_area_treated) || 0,
|
|
avgApplicationSpeed: parseFloat(stats.avg_application_speed) || 0,
|
|
completionRate: stats.total_plans > 0 ?
|
|
Math.round((stats.completed_plans / stats.total_plans) * 100) : 0
|
|
},
|
|
monthlyBreakdown: monthlyResult.rows.map(row => ({
|
|
month: parseInt(row.month),
|
|
applications: parseInt(row.applications),
|
|
areaCovered: parseFloat(row.area_covered)
|
|
}))
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
module.exports = router; |