diff --git a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php index 6c4ecd80..130df6a7 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php @@ -37,6 +37,12 @@ class SalesProspectController extends Controller /** Стадии, по которым делать уже нечего — в фильтр «что надо сделать» не попадают. */ private const DEAD_STAGES = ['rejected', 'trash']; + /** Сроки режима «что надо сделать» — смотрят ВПЕРЁД плюс «просроченные». */ + private const TODO_PERIODS = ['overdue', 'today', 'tomorrow', 'next7', 'next30', 'custom']; + + /** Сроки режима «что менялось» — смотрят НАЗАД: будущего в истории не бывает. */ + private const CHANGED_PERIODS = ['today', 'yesterday', 'd7', 'd30', 'custom']; + /** Все стадии в порядке колонок канбана. Должно совпадать с PROSPECT_STAGES (фронт). */ private const STAGES = [ 'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing', @@ -120,15 +126,21 @@ class SalesProspectController extends Controller /** * Фильтр доски по датам. Два режима, они взаимоисключающие по смыслу: * - * todo — «что надо сделать»: созвон попадает в период ИЛИ уже просрочен. - * Просроченное показывается ВСЕГДА, какой бы период ни выбрали: - * забытый звонок недельной давности важнее завтрашнего. Отказ и - * корзина не показываются — по ним делать нечего. + * todo — «что надо сделать»: срок созвона попадает в выбранный отрезок. + * Просроченное — ОТДЕЛЬНЫЙ пункт срока (period=overdue), а не + * добавка к каждому: владелец просил, чтобы каждый пункт списка + * показывал ровно то, что написано (01.08.2026). Отказ и корзина + * не показываются никогда — по ним делать нечего. * changed — «что менялось»: у карточки есть движение по стадиям ИЛИ запись * в журнале разговоров за период. Движения включают автоматические * (деньги → тестирование/пополнил/пользователь) — владелец просил * считать ЛЮБОЕ движение, а не только разговоры. * + * Сроки у режимов РАЗНЫЕ и не пересекаются: «что надо сделать» смотрит вперёд + * (просрочено / сегодня / завтра / ближайшие 7–30 дней), «что менялось» — назад + * (сегодня / вчера / прошедшие 7–30 дней). Срок не из своего режима — 422: + * «что менялось завтра» не бывает, и молча подменять его на месяц нельзя. + * * Без date_mode доска отдаёт всё, как раньше. * * @param Builder $query @@ -140,19 +152,27 @@ class SalesProspectController extends Controller return; } - $range = $this->resolvePeriod($request); + $kind = (string) $request->query('period', $mode === 'todo' ? 'today' : 'd30'); + $this->assertPeriodFitsMode($mode, $kind); if ($mode === 'todo') { - $query->whereNotIn('stage', self::DEAD_STAGES) - ->whereNotNull('next_call_at') - ->where(function ($q) use ($range) { - $q->whereBetween('next_call_at', [$range->start, $range->end]) - ->orWhere('next_call_at', '<', now()); - }); + $query->whereNotIn('stage', self::DEAD_STAGES)->whereNotNull('next_call_at'); + + // «Просроченные» — не отрезок календаря, а всё, что раньше «сейчас». + if ($kind === 'overdue') { + $query->where('next_call_at', '<', now()); + + return; + } + + $range = $this->resolvePeriod($request, 'today'); + $query->whereBetween('next_call_at', [$range->start, $range->end]); return; } + $range = $this->resolvePeriod($request); + $query->where(function ($q) use ($range) { $q->whereExists(function ($sub) use ($range) { $sub->selectRaw('1') @@ -168,6 +188,28 @@ class SalesProspectController extends Controller }); } + /** + * Срок должен подходить режиму: «что менялось завтра» и «просроченные за + * вчера» — бессмыслица. Раньше чужой срок молча падал в резолвере на + * «текущий месяц», и доска показывала совсем не то, что выбрано. + * + * @throws ValidationException + */ + private function assertPeriodFitsMode(string $mode, string $kind): void + { + $allowed = $mode === 'todo' + ? self::TODO_PERIODS + : self::CHANGED_PERIODS; + + if (in_array($kind, $allowed, true)) { + return; + } + + throw ValidationException::withMessages([ + 'period' => 'Этот срок не подходит к выбранному фильтру по датам.', + ]); + } + /** * Менеджер заводит СВОЕГО кандидата (инициатива, не из поиска). * Карточка всегда создаётся автору (sales_user_id из тела игнорируется), diff --git a/app/app/Http/Controllers/Concerns/ResolvesSalesPeriod.php b/app/app/Http/Controllers/Concerns/ResolvesSalesPeriod.php index 9032fc6f..501802f8 100644 --- a/app/app/Http/Controllers/Concerns/ResolvesSalesPeriod.php +++ b/app/app/Http/Controllers/Concerns/ResolvesSalesPeriod.php @@ -26,15 +26,16 @@ trait ResolvesSalesPeriod * Период из query-параметров запроса. * * По умолчанию — последние 30 дней: тот же период, что PeriodPicker - * показывает при первом заходе. + * показывает при первом заходе. Экраны, где «назад на 30 дней» бессмысленно + * (воронка «что надо сделать» смотрит вперёд), передают свой $default. * * @throws ValidationException при неполном или перевёрнутом произвольном периоде */ - protected function resolvePeriod(Request $request): SalesPeriodRange + protected function resolvePeriod(Request $request, string $default = 'd30'): SalesPeriodRange { try { return app(SalesPeriodResolver::class)->resolve([ - 'kind' => (string) $request->query('period', 'd30'), + 'kind' => (string) $request->query('period', $default), 'from' => $request->query('from'), 'to' => $request->query('to'), ]); diff --git a/app/app/Services/Sales/SalesPeriodResolver.php b/app/app/Services/Sales/SalesPeriodResolver.php index fa70371a..0bae976e 100644 --- a/app/app/Services/Sales/SalesPeriodResolver.php +++ b/app/app/Services/Sales/SalesPeriodResolver.php @@ -16,6 +16,8 @@ use InvalidArgumentException; * 'yesterday' — вчера. * 'd7' — последние 7 дней, включая сегодня. * 'd30' — последние 30 дней, включая сегодня. + * 'next7' — ближайшие 7 дней, включая сегодня (вперёд). + * 'next30' — ближайшие 30 дней, включая сегодня (вперёд). * 'this' — текущий месяц целиком. * 'prev' — предыдущий месяц целиком. * 'prev2' — месяц перед предыдущим целиком. @@ -45,6 +47,8 @@ final class SalesPeriodResolver 'yesterday' => $this->dayRange(-1), 'd7' => $this->lastDays(7), 'd30' => $this->lastDays(30), + 'next7' => $this->nextDays(7), + 'next30' => $this->nextDays(30), 'prev' => $this->monthRange(-1), 'prev2' => $this->monthRange(-2), 'custom' => $this->customRange($period), @@ -88,6 +92,22 @@ final class SalesPeriodResolver ); } + /** + * Ближайшие $days дней, ВКЛЮЧАЯ сегодня: next7 = сегодня и 6 следующих. + * + * Зеркало lastDays() для фильтра «что надо сделать»: там срок смотрит + * ВПЕРЁД (когда звонить), а не назад (что уже случилось). + */ + private function nextDays(int $days): SalesPeriodRange + { + $today = CarbonImmutable::now(self::TZ)->startOfDay(); + + return new SalesPeriodRange( + $today, + $today->addDays($days - 1)->setTime(23, 59, 59), + ); + } + /** Один день, смещённый на $offset от сегодняшнего (0 — сегодня, -1 — вчера). */ private function dayRange(int $offset): SalesPeriodRange { diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 14e300cc..1b0e1725 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -5067,7 +5067,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 9 + count: 14 path: tests/Feature/Sales/SalesProspectDateFilterTest.php - diff --git a/app/resources/js/api/sales.ts b/app/resources/js/api/sales.ts index 13100ae3..8db657b4 100644 --- a/app/resources/js/api/sales.ts +++ b/app/resources/js/api/sales.ts @@ -1302,13 +1302,28 @@ export interface ProspectsResponse { /** * Фильтр доски по датам: - * todo — что надо сделать в этот срок (плюс всё просроченное); - * changed — что менялось в этот срок (любое движение карточки). + * todo — что надо сделать в этот срок (срок смотрит вперёд, плюс отдельный + * пункт «Просроченные»); + * changed — что менялось в этот срок (срок смотрит назад, любое движение карточки). */ export type ProspectDateMode = 'todo' | 'changed'; -/** Вид периода — те же, что понимает SalesPeriodResolver на сервере. */ -export type ProspectPeriodKind = 'today' | 'tomorrow' | 'yesterday' | 'd7' | 'd30' | 'custom'; +/** + * Вид периода — те же, что понимает SalesPeriodResolver на сервере. + * Наборы у режимов разные: overdue/tomorrow/next7/next30 — только для «что надо + * сделать», yesterday/d7/d30 — только для «что менялось». Пара из чужого набора + * получает от сервера 422 (SalesProspectController::assertPeriodFitsMode). + */ +export type ProspectPeriodKind = + | 'overdue' + | 'today' + | 'tomorrow' + | 'next7' + | 'next30' + | 'yesterday' + | 'd7' + | 'd30' + | 'custom'; export interface ProspectDateFilterValue { mode: ProspectDateMode | null; diff --git a/app/resources/js/components/sales/ProspectDateFilter.vue b/app/resources/js/components/sales/ProspectDateFilter.vue index 7973e3bb..34aa2a3a 100644 --- a/app/resources/js/components/sales/ProspectDateFilter.vue +++ b/app/resources/js/components/sales/ProspectDateFilter.vue @@ -2,11 +2,17 @@ /** * Фильтр воронки по датам. Два режима, взаимоисключающие по смыслу: * - * «Что надо сделать» — утром: по кому сегодня/завтра звонить, плюс всё - * просроченное (забытый звонок важнее завтрашнего). - * «Что менялось» — вечером: что я сделал за день или за период. Считается - * ЛЮБОЕ движение карточки, включая автоматическое - * (потратил деньги, пополнил счёт, стал клиентом). + * «Что надо сделать» — утром: по кому звонить. Срок смотрит ВПЕРЁД, и первым + * пунктом идут «Просроченные» — то, что уже пропустили. + * «Что менялось» — вечером: что я сделал за день или за период. Срок + * смотрит НАЗАД. Считается ЛЮБОЕ движение карточки, + * включая автоматическое (потратил деньги, пополнил + * счёт, стал клиентом). + * + * Списки сроков у режимов РАЗНЫЕ и намеренно не пересекаются (01.08.2026): + * «завтра» в истории не бывает, «вчера» в планах — это просто «просроченное». + * Сервер такую пару отвергает с 422, чтобы никакой пункт не показывал молча + * не то, что на нём написано. * * Владелец назвал это «двумя фильтрами», но сделан один орган с выбором режима: * одновременно они не нужны никогда, а два независимых органа пересекались бы @@ -16,7 +22,7 @@ * отмечены ОБЕ даты: на половинчатом диапазоне сервер отвечает ошибкой. * Тот же приём, что в PeriodPicker. */ -import { ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import type { ProspectDateFilterValue, ProspectDateMode, ProspectPeriodKind } from '../../api/sales'; const emit = defineEmits<{ change: [value: ProspectDateFilterValue] }>(); @@ -35,15 +41,27 @@ const modeItems: { value: ProspectDateMode | null; title: string }[] = [ { value: 'changed', title: 'Что менялось' }, ]; -const periodItems: { value: ProspectPeriodKind; title: string }[] = [ +/** Планы — вперёд, и «Просроченные» первым пунктом (просьба владельца). */ +const TODO_PERIODS: { value: ProspectPeriodKind; title: string }[] = [ + { value: 'overdue', title: 'Просроченные' }, { value: 'today', title: 'Сегодня' }, { value: 'tomorrow', title: 'Завтра' }, + { value: 'next7', title: 'Ближайшие 7 дней' }, + { value: 'next30', title: 'Ближайшие 30 дней' }, + { value: 'custom', title: 'Произвольный период' }, +]; + +/** История — назад. «Завтра» здесь нет: будущего в истории не бывает. */ +const CHANGED_PERIODS: { value: ProspectPeriodKind; title: string }[] = [ + { value: 'today', title: 'Сегодня' }, { value: 'yesterday', title: 'Вчера' }, { value: 'd7', title: '7 дней' }, { value: 'd30', title: '30 дней' }, { value: 'custom', title: 'Произвольный период' }, ]; +const periodItems = computed(() => (mode.value === 'todo' ? TODO_PERIODS : CHANGED_PERIODS)); + /** Дата из календаря (Date или строка) → YYYY-MM-DD по местному времени. */ function toIsoDay(value: unknown): string | null { const d = value instanceof Date ? value : new Date(String(value)); @@ -113,10 +131,6 @@ defineExpose({ mode, period, customRange, applyCustom, modeItems, periodItems, c -->