17 - Rewrite PipelineAllocationService for phased distribution

This commit is contained in:
myrmidex 2026-03-21 18:23:38 +01:00
parent f14057d6d9
commit d45ca30151
2 changed files with 177 additions and 50 deletions

View file

@ -2,7 +2,8 @@
namespace App\Services\Projection; namespace App\Services\Projection;
use App\Enums\BucketAllocationTypeEnum; use App\Enums\BucketTypeEnum;
use App\Enums\DistributionModeEnum;
use App\Models\Bucket; use App\Models\Bucket;
use App\Models\Draw; use App\Models\Draw;
use App\Models\Scenario; use App\Models\Scenario;
@ -11,89 +12,203 @@
readonly class PipelineAllocationService readonly class PipelineAllocationService
{ {
private const string CAP_BASE = 'base';
private const string CAP_FILL = 'fill';
/** /**
* Allocate an inflow amount across scenario buckets according to priority rules. * Allocate an inflow amount across scenario buckets using phased distribution.
* *
* @return Collection<Draw> Collection of Draw models * 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)
*
* @return Collection<int, Draw> Collection of Draw models (unsaved)
*/ */
public function allocateInflow(Scenario $scenario, int $amount, ?Carbon $date = null, ?string $description = null): Collection public function allocateInflow(Scenario $scenario, int $amount, ?Carbon $date = null, ?string $description = null): Collection
{ {
$draws = collect();
// Guard clauses
if ($amount <= 0) { if ($amount <= 0) {
return $draws; return collect();
} }
// Get buckets ordered by priority
$buckets = $scenario->buckets() $buckets = $scenario->buckets()
->orderBy('priority') ->orderBy('priority')
->get(); ->get();
if ($buckets->isEmpty()) { if ($buckets->isEmpty()) {
return $draws; return collect();
} }
$priorityOrder = 1; $mode = $scenario->distribution_mode ?? DistributionModeEnum::EVEN;
$remainingAmount = $amount;
$allocationDate = $date ?? now(); $allocationDate = $date ?? now();
$allocationDescription = $description ?? 'Allocation from inflow';
foreach ($buckets as $bucket) { // Track cumulative allocations per bucket across phases
if ($remainingAmount <= 0) { /** @var array<int, int> $allocated bucket_id => total allocated cents */
break; $allocated = [];
$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);
$remaining = $amount;
// 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);
} }
$allocation = $this->calculateBucketAllocation($bucket, $remainingAmount); // Phase 5: Overflow
if ($remaining > 0 && $overflow !== null) {
$allocated[$overflow->id] = ($allocated[$overflow->id] ?? 0) + $remaining;
}
if ($allocation > 0) { // Build Draw models from cumulative allocations
$draw = new Draw([ $draws = collect();
'bucket_id' => $bucket->id, foreach ($allocated as $bucketId => $totalAmount) {
'amount' => $allocation, if ($totalAmount <= 0) {
continue;
}
$draws->push(new Draw([
'bucket_id' => $bucketId,
'amount' => $totalAmount,
'date' => $allocationDate, 'date' => $allocationDate,
'description' => $description ?? 'Allocation from inflow', 'description' => $allocationDescription,
'is_projected' => true, 'is_projected' => true,
]); ]));
$draws->push($draw);
$remainingAmount -= $allocation;
$priorityOrder++;
}
} }
return $draws; return $draws;
} }
/** /**
* Calculate how much should be allocated to a specific bucket. * 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
*/ */
private function calculateBucketAllocation(Bucket $bucket, int $remainingAmount): int private function allocateToGroup(Collection $buckets, int $amount, DistributionModeEnum $mode, string $capMethod, array &$allocated): int
{ {
return match ($bucket->allocation_type) { if ($amount <= 0 || $buckets->isEmpty()) {
BucketAllocationTypeEnum::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount), return $amount;
BucketAllocationTypeEnum::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount), }
BucketAllocationTypeEnum::UNLIMITED => $remainingAmount, // Takes all remaining
default => 0, return match ($mode) {
DistributionModeEnum::PRIORITY => $this->allocatePriority($buckets, $amount, $capMethod, $allocated),
DistributionModeEnum::EVEN => $this->allocateEven($buckets, $amount, $capMethod, $allocated),
}; };
} }
/** /**
* Calculate allocation for fixed limit buckets. * Priority mode: fill highest-priority bucket first, then next.
*/ */
private function calculateFixedAllocation(Bucket $bucket, int $remainingAmount): int private function allocatePriority(Collection $buckets, int $amount, string $capMethod, array &$allocated): int
{ {
$availableSpace = $bucket->getAvailableSpace(); $remaining = $amount;
return min($availableSpace, $remainingAmount); 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;
}
return $remaining;
} }
/** /**
* Calculate allocation for percentage buckets. * Even mode: split evenly across eligible buckets, redistributing when one fills up.
* allocation_value is stored in basis points (2500 = 25%).
*/ */
private function calculatePercentageAllocation(Bucket $bucket, int $remainingAmount): int private function allocateEven(Collection $buckets, int $amount, string $capMethod, array &$allocated): int
{ {
$basisPoints = $bucket->allocation_value ?? 0; $remaining = $amount;
return (int) round($remainingAmount * ($basisPoints / 10000)); // 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);
} }
} }

View file

@ -538,21 +538,33 @@ parameters:
- -
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound identifier: property.notFound
count: 1 count: 2
path: app/Services/Projection/PipelineAllocationService.php path: app/Services/Projection/PipelineAllocationService.php
- -
message: '#^Match arm comparison between App\\Enums\\BucketAllocationTypeEnum\:\:UNLIMITED and App\\Enums\\BucketAllocationTypeEnum\:\:UNLIMITED is always true\.$#' message: '#^Parameter \#1 \$callback of method Illuminate\\Support\\Collection\<int,Illuminate\\Database\\Eloquent\\Model\>\:\:filter\(\) expects \(callable\(Illuminate\\Database\\Eloquent\\Model, int\)\: bool\)\|null, Closure\(App\\Models\\Bucket\)\: bool given\.$#'
identifier: match.alwaysTrue identifier: argument.type
count: 1 count: 2
path: app/Services/Projection/PipelineAllocationService.php path: app/Services/Projection/PipelineAllocationService.php
- -
message: '#^Parameter \#1 \$bucket of method App\\Services\\Projection\\PipelineAllocationService\:\:calculateBucketAllocation\(\) expects App\\Models\\Bucket, Illuminate\\Database\\Eloquent\\Model given\.$#' message: '#^Parameter \#1 \$callback of method Illuminate\\Support\\Collection\<int,Illuminate\\Database\\Eloquent\\Model\>\:\:first\(\) expects \(callable\(Illuminate\\Database\\Eloquent\\Model, int\)\: bool\)\|null, Closure\(App\\Models\\Bucket\)\: bool given\.$#'
identifier: argument.type identifier: argument.type
count: 1 count: 1
path: app/Services/Projection/PipelineAllocationService.php path: app/Services/Projection/PipelineAllocationService.php
-
message: '#^Parameter \#1 \$buckets of method App\\Services\\Projection\\PipelineAllocationService\:\:allocateToGroup\(\) expects Illuminate\\Support\\Collection\<int, App\\Models\\Bucket\>, Illuminate\\Database\\Eloquent\\Collection\<int, Illuminate\\Database\\Eloquent\\Model\> given\.$#'
identifier: argument.type
count: 1
path: app/Services/Projection/PipelineAllocationService.php
-
message: '#^Parameter &\$allocated by\-ref type of method App\\Services\\Projection\\PipelineAllocationService\:\:allocateToGroup\(\) expects array\<int, int\>, array given\.$#'
identifier: parameterByRef.type
count: 2
path: app/Services/Projection/PipelineAllocationService.php
- -
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$amount\.$#' message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$amount\.$#'
identifier: property.notFound identifier: property.notFound