17 - Rewrite PipelineAllocationService for phased distribution
This commit is contained in:
parent
f14057d6d9
commit
d45ca30151
2 changed files with 177 additions and 50 deletions
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
namespace App\Services\Projection;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Enums\DistributionModeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Draw;
|
||||
use App\Models\Scenario;
|
||||
|
|
@ -11,89 +12,203 @@
|
|||
|
||||
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
|
||||
{
|
||||
$draws = collect();
|
||||
|
||||
// Guard clauses
|
||||
if ($amount <= 0) {
|
||||
return $draws;
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get buckets ordered by priority
|
||||
$buckets = $scenario->buckets()
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
|
||||
if ($buckets->isEmpty()) {
|
||||
return $draws;
|
||||
return collect();
|
||||
}
|
||||
|
||||
$priorityOrder = 1;
|
||||
$remainingAmount = $amount;
|
||||
$mode = $scenario->distribution_mode ?? DistributionModeEnum::EVEN;
|
||||
$allocationDate = $date ?? now();
|
||||
$allocationDescription = $description ?? 'Allocation from inflow';
|
||||
|
||||
foreach ($buckets as $bucket) {
|
||||
if ($remainingAmount <= 0) {
|
||||
break;
|
||||
// Track cumulative allocations per bucket across phases
|
||||
/** @var array<int, int> $allocated bucket_id => total allocated cents */
|
||||
$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) {
|
||||
$draw = new Draw([
|
||||
'bucket_id' => $bucket->id,
|
||||
'amount' => $allocation,
|
||||
// Build Draw models from cumulative allocations
|
||||
$draws = collect();
|
||||
foreach ($allocated as $bucketId => $totalAmount) {
|
||||
if ($totalAmount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$draws->push(new Draw([
|
||||
'bucket_id' => $bucketId,
|
||||
'amount' => $totalAmount,
|
||||
'date' => $allocationDate,
|
||||
'description' => $description ?? 'Allocation from inflow',
|
||||
'description' => $allocationDescription,
|
||||
'is_projected' => true,
|
||||
]);
|
||||
|
||||
$draws->push($draw);
|
||||
$remainingAmount -= $allocation;
|
||||
$priorityOrder++;
|
||||
}
|
||||
]));
|
||||
}
|
||||
|
||||
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) {
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount),
|
||||
BucketAllocationTypeEnum::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount),
|
||||
BucketAllocationTypeEnum::UNLIMITED => $remainingAmount, // Takes all remaining
|
||||
default => 0,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* allocation_value is stored in basis points (2500 = 25%).
|
||||
* Even mode: split evenly across eligible buckets, redistributing when one fills up.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -538,21 +538,33 @@ parameters:
|
|||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
count: 2
|
||||
path: app/Services/Projection/PipelineAllocationService.php
|
||||
|
||||
-
|
||||
message: '#^Match arm comparison between App\\Enums\\BucketAllocationTypeEnum\:\:UNLIMITED and App\\Enums\\BucketAllocationTypeEnum\:\:UNLIMITED is always true\.$#'
|
||||
identifier: match.alwaysTrue
|
||||
count: 1
|
||||
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: argument.type
|
||||
count: 2
|
||||
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
|
||||
count: 1
|
||||
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\.$#'
|
||||
identifier: property.notFound
|
||||
|
|
|
|||
Loading…
Reference in a new issue