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;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue