diff --git a/app/app/Services/Sales/SalesPeriodRange.php b/app/app/Services/Sales/SalesPeriodRange.php new file mode 100644 index 00000000..6ee2e78d --- /dev/null +++ b/app/app/Services/Sales/SalesPeriodRange.php @@ -0,0 +1,22 @@ + $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 + */ + 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); + } +} diff --git a/app/tests/Unit/Sales/SalesPeriodResolverTest.php b/app/tests/Unit/Sales/SalesPeriodResolverTest.php new file mode 100644 index 00000000..10b1e1f9 --- /dev/null +++ b/app/tests/Unit/Sales/SalesPeriodResolverTest.php @@ -0,0 +1,125 @@ +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'); +});