feat(sales): резолвер периода (этот/прошлый/позапрошлый/произвольный)

Task 1.1: SalesPeriodResolver.resolve({kind,from,to})→SalesPeriodRange (МСК, Europe/Moscow) + monthsIn() для ступенчатого тарифа. Поддержка this/prev/prev2/custom; неизвестный kind→this; custom from>to→исключение. Тест 10/10, stan 0. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 13:35:48 +03:00
parent d880f87e3d
commit 9f739109cd
3 changed files with 263 additions and 0 deletions
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use Carbon\CarbonImmutable;
/**
* Конкретный диапазон дат для периода продаж.
*
* start начало диапазона (00:00:00 МСК, включительно).
* end конец диапазона (23:59:59 МСК, включительно последнего дня).
* Оба значения в часовом поясе Europe/Moscow.
*/
final readonly class SalesPeriodRange
{
public function __construct(
public CarbonImmutable $start,
public CarbonImmutable $end,
) {}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Services\Sales;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
/**
* Преобразует выбор периода с фронтенда в конкретный диапазон дат МСК.
*
* Поддерживаемые kind:
* 'this' текущий месяц целиком.
* 'prev' предыдущий месяц целиком.
* 'prev2' месяц перед предыдущим целиком.
* 'custom' явный диапазон from..to (YYYY-MM-DD МСК).
*
* Неизвестный kind по умолчанию трактуется как 'this'.
* Все вычисления в Europe/Moscow через CarbonImmutable.
* "Now" берётся из CarbonImmutable::now('Europe/Moscow'),
* поэтому тесты могут замораживать время через CarbonImmutable::setTestNow().
*/
final class SalesPeriodResolver
{
private const TZ = 'Europe/Moscow';
/**
* @param array{kind?: string, from?: string|null, to?: string|null} $period
*
* @throws InvalidArgumentException для kind=custom при неверных/отсутствующих датах
*/
public function resolve(array $period): SalesPeriodRange
{
$kind = $period['kind'] ?? 'this';
return match ($kind) {
'prev' => $this->monthRange(-1),
'prev2' => $this->monthRange(-2),
'custom' => $this->customRange($period),
default => $this->monthRange(0), // 'this' и любой неизвестный kind
};
}
/**
* Список первых чисел каждого месяца (00:00 МСК), попадающего в диапазон.
*
* Например, диапазон 10 марта 20 мая вернёт [1 марта, 1 апреля, 1 мая].
*
* @return list<CarbonImmutable>
*/
public function monthsIn(SalesPeriodRange $range): array
{
$months = [];
$cursor = $range->start->startOfMonth();
while ($cursor->lte($range->end)) {
$months[] = $cursor;
$cursor = $cursor->addMonth();
}
return $months;
}
// ─── private ───────────────────────────────────────────────────────────────
/**
* Полный диапазон месяца, смещённого на $offset от текущего.
*
* $offset = 0 текущий месяц
* $offset = -1 предыдущий месяц
* $offset = -2 позапрошлый месяц
*/
private function monthRange(int $offset): SalesPeriodRange
{
$now = CarbonImmutable::now(self::TZ);
$base = $offset === 0
? $now
: $now->addMonths($offset);
$start = $base->startOfMonth()->startOfDay();
$end = $base->endOfMonth()->setTime(23, 59, 59);
return new SalesPeriodRange($start, $end);
}
/**
* @param array{kind?: string, from?: string|null, to?: string|null} $period
*/
private function customRange(array $period): SalesPeriodRange
{
if (empty($period['from'])) {
throw new InvalidArgumentException(
'Для произвольного периода необходимо указать дату «от» (from).',
);
}
if (empty($period['to'])) {
throw new InvalidArgumentException(
'Для произвольного периода необходимо указать дату «до» (to).',
);
}
$start = CarbonImmutable::parse($period['from'], self::TZ)->startOfDay();
$end = CarbonImmutable::parse($period['to'], self::TZ)->setTime(23, 59, 59);
if ($start->gt($end)) {
throw new InvalidArgumentException(
'Дата начала периода не может быть позже даты окончания.',
);
}
return new SalesPeriodRange($start, $end);
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
use App\Services\Sales\SalesPeriodRange;
use App\Services\Sales\SalesPeriodResolver;
use Carbon\CarbonImmutable;
// Заморозка времени: 15 июня 2026, 12:00 МСК
beforeEach(function (): void {
CarbonImmutable::setTestNow(
CarbonImmutable::parse('2026-06-15 12:00:00', 'Europe/Moscow'),
);
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
// ─── kind=this ────────────────────────────────────────────────────────────────
it('kind=this returns current month range (June 2026)', function (): void {
$resolver = new SalesPeriodResolver;
$range = $resolver->resolve(['kind' => 'this']);
expect($range)->toBeInstanceOf(SalesPeriodRange::class);
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-06-01 00:00:00');
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-06-30 23:59:59');
});
// ─── kind=prev ────────────────────────────────────────────────────────────────
it('kind=prev returns previous month range (May 2026)', function (): void {
$resolver = new SalesPeriodResolver;
$range = $resolver->resolve(['kind' => 'prev']);
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-05-01 00:00:00');
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-05-31 23:59:59');
});
// ─── kind=prev2 ───────────────────────────────────────────────────────────────
it('kind=prev2 returns month-before-previous range (April 2026)', function (): void {
$resolver = new SalesPeriodResolver;
$range = $resolver->resolve(['kind' => 'prev2']);
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-04-01 00:00:00');
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-04-30 23:59:59');
});
// ─── kind=custom ──────────────────────────────────────────────────────────────
it('kind=custom returns exact from/to bounds', function (): void {
$resolver = new SalesPeriodResolver;
$range = $resolver->resolve([
'kind' => 'custom',
'from' => '2026-03-10',
'to' => '2026-05-20',
]);
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-03-10 00:00:00');
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-05-20 23:59:59');
});
it('kind=custom: monthsIn returns all three month-starts for Mar-May span', function (): void {
$resolver = new SalesPeriodResolver;
$range = $resolver->resolve([
'kind' => 'custom',
'from' => '2026-03-10',
'to' => '2026-05-20',
]);
$months = $resolver->monthsIn($range);
expect($months)->toHaveCount(3);
expect($months[0]->format('Y-m-d'))->toBe('2026-03-01');
expect($months[1]->format('Y-m-d'))->toBe('2026-04-01');
expect($months[2]->format('Y-m-d'))->toBe('2026-05-01');
});
it('kind=custom: throws InvalidArgumentException when from > to', function (): void {
$resolver = new SalesPeriodResolver;
expect(fn () => $resolver->resolve([
'kind' => 'custom',
'from' => '2026-05-20',
'to' => '2026-03-10',
]))->toThrow(InvalidArgumentException::class);
});
it('kind=custom: throws InvalidArgumentException when from is missing', function (): void {
$resolver = new SalesPeriodResolver;
expect(fn () => $resolver->resolve([
'kind' => 'custom',
'to' => '2026-05-20',
]))->toThrow(InvalidArgumentException::class);
});
it('kind=custom: throws InvalidArgumentException when to is missing', function (): void {
$resolver = new SalesPeriodResolver;
expect(fn () => $resolver->resolve([
'kind' => 'custom',
'from' => '2026-03-10',
]))->toThrow(InvalidArgumentException::class);
});
// ─── monthsIn ─────────────────────────────────────────────────────────────────
it('monthsIn for single-month "this" range returns one entry (June 2026)', function (): void {
$resolver = new SalesPeriodResolver;
$range = $resolver->resolve(['kind' => 'this']);
$months = $resolver->monthsIn($range);
expect($months)->toHaveCount(1);
expect($months[0]->format('Y-m-d'))->toBe('2026-06-01');
});
// ─── timezone ─────────────────────────────────────────────────────────────────
it('range start carries Europe/Moscow timezone', function (): void {
$resolver = new SalesPeriodResolver;
$range = $resolver->resolve(['kind' => 'this']);
expect($range->start->timezoneName)->toBe('Europe/Moscow');
});