# Design Spec: Plan 6 — Subject-level regions для outbound supplier API

**Дата:** 2026-05-14 (вечер)
**Триггер:** В сессии #909-debug обнаружено, что текущая колонка `projects.region_mask` — 8-битная маска федеральных округов (CHECK 0..255), а PDD-feature от 2026-05-14 (commits `4f60add` spec + `159ed3e` plan + `f982046` feat) предполагал 32-bit маску под 31 субъект РФ. Конфликт привёл к CHECK violation 500 при создании проекта. Interim A откатил regions UI; этот Plan 6 возвращает поле в правильной модели.
**Бизнес-цель (подтверждено заказчиком 2026-05-14):** клиент выбирает гранулярный регион (один из 89 субъектов РФ) → Лидерра хранит → передаёт поставщику crm.bp-gr.ru через outbound API ([SupplierPortalClient::toPayload()](../../../app/app/Services/Supplier/SupplierPortalClient.php#L253-L265)). Поставщик фильтрует исходящие лиды на своей стороне. Внутренний routing (PhonePrefixService, LeadRouter) **не затрагивается** — он остаётся на ФО-уровне.
**Связано:**

- [2026-05-14-pdd-regions-field-design.md](./2026-05-14-pdd-regions-field-design.md) — предыдущая PDD-spec, отзывается этим эпиком (32-bit→89-codes pivot)
- [2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) §4.4 — supplier_projects.current_regions JSONB array (уже generic, поддержит 89)
- [PDD plan](../plans/2026-05-14-pdd-regions-field.md) — TDD task list под отозванную модель, заменяется новым writing-plans артефактом

---

## 1. Цель и scope

**Цель (in-scope):**

- Заменить `projects.region_mask INT` (8 ФО bitmask) на `projects.regions INT[]` (subject-level array, 89 кодов)
- UI dropdown в [NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue) + [ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) с group-by федокруг
- Backend validation в [StoreProjectRequest.php](../../../app/app/Http/Requests/StoreProjectRequest.php) + [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php)
- Outbound: [SyncSupplierProjectsJob::adaptProjectsForAllocator()](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L215-L231) копирует `projects.regions[]` напрямую в `supplier_projects.current_regions`
- Schema v8.19 → v8.20 с CHANGELOG entry
- TDD red-green coverage всех вышеуказанных слоёв

**НЕ цели (out-of-scope, отдельные эпики):**

- Удаление колонки `region_mask` и CHECK constraint — Plan 6.5 после deprecation period
- Phone-prefix → subject matrix (1500+ DEF codes) — не нужна, поставщик сам определяет регион лида и фильтрует у себя
- Рефакторинг [PhonePrefixService.php](../../../app/app/Services/PhonePrefixService.php) — остаётся на ФО-уровне для других use cases
- Рефакторинг [LeadRouter.php](../../../app/app/Services/LeadRouter.php#L90) — продолжает читать `region_mask` (legacy), переключение на `regions[]` отложено до Plan 6.5
- Изменение supplier API contract — [SupplierProjectDto.php:21](../../../app/app/Services/Supplier/Dto/SupplierProjectDto.php#L21) уже типизировано `array<int, int|string>`, нативно поддерживает 89 кодов
- Per-subject pricing / weighted quotas — Plan 7+ (если потребуется)

---

## 2. Архитектурный подход: Approach 2 — dual-write transition

Из трёх рассмотренных подходов (Replace / Dual-write / Separate table) выбран **dual-write**:

| Подход | Pros | Cons | Решение |
|---|---|---|---|
| 1. Replace `region_mask` → `regions[]` | Один источник истины, чисто | Atomic migration 32 файлов; высокий риск регрессий | ❌ |
| **2. Add `regions[]` + deprecate `region_mask`** | **Постепенно, backward-compat, low-risk, легко откатить** | **2 поля в sync transient; cleanup отдельным эпиком** | ✅ |
| 3. Separate table `project_regions` | Нормализовано, extensible | Overkill для текущего use case; extra JOIN | ❌ |

**Переход:**

1. **Plan 6 (этот эпик):** ADD `regions INT[]` + UI/backend пишут оба поля (`regions=[...]` источник истины + `region_mask=255` legacy default)
2. **Plan 6.5 (отдельно, не сейчас):** переключить читатели на `regions[]`, удалить dual-write, DROP COLUMN `region_mask` + `region_mode`, удалить CHECK constraint

---

## 3. Schema delta (v8.19 → v8.20)

### 3.1. Новая колонка `projects.regions INT[]`

```sql
ALTER TABLE projects
    ADD COLUMN regions INT[] NOT NULL DEFAULT '{}'::INT[];

CREATE INDEX idx_projects_regions
    ON projects USING GIN (regions);

COMMENT ON COLUMN projects.regions IS
    'Subject-level region filter (1..89 коды субъектов РФ). Пустой массив = вся РФ. Plan 6 (v8.20). См. resources/js/constants/regions.ts.';
```

### 3.2. Deprecation note для `region_mask`

```sql
-- v8.20 (Plan 6): regions[] INT[] заменяет region_mask 8-bit. DEPRECATED для удаления в Plan 6.5.
-- Остаётся для backward-compat читателей (PhonePrefixService, LeadRouter) до отдельного эпика.
CONSTRAINT chk_projects_region_mask_range
    CHECK (region_mask BETWEEN 0 AND 255),  -- УДАЛЯЕМ в Plan 6.5
```

В [db/schema.sql:811-816](../../../db/schema.sql#L811-L816) — добавить inline-комментарий `-- DEPRECATED Plan 6.5: см. regions INT[] column`.

### 3.3. CHANGELOG entry

Добавить в [db/CHANGELOG_schema.md](../../../db/CHANGELOG_schema.md):

```markdown
## v8.20 от 2026-05-14 (Plan 6 — subject-level regions)

**Изменения:**
- `projects` +1 колонка: `regions INT[] NOT NULL DEFAULT '{}'`
- `projects` +1 GIN-индекс: `idx_projects_regions`

**Не изменено (deprecated, удаление в Plan 6.5):**
- `projects.region_mask`, `projects.region_mode`, CHECK `chk_projects_region_mask_range`

**Семантика:**
- `regions=[]` → «вся РФ» (паритет с legacy `region_mask=255 + region_mode=include`)
- `regions=[77,78]` → проект принимает лиды только из Москвы (77) и СПб (78)

**Метрики schema baseline:** 63 базовых таблиц / 12 партиций / 117 индексов → **118 индексов** / 39 RLS / 5 функций / 13 триггеров.
```

---

## 4. REGIONS constant: расширение 31 → 89

[app/resources/js/constants/regions.ts](../../../app/resources/js/constants/regions.ts) — текущие 32 entries (включая `code:0 = "Вся РФ"` sentinel) расширяются до 90:

```ts
export interface Region {
    code: number;            // 1..89, sequential по конституционному порядку
    name: string;            // официальное название субъекта
    federalDistrict: number; // 1..8 (1=Центр, 2=СЗ, 3=Юг, 4=СК, 5=Приволжье, 6=Урал, 7=Сибирь, 8=ДВ)
}

export const REGIONS: Region[] = [
    { code: 0, name: 'Вся РФ', federalDistrict: 0 },  // sentinel (UI hint)
    { code: 1, name: 'Республика Адыгея', federalDistrict: 3 /* Юг */ },
    { code: 2, name: 'Республика Башкортостан', federalDistrict: 5 /* Приволжье */ },
    // ... (полный список 1..89 по Конституции РФ)
    { code: 77, name: 'Москва', federalDistrict: 1 /* Центр */ },
    { code: 78, name: 'Санкт-Петербург', federalDistrict: 2 /* СЗ */ },
    { code: 89, name: 'Чукотский АО', federalDistrict: 8 /* ДВ */ },
];

export const FEDERAL_DISTRICT_NAMES: Record<number, string> = {
    1: 'Центральный', 2: 'Северо-Западный', 3: 'Южный', 4: 'Северо-Кавказский',
    5: 'Приволжский', 6: 'Уральский', 7: 'Сибирский', 8: 'Дальневосточный',
};
```

**ASSUMPTION:** sequential 1..89 — точный порядок и список генерится по конституционному порядку (Адыгея..Чукотка). Москва+МО, СПб+ЛО, Севастополь — раздельные субъекты (89 итого).

**`code:0` sentinel** — остаётся для UI hint «Вся РФ», но в БД не пишется (соответствует пустому массиву `[]`). Должен быть исключён из selectableRegions через `.filter(r => r.code !== 0)`.

---

## 5. Backend validation

### 5.1. StoreProjectRequest (POST /api/projects)

[app/app/Http/Requests/StoreProjectRequest.php](../../../app/app/Http/Requests/StoreProjectRequest.php) — изменения в `rules()`:

**Удаляются:**

```php
'region_mask' => ['required', 'integer', 'min:0'],
'region_mode' => ['required', Rule::in(['include', 'exclude'])],
```

**Добавляются:**

```php
'regions' => ['present', 'array'],
'regions.*' => ['integer', 'between:1,89'],
```

`present` принимает пустой массив `[]`; `between:1,89` — soft validation (без БД-CHECK constraint).

### 5.2. UpdateProjectRequest (PATCH /api/projects/{id})

[app/app/Http/Requests/UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) — аналогично, `sometimes` вместо `present`:

```php
'regions' => ['sometimes', 'array'],
'regions.*' => ['integer', 'between:1,89'],
```

### 5.3. ProjectService::create() и update()

[app/app/Services/Project/ProjectService.php:182-199](../../../app/app/Services/Project/ProjectService.php#L182-L199):

```php
public function create(Tenant $tenant, array $data): Project
{
    $limit = (int) ($tenant->limits['max_projects'] ?? 10);
    $current = Project::where('tenant_id', $tenant->id)->active()->count();
    if ($current >= $limit) {
        throw new HttpResponseException(response()->json([
            'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
        ], 403));
    }

    $data['tenant_id'] = $tenant->id;
    $data['is_active'] = true;
    $data['regions'] = $data['regions'] ?? [];
    // Plan 6 dual-write: regions[] = источник истины; region_mask/mode legacy для PhonePrefixService.
    // Cleanup в Plan 6.5 после переключения читателей.
    $data['region_mask'] = 255;
    $data['region_mode'] = 'include';

    $project = Project::create($data);
    SyncSupplierProjectJob::dispatch($project->id);
    return $project->fresh();
}
```

### 5.4. Project model

[app/app/Models/Project.php](../../../app/app/Models/Project.php) — добавить `regions` в `$fillable` и в `$casts`:

```php
protected $fillable = [
    /* existing */,
    'regions',
];

protected $casts = [
    /* existing */,
    'regions' => 'array',
];
```

---

## 6. UI — NewProjectDialog + ProjectDetailsDrawer

### 6.1. NewProjectDialog.vue

[app/resources/js/views/projects/NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue):

**Template (на месте interim A `<v-alert>` placeholder, между «Лимит» и «Дни недели»):**

```vue
<v-autocomplete
    v-model="form.regions"
    :items="selectableRegions"
    item-title="name"
    item-value="code"
    label="Регионы (пусто = вся РФ)"
    multiple chips clearable
    density="comfortable"
    data-testid="regions-autocomplete"
>
    <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>
```

**Script:**

```ts
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';

const selectableRegions = REGIONS.filter(r => r.code !== 0); // exclude "Вся РФ" sentinel

const form = reactive({
    /* existing */,
    regions: [] as number[],   // NEW (vместо region_mask/region_mode в form-state)
});

// region_mode + region_mask убираются полностью из form / payload.
// Backend dual-write устанавливает их сам в ProjectService::create.
```

Удалить error banner placeholder (теперь его место занимает v-autocomplete).

### 6.2. ProjectDetailsDrawer.vue

[app/resources/js/components/projects/ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) — аналогичная вставка autocomplete, refactor 3 existing region-tests на 89-code модель. `maskToCodes()` helper удаляется (больше не нужен).

### 6.3. Project type в projectsStore

[app/resources/js/stores/projectsStore.ts](../../../app/resources/js/stores/projectsStore.ts) — добавить `regions?: number[]` в `Project` interface.

---

## 7. Outbound mapping — SyncSupplierProjectsJob

[app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:215-231](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L215-L231):

**До (ФО-bitmask конверсия):**

```php
$regionMask = (int) $p->region_mask;
$obj->regions = $regionMask === 255
    ? []
    : $this->bitmaskToList($regionMask, 8);
```

**После (direct copy 89-code array):**

```php
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
// Empty array = "вся РФ" (паритет с supplier API semantics).
$obj->regions = array_values((array) $p->regions);
```

`bitmaskToList()` остаётся для `workdays` (delivery_days_mask, 7 битов). Не удаляется.

`SupplierQuotaAllocator::unionInts()` [SupplierQuotaAllocator.php:57](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L57) уже работает с массивами — no changes.

`SupplierPortalClient::toPayload()` [SupplierPortalClient.php:261](../../../app/app/Services/Supplier/SupplierPortalClient.php#L261) уже шлёт `regions: array` — поддержит 89 codes нативно.

---

## 8. Data flow

```
User selects regions in <v-autocomplete>
  → form.regions = [77, 78]
  → POST /api/projects { ..., regions: [77, 78] }
    → StoreProjectRequest validates (array, each 1..89)
    → ProjectService::create
      → $data['regions'] = [77, 78]
      → $data['region_mask'] = 255 (legacy dual-write)
      → $data['region_mode'] = 'include' (legacy dual-write)
      → Project::create → INSERT INTO projects (regions, region_mask, region_mode, ...)
      → SyncSupplierProjectJob::dispatch
        → Redis queue (job runs async via queue worker)
          → adaptProjectsForAllocator: $obj->regions = $p->regions  (direct copy)
          → SupplierQuotaAllocator → SupplierProjectDto(regions: [77, 78])
          → SupplierPortalClient::toPayload → POST к crm.bp-gr.ru { regions: [77, 78] }
```

---

## 9. Migration & data backfill

**Existing projects (production data):**

- При ALTER TABLE с `DEFAULT '{}'::INT[]` все existing rows получают `regions=[]`
- `regions=[]` ≡ «вся РФ» — нейтральный default, **не теряет данные** ([потому что region_mask=255 default — тоже «вся РФ»])
- Projects с `region_mask ≠ 255` (если есть в production!) — теряют ФО-фильтр. Это **acceptable trade-off**:
  - DEV — нет prod data
  - PROD ещё не запущен (Б-1 блокер, ООО не зарегистрирован)
  - Альтернатива: derive `regions[]` из ФО→subject mapping в migration — overkill, добавляет complexity

**ASSUMPTION:** на момент Plan 6 нет production-данных с осознанно установленным `region_mask` ≠ 255. Если появятся до Plan 6.5 — отдельная migration с ФО→subjects expansion.

---

## 10. Testing strategy (TDD red-green)

### 10.1. Backend (Pest)

**[tests/Feature/Plan5/Projects/ProjectsStoreTest.php](../../../app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php) — 3 новых теста:**

1. `it('creates project with regions array')` — POST `{regions: [77, 78]}` → assert `project.regions === [77, 78]`, response 201
2. `it('dual-writes region_mask=255 for backward-compat')` — POST с regions → assert DB row `region_mask=255 AND region_mode='include'`
3. `it('rejects regions with code out of 1..89 range')` — POST `{regions: [90]}` → 422 with error on `regions.0`

**[tests/Feature/Plan5/Projects/ProjectsUpdateTest.php](../../../app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php) — 2 новых теста:**

1. `it('updates regions array via PATCH')` — PATCH `{regions: [77]}` → assert DB row `regions=[77]`
2. `it('preserves regions when PATCH omits the field (sometimes rule)')` — PATCH без `regions` → DB regions unchanged

**[tests/Feature/Supplier/SyncSupplierProjectsJobTest.php](../../../app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php) — 2 новых теста:**

1. `it('passes regions directly to allocator without bitmask conversion')` — project с `regions=[77,78]` → assert `SupplierProjectDto::regions === [77,78]`
2. `it('passes empty array to allocator when project has regions=[]')` — project с `regions=[]` → assert DTO regions `===[]`

### 10.2. Frontend (Vitest)

**[tests/Frontend/NewProjectDialog.spec.ts](../../../app/tests/Frontend/NewProjectDialog.spec.ts) — 2 новых теста:**

1. `it('shows regions autocomplete with 89 options grouped by federal district')` — mount → assert v-autocomplete `:items.length === 89` (без sentinel)
2. `it('sends regions array in POST payload')` — fill regions [77, 78] → click СОЗДАТЬ → assert `apiClient.post` called with `regions: [77, 78]`

**[tests/Frontend/ProjectDetailsDrawer.spec.ts](../../../app/tests/Frontend/ProjectDetailsDrawer.spec.ts) — refactor 3 existing region tests:**

- `renders region chips when project has non-zero region_mask` → переписать на `renders region chips for project.regions = [1, 2]`
- `selecting regions encodes mask + sets mode='exclude'` → переписать на `selecting regions adds to regions array`
- `clearing all regions resets mask=0 + mode='include'` → переписать на `clearing all regions sets regions=[]`

### 10.3. Regression must-stay-green

- Pest --parallel **742 / 739 passed / 3 skipped / 0 failed** (current baseline)
- Vitest **92 / 758 passed / 3 skipped / 0 failed**
- vue-tsc 0 errors
- ESLint 0 errors, Prettier clean
- Migration smoke: `php artisan migrate:fresh` + Pest на RlsSmokeTest.php + TenantModelsTest.php

### 10.4. Visual smoke (manual, заказчик)

1. /projects → нажать СОЗДАТЬ → dialog open
2. Поле «Регионы» видно между «Лимит» и «Дни недели»
3. Click → dropdown показывает 89 субъектов с подзаголовком ФО
4. Поиск «Москва» → отфильтровывается «Москва» (code 77)
5. Выбрать «Москва» + «СПб» → 2 chips
6. Click СОЗДАТЬ → 201, project появляется в списке
7. Открыть карточку этого проекта (PDD) → видны 2 chips
8. PATCH → удалить chip, добавить другой → сохранить → reload PDD → актуальное состояние

---

## 11. Error handling

- `regions=null` в payload (legacy client) → backend `?? []` → пустой массив, no error
- `regions=[90, 100]` → 422 с error на `regions.0` и `regions.1`
- `regions=['abc']` → 422 (integer rule fails)
- БД INSERT с `regions = NULL` → ERROR (NOT NULL constraint); защита — `present` rule в StoreProjectRequest + `?? []` в ProjectService
- Outbound к crm.bp-gr.ru с regions=[77,78] и supplier API недоступен → existing retry logic [SyncSupplierProjectsJob.php](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php) (3 retries, 15/60/300s backoff)

---

## 12. Open questions / Assumptions требующие подтверждения

Помечены **ASSUMPTION** в spec'е — твоё ревью либо подтверждает, либо переписывает:

1. **Список 89 субъектов** — generate sequential 1..89 по конституционному порядку. **ASSUMPTION:** ОК; если нужен другой порядок (ОКАТО / ISO 3166-2:RU) — укажи.
2. **Москва/Москва обл./СПб/ЛО/Севастополь** — раздельные субъекты, **итого 89**. **ASSUMPTION:** ОК.
3. **Existing projects с `region_mask ≠ 255`** — теряют ФО-фильтр (получают `regions=[]` = «вся РФ»). **ASSUMPTION:** на момент Plan 6 нет таких prod-данных (Б-1 не закрыт). Если есть в dev — допустимо потерять.
4. **Удаление колонки `region_mask`** — отложено до Plan 6.5. **ASSUMPTION:** dual-write не блокирует параллельные эпики (LeadRouter / PhonePrefixService продолжают работать с `region_mask`).
5. **Группировка в dropdown** — `v-list-item subtitle = federal_district_name` (плоский список с subtitle). Альтернатива — `<v-list-subheader>` группы. **ASSUMPTION:** subtitle проще и работает с search filter.

---

## 13. Out of scope (Plan 6.5+)

- Drop COLUMN `region_mask`, `region_mode`, CHECK `chk_projects_region_mask_range`
- Refactor [LeadRouter.php:90](../../../app/app/Services/LeadRouter.php#L90) на `regions[]`
- Refactor / удалить [PhonePrefixService.php](../../../app/app/Services/PhonePrefixService.php) (если он не нужен по другим reasons)
- Per-subject pricing / weighted quotas
- Mobile-адаптация autocomplete (touch keyboard / full-screen modal)
- Bulk-edit regions через [BulkActionsBar](../../../app/resources/js/components/projects/BulkActionsBar.vue)
- Region-based reports / analytics

---

## 14. Связанные документы и cross-refs

- [2026-05-14-pdd-regions-field-design.md](./2026-05-14-pdd-regions-field-design.md) — отзывается этим эпиком (Section §7 «Bigint upgrade — Plan 6» ссылается сюда)
- [2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) — supplier_projects schema + DTO
- [docs/CRM_bp-gr_Инструкция_v8_5.md](../../CRM_bp-gr_Инструкция_v8_5.md) §5.5 — webhook / supplier-integration spec
- [db/schema.sql:811-816](../../../db/schema.sql#L811-L816) — current `region_mask` definition (DEPRECATED после Plan 6)
- [db/CHANGELOG_schema.md](../../../db/CHANGELOG_schema.md) — schema versioning history
- [Открытые_вопросы](../../Открытые_вопросы_v8_3.md) — новый ticket Биз-XX «Plan 6 — subject-level regions» нужен после approval этой spec'и
