ultimatepos/Modules/AdvancedPlanning/Services/SopCycleService.php

239 lines
8.6 KiB
PHP

<?php
namespace Modules\AdvancedPlanning\Services;
use App\Transaction;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\AdvancedPlanning\Models\CapacityResource;
use Modules\AdvancedPlanning\Models\ScheduleRun;
use Modules\AdvancedPlanning\Models\SopCycle;
class SopCycleService
{
public function __construct(
protected IntegrationBridgeService $bridge,
protected FiniteCapacitySchedulerService $scheduler
) {
}
public function createCycle(int $businessId, Carbon $cycleMonth, ?int $userId = null): SopCycle
{
$monthStart = $cycleMonth->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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $demand
* @param array<string, mixed> $supply
* @return array<string, mixed>
*/
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<string, mixed> $demand
* @param array<string, mixed> $supply
* @param array<string, mixed> $financial
* @return array<string, mixed>
*/
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<int, string>
*/
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;
}
}