🫐 Phase 4: Harvest & Production

Harvest Management & Value-Added Products

Duration: 2-3 weeks | Complexity: Intermediate

📋 Phase Overview

This phase focuses on building comprehensive harvest management and value-added product production systems. You'll create interfaces for recording harvests with detailed quality metrics, tracking yields per plant and variety, managing product catalogs, linking production batches to specific harvests, and implementing sales tracking with customer management.

End Goal: Complete harvest-to-sale tracking system with production batch management and customer relationship tools.

⚠️ Prerequisites

  • Phase 3 Completed: Operations modules functional
  • Plant System: Plant tracking and QR system operational
  • Database Models: Harvest, sales, and product tables ready
  • User System: Authentication for harvest logging
1

Harvest Recording & Quality Metrics

Create comprehensive harvest recording system with detailed quality tracking.

Create Harvest Controller:

# Create harvest controller if not exists php artisan make:controller HarvestController --resource # Create harvest summary controller for analytics php artisan make:controller HarvestSummaryController

Enhanced Harvest Model (app/Models/Harvest.php):

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Carbon\Carbon; class Harvest extends Model { protected $table = 'harvests'; protected $primaryKey = 'harvest_id'; protected $fillable = [ 'plant_id', 'harvest_date', 'season', 'yield_lbs', 'berry_count', 'average_berry_size_mm', 'largest_berry_size_mm', 'average_sweetness_brix', 'berry_quality_grade', 'berry_firmness', 'color_quality', 'flavor_profile', 'harvest_duration_minutes', 'weather_during_harvest', 'harvest_notes', 'harvested_by', 'processing_method', 'market_destination' ]; protected $casts = [ 'harvest_date' => 'date', 'yield_lbs' => 'decimal:3', 'average_berry_size_mm' => 'decimal:2', 'largest_berry_size_mm' => 'decimal:2', 'average_sweetness_brix' => 'decimal:2' ]; public function plant(): BelongsTo { return $this->belongsTo(Plant::class, 'plant_id', 'plant_id'); } public function getQualityScoreAttribute(): int { $score = 0; // Size score (0-25 points) if ($this->average_berry_size_mm >= 20) $score += 25; elseif ($this->average_berry_size_mm >= 15) $score += 20; elseif ($this->average_berry_size_mm >= 10) $score += 15; else $score += 10; // Sweetness score (0-25 points) if ($this->average_sweetness_brix >= 12) $score += 25; elseif ($this->average_sweetness_brix >= 10) $score += 20; elseif ($this->average_sweetness_brix >= 8) $score += 15; else $score += 10; // Quality grade score (0-25 points) switch ($this->berry_quality_grade) { case 'premium': $score += 25; break; case 'grade_a': $score += 20; break; case 'grade_b': $score += 15; break; default: $score += 10; } // Firmness score (0-25 points) switch ($this->berry_firmness) { case 'very_firm': $score += 25; break; case 'firm': $score += 20; break; case 'moderate': $score += 15; break; default: $score += 10; } return $score; } public function getYieldPerMinuteAttribute(): ?float { if ($this->harvest_duration_minutes && $this->harvest_duration_minutes > 0) { return round($this->yield_lbs / ($this->harvest_duration_minutes / 60), 3); } return null; } public function getBerriesPerPoundAttribute(): ?int { if ($this->berry_count && $this->yield_lbs) { return round($this->berry_count / $this->yield_lbs); } return null; } public function scopeCurrentSeason($query) { $currentYear = Carbon::now()->year; return $query->whereYear('harvest_date', $currentYear); } public function scopeBySeason($query, string $season) { return $query->where('season', $season); } public function scopeByQualityGrade($query, string $grade) { return $query->where('berry_quality_grade', $grade); } }

HarvestController with Advanced Features:

<?php namespace App\Http\Controllers; use App\Models\Harvest; use App\Models\Plant; use App\Services\WeatherService; use Illuminate\Http\Request; use Carbon\Carbon; class HarvestController extends Controller { protected WeatherService $weatherService; public function __construct(WeatherService $weatherService) { $this->weatherService = $weatherService; } public function index(Request $request) { $harvests = Harvest::with(['plant', 'plant.variety']) ->when($request->plant_id, fn($q, $plant) => $q->where('plant_id', $plant)) ->when($request->season, fn($q, $season) => $q->where('season', $season)) ->when($request->quality_grade, fn($q, $grade) => $q->where('berry_quality_grade', $grade)) ->when($request->date_from, fn($q, $date) => $q->whereDate('harvest_date', '>=', $date)) ->when($request->date_to, fn($q, $date) => $q->whereDate('harvest_date', '<=', $date)) ->orderByDesc('harvest_date') ->paginate(20); $plants = Plant::where('status', 'active')->get(); // Summary statistics $stats = $this->getHarvestStats($request); return view('harvests.index', compact('harvests', 'plants', 'stats')); } public function create(Request $request) { $plants = Plant::where('status', 'active')->get(); $selectedPlant = $request->plant_id ? Plant::find($request->plant_id) : null; // Get current weather for harvest conditions $weather = $this->weatherService->fetchCurrentWeather(); return view('harvests.create', compact('plants', 'selectedPlant', 'weather')); } public function store(Request $request) { $validated = $request->validate([ 'plant_id' => 'required|exists:plants,plant_id', 'harvest_date' => 'required|date|before_or_equal:today', 'season' => 'required|in:spring,summer,fall', 'yield_lbs' => 'required|numeric|min:0|max:50', 'berry_count' => 'nullable|integer|min:0', 'average_berry_size_mm' => 'nullable|numeric|min:0|max:50', 'largest_berry_size_mm' => 'nullable|numeric|min:0|max:100', 'average_sweetness_brix' => 'nullable|numeric|min:0|max:25', 'berry_quality_grade' => 'required|in:premium,grade_a,grade_b,grade_c,processing', 'berry_firmness' => 'nullable|in:very_firm,firm,moderate,soft', 'color_quality' => 'nullable|in:excellent,good,fair,poor', 'flavor_profile' => 'nullable|string|max:500', 'harvest_duration_minutes' => 'nullable|integer|min:1|max:480', 'processing_method' => 'nullable|in:fresh,frozen,processing,value_add', 'market_destination' => 'nullable|in:personal,farmers_market,wholesale,upick,processing', 'harvest_notes' => 'nullable|string|max:1000' ]); $validated['harvested_by'] = auth()->user()->name; // Get weather conditions during harvest $weather = $this->weatherService->fetchCurrentWeather(); if ($weather) { $conditions = [ 'temperature' => $weather['main']['temp'] . '°F', 'humidity' => $weather['main']['humidity'] . '%', 'conditions' => $weather['weather'][0]['description'], 'wind' => ($weather['wind']['speed'] ?? 0) . ' mph' ]; $validated['weather_during_harvest'] = json_encode($conditions); } $harvest = Harvest::create($validated); return redirect()->route('harvests.show', $harvest) ->with('success', 'Harvest recorded successfully!'); } public function bulkCreate(Request $request) { $plants = Plant::where('status', 'active') ->when($request->row, fn($q, $row) => $q->where('location_row', $row)) ->get(); $weather = $this->weatherService->fetchCurrentWeather(); return view('harvests.bulk-create', compact('plants', 'weather')); } public function bulkStore(Request $request) { $validated = $request->validate([ 'plant_data' => 'required|array', 'plant_data.*.plant_id' => 'required|exists:plants,plant_id', 'plant_data.*.yield_lbs' => 'required|numeric|min:0|max:50', 'plant_data.*.berry_quality_grade' => 'required|in:premium,grade_a,grade_b,grade_c,processing', 'harvest_date' => 'required|date|before_or_equal:today', 'season' => 'required|in:spring,summer,fall', 'processing_method' => 'nullable|in:fresh,frozen,processing,value_add', 'harvest_notes' => 'nullable|string|max:1000' ]); $weather = $this->weatherService->fetchCurrentWeather(); $weatherConditions = null; if ($weather) { $weatherConditions = json_encode([ 'temperature' => $weather['main']['temp'] . '°F', 'humidity' => $weather['main']['humidity'] . '%', 'conditions' => $weather['weather'][0]['description'] ]); } $created = 0; foreach ($validated['plant_data'] as $plantData) { if ($plantData['yield_lbs'] > 0) { Harvest::create([ 'plant_id' => $plantData['plant_id'], 'harvest_date' => $validated['harvest_date'], 'season' => $validated['season'], 'yield_lbs' => $plantData['yield_lbs'], 'berry_quality_grade' => $plantData['berry_quality_grade'], 'processing_method' => $validated['processing_method'], 'weather_during_harvest' => $weatherConditions, 'harvest_notes' => $validated['harvest_notes'], 'harvested_by' => auth()->user()->name ]); $created++; } } return redirect()->route('harvests.index') ->with('success', "Bulk harvest recorded for {$created} plants!"); } private function getHarvestStats($request): array { $query = Harvest::query() ->when($request->season, fn($q, $season) => $q->where('season', $season)) ->when($request->date_from, fn($q, $date) => $q->whereDate('harvest_date', '>=', $date)) ->when($request->date_to, fn($q, $date) => $q->whereDate('harvest_date', '<=', $date)); return [ 'total_yield' => $query->sum('yield_lbs'), 'total_harvests' => $query->count(), 'avg_yield_per_harvest' => round($query->avg('yield_lbs'), 2), 'avg_quality_score' => round($query->get()->avg('quality_score'), 1), 'premium_percentage' => $query->where('berry_quality_grade', 'premium')->count() / max($query->count(), 1) * 100 ]; } }
2

Value-Added Product Catalog

Create comprehensive product management for jams, jellies, plants, and other value-added products.

Enhanced ValueAddProduct Model:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class ValueAddProduct extends Model { protected $table = 'value_add_products'; protected $primaryKey = 'product_id'; protected $fillable = [ 'product_name', 'product_type', 'product_category', 'berry_pounds_required', 'recipe_id', 'production_cost_per_unit', 'labor_cost_per_unit', 'material_cost_per_unit', 'packaging_cost_per_unit', 'retail_price', 'wholesale_price', 'farmers_market_price', 'online_price', 'bulk_price', 'shelf_life_days', 'storage_requirements', 'packaging_description', 'weight_per_unit', 'dimensions', 'barcode', 'nutrition_info', 'allergen_info', 'certifications', 'seasonal_availability', 'target_market' ]; protected $casts = [ 'berry_pounds_required' => 'decimal:2', 'production_cost_per_unit' => 'decimal:2', 'labor_cost_per_unit' => 'decimal:2', 'material_cost_per_unit' => 'decimal:2', 'packaging_cost_per_unit' => 'decimal:2', 'retail_price' => 'decimal:2', 'wholesale_price' => 'decimal:2', 'farmers_market_price' => 'decimal:2', 'online_price' => 'decimal:2', 'bulk_price' => 'decimal:2', 'weight_per_unit' => 'decimal:3', 'nutrition_info' => 'array', 'seasonal_availability' => 'array' ]; public function productionBatches(): HasMany { return $this->hasMany(ProductionBatch::class, 'product_id'); } public function sales(): HasMany { return $this->hasMany(Sale::class, 'product_id'); } public function getTotalCostPerUnitAttribute(): float { return $this->production_cost_per_unit + $this->labor_cost_per_unit + $this->material_cost_per_unit + $this->packaging_cost_per_unit; } public function getProfitMarginAttribute(): float { if ($this->retail_price > 0) { return (($this->retail_price - $this->getTotalCostPerUnitAttribute()) / $this->retail_price) * 100; } return 0; } public function getBerryCostPerUnitAttribute(): ?float { if ($this->berry_pounds_required) { // Assume average berry cost per pound (this could be dynamic) $avgBerryCostPerPound = 8.00; // $8/lb fresh berries return $this->berry_pounds_required * $avgBerryCostPerPound; } return null; } public function isInSeason(): bool { if (!$this->seasonal_availability) { return true; // Available year-round } $currentMonth = now()->month; return in_array($currentMonth, $this->seasonal_availability); } public function getTotalUnitsProduced(): int { return $this->productionBatches()->sum('units_produced'); } public function getTotalUnitsSold(): int { return $this->sales()->sum('quantity'); } public function getCurrentInventory(): int { return $this->getTotalUnitsProduced() - $this->getTotalUnitsSold(); } }
3

Production Batch Management

Link production batches to specific harvests for complete traceability.

ProductionBatch Model Enhancement:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Carbon\Carbon; class ProductionBatch extends Model { protected $table = 'production_batches'; protected $primaryKey = 'batch_id'; protected $fillable = [ 'product_id', 'farm_id', 'batch_number', 'production_date', 'harvest_ids', 'berries_used_lbs', 'other_ingredients', 'recipe_version', 'units_produced', 'units_packaged', 'units_sold', 'units_remaining', 'production_cost', 'labor_hours', 'energy_cost', 'equipment_used', 'production_location', 'batch_notes', 'quality_grade', 'quality_control_notes', 'production_start_time', 'production_end_time', 'packaging_date', 'expiration_date', 'storage_location', 'temperature_log', 'produced_by', 'quality_checked_by' ]; protected $casts = [ 'production_date' => 'date', 'packaging_date' => 'date', 'expiration_date' => 'date', 'production_start_time' => 'datetime', 'production_end_time' => 'datetime', 'berries_used_lbs' => 'decimal:2', 'production_cost' => 'decimal:2', 'labor_hours' => 'decimal:2', 'energy_cost' => 'decimal:2', 'harvest_ids' => 'array', 'other_ingredients' => 'array', 'temperature_log' => 'array' ]; public function product(): BelongsTo { return $this->belongsTo(ValueAddProduct::class, 'product_id'); } public function harvests(): BelongsToMany { // This would require a pivot table or we use the harvest_ids JSON field if ($this->harvest_ids) { return Harvest::whereIn('harvest_id', $this->harvest_ids); } return collect(); } public function getProductionDurationAttribute(): ?int { if ($this->production_start_time && $this->production_end_time) { return $this->production_start_time->diffInMinutes($this->production_end_time); } return null; } public function getUnitsRemainingAttribute(): int { return $this->units_produced - $this->units_sold; } public function getCostPerUnitAttribute(): ?float { if ($this->units_produced > 0) { return round($this->production_cost / $this->units_produced, 2); } return null; } public function isExpired(): bool { return $this->expiration_date && $this->expiration_date->isPast(); } public function getDaysUntilExpirationAttribute(): ?int { if ($this->expiration_date) { return now()->diffInDays($this->expiration_date, false); } return null; } public function getYieldPercentageAttribute(): ?float { if ($this->berries_used_lbs && $this->units_produced && $this->product) { $expectedUnits = $this->berries_used_lbs / $this->product->berry_pounds_required; return round(($this->units_produced / $expectedUnits) * 100, 1); } return null; } public function scopeExpiringWithin($query, int $days) { return $query->whereDate('expiration_date', '<=', now()->addDays($days)) ->whereDate('expiration_date', '>=', now()); } public function scopeExpired($query) { return $query->whereDate('expiration_date', '<', now()); } }
4

Sales & Customer Management

Create comprehensive sales tracking with customer relationship management.

Sale Model Enhancement:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Carbon\Carbon; class Sale extends Model { protected $table = 'sales'; protected $primaryKey = 'sale_id'; protected $fillable = [ 'farm_id', 'sale_date', 'sale_time', 'customer_name', 'customer_email', 'customer_phone', 'customer_address', 'customer_type', 'sale_channel', 'sale_type', 'item_description', 'product_id', 'batch_id', 'quantity', 'unit_of_measure', 'unit_price', 'discount_percent', 'discount_amount', 'subtotal', 'tax_rate', 'tax_amount', 'total_amount', 'payment_method', 'payment_status', 'payment_date', 'shipping_cost', 'delivery_method', 'delivery_date', 'delivery_status', 'customer_satisfaction', 'repeat_customer', 'referral_source', 'notes', 'processed_by' ]; protected $casts = [ 'sale_date' => 'date', 'payment_date' => 'date', 'delivery_date' => 'date', 'sale_time' => 'time', 'quantity' => 'decimal:2', 'unit_price' => 'decimal:2', 'discount_percent' => 'decimal:2', 'discount_amount' => 'decimal:2', 'subtotal' => 'decimal:2', 'tax_rate' => 'decimal:3', 'tax_amount' => 'decimal:2', 'total_amount' => 'decimal:2', 'shipping_cost' => 'decimal:2', 'repeat_customer' => 'boolean' ]; public function product(): BelongsTo { return $this->belongsTo(ValueAddProduct::class, 'product_id'); } public function batch(): BelongsTo { return $this->belongsTo(ProductionBatch::class, 'batch_id'); } public function getNetSaleAmountAttribute(): float { return $this->total_amount - ($this->shipping_cost ?? 0); } public function getProfitMarginAttribute(): ?float { if ($this->product && $this->unit_price > 0) { $cost = $this->product->getTotalCostPerUnitAttribute(); return (($this->unit_price - $cost) / $this->unit_price) * 100; } return null; } public function isPaid(): bool { return $this->payment_status === 'paid'; } public function isOverdue(): bool { if ($this->payment_status === 'pending' && $this->sale_date) { return $this->sale_date->addDays(30)->isPast(); // 30-day payment terms } return false; } public function scopeByChannel($query, string $channel) { return $query->where('sale_channel', $channel); } public function scopeByCustomerType($query, string $type) { return $query->where('customer_type', $type); } public function scopePaid($query) { return $query->where('payment_status', 'paid'); } public function scopePending($query) { return $query->where('payment_status', 'pending'); } // Customer analytics methods public static function getCustomerStats(string $email): array { $sales = static::where('customer_email', $email)->get(); return [ 'total_purchases' => $sales->count(), 'total_spent' => $sales->sum('total_amount'), 'avg_order_value' => round($sales->avg('total_amount'), 2), 'first_purchase' => $sales->min('sale_date'), 'last_purchase' => $sales->max('sale_date'), 'favorite_products' => $sales->groupBy('product_id') ->map(fn($group) => $group->count()) ->sortDesc() ->take(3) ]; } }
5

Inventory & Stock Management

Create inventory tracking for fresh berries and processed products.

Create Inventory Controller:

# Create inventory management controller php artisan make:controller InventoryController # Create inventory alerts system php artisan make:command CheckInventoryLevels

Inventory Management Features:

// Key inventory features to implement: 1. Fresh Berry Inventory: - Track harvested berries by date and quality - Monitor shelf life and freshness - Alert for berries approaching spoilage - Track usage for processing vs fresh sales 2. Processed Product Inventory: - Link to production batches - Track expiration dates - Monitor stock levels by product - Alert for low inventory levels - FIFO (First In, First Out) tracking 3. Inventory Valuation: - Calculate inventory value by cost - Track cost basis for accounting - Monitor inventory turnover rates - Generate inventory reports 4. Automated Alerts: - Low stock notifications - Expiration date warnings - Reorder point notifications - Seasonal inventory planning
6

Customer Relationship Management

Build customer database with purchase history and preferences.

Customer Model:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Customer extends Model { protected $fillable = [ 'name', 'email', 'phone', 'address', 'city', 'state', 'zip_code', 'customer_type', 'preferred_contact_method', 'marketing_opt_in', 'first_purchase_date', 'total_purchases', 'total_spent', 'avg_order_value', 'last_purchase_date', 'customer_notes', 'referral_source', 'loyalty_status' ]; protected $casts = [ 'first_purchase_date' => 'date', 'last_purchase_date' => 'date', 'total_spent' => 'decimal:2', 'avg_order_value' => 'decimal:2', 'marketing_opt_in' => 'boolean' ]; public function sales(): HasMany { return $this->hasMany(Sale::class, 'customer_email', 'email'); } public function updateCustomerStats(): void { $sales = $this->sales; $this->update([ 'total_purchases' => $sales->count(), 'total_spent' => $sales->sum('total_amount'), 'avg_order_value' => $sales->avg('total_amount'), 'first_purchase_date' => $sales->min('sale_date'), 'last_purchase_date' => $sales->max('sale_date') ]); } public function getLoyaltyTierAttribute(): string { if ($this->total_spent >= 500) return 'platinum'; if ($this->total_spent >= 200) return 'gold'; if ($this->total_spent >= 100) return 'silver'; return 'bronze'; } public function getRepeatCustomerAttribute(): bool { return $this->total_purchases > 1; } }

✅ Phase 4 Completion Checklist

  • Harvest recording system with quality metrics
  • Bulk harvest operations for multiple plants
  • Weather integration for harvest conditions
  • Value-added product catalog management
  • Production batch tracking with harvest linking
  • Comprehensive sales management system
  • Customer relationship management
  • Inventory tracking for fresh and processed products
  • Quality scoring and grading systems
  • Profit margin calculations and analysis

🎯 Next Steps: Phase 5 Preparation

With Phase 4 complete, you're ready for Phase 5: Projections & Reports. The next phase will involve:

  • Growth projection calculators
  • Yield forecasting based on historical data
  • Financial projection and ROI analysis
  • Interactive charts and reporting dashboard
  • Export functionality for reports
💾

Create Phase 4 Backup

# Create database backup mysqldump -u wwwhom8_main -p wwwhom8_blackberries > phase4_backup.sql # Create git commit git add . git commit -m "Phase 4: Harvest & Production completed - harvest tracking, product management, sales system" # Create git tag git tag -a v4.0-phase4 -m "Phase 4: Harvest & Production completed"