1e0c0ab90a
- PricingTierResolver::resolveForCount — InvalidArgumentException на $leadOrdinal < 1 (closes I-1: defensive contract validation). - PricingTierRepository::activeAt — explicit @var Collection<int, PricingTier> annotation для type narrowing (closes I-2; firstOrFail отвергнут — Stan ругается на Eloquent\Model return-type). - PricingTierResolverTest — +1 unit test (8/8 PASS): throws на 0/-1. - PricingTierRepositoryTest — +1 integration test (5/5 PASS): excludes inactive tiers (closes M-2 coverage gap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
45 lines
1.4 KiB
PHP
45 lines
1.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Repositories;
|
||
|
||
use App\Models\PricingTier;
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Database\Eloquent\Collection;
|
||
|
||
/**
|
||
* DB-обёртка для текущей активной сетки pricing_tiers.
|
||
*
|
||
* Логика: для данной даты `$at` возвращаются все ступени с
|
||
* MAX(effective_from) WHERE effective_from <= $at AND is_active=true,
|
||
* сгруппированные по tier_no.
|
||
*
|
||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3.1
|
||
*/
|
||
final class PricingTierRepository
|
||
{
|
||
/**
|
||
* @return Collection<int, PricingTier>
|
||
*/
|
||
public function activeAt(CarbonInterface $at): Collection
|
||
{
|
||
// Для каждого tier_no берём строку с MAX(effective_from) <= $at
|
||
// (учёт сценария «новая сетка перекрывает старую»).
|
||
/** @var Collection<int, PricingTier> $result */
|
||
$result = PricingTier::query()
|
||
->where('is_active', true)
|
||
->where('effective_from', '<=', $at->toDateString())
|
||
->orderBy('tier_no')
|
||
->orderBy('effective_from', 'desc')
|
||
->get()
|
||
->groupBy('tier_no')
|
||
->map(fn (Collection $group) => $group->first())
|
||
->values()
|
||
->sortBy('tier_no')
|
||
->values();
|
||
|
||
return $result;
|
||
}
|
||
}
|