diff --git a/app/Services/Projection/PipelineAllocationService.php b/app/Services/Projection/PipelineAllocationService.php index a6abb88..d8c4998 100644 --- a/app/Services/Projection/PipelineAllocationService.php +++ b/app/Services/Projection/PipelineAllocationService.php @@ -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 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 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 $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); + } + + // 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; } - $allocation = $this->calculateBucketAllocation($bucket, $remainingAmount); - - if ($allocation > 0) { - $draw = new Draw([ - 'bucket_id' => $bucket->id, - 'amount' => $allocation, - 'date' => $allocationDate, - 'description' => $description ?? 'Allocation from inflow', - 'is_projected' => true, - ]); - - $draws->push($draw); - $remainingAmount -= $allocation; - $priorityOrder++; - } + $draws->push(new Draw([ + 'bucket_id' => $bucketId, + 'amount' => $totalAmount, + 'date' => $allocationDate, + 'description' => $allocationDescription, + 'is_projected' => true, + ])); } 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 $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 &$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); } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b2e1d99..193b598 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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\\:\: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\\:\: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\, Illuminate\\Database\\Eloquent\\Collection\ 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\, 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