2025-12-31 01:56:50 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services\Projection;
|
|
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
use App\Enums\BucketTypeEnum;
|
|
|
|
|
use App\Enums\DistributionModeEnum;
|
2025-12-31 01:56:50 +01:00
|
|
|
use App\Models\Bucket;
|
|
|
|
|
use App\Models\Draw;
|
|
|
|
|
use App\Models\Scenario;
|
|
|
|
|
use Carbon\Carbon;
|
|
|
|
|
use Illuminate\Support\Collection;
|
|
|
|
|
|
|
|
|
|
readonly class PipelineAllocationService
|
|
|
|
|
{
|
2026-03-21 18:23:38 +01:00
|
|
|
private const string CAP_BASE = 'base';
|
2026-03-30 00:00:54 +02:00
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
private const string CAP_FILL = 'fill';
|
|
|
|
|
|
2025-12-31 01:56:50 +01:00
|
|
|
/**
|
2026-03-21 18:23:38 +01:00
|
|
|
* Allocate an inflow amount across scenario buckets using phased distribution.
|
|
|
|
|
*
|
|
|
|
|
* Phases:
|
|
|
|
|
* 1. Needs to base (allocation_value)
|
|
|
|
|
* 2. Wants to base (allocation_value)
|
|
|
|
|
* 3. Needs to buffer (getEffectiveCapacity)
|
|
|
|
|
* 4. Wants to buffer (getEffectiveCapacity)
|
|
|
|
|
* 5. Overflow (all remaining)
|
2025-12-31 01:56:50 +01:00
|
|
|
*
|
2026-03-21 18:23:38 +01:00
|
|
|
* @return Collection<int, Draw> Collection of Draw models (unsaved)
|
2025-12-31 01:56:50 +01:00
|
|
|
*/
|
2025-12-31 02:34:30 +01:00
|
|
|
public function allocateInflow(Scenario $scenario, int $amount, ?Carbon $date = null, ?string $description = null): Collection
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
|
|
|
|
if ($amount <= 0) {
|
2026-03-21 18:23:38 +01:00
|
|
|
return collect();
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$buckets = $scenario->buckets()
|
|
|
|
|
->orderBy('priority')
|
|
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
if ($buckets->isEmpty()) {
|
2026-03-21 18:23:38 +01:00
|
|
|
return collect();
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
$mode = $scenario->distribution_mode ?? DistributionModeEnum::EVEN;
|
2025-12-31 01:56:50 +01:00
|
|
|
$allocationDate = $date ?? now();
|
2026-03-21 18:23:38 +01:00
|
|
|
$allocationDescription = $description ?? 'Allocation from inflow';
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
// Track cumulative allocations per bucket across phases
|
|
|
|
|
/** @var array<int, int> $allocated bucket_id => total allocated cents */
|
|
|
|
|
$allocated = [];
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
$needs = $buckets->filter(fn (Bucket $b) => $b->type === BucketTypeEnum::NEED);
|
|
|
|
|
$wants = $buckets->filter(fn (Bucket $b) => $b->type === BucketTypeEnum::WANT);
|
|
|
|
|
$overflow = $buckets->first(fn (Bucket $b) => $b->type === BucketTypeEnum::OVERFLOW);
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
$remaining = $amount;
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
// Phases 1-4: needs/wants to base/buffer
|
|
|
|
|
$phases = [
|
|
|
|
|
[$needs, self::CAP_BASE],
|
|
|
|
|
[$wants, self::CAP_BASE],
|
|
|
|
|
[$needs, self::CAP_FILL],
|
|
|
|
|
[$wants, self::CAP_FILL],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($phases as [$group, $cap]) {
|
|
|
|
|
$remaining = $this->allocateToGroup($group, $remaining, $mode, $cap, $allocated);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 5: Overflow
|
|
|
|
|
if ($remaining > 0 && $overflow !== null) {
|
|
|
|
|
$allocated[$overflow->id] = ($allocated[$overflow->id] ?? 0) + $remaining;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build Draw models from cumulative allocations
|
|
|
|
|
$draws = collect();
|
|
|
|
|
foreach ($allocated as $bucketId => $totalAmount) {
|
|
|
|
|
if ($totalAmount <= 0) {
|
|
|
|
|
continue;
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
2026-03-21 18:23:38 +01:00
|
|
|
|
|
|
|
|
$draws->push(new Draw([
|
|
|
|
|
'bucket_id' => $bucketId,
|
|
|
|
|
'amount' => $totalAmount,
|
|
|
|
|
'date' => $allocationDate,
|
|
|
|
|
'description' => $allocationDescription,
|
|
|
|
|
'is_projected' => true,
|
|
|
|
|
]));
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $draws;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-21 18:23:38 +01:00
|
|
|
* Allocate money to a group of buckets up to a capacity target.
|
|
|
|
|
*
|
|
|
|
|
* @param Collection<int, Bucket> $buckets Buckets ordered by priority
|
|
|
|
|
* @param int $amount Available money in cents
|
|
|
|
|
* @param DistributionModeEnum $mode Even or priority distribution
|
|
|
|
|
* @param string $capMethod self::CAP_BASE = allocation_value, self::CAP_FILL = getEffectiveCapacity()
|
|
|
|
|
* @param array<int, int> &$allocated Cumulative allocations per bucket (mutated)
|
|
|
|
|
* @return int Remaining amount after allocation
|
2025-12-31 01:56:50 +01:00
|
|
|
*/
|
2026-03-21 18:23:38 +01:00
|
|
|
private function allocateToGroup(Collection $buckets, int $amount, DistributionModeEnum $mode, string $capMethod, array &$allocated): int
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 18:23:38 +01:00
|
|
|
if ($amount <= 0 || $buckets->isEmpty()) {
|
|
|
|
|
return $amount;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return match ($mode) {
|
|
|
|
|
DistributionModeEnum::PRIORITY => $this->allocatePriority($buckets, $amount, $capMethod, $allocated),
|
|
|
|
|
DistributionModeEnum::EVEN => $this->allocateEven($buckets, $amount, $capMethod, $allocated),
|
2025-12-31 01:56:50 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-21 18:23:38 +01:00
|
|
|
* Priority mode: fill highest-priority bucket first, then next.
|
2025-12-31 01:56:50 +01:00
|
|
|
*/
|
2026-03-21 18:23:38 +01:00
|
|
|
private function allocatePriority(Collection $buckets, int $amount, string $capMethod, array &$allocated): int
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 18:23:38 +01:00
|
|
|
$remaining = $amount;
|
|
|
|
|
|
|
|
|
|
foreach ($buckets as $bucket) {
|
|
|
|
|
if ($remaining <= 0) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$space = $this->getAvailableSpace($bucket, $capMethod, $allocated);
|
|
|
|
|
if ($space <= 0) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$allocation = min($space, $remaining);
|
|
|
|
|
$allocated[$bucket->id] = ($allocated[$bucket->id] ?? 0) + $allocation;
|
|
|
|
|
$remaining -= $allocation;
|
|
|
|
|
}
|
2025-12-31 01:56:50 +01:00
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
return $remaining;
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-21 18:23:38 +01:00
|
|
|
* Even mode: split evenly across eligible buckets, redistributing when one fills up.
|
2025-12-31 01:56:50 +01:00
|
|
|
*/
|
2026-03-21 18:23:38 +01:00
|
|
|
private function allocateEven(Collection $buckets, int $amount, string $capMethod, array &$allocated): int
|
2025-12-31 01:56:50 +01:00
|
|
|
{
|
2026-03-21 18:23:38 +01:00
|
|
|
$remaining = $amount;
|
|
|
|
|
|
|
|
|
|
// Filter to buckets that still have space
|
|
|
|
|
$eligible = $buckets->filter(fn (Bucket $b) => $this->getAvailableSpace($b, $capMethod, $allocated) > 0);
|
|
|
|
|
|
|
|
|
|
while ($remaining > 0 && $eligible->isNotEmpty()) {
|
|
|
|
|
$share = intdiv($remaining, $eligible->count());
|
|
|
|
|
$remainder = $remaining % $eligible->count();
|
|
|
|
|
|
|
|
|
|
$spent = 0;
|
|
|
|
|
$filledUp = [];
|
|
|
|
|
|
|
|
|
|
foreach ($eligible as $index => $bucket) {
|
|
|
|
|
$bucketShare = $share + ($remainder > 0 ? 1 : 0);
|
|
|
|
|
if ($remainder > 0) {
|
|
|
|
|
$remainder--;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($bucketShare <= 0) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$space = $this->getAvailableSpace($bucket, $capMethod, $allocated);
|
|
|
|
|
$allocation = min($bucketShare, $space);
|
|
|
|
|
|
|
|
|
|
if ($allocation > 0) {
|
|
|
|
|
$allocated[$bucket->id] = ($allocated[$bucket->id] ?? 0) + $allocation;
|
|
|
|
|
$spent += $allocation;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($allocation >= $space) {
|
|
|
|
|
$filledUp[] = $index;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$remaining -= $spent;
|
|
|
|
|
|
|
|
|
|
if (empty($filledUp)) {
|
|
|
|
|
// No bucket filled up, so we're done (all money placed or share was 0)
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove filled buckets and retry with remaining money
|
|
|
|
|
$eligible = $eligible->forget($filledUp)->values();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $remaining;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get available space for a bucket given the cap method and already-allocated amounts.
|
|
|
|
|
*/
|
|
|
|
|
private function getAvailableSpace(Bucket $bucket, string $capMethod, array $allocated): int
|
|
|
|
|
{
|
|
|
|
|
// Non-fixed-limit buckets (percentage, unlimited) have no meaningful cap in phased distribution
|
|
|
|
|
if (! $bucket->hasFiniteCapacity()) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$cap = $capMethod === self::CAP_BASE
|
|
|
|
|
? ($bucket->allocation_value ?? 0)
|
|
|
|
|
: $bucket->getEffectiveCapacity();
|
|
|
|
|
|
|
|
|
|
$currentBalance = $bucket->getCurrentBalance();
|
|
|
|
|
$alreadyAllocated = $allocated[$bucket->id] ?? 0;
|
2026-03-19 01:09:47 +01:00
|
|
|
|
2026-03-21 18:23:38 +01:00
|
|
|
return max(0, $cap - $currentBalance - $alreadyAllocated);
|
2025-12-31 01:56:50 +01:00
|
|
|
}
|
|
|
|
|
}
|