copy()->startOfMonth(); return SopCycle::firstOrCreate( [ 'business_id' => $businessId, 'cycle_month' => $monthStart->toDateString(), ], [ 'status' => 'draft', 'created_by' => $userId ?? auth()->id(), ] ); } /** * Build or refresh demand, supply, financial plans and gap analysis. */ public function refreshPlans(SopCycle $cycle): SopCycle { return DB::transaction(function () use ($cycle) { $businessId = (int) $cycle->business_id; $monthStart = Carbon::parse($cycle->cycle_month)->startOfMonth(); $monthEnd = $monthStart->copy()->endOfMonth(); $demandPlan = $this->buildDemandPlan($businessId, $monthStart, $monthEnd); $supplyPlan = $this->buildSupplyPlan($businessId, $monthStart, $monthEnd); $financialPlan = $this->buildFinancialPlan($demandPlan, $supplyPlan); $gaps = $this->calculateGaps($demandPlan, $supplyPlan, $financialPlan); $cycle->update([ 'demand_plan' => $demandPlan, 'supply_plan' => $supplyPlan, 'financial_plan' => $financialPlan, 'gaps' => $gaps, ]); return $cycle->fresh(); }); } public function advanceStatus(SopCycle $cycle, string $newStatus): SopCycle { $allowed = [ 'draft' => ['consensus'], 'consensus' => ['approved', 'draft'], 'approved' => ['draft'], ]; $current = $cycle->status; if (! in_array($newStatus, $allowed[$current] ?? [], true)) { throw new \InvalidArgumentException(__('advancedplanning::lang.invalid_status_transition', [ 'from' => $current, 'to' => $newStatus, ])); } $cycle->update(['status' => $newStatus]); return $cycle->fresh(); } /** * @return array */ protected function buildDemandPlan(int $businessId, Carbon $monthStart, Carbon $monthEnd): array { $salesLines = Transaction::where('business_id', $businessId) ->where('type', 'sell') ->where('status', 'final') ->whereBetween('transaction_date', [$monthStart, $monthEnd]) ->with(['sell_lines' => fn ($q) => $q->select('id', 'transaction_id', 'product_id', 'variation_id', 'quantity')]) ->get(); $byProduct = []; foreach ($salesLines as $txn) { foreach ($txn->sell_lines ?? [] as $line) { $key = (string) ($line->variation_id ?: $line->product_id); if (! isset($byProduct[$key])) { $byProduct[$key] = [ 'product_id' => $line->product_id, 'variation_id' => $line->variation_id, 'quantity' => 0, ]; } $byProduct[$key]['quantity'] += (float) $line->quantity; } } $mrpDemand = $this->bridge->loadMrpDemandForPeriod($businessId, $monthStart, $monthEnd); return [ 'period' => [ 'start' => $monthStart->toDateString(), 'end' => $monthEnd->toDateString(), ], 'sales_history' => array_values($byProduct), 'mrp_demand' => $mrpDemand, 'total_demand_units' => collect($byProduct)->sum('quantity') + collect($mrpDemand)->sum('quantity'), 'generated_at' => now()->toIso8601String(), ]; } /** * @return array */ protected function buildSupplyPlan(int $businessId, Carbon $monthStart, Carbon $monthEnd): array { $resources = CapacityResource::forBusiness($businessId) ->where('is_active', true) ->get(); $days = $monthStart->diffInDays($monthEnd) + 1; $capacityByResource = $resources->map(fn ($r) => [ 'resource_id' => $r->id, 'name' => $r->name, 'type' => $r->resource_type, 'available_hours' => round((float) $r->effectiveDailyHours() * $days, 2), ])->values()->all(); $scheduledRuns = ScheduleRun::forBusiness($businessId) ->where('status', 'completed') ->where('horizon_start', '<=', $monthEnd) ->where('horizon_end', '>=', $monthStart) ->with('scheduledOperations') ->latest('id') ->limit(5) ->get(); $scheduledHours = $scheduledRuns->flatMap(fn ($run) => $run->scheduledOperations) ->sum(fn ($op) => (float) $op->duration_hours); $workOrders = $this->bridge->loadSchedulableWorkOrders($businessId, $monthStart, $monthEnd); return [ 'capacity_resources' => $capacityByResource, 'total_available_hours' => collect($capacityByResource)->sum('available_hours'), 'scheduled_hours' => round($scheduledHours, 2), 'open_work_orders' => $workOrders->count(), 'open_work_order_qty' => $workOrders->sum('planned_quantity'), 'generated_at' => now()->toIso8601String(), ]; } /** * @param array $demand * @param array $supply * @return array */ protected function buildFinancialPlan(array $demand, array $supply): array { $demandUnits = (float) ($demand['total_demand_units'] ?? 0); $supplyHours = (float) ($supply['total_available_hours'] ?? 0); $scheduledHours = (float) ($supply['scheduled_hours'] ?? 0); $avgRevenuePerUnit = 100; // placeholder; extend with product pricing integration $avgCostPerHour = 50; return [ 'projected_revenue' => round($demandUnits * $avgRevenuePerUnit, 2), 'projected_supply_cost' => round($scheduledHours * $avgCostPerHour, 2), 'capacity_cost_ceiling' => round($supplyHours * $avgCostPerHour, 2), 'margin_estimate' => round(($demandUnits * $avgRevenuePerUnit) - ($scheduledHours * $avgCostPerHour), 2), 'generated_at' => now()->toIso8601String(), ]; } /** * @param array $demand * @param array $supply * @param array $financial * @return array */ protected function calculateGaps(array $demand, array $supply, array $financial): array { $demandUnits = (float) ($demand['total_demand_units'] ?? 0); $openWoQty = (float) ($supply['open_work_order_qty'] ?? 0); $capacityHours = (float) ($supply['total_available_hours'] ?? 0); $scheduledHours = (float) ($supply['scheduled_hours'] ?? 0); $demandSupplyGap = $demandUnits - $openWoQty; $capacityGap = $capacityHours - $scheduledHours; return [ 'demand_vs_supply_units' => round($demandSupplyGap, 2), 'demand_exceeds_supply' => $demandSupplyGap > 0, 'capacity_vs_scheduled_hours' => round($capacityGap, 2), 'capacity_shortfall' => $capacityGap < 0, 'financial_margin' => $financial['margin_estimate'] ?? 0, 'recommendations' => $this->buildRecommendations($demandSupplyGap, $capacityGap), 'generated_at' => now()->toIso8601String(), ]; } /** * @return array */ protected function buildRecommendations(float $demandGap, float $capacityGap): array { $recs = []; if ($demandGap > 0) { $recs[] = __('advancedplanning::lang.rec_increase_production', ['units' => round($demandGap, 0)]); } elseif ($demandGap < -10) { $recs[] = __('advancedplanning::lang.rec_reduce_inventory'); } if ($capacityGap < 0) { $recs[] = __('advancedplanning::lang.rec_add_capacity', ['hours' => round(abs($capacityGap), 1)]); } if (empty($recs)) { $recs[] = __('advancedplanning::lang.rec_balanced'); } return $recs; } }