From 54451d2ea6af9ecdb59a4d8f553e8e2b8d11e015 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: Sun, 17 May 2026 14:35:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(projects):=20RegionsBulkDialog=20=E2=80=94?= =?UTF-8?q?=20subject-level=20regions=20(89=20RF=20subjects)=20#1426?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk regions dialog reworked from federal-district bitmask to subject/region selection, consistent with ProjectDetailsDrawer/NewProjectDialog. Full-stack: add_regions/remove_regions on projects.regions INT[], BulkProjectActionRequest split validation, ProjectService model-instance update. federal-districts.ts removed (zero consumers). +menuRepositionFix util for v-autocomplete menu. phpstan-baseline: bump actingAs ignore count 14->15 (new validation test). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Requests/BulkProjectActionRequest.php | 15 ++- app/app/Services/Project/ProjectService.php | 41 ++++-- app/phpstan-baseline.neon | 2 +- .../projects/ProjectDetailsDrawer.vue | 2 + .../components/projects/RegionsBulkDialog.vue | 123 ++++++++++-------- .../js/constants/federal-districts.ts | 18 --- app/resources/js/stores/projectsStore.ts | 3 + app/resources/js/utils/menuRepositionFix.ts | 52 ++++++++ .../js/views/projects/NewProjectDialog.vue | 2 + .../Feature/Api/ProjectBulkActionsTest.php | 37 ++++-- app/tests/Frontend/RegionsBulkDialog.spec.ts | 36 +++-- app/tests/Frontend/menuRepositionFix.spec.ts | 56 ++++++++ .../Frontend/projectsStore.bulkUpdate.spec.ts | 6 +- 13 files changed, 280 insertions(+), 113 deletions(-) delete mode 100644 app/resources/js/constants/federal-districts.ts create mode 100644 app/resources/js/utils/menuRepositionFix.ts create mode 100644 app/tests/Frontend/menuRepositionFix.spec.ts diff --git a/app/app/Http/Requests/BulkProjectActionRequest.php b/app/app/Http/Requests/BulkProjectActionRequest.php index dcdcf3e7..add29113 100644 --- a/app/app/Http/Requests/BulkProjectActionRequest.php +++ b/app/app/Http/Requests/BulkProjectActionRequest.php @@ -32,10 +32,17 @@ class BulkProjectActionRequest extends FormRequest 'scope.filter.search' => ['nullable', 'string', 'max:255'], ]; - if ($action === 'update_regions' || $action === 'update_days') { - $maxMask = $action === 'update_regions' ? 255 : 127; - $rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"]; - $rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"]; + if ($action === 'update_regions') { + // Plan 6.5: субъект-уровневые коды 1..89 (см. resources/js/constants/regions.ts). + $rules['add_regions'] = ['nullable', 'array']; + $rules['add_regions.*'] = ['integer', 'between:1,89']; + $rules['remove_regions'] = ['nullable', 'array']; + $rules['remove_regions.*'] = ['integer', 'between:1,89']; + } + + if ($action === 'update_days') { + $rules['add'] = ['nullable', 'integer', 'min:0', 'max:127']; + $rules['remove'] = ['nullable', 'integer', 'min:0', 'max:127']; } if ($action === 'update_limit') { diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 78278c55..4460cbe4 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -115,21 +115,40 @@ class ProjectService } /** - * LEGACY (Plan 6): обновляет только bitmask `region_mask` федеральных округов. - * После Plan 6 источник истины региональной фильтрации — `regions` INT[]; - * outbound SyncSupplierProjectsJob читает `regions[]`, НЕ `region_mask`. Значит - * этот bulk-action на реальную фильтрацию у поставщика не влияет. Субъект-уровневый - * bulk-edit `regions[]` запланирован в Plan 6.5 (spec §13 — out of scope C9). + * Plan 6.5: субъект-уровневый bulk-edit `regions` INT[]. + * + * Для каждого проекта: regions := unique(regions ∪ add_regions) \ remove_regions, + * отсортировано по возрастанию. `regions[]` — источник истины региональной + * фильтрации с Plan 6 (outbound SyncSupplierProjectsJob читает именно его). + * Legacy `region_mask` здесь не трогается — как и в одиночном PATCH + * /api/projects/{id}; его удаление — Plan 6.5 cleanup. + * + * NB: проект с regions=[] («вся РФ») при add_regions сужается до выбранных + * субъектов — это осознанное действие оператора bulk-диалога. + * + * Обновление идёт через model-инстанс (не query-builder mass update): каст + * PostgresIntArray::set() сериализует PHP-массив в PG-литерал `{1,2,3}`, а + * mass update каст не применяет. count ≤ BULK_MAX (500) — допустимо. */ private function bulkUpdateRegions($query, array $payload): array { - $add = (int) ($payload['add'] ?? 0); - $remove = (int) ($payload['remove'] ?? 0); + $add = array_map('intval', $payload['add_regions'] ?? []); + $remove = array_map('intval', $payload['remove_regions'] ?? []); - // region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0–255) - $updated = $query->update([ - 'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"), - ]); + if ($add === [] && $remove === []) { + return ['updated' => 0, 'skipped' => [], 'warnings' => []]; + } + + $projects = (clone $query)->get(['id', 'regions']); + $updated = 0; + + foreach ($projects as $project) { + $next = array_values(array_unique([...($project->regions ?? []), ...$add])); + $next = array_values(array_diff($next, $remove)); + sort($next); + $project->update(['regions' => $next]); + $updated++; + } return ['updated' => $updated, 'skipped' => [], 'warnings' => []]; } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index cad809eb..aab93556 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -411,7 +411,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 14 + count: 15 path: tests/Feature/Api/ProjectBulkActionsTest.php - diff --git a/app/resources/js/components/projects/ProjectDetailsDrawer.vue b/app/resources/js/components/projects/ProjectDetailsDrawer.vue index ecbdcd04..36fc5c37 100644 --- a/app/resources/js/components/projects/ProjectDetailsDrawer.vue +++ b/app/resources/js/components/projects/ProjectDetailsDrawer.vue @@ -4,6 +4,7 @@ import axios from 'axios'; import type { Project } from '../../stores/projectsStore'; import { useProjectsStore } from '../../stores/projectsStore'; import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions'; +import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix'; const props = defineProps<{ project: Project | null }>(); const emit = defineEmits<{ close: []; saved: [] }>(); @@ -152,6 +153,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; density="comfortable" hide-details data-testid="pdd-regions" + @update:menu="repositionMenuAfterOpen" >