From de4872a6b3ebec788b95ac1e3f86a2ea3255f2ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 25 Jun 2026 19:08:47 +0300 Subject: [PATCH] =?UTF-8?q?feat/ui:=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20=D1=81=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B8=D0=B7=20API,=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=20=D0=B2=20JS=20(=D0=AD=D0=BF=D0=B8=D0=BA=20?= =?UTF-8?q?6.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectResource.source_change_message = ProjectRuleMessages.sourceChanged (тот же текст, что in-app уведомление 6.2). Диалоги подтверждения (дроуэр + окно Редактировать) тянут его из API с fallback на локальный текст. Бэкенд — единственный источник строк правил, экран и колокольчик не расходятся. Проверено глазами (epic6-unified-rule-text-confirm.png). Тесты: ProjectResource 5/5, дроуэр 27/27, EditProjectDialog 7/7. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Http/Resources/ProjectResource.php | 9 +++++++++ .../projects/ProjectDetailsDrawer.vue | 5 +++++ app/resources/js/stores/projectsStore.ts | 2 ++ .../js/views/projects/NewProjectDialog.vue | 5 +++++ .../ProjectResourceSourceLockTest.php | 13 +++++++++++++ .../Frontend/ProjectDetailsDrawer.spec.ts | 19 +++++++++++++++++++ 6 files changed, 53 insertions(+) diff --git a/app/app/Http/Resources/ProjectResource.php b/app/app/Http/Resources/ProjectResource.php index 7909e451..ba31b0b8 100644 --- a/app/app/Http/Resources/ProjectResource.php +++ b/app/app/Http/Resources/ProjectResource.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Resources; use App\Models\Project; +use App\Services\Project\ProjectRuleMessages; use App\Services\Project\SupplierSnapshotGuard; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -25,6 +26,12 @@ class ProjectResource extends JsonResource pausedAt: $this->paused_at, ); + // Эпик 6.3: единый текст правила смены источника (из ProjectRuleMessages) — + // диалог подтверждения на экране тянет его отсюда, не дублирует строку в JS. + $sourceChangeMessage = $sourceLock['locked'] && $sourceLock['unlock_at'] !== null + ? (new ProjectRuleMessages)->sourceChanged($sourceLock['unlock_at']) + : null; + return [ 'id' => $this->id, 'name' => $this->name, @@ -57,6 +64,8 @@ class ProjectResource extends JsonResource 'source_locked' => $sourceLock['locked'], 'source_unlock_at' => $sourceLock['unlock_at']?->toIso8601String(), 'source_unlock_projected' => $sourceLock['projected'], + // Эпик 6.3: единый текст правила (бэкенд — источник истины для строк). + 'source_change_message' => $sourceChangeMessage, ]; } } diff --git a/app/resources/js/components/projects/ProjectDetailsDrawer.vue b/app/resources/js/components/projects/ProjectDetailsDrawer.vue index 3c632d40..6949e341 100644 --- a/app/resources/js/components/projects/ProjectDetailsDrawer.vue +++ b/app/resources/js/components/projects/ProjectDetailsDrawer.vue @@ -89,6 +89,11 @@ const sourceDirty = computed(() => { // Диалог подтверждения смены источника на залоченном проекте. const sourceConfirmOpen = ref(false); const sourceConfirmText = computed(() => { + // Эпик 6.3: единый текст правила из API (ProjectRuleMessages). Fallback — локальный. + const fromApi = props.project?.source_change_message; + if (fromApi) { + return `Мы уже ведём сбор на завтра. ${fromApi} Подтвердите смену источника.`; + } const d = formatLeadDate(props.project?.source_unlock_at); return ( 'Мы уже ведём сбор на завтра. Лиды по старому источнику придут завтра в любом случае. ' + diff --git a/app/resources/js/stores/projectsStore.ts b/app/resources/js/stores/projectsStore.ts index a7a87d79..f4357220 100644 --- a/app/resources/js/stores/projectsStore.ts +++ b/app/resources/js/stores/projectsStore.ts @@ -25,6 +25,8 @@ export interface Project { source_locked?: boolean; source_unlock_at?: string | null; source_unlock_projected?: boolean; + // Эпик 6.3: единый текст правила смены источника (из ProjectRuleMessages на бэкенде). + source_change_message?: string | null; } export const useProjectsStore = defineStore('projects', () => { diff --git a/app/resources/js/views/projects/NewProjectDialog.vue b/app/resources/js/views/projects/NewProjectDialog.vue index 04577739..15dbcd72 100644 --- a/app/resources/js/views/projects/NewProjectDialog.vue +++ b/app/resources/js/views/projects/NewProjectDialog.vue @@ -357,6 +357,11 @@ const sourceDirty = computed(() => { const sourceConfirmOpen = ref(false); const sourceConfirmText = computed(() => { + // Эпик 6.3: единый текст правила из API (ProjectRuleMessages). Fallback — локальный. + const fromApi = props.project?.source_change_message; + if (fromApi) { + return `Мы уже ведём сбор на завтра. ${fromApi} Подтвердите смену источника.`; + } const d = formatLeadDate(props.project?.source_unlock_at); return ( 'Мы уже ведём сбор на завтра. Лиды по старому источнику придут завтра в любом случае. ' + diff --git a/app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php b/app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php index c6cd77c9..2609cc75 100644 --- a/app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php +++ b/app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php @@ -43,6 +43,19 @@ it('project with no supplier links → source_locked false', function (): void { expect($res['source_unlock_projected'])->toBeFalse(); }); +it('отдаёт source_change_message (единый текст правила) для залоченного проекта', function (): void { + $res = (new ProjectResource(linkedProject(true)))->toArray(request()); + expect($res['source_change_message'])->toBeString() + ->and($res['source_change_message'])->toContain('по старому источнику'); +}); + +it('source_change_message = null когда источник не залочен', function (): void { + $tenant = Tenant::factory()->create(); + $p = Project::factory()->for($tenant)->create(['is_active' => true])->loadCount('supplierProjects'); + $res = (new ProjectResource($p))->toArray(request()); + expect($res['source_change_message'])->toBeNull(); +}); + it('paused linked project in grace → source_locked true, твёрдая дата, projected=false', function (): void { // На паузе час назад → в grace-окне (до следующего 21:00 МСК + 24ч). $res = (new ProjectResource(linkedProject(false, now()->subHour()->toDateTimeString())))->toArray(request()); diff --git a/app/tests/Frontend/ProjectDetailsDrawer.spec.ts b/app/tests/Frontend/ProjectDetailsDrawer.spec.ts index 3605c8b9..33344fee 100644 --- a/app/tests/Frontend/ProjectDetailsDrawer.spec.ts +++ b/app/tests/Frontend/ProjectDetailsDrawer.spec.ts @@ -351,6 +351,25 @@ describe('ProjectDetailsDrawer', () => { ); }); + it('диалог подтверждения берёт текст правила из source_change_message (единый источник)', async () => { + vi.mocked(axios.patch).mockReset(); + (axios.patch as unknown as ReturnType).mockResolvedValue({ data: { data: sampleProject } }); + const locked: Project = { + ...sampleProject, + signal_type: 'call', + signal_identifier: '79161112233', + source_locked: true, + source_change_message: 'Лиды по старому источнику придут до 27 июня, дальше — по новому.', + }; + const wrapper = mount(ProjectDetailsDrawer, { props: { project: locked } }); + await wrapper.get('[data-testid="pdd-signal-identifier"]').setValue('79990001122'); + await wrapper.get('[data-testid="pdd-save"]').trigger('click'); + await wrapper.vm.$nextTick(); + const dialog = wrapper.find('[data-testid="pdd-source-change-confirm"]'); + expect(dialog.exists()).toBe(true); + expect(dialog.text()).toContain('27 июня'); // дата из API-текста, не из JS-хардкода + }); + it('смена источника на незалоченном проекте сохраняется без подтверждения', async () => { vi.mocked(axios.patch).mockReset(); (axios.patch as unknown as ReturnType).mockResolvedValue({ data: { data: sampleProject } });