buckets/app/Services/Projection/PipelineAllocationService.php

216 lines
7.1 KiB
PHP
Raw Normal View History

2025-12-31 01:56:50 +01:00
<?php
namespace App\Services\Projection;
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
{
private const string CAP_BASE = 'base';
private const string CAP_FILL = 'fill';
2025-12-31 01:56:50 +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
*
* @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) {
return collect();
2025-12-31 01:56:50 +01:00
}
$buckets = $scenario->buckets()
->orderBy('priority')
->get();
if ($buckets->isEmpty()) {
return collect();
2025-12-31 01:56:50 +01:00
}
$mode = $scenario->distribution_mode ?? DistributionModeEnum::EVEN;
2025-12-31 01:56:50 +01:00
$allocationDate = $date ?? now();
$allocationDescription = $description ?? 'Allocation from inflow';
2025-12-31 01:56:50 +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
$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
$remaining = $amount;
2025-12-31 01:56:50 +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
}
$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;
}
/**
* 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
*/
private function allocateToGroup(Collection $buckets, int $amount, DistributionModeEnum $mode, string $capMethod, array &$allocated): int
2025-12-31 01:56:50 +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
};
}
/**
* Priority mode: fill highest-priority bucket first, then next.
2025-12-31 01:56:50 +01:00
*/
private function allocatePriority(Collection $buckets, int $amount, string $capMethod, array &$allocated): int
2025-12-31 01:56:50 +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
return $remaining;
2025-12-31 01:56:50 +01:00
}
/**
* Even mode: split evenly across eligible buckets, redistributing when one fills up.
2025-12-31 01:56:50 +01:00
*/
private function allocateEven(Collection $buckets, int $amount, string $capMethod, array &$allocated): int
2025-12-31 01:56:50 +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;
return max(0, $cap - $currentBalance - $alreadyAllocated);
2025-12-31 01:56:50 +01:00
}
}