Merge remote-tracking branch 'gitea/main' into fix/tg-zagolovok-obyavleniya
# Conflicts: # docs/observer/STATUS.md
This commit is contained in:
@@ -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<SalesProspect> $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 из тела игнорируется),
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
-
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
-->
|
||||
<div class="d-flex align-center ga-3" role="group" aria-label="Фильтр по датам">
|
||||
<template v-if="mode">
|
||||
<span v-if="mode === 'todo'" class="text-caption text-medium-emphasis text-no-wrap">
|
||||
плюс всё просроченное
|
||||
</span>
|
||||
|
||||
<!-- Календарь для произвольного периода: отмечаются начало и конец. -->
|
||||
<v-menu v-model="calendarOpen" :close-on-content-click="false" location="bottom end">
|
||||
<template #activator="{ props: activator }">
|
||||
|
||||
@@ -50,7 +50,7 @@ function df_names(TestResponse $res): array
|
||||
|
||||
// ── режим «что надо сделать» ──────────────────────────────────────────────────
|
||||
|
||||
test('что надо сделать на сегодня: сегодняшний созвон и просроченный, но не завтрашний', function () {
|
||||
test('что надо сделать на сегодня: только сегодняшний созвон', function () {
|
||||
$mgr = df_user();
|
||||
df_card($mgr, 'СЕГОДНЯ', 'negotiation', '2026-06-15T14:00:00');
|
||||
df_card($mgr, 'ПРОСРОЧЕН', 'negotiation', '2026-06-10T14:00:00');
|
||||
@@ -61,10 +61,10 @@ test('что надо сделать на сегодня: сегодняшний
|
||||
->getJson('/api/sales/prospects?date_mode=todo&period=today');
|
||||
|
||||
$res->assertOk();
|
||||
expect(df_names($res))->toBe(['ПРОСРОЧЕН', 'СЕГОДНЯ']);
|
||||
expect(df_names($res))->toBe(['СЕГОДНЯ']);
|
||||
});
|
||||
|
||||
test('что надо сделать на завтра: завтрашний созвон И просроченное — забытое важнее', function () {
|
||||
test('что надо сделать на завтра: только завтрашний созвон', function () {
|
||||
$mgr = df_user();
|
||||
df_card($mgr, 'ЗАВТРА', 'negotiation', '2026-06-16T14:00:00');
|
||||
df_card($mgr, 'ПРОСРОЧЕН', 'negotiation', '2026-06-10T14:00:00');
|
||||
@@ -74,7 +74,63 @@ test('что надо сделать на завтра: завтрашний с
|
||||
->getJson('/api/sales/prospects?date_mode=todo&period=tomorrow');
|
||||
|
||||
$res->assertOk();
|
||||
expect(df_names($res))->toBe(['ЗАВТРА', 'ПРОСРОЧЕН']);
|
||||
expect(df_names($res))->toBe(['ЗАВТРА']);
|
||||
});
|
||||
|
||||
test('просроченные: всё, чей срок раньше «сейчас», и ничего из будущего', function () {
|
||||
$mgr = df_user();
|
||||
df_card($mgr, 'ЗАБЫТ ДАВНО', 'negotiation', '2026-05-30T14:00:00');
|
||||
df_card($mgr, 'ПРОСРОЧЕН УТРОМ', 'negotiation', '2026-06-15T05:00:00');
|
||||
df_card($mgr, 'СЕГОДНЯ ПОЗЖЕ', 'negotiation', '2026-06-15T18:00:00');
|
||||
df_card($mgr, 'ЗАВТРА', 'negotiation', '2026-06-16T14:00:00');
|
||||
df_card($mgr, 'БЕЗ ДАТЫ', 'in_work');
|
||||
|
||||
$res = $this->actingAs($mgr, 'sales')
|
||||
->getJson('/api/sales/prospects?date_mode=todo&period=overdue');
|
||||
|
||||
$res->assertOk();
|
||||
expect(df_names($res))->toBe(['ЗАБЫТ ДАВНО', 'ПРОСРОЧЕН УТРОМ']);
|
||||
});
|
||||
|
||||
test('ближайшие 7 дней смотрят ВПЕРЁД, а не назад', function () {
|
||||
$mgr = df_user();
|
||||
df_card($mgr, 'СЕГОДНЯ', 'negotiation', '2026-06-15T14:00:00');
|
||||
df_card($mgr, 'ЧЕРЕЗ 6 ДНЕЙ', 'negotiation', '2026-06-21T14:00:00');
|
||||
df_card($mgr, 'ЧЕРЕЗ 8 ДНЕЙ', 'negotiation', '2026-06-23T14:00:00');
|
||||
df_card($mgr, 'НЕДЕЛЮ НАЗАД', 'negotiation', '2026-06-09T14:00:00');
|
||||
|
||||
$res = $this->actingAs($mgr, 'sales')
|
||||
->getJson('/api/sales/prospects?date_mode=todo&period=next7');
|
||||
|
||||
$res->assertOk();
|
||||
expect(df_names($res))->toBe(['СЕГОДНЯ', 'ЧЕРЕЗ 6 ДНЕЙ']);
|
||||
});
|
||||
|
||||
test('ближайшие 30 дней тоже вперёд', function () {
|
||||
$mgr = df_user();
|
||||
df_card($mgr, 'ЧЕРЕЗ 29 ДНЕЙ', 'negotiation', '2026-07-14T14:00:00');
|
||||
df_card($mgr, 'ЧЕРЕЗ 31 ДЕНЬ', 'negotiation', '2026-07-16T14:00:00');
|
||||
df_card($mgr, 'МЕСЯЦ НАЗАД', 'negotiation', '2026-05-16T14:00:00');
|
||||
|
||||
$res = $this->actingAs($mgr, 'sales')
|
||||
->getJson('/api/sales/prospects?date_mode=todo&period=next30');
|
||||
|
||||
$res->assertOk();
|
||||
expect(df_names($res))->toBe(['ЧЕРЕЗ 29 ДНЕЙ']);
|
||||
});
|
||||
|
||||
test('срок не из своего режима — отказ 422, а не молчаливая подмена периодом', function () {
|
||||
$mgr = df_user();
|
||||
|
||||
$this->actingAs($mgr, 'sales')
|
||||
->getJson('/api/sales/prospects?date_mode=changed&period=tomorrow')
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('period');
|
||||
|
||||
$this->actingAs($mgr, 'sales')
|
||||
->getJson('/api/sales/prospects?date_mode=todo&period=yesterday')
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('period');
|
||||
});
|
||||
|
||||
test('что надо сделать: отказ и корзина не показываются даже с просроченной датой', function () {
|
||||
@@ -86,7 +142,7 @@ test('что надо сделать: отказ и корзина не пока
|
||||
df_card($mgr, 'ЖИВАЯ', 'negotiation', '2026-06-10T14:00:00');
|
||||
|
||||
$res = $this->actingAs($mgr, 'sales')
|
||||
->getJson('/api/sales/prospects?date_mode=todo&period=today');
|
||||
->getJson('/api/sales/prospects?date_mode=todo&period=overdue');
|
||||
|
||||
$res->assertOk();
|
||||
expect(df_names($res))->toBe(['ЖИВАЯ']);
|
||||
|
||||
@@ -32,9 +32,52 @@ describe('ProspectDateFilter', () => {
|
||||
expect(values).toEqual([null, 'todo', 'changed']);
|
||||
});
|
||||
|
||||
it('в периодах есть «завтра» — по нему смотрят, что делать назавтра', () => {
|
||||
const { vm } = factory();
|
||||
expect(vm.periodItems.map((i) => i.value)).toContain('tomorrow');
|
||||
it('в планах: «просроченные» первым пунктом, «завтра» есть, «вчера» нет', async () => {
|
||||
const { w, vm } = factory();
|
||||
vm.mode = 'todo';
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const values = vm.periodItems.map((i) => i.value);
|
||||
expect(values[0]).toBe('overdue');
|
||||
expect(values).toEqual(['overdue', 'today', 'tomorrow', 'next7', 'next30', 'custom']);
|
||||
expect(values).not.toContain('yesterday');
|
||||
});
|
||||
|
||||
it('в истории нет ни «завтра», ни «просроченных» — будущего в истории не бывает', async () => {
|
||||
const { w, vm } = factory();
|
||||
vm.mode = 'changed';
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const values = vm.periodItems.map((i) => i.value);
|
||||
expect(values).toEqual(['today', 'yesterday', 'd7', 'd30', 'custom']);
|
||||
expect(values).not.toContain('tomorrow');
|
||||
expect(values).not.toContain('overdue');
|
||||
});
|
||||
|
||||
it('«7 дней» в планах — вперёд (next7), в истории — назад (d7)', async () => {
|
||||
const { w, vm } = factory();
|
||||
vm.mode = 'todo';
|
||||
await w.vm.$nextTick();
|
||||
expect(vm.periodItems.find((i) => i.value === 'next7')?.title).toBe('Ближайшие 7 дней');
|
||||
|
||||
vm.mode = 'changed';
|
||||
await w.vm.$nextTick();
|
||||
expect(vm.periodItems.find((i) => i.value === 'd7')?.title).toBe('7 дней');
|
||||
});
|
||||
|
||||
it('после смены режима выбранный срок остаётся допустимым для нового режима', async () => {
|
||||
const { w, vm } = factory();
|
||||
vm.mode = 'todo';
|
||||
await w.vm.$nextTick();
|
||||
vm.period = 'next7';
|
||||
await w.vm.$nextTick();
|
||||
|
||||
vm.mode = 'changed';
|
||||
await w.vm.$nextTick();
|
||||
|
||||
// Иначе наружу ушла бы пара «что менялось + ближайшие 7 дней» → 422 с сервера.
|
||||
expect(vm.periodItems.map((i) => i.value)).toContain(vm.period);
|
||||
expect(w.emitted('change')?.at(-1)?.[0]).toMatchObject({ mode: 'changed', period: 'today' });
|
||||
});
|
||||
|
||||
it('выбор режима показывает период и сообщает наружу', async () => {
|
||||
|
||||
@@ -2,10 +2,20 @@ import { describe, it, expect } from 'vitest';
|
||||
import { AD_CHANNELS } from '../../resources/js/components/layout/advertisingChannels';
|
||||
|
||||
describe('AD_CHANNELS', () => {
|
||||
it('yandex audience is a real route now, others remain stubs', () => {
|
||||
const yandex = AD_CHANNELS.find((c) => c.testid === 'ad-nav-yandex-audience');
|
||||
expect(yandex?.route).toBe('/advertising/yandex');
|
||||
const stubs = AD_CHANNELS.filter((c) => c.testid !== 'ad-nav-yandex-audience');
|
||||
// Живых площадок стало две: к Яндекс.Аудитории добавился Телеграм
|
||||
// (модуль телеграм-рекламы). Тест перечисляет их поимённо, чтобы новая
|
||||
// заглушка с случайным роутом не проехала молча.
|
||||
it('живые площадки — Яндекс и Телеграм, остальные пока заглушки', () => {
|
||||
const live: Record<string, string> = {
|
||||
'ad-nav-yandex-audience': '/advertising/yandex',
|
||||
'ad-nav-telegram': '/advertising/telegram',
|
||||
};
|
||||
|
||||
for (const [testid, route] of Object.entries(live)) {
|
||||
expect(AD_CHANNELS.find((c) => c.testid === testid)?.route).toBe(route);
|
||||
}
|
||||
|
||||
const stubs = AD_CHANNELS.filter((c) => !(c.testid in live));
|
||||
expect(stubs.every((c) => !c.route)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +147,20 @@ it('kind=tomorrow — только завтрашний день', function ():
|
||||
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-06-16 23:59:59');
|
||||
});
|
||||
|
||||
it('kind=next7 — ближайшие 7 дней ВПЕРЁД, включая сегодня', function (): void {
|
||||
$range = (new SalesPeriodResolver)->resolve(['kind' => 'next7']);
|
||||
|
||||
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-06-15 00:00:00');
|
||||
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-06-21 23:59:59');
|
||||
});
|
||||
|
||||
it('kind=next30 — ближайшие 30 дней ВПЕРЁД, включая сегодня', function (): void {
|
||||
$range = (new SalesPeriodResolver)->resolve(['kind' => 'next30']);
|
||||
|
||||
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-06-15 00:00:00');
|
||||
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-07-14 23:59:59');
|
||||
});
|
||||
|
||||
it('kind=d7 — последние 7 дней, включая сегодня', function (): void {
|
||||
$range = (new SalesPeriodResolver)->resolve(['kind' => 'd7']);
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
@@ -145,20 +145,36 @@ Append-only, как и журнал разговоров: движение —
|
||||
Что менялось
|
||||
```
|
||||
|
||||
Второй список — период: **Сегодня / Завтра / Вчера / 7 дней / 30 дней / Произвольный
|
||||
период** (календарь-диапазон). Он появляется, только когда выбран режим.
|
||||
Второй список — срок. Он появляется, только когда выбран режим, и **у каждого режима
|
||||
свой набор** (уточнение владельца 01.08.2026, вечер):
|
||||
|
||||
| «Что надо сделать» (вперёд) | «Что менялось» (назад) |
|
||||
|---|---|
|
||||
| **Просроченные** | Сегодня |
|
||||
| Сегодня | Вчера |
|
||||
| Завтра | 7 дней |
|
||||
| Ближайшие 7 дней | 30 дней |
|
||||
| Ближайшие 30 дней | Произвольный период |
|
||||
| Произвольный период | |
|
||||
|
||||
Наборы намеренно не пересекаются: «что менялось завтра» не бывает, а «планы на вчера» —
|
||||
это и есть «просроченное». Пару из чужого набора сервер отвергает с 422, а не подменяет
|
||||
молча другим периодом (`SalesProspectController::assertPeriodFitsMode`).
|
||||
|
||||
Одинаковый орган и у менеджера, и у начальника — компонент один.
|
||||
|
||||
### 8.2. Режим «Что надо сделать»
|
||||
|
||||
Карточки, по которым в выбранный срок надо звонить или что-то делать:
|
||||
Карточки, по которым в выбранный срок надо звонить или что-то делать: `next_call_at`
|
||||
попадает в выбранный отрезок. **Каждый пункт показывает ровно то, что на нём написано** —
|
||||
«Сегодня» это только сегодняшние созвоны, ничего лишнего.
|
||||
|
||||
`next_call_at` попадает в период **ИЛИ** `next_call_at` уже просрочен (`< сейчас`).
|
||||
Просроченное — **отдельный первый пункт** списка (`period=overdue`): всё, чей срок раньше
|
||||
«сейчас». Раньше просроченное подмешивалось в любой выбранный период, и «Сегодня»
|
||||
показывало не только сегодняшнее — владелец попросил развести (01.08.2026).
|
||||
|
||||
Просроченные показываются **всегда**, каким бы период ни выбрали — владелец просил их
|
||||
отдельно («и что просрочено»), и забытый звонок недельной давности важнее завтрашнего.
|
||||
Они и так подсвечены красным на доске.
|
||||
Сроки этого режима смотрят **вперёд**: «Ближайшие 7 дней» — сегодня и 6 следующих
|
||||
(`next7`), а не прошедшая неделя. Иначе «что надо сделать» показывало бы прошлое.
|
||||
|
||||
Из выборки исключены стадии `rejected` и `trash` — по ним делать нечего. Карточки без
|
||||
даты созвона в этот режим не попадают: делать по ним нечего в конкретный срок.
|
||||
@@ -177,9 +193,12 @@ Append-only, как и журнал разговоров: движение —
|
||||
### 8.4. Границы дат
|
||||
|
||||
Периоды считаются в МСК существующим `SalesPeriodResolver` — тем же, что «30 дней»
|
||||
на других экранах портала. К нему добавляется вид `tomorrow` (владелец назвал «завтра»
|
||||
прямо). Приложение живёт в UTC, менеджер думает в МСК — своей арифметики дат не заводим,
|
||||
это уже решённая в проекте задача.
|
||||
на других экранах портала. К нему добавлены виды `tomorrow` (владелец назвал «завтра»
|
||||
прямо) и зеркала `next7` / `next30` — те же 7 и 30 дней, но вперёд. Приложение живёт
|
||||
в UTC, менеджер думает в МСК — своей арифметики дат не заводим, это уже решённая
|
||||
в проекте задача.
|
||||
|
||||
`overdue` резолвером не считается вовсе: это не отрезок календаря, а «раньше сейчас».
|
||||
|
||||
## 9. Что отрезано (осознанно)
|
||||
|
||||
@@ -199,6 +218,8 @@ Append-only, как и журнал разговоров: движение —
|
||||
2. Платящему клиенту результат «В корзину» не предлагается.
|
||||
3. «Выслано КП» сохраняется **без** даты и **с** датой.
|
||||
4. После «ручное тестирование → отказ» в шапке «Отказа» появляется дробь.
|
||||
5. Фильтр «Что надо сделать» на «Сегодня» показывает сегодняшние созвоны и просроченные.
|
||||
6. Фильтр «Что менялось» на «Сегодня» показывает карточку, которую только что подвинули.
|
||||
7. Всё то же самое в «Воронке отдела» у начальника.
|
||||
5. Фильтр «Что надо сделать» на «Сегодня» показывает **только** сегодняшние созвоны,
|
||||
на «Просроченные» — только забытые, на «Завтра» — только завтрашние.
|
||||
6. В сроках «Что надо сделать» нет «Вчера», в сроках «Что менялось» нет «Завтра».
|
||||
7. Фильтр «Что менялось» на «Сегодня» показывает карточку, которую только что подвинули.
|
||||
8. Всё то же самое в «Воронке отдела» у начальника.
|
||||
|
||||
Reference in New Issue
Block a user