feat(projects): RegionsBulkDialog — subject-level regions (89 RF subjects) #1426

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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-17 14:35:16 +03:00
parent 9cf0f0c0c7
commit 54451d2ea6
13 changed files with 280 additions and 113 deletions
@@ -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') {
+30 -11
View File
@@ -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 (0255)
$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' => []];
}
+1 -1
View File
@@ -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
-
@@ -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"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
@@ -3,41 +3,69 @@
<v-card>
<v-card-title>Регионы для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in FEDERAL_DISTRICTS"
:key="`add-${r.bit}`"
:data-testid="`region-add-${r.bit}`"
:color="addMask & r.bit ? 'success' : undefined"
:variant="addMask & r.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleAdd(r.bit)"
>{{ r.label }}</v-chip
>
</div>
<p class="text-caption text-medium-emphasis mb-4">
Изменения применяются к каждому из {{ count }} выбранных проектов: выбранные субъекты
добавляются к их регионам или убираются из них.
</p>
<div class="mb-2">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить регионы</div>
<v-autocomplete
v-model="addRegions"
:items="selectableRegions"
item-title="name"
item-value="code"
label="Субъекты РФ"
multiple
chips
clearable
density="comfortable"
data-testid="region-add-select"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in FEDERAL_DISTRICTS"
:key="`remove-${r.bit}`"
:data-testid="`region-remove-${r.bit}`"
:color="removeMask & r.bit ? 'error' : undefined"
:variant="removeMask & r.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleRemove(r.bit)"
>{{ r.label }}</v-chip
>
</div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать регионы</div>
<v-autocomplete
v-model="removeRegions"
:items="selectableRegions"
item-title="name"
item-value="code"
label="Субъекты РФ"
multiple
chips
clearable
density="comfortable"
data-testid="region-remove-select"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
<v-btn
color="primary"
data-testid="apply"
:disabled="addRegions.length === 0 && removeRegions.length === 0"
@click="apply"
>Применить к {{ count }}</v-btn
>
</v-card-actions>
@@ -47,47 +75,40 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { FEDERAL_DISTRICTS } from '../../constants/federal-districts';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { add: number; remove: number }];
apply: [payload: { add_regions: number[]; remove_regions: number[] }];
}>();
// code:0 — sentinel «Вся РФ»; в bulk add/remove субъектов не выбирается.
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
const open = ref(props.modelValue);
const addMask = ref(0);
const removeMask = ref(0);
const addRegions = ref<number[]>([]);
const removeRegions = ref<number[]>([]);
watch(
() => props.modelValue,
(val) => {
open.value = val;
if (val) {
addMask.value = 0;
removeMask.value = 0;
addRegions.value = [];
removeRegions.value = [];
}
},
);
watch(open, (val) => {
emit('update:modelValue', val);
});
function toggleAdd(bit: number) {
addMask.value ^= bit;
if (addMask.value & bit) removeMask.value &= ~bit;
}
function toggleRemove(bit: number) {
removeMask.value ^= bit;
if (removeMask.value & bit) addMask.value &= ~bit;
}
watch(open, (val) => emit('update:modelValue', val));
function apply() {
emit('apply', { add: addMask.value, remove: removeMask.value });
addMask.value = 0;
removeMask.value = 0;
emit('apply', { add_regions: [...addRegions.value], remove_regions: [...removeRegions.value] });
addRegions.value = [];
removeRegions.value = [];
open.value = false;
}
defineExpose({ addRegions, removeRegions, apply });
</script>
@@ -1,18 +0,0 @@
export interface FederalDistrict {
bit: number; // 1, 2, 4, ..., 128
label: string;
}
// 8 ФО РФ — соответствует schema `projects.region_mask BETWEEN 0 AND 255`.
// Используется в bulk-операциях по проектам (грубое выделение).
// Для тонкого pick'а subject-level см. constants/regions.ts.
export const FEDERAL_DISTRICTS: FederalDistrict[] = [
{ bit: 1, label: 'Центральный' },
{ bit: 2, label: 'Северо-Западный' },
{ bit: 4, label: 'Южный' },
{ bit: 8, label: 'Северо-Кавказский' },
{ bit: 16, label: 'Приволжский' },
{ bit: 32, label: 'Уральский' },
{ bit: 64, label: 'Сибирский' },
{ bit: 128, label: 'Дальневосточный' },
];
+3
View File
@@ -106,6 +106,9 @@ export const useProjectsStore = defineStore('projects', () => {
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
add?: number;
remove?: number;
// Plan 6.5 — update_regions оперирует кодами субъектов (1..89), не bitmask ФО.
add_regions?: number[];
remove_regions?: number[];
delta?: number;
replace?: number;
}
@@ -0,0 +1,52 @@
/**
* Workaround для бага позиционирования Vuetify connected-location-strategy.
*
* Когда активатор `v-select`/`v-autocomplete` находится внутри
* `position: fixed`-контейнера (кастомный дровер, диалог), Vuetify включает
* ветку `activatorFixed` (`isFixedPosition()` → true). Её `getIntrinsicSize()`
* вычитает `el.style.left` из измеренной геометрии оверлея; на переходном
* кадре, когда контент ещё отрисован в нулевой позиции, а инлайновый
* `style.left` уже не нулевой, `contentBox.x` становится отрицательным и
* стратегия аккумулирует смещение — меню уезжает на кратное X активатора
* (за край экрана).
*
* Обычно гонку сглаживают пересчёты, размазанные по анимации открытия. Под
* `prefers-reduced-motion: reduce` (умолчание Windows Server) анимации нет —
* один пересчёт на «плохом» кадре остаётся финальным.
*
* Фикс: дождаться, пока контент оверлея отрисован и геометрически стабилен,
* затем один раз послать `resize` — Vuetify пересчитает позицию по уже
* устоявшейся геометрии и поставит меню корректно. Безопасно при motion ON
* (пересчёт по стабильной геометрии идемпотентен) и для не-fixed контейнеров.
*
* Привязывать к `@update:menu` нужного `v-autocomplete`/`v-select`.
*/
export function repositionMenuAfterOpen(open: boolean): void {
if (!open || typeof window === 'undefined') return;
let prevLeft = Number.NaN;
let stableFrames = 0;
let totalFrames = 0;
const tick = (): void => {
// Последний открытый overlay-menu (на случай вложенных оверлеев).
const menus = document.querySelectorAll<HTMLElement>('.v-overlay.v-menu .v-overlay__content');
const el = menus[menus.length - 1];
if (el && el.getBoundingClientRect().width > 0) {
const left = Math.round(el.getBoundingClientRect().left);
stableFrames = left === prevLeft ? stableFrames + 1 : 0;
prevLeft = left;
// 3 кадра без движения = геометрия устоялась → один чистый пересчёт.
if (stableFrames >= 3) {
window.dispatchEvent(new Event('resize'));
return;
}
}
// Предохранитель ~1.5 c: не зацикливаться, если оверлей не появился.
if (++totalFrames < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
@@ -88,6 +88,7 @@
density="comfortable"
class="ld-input-quiet"
data-testid="regions-autocomplete"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
@@ -137,6 +138,7 @@
import { ref, reactive, watch } from 'vue';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
@@ -6,17 +6,17 @@ use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
it('accepts update_regions action with add/remove bitmask', function () {
it('accepts update_regions action with subject-code arrays', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create(['region_mask' => 1]);
$p = Project::factory()->for($tenant)->create(['regions' => [82]]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p->id],
'add' => 6, // биты 2+4 = Северо-Западный + Южный
'remove' => 1, // бит 1 = Центральный
'add_regions' => [83, 84], // Санкт-Петербург + Севастополь
'remove_regions' => [82], // Москва
])
->assertOk()
->assertJsonStructure(['updated', 'skipped', 'warnings']);
@@ -69,24 +69,39 @@ it('accepts empty scope.filter as valid scope (all projects)', function () {
->assertOk();
});
it('applies update_regions add and remove correctly', function () {
it('applies update_regions add_regions and remove_regions to the regions array', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['region_mask' => 3]); // 1+2
$p2 = Project::factory()->for($tenant)->create(['region_mask' => 5]); // 1+4
$p1 = Project::factory()->for($tenant)->create(['regions' => [82, 56]]); // Москва + Московская обл.
$p2 = Project::factory()->for($tenant)->create(['regions' => []]); // вся РФ
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p1->id, $p2->id],
'add' => 16, // 16 = Приволжский
'remove' => 1, // 1 = Центральный
'add_regions' => [83], // Санкт-Петербург
'remove_regions' => [56], // Московская область
])
->assertOk()
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
expect($p1->fresh()->region_mask)->toBe((3 | 16) & ~1); // = 18
expect($p2->fresh()->region_mask)->toBe((5 | 16) & ~1); // = 20
expect($p1->fresh()->regions)->toBe([82, 83]); // [82,56] {83} \ {56}, отсортировано
expect($p2->fresh()->regions)->toBe([83]); // [] {83} \ {56}
});
it('rejects update_regions with out-of-range subject code', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p->id],
'add_regions' => [90], // > 89 — невалидный код субъекта РФ
])
->assertStatus(422)
->assertJsonValidationErrors(['add_regions.0']);
});
it('applies update_days add and remove correctly', function () {
+22 -14
View File
@@ -15,15 +15,18 @@ const mountDialog = (count = 5) =>
},
});
interface DialogVm {
addRegions: number[];
removeRegions: number[];
}
describe('RegionsBulkDialog', () => {
beforeEach(() => setActivePinia(createPinia()));
it('renders 8 federal-district chips for Add and Remove', () => {
it('renders subject-level Add and Remove selectors (not federal districts)', () => {
const wrapper = mountDialog();
const addChips = wrapper.findAll('[data-testid^="region-add-"]');
const removeChips = wrapper.findAll('[data-testid^="region-remove-"]');
expect(addChips.length).toBe(8);
expect(removeChips.length).toBe(8);
expect(wrapper.find('[data-testid="region-add-select"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="region-remove-select"]').exists()).toBe(true);
});
it('shows count from prop', () => {
@@ -31,20 +34,25 @@ describe('RegionsBulkDialog', () => {
expect(wrapper.text()).toContain('7');
});
it('emits apply with computed bitmasks', async () => {
it('emits apply with selected subject codes', async () => {
const wrapper = mountDialog();
// Toggle Центральный (bit 1) in Add
await wrapper.find('[data-testid="region-add-1"]').trigger('click');
// Toggle Сибирский (bit 64) in Remove
await wrapper.find('[data-testid="region-remove-64"]').trigger('click');
(wrapper.vm as unknown as DialogVm).addRegions = [82, 83];
(wrapper.vm as unknown as DialogVm).removeRegions = [56];
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="apply"]').trigger('click');
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add: 1, remove: 64 }]);
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add_regions: [82, 83], remove_regions: [56] }]);
});
it('apply button disabled when both add and remove are 0', () => {
it('apply button disabled when nothing selected', () => {
const wrapper = mountDialog();
const btn = wrapper.find('[data-testid="apply"]');
expect(btn.attributes('disabled')).toBeDefined();
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeDefined();
});
it('apply button enabled once a subject is picked', async () => {
const wrapper = mountDialog();
(wrapper.vm as unknown as DialogVm).addRegions = [82];
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeUndefined();
});
});
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { repositionMenuAfterOpen } from '../../resources/js/utils/menuRepositionFix';
/**
* Unit-тесты воркэраунда Vuetify location-strategy (см. menuRepositionFix.ts).
* Реальный баг — гонка позиционирования в браузере под prefers-reduced-motion —
* в jsdom не воспроизводится (нет layout); он покрыт Playwright-пробой. Здесь
* проверяется контракт утилиты: при стабилизации overlay-меню шлётся один resize.
*/
function makeStableMenu(left: number): HTMLElement {
const overlay = document.createElement('div');
overlay.className = 'v-overlay v-menu';
const content = document.createElement('div');
content.className = 'v-overlay__content';
content.getBoundingClientRect = () =>
({ width: 400, height: 300, left, top: 50, right: left + 400, bottom: 350, x: left, y: 50, toJSON() {} }) as DOMRect;
overlay.appendChild(content);
document.body.appendChild(overlay);
return overlay;
}
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
describe('repositionMenuAfterOpen', () => {
afterEach(() => {
document.querySelectorAll('.v-overlay').forEach((el) => el.remove());
});
it('does nothing when menu is closing (open=false)', async () => {
const spy = vi.fn();
window.addEventListener('resize', spy);
repositionMenuAfterOpen(false);
await wait(200);
window.removeEventListener('resize', spy);
expect(spy).not.toHaveBeenCalled();
});
it('dispatches a single resize once the overlay content is geometrically stable', async () => {
makeStableMenu(120);
const spy = vi.fn();
window.addEventListener('resize', spy);
repositionMenuAfterOpen(true);
await wait(400);
window.removeEventListener('resize', spy);
expect(spy).toHaveBeenCalled();
});
it('does not dispatch resize or throw when no overlay is present', async () => {
const spy = vi.fn();
window.addEventListener('resize', spy);
expect(() => repositionMenuAfterOpen(true)).not.toThrow();
await wait(300);
window.removeEventListener('resize', spy);
expect(spy).not.toHaveBeenCalled();
});
});
@@ -32,12 +32,12 @@ describe('projectsStore.bulkUpdate', () => {
store.filters.status = 'active';
store.filters.search = 'окна';
await store.bulkUpdate({ action: 'update_regions', add: 6, remove: 1 });
await store.bulkUpdate({ action: 'update_regions', add_regions: [3, 5], remove_regions: [1] });
expect(axios.post).toHaveBeenCalledWith('/api/projects/bulk', {
action: 'update_regions',
add: 6,
remove: 1,
add_regions: [3, 5],
remove_regions: [1],
scope: { filter: { signal_type: 'sms', status: 'active', search: 'окна' } },
});
});