239 lines
8.6 KiB
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;
|
|
}
|
|
}
|