---
title: Воркфлоу задач
description: Статуси, переходи, автоматичне створення креативів, неймінг, сповіщення в Slack і Google Drive.
---

Документ описує, як працює виробничий воркфлоу задач на бекенді kumquat: статуси, переходи,
автоматичне створення креативів, неймінг, сповіщення в Slack і роботу з Google Drive.

Основні файли:

- `kumquat_tasks/enums.py` — статуси, дозволені переходи (`ALLOWED_TRANSITIONS`), групи типів задач, стан автоматизації.
- `kumquat_tasks/field_policy.py` — які поля редагуються/обов'язкові/сховані для стану задачі.
- `kumquat_tasks/workflow/transitions.py` — перевірка переходів.
- `kumquat_tasks/workflow/handlers.py` — обробники статусів.
- `kumquat_tasks/workflow/creative_factory.py` — генерація креативів і неймінг.
- `kumquat_tasks/workflow/legacy_remap.py` — розбір Legacy Remap List.
- `kumquat_tasks/notifications.py` — сповіщення в Slack; `kumquat_tasks/drive.py` — папки Google Drive.
- `kumquat_api/routes/tasks.py` — ендпоінт зміни статусу.
- `kumquat_api/worker/tasks.py` — Celery-задача.


## Задача і креатив

- **Задача (Task)** — одиниця виробництва. Вона групує креативи, які з неї виходять, і
  несе таксономію (продукт, тип задачі, медіатип, мову тощо), кількість, дедлайн, людей і статус.
- **Креатив (Creative)** — конкретна відео/статик-картка, що йде в тестування в рекламному
  кабінеті. Кожен креатив належить одній задачі (`task_id`).
- Зв'язки не дублюються масивами: «креативи задачі» — це запит `find({task_id})`, «похідні
  креативи» — запит по `origin_ref`. Джерело правди — сам документ, а не збережені списки id.
- **Люди:** `Task Owner` — постановник задачі; `Assignee` — виконавець.

Два поля-посилання на вихідні креативи:

- `origin_creative_id` — один вихідний креатив, обов'язковий для `VAR`/`VARDES`/`VARAI`.
- `source_creative_ids` — кілька вихідних креативів, для `LOC`/`WHT`/`ADPT`/`RSZ`.


## Види задач (Task Type)

`Task Type` визначає, як автоматизація створює креативи і звідки бере дані.

| Task Type | Що це | Картки |
| --- | --- | --- |
| `NEW` | Нові базові креативи з нуля | `count` карток, з полів задачі |
| `VAR` | Варіації наявного креативу | `count` карток; джерело — `Origin Creative` |
| `NEWDES` | Те саме, що `NEW`, дизайнерська задача | `count` карток, з полів задачі |
| `VARDES` | Варіації наявного креативу, дизайнерська задача | `count` карток; джерело — `Origin Creative` |
| `DES` | Legacy-тип дизайну | Поводиться як `VARDES`, якщо є `Origin`; інакше як `NEWDES` |
| `LOC` | Локалізація (переклад під іншу мову) | Одна картка на кожен `Source Creative`; мова — цільова, решта метаданих з оригінала |
| `WHT` | Вайтенінг (версія під іншу платформу) | Одна картка на кожен `Source Creative`; `Source` — цільовий, ставиться `is_whitened` |
| `CLN` | Ручні картки | Автоматика не запускається |
| `VARAI` | AI-варіації | `count` карток, як `VAR`/`VARDES` (зазвичай проставляється креативницею — 4); `Origin Creative` обов'язковий, частина полів успадковується з нього |
| `ADPT` | Адаптація під інший сорс | Одна картка на кожен `Source Creative`, як `LOC`/`WHT`; `Source` — цільовий (з задачі), решта полів — з оригінального креативу |
| `RSZ` | Ресайз (зміна розміру, більше нічого) | Одна картка на кожен `Source Creative`, як `ADPT`; `Resolution` — цільовий (з задачі), решта полів — з оригінального креативу |

`LOC`/`WHT`/`ADPT`/`RSZ` — похідні задачі: беруть готові креативи (`Source Creatives`) і роблять
їхню локалізовану / відбілену / адаптовану / ресайзнуту версію. `VAR`/`VARDES`/`VARAI` — варіації
від одного оригіналу (`Origin Creative`).


## Робочі напрями (Work Stream)

`Performance`, `Product`, `Web`, `Brand`, `SMM`, `HR`. Автоматизація обробляє **лише
`Performance`**; задачі інших напрямів ігноруються (задача повертається в `backlog` зі станом
`warning`).


## Довідники

- **Продукти** (`product_id`): `PLT` (PlantIn), `CNI` (CoinIn), `RKI` (RockIn), `SPD` (SportDex).
- **Media Type**: `VID` (відео), `STA` (статик), `ANI` (аніма/gif), `WEB`, `LND` (лендинг).
- **Resolution** (розмір готового файлу): `1080x1920`, `1920x1080`, `640x768`, `800x800`,
  `320x50`, `1080x1350`, `250x250`, `300x250`, `336x280`, `200x200`, `300x600`, `1242x2688`, `1080x1080`,
  `600x800`, `800x600`.
- **Source** (рекламна платформа): `META`, `TIK` (TikTok), `APL` (AppLovin), `GGL` (Google),
  `SNA` (Snapchat), `SKN`, `TAB`, `UNT` (Unity).
- **Language**: `EN`, `FR`, `DE`, `ES`, `PT`, `UA` тощо — локаль креативу; для `LOC` кодує цільову
  мову (гео в назві не бере участі).
- **Design Complexity** / **Marketing Complexity** — 1–5 кожна: складність задачі окремо для
  дизайну й для маркетингу. У Points іде лише Design Complexity. **Priority** — 1–3 (🔴/🟡/⚪).
- **Modifier** (на креативі, зазвичай проставляється вручну): `HK1`/`HK2`/`HK3` (варіант хука),
  `UC`, `AI`, `RSIZ` (ресайз), `CTA1`/`CTA2`, `PX`.
- **Varied Element** (що варіюється в задачі): `hook`, `cta`, `body_concept`, `background`,
  `hook_script`, `voice`, `music`, `persona`.


## Два незалежні статуси

У задачі є два окремі статус-поля, які не пов'язані між собою:

- `status` (`TaskStatus`) — виробничий процес (від постановки задачі до здачі файлів).
- `test_status` (`TestStatus`) — стан у рекламному кабінеті (не тестувався / тестується / протестовано / на паузі).

Задача може бути `done` по виробництву і водночас `tested` у рекламі — це нормально. Автоматика
описана нижче реагує тільки на `status`. `test_status` змінюється звичайним оновленням задачі.


## Статуси і потік

```
backlog → ready_for_production → in_progress → tech_check / creative_check → done → archived
```

| Статус | Група | Що відбувається |
| --- | --- | --- |
| `backlog` | To-do | Задача створена, автоматика не запускається. |
| `ready_for_production` | To-do | Тригер автоматизації: валідація, створення креативів, папки Drive. Поки прогон іде — `automation.state = pending` і задача заморожена. Успіх лишає її тут чекати на виконавця, помилка повертає в `backlog`. |
| `in_progress` | In progress | Виконавець узяв задачу. Автоматично проставляється `started_at`. |
| `tech_check` | In progress | Технічна перевірка (NEW/VAR). Проставляється `Assignee` на креативи, `submitted_at`, Slack відповідальному за Tech Check. |
| `creative_check` | In progress | Креативна перевірка (NEWDES/VARDES). Те саме, плюс фолбек `Assignee` на `Task Owner`. |
| `done` | Complete | Файли здані. Проставляється `completed_at`, папка Drive переміщується. |
| `archived` | Complete | Задача закрита. Кінцевий стан. |

Дозволені переходи описані в `ALLOWED_TRANSITIONS` (`kumquat_tasks/enums.py`). З `backlog` є рівно
один вихід — `ready_for_production`: це той перехід, що запускає автоматизацію, і без нього немає ні
назви, ні папки Drive. Заборонено перестрибувати етапи (наприклад `backlog → done`); частина
зворотних переходів дозволена для правок (наприклад `done → in_progress`); `archived` — кінцевий,
виходів немає.


## Як змінюється статус

Статус змінюється тільки через окремий ендпоінт:

```
PATCH /api/v1/tasks/{task_id}/status
{ "target_status": "ready_for_production" }
```

Поле `status` навмисно прибране зі звичайного `PATCH /tasks/{id}` — так зміна статусу завжди
проходить через перевірки й запускає побічні ефекти.

З того самого міркування — і на крок далі — `started_at` / `submitted_at` / `completed_at` немає
**ні в `TaskUpdate`, ні в `TaskCreate`**. Їх ставить сам воркфлоу: перше — перехід у `in_progress`,
решту — хендлери за перевірками, і пишуть вони через репозиторій, який цими схемами не проходить.
Тобто по HTTP їх не виставить ніхто — ні правкою, ні створенням задачі; клієнт, який їх усе одно
надішле, отримає те саме, що й зі `status` — поля просто проігнорують (`extra="ignore"`), а не 422.
У формі вони read-only.

`TaskCreate` тримає їх поза схемою окремо від `TaskUpdate`: новостворена задача сидить у `backlog`
і за визначенням ще нікуди не рухалася, тож дати старту, здачі й завершення в неї бути не може.
Виняток — легасі-імпорт (`backend/scripts/backfill_creatives.py`), який проставляє `completed_at`
з вивантаженої таблиці; він збирає `Task` напряму й через цю схему не йде.

Поля, які схема пропускає, все одно проходять через **політику полів** (розділ нижче): те, що
поле є в `TaskUpdate`, не означає, що його можна записати в будь-якому статусі.

### Передумови входу в статус (`REQUIRED_TO_ENTER`)

Окрім графа переходів, статус може вимагати, щоб задача **вже щось несла** до моменту переїзду.
Сьогодні їх два:

| Статус | Вимагає | Чому |
|---|---|---|
| `ready_for_production` | `execution_complexity`, `creation_complexity` | Обидві навмисно не питаються при створенні (`field_policy.DEFERRED_AT_CREATE`) — бриф пишуть раніше, ніж оцінено обсяг. Тут відповідь уперше потрібна: кожна рахує свої власні Points, і `_validate` без обох прогон не запустить. |
| `in_progress` | `assignee_id` | In Progress означає, що хтось робить роботу, тож задача мусить казати хто — інакше вона висить у колонці без відповідального, а `on_tech_check` пізніше розносить Assignee по Creative-картках і розносити нема чого. |

Перший пункт свідомо дублює те, що перевірить `_validate`, і робить це раніше: інакше задача
переїжджає, прогон падає й `_revert` вертає її в Backlog з банером — довгий шлях, щоб сказати
«заповніть два поля». Решту входів прогону так не піднято: вони питаються ще при створенні, тож
задача без них — вже крайній випадок, і його ловить прогон.

`in_progress` навмисно **не** повторює обидві Complexity, хоч і стоїть за Ready for Production:
кожен вхід у нього йде через двері RFP — прямий перехід або повернення з Tech / Creative Check, куди
інакше як через RFP не потрапити. Друга копія того самого правила лише дала б їм нагоду розійтися.
Ціна цього рішення відома: обидві Complexity лишаються редагованими до Creative Check
(`PLANNING_FIELDS`), а `required_fields` віддається формі, а не перевіряється на записі — тож
`PATCH` з явним `null` їх таки очистить, і задача доїде в In Progress без них. Наслідок видимий, але
не фатальний: `points_for_level` без відповідної складності віддає `None` лише для тієї сторони —
Execution Points чи Creation Points у картки просто немає, а форма показує рядок як обов'язковий
незаповнений.

Це **не** політика полів: `field_policy.py` відповідає, що можна *записати* в статусі, який задача
вже тримає, а це — умова *прибуття*. `assignee_id` лишається редагованим скрізь. Перевірка стоїть у
`transition_status` поряд з `assert_transition_allowed`, до будь-якого запису, тож відмова не лишає
ні рядка історії, ні напівзастосованого стану. Область та сама, що й у графа — тільки Performance.

Відмова — **409** `MissingForStatusError` з полем у тексті. Але доходити до неї не мало б: матриця
віддається в `status_schema.required_to_enter` (`GET /tasks/schema` і `meta.status_schema`), тож
селект статусу гасить пункт, а колонка дошки не приймає картку — та сама причина, з якої віддається
граф переходів, і те саме правило, не друга його копія. Клієнт, який отримав відповідь без цього
поля, не блокує нічого: сервер відмовить у будь-якому разі.

`drive_url` у Performance-задачі теж пише тільки автоматизація — разом із `drive_folder_id`,
який жодна форма подати не може. Спроба виставити його руками (у `POST /tasks` чи в
`PATCH /tasks/{id}`) відхиляється — `DriveLinkNotEditableError`, `422`. Перевірка стоїть після
дифу зі збереженою задачею, тож клієнт, який просто відлунює вже записане посилання, помилки не
дістає; очищення (`null`) теж дозволене — це не вказівка на папку. Решта воркстрімів
автоматизацію не запускає, тому для них поле лишається звичайним редагованим.

Обробка ділиться на дві частини.

**Синхронна (в межах запиту, мілісекунди):**

1. Перевірка переходу через `assert_transition_allowed`. Недозволений перехід → `409`.
2. Запис нового статусу. Для `in_progress` проставляється `started_at` (лише якщо ще
   порожній). Для статусів із побічними ефектами `automation.state` ставиться в `pending`.
3. Запис рядка в колекцію `task_transitions` (аудит: звідки, куди, хто, коли).
4. Для статусів `ready_for_production`, `tech_check`, `creative_check`, `done` — постановка
   Celery-задачі `tasks.transition` у чергу (Redis).

**Асинхронна (Celery-воркер, секунди):**

Воркер (`worker/tasks.py::run_task_transition`) створює клієнтів (Mongo, Slack, Drive) через
`services/integrations/factory.py`, дістає задачу і викликає відповідний обробник у
`TaskWorkflowHandlers`. Обробники ніколи не падають через помилку Slack/Drive — такі збої
логуються, а не зупиняють процес.

Ланцюжок `kumquat_core` не імпортує `kumquat_api`: постановку задачі в чергу робить залежність
`get_transition_enqueuer` на рівні роута (той самий підхід, що й у графах — `get_run_enqueuer`).


## Політика полів (`field_policy.py`)

Одна декларація відповідає на три питання про поле — **редаговане / обов'язкове / сховане** — для
пари «стан задачі × хто питає». Її читають три місця: `TasksService.update_task` (enforcement),
`TaskWorkflowHandlers._validate` (обов'язковість перед прогоном) і API, який віддає її фронтенду.

До цього правило «naming-поля Performance-задачі редагуються лише в Backlog» існувало **тільки на
фронтенді**. `PATCH /api/v1/tasks/{id}` з `{"product_id": "CNI"}` на задачі в `done` приймався, і з
цього моменту `generated_name`, назви карток креативів і папка `{Product}/{TaskName}` описували щось
інше — без жодної помилки. Будь-яка інтеграція чи `curl` обходили замок повністю.

### Групи полів

Кожне записуване поле `TaskUpdate` належить рівно одній групі. Це перевіряється тестом
(`set(TaskUpdate.model_fields) == ALL_WRITABLE_FIELDS` у `tests/tasks/test_field_policy.py`), тому
нове поле без групи — падіння тесту, а не тихо відкрите поле в кожному статусі.

| Група | Поля | Чому |
| --- | --- | --- |
| `NAMING_FIELDS` | `name`, `work_stream`, `task_type`, `product_id`, `media_type`, `resolution`, `source`, `geo`, `language`, `count` | входять у `build_task_name`, назви карток і папку Drive |
| `LINEAGE_FIELDS` | `origin_creative_id`, `source_creative_ids`, `legacy_remap_list` | з яких оригіналів будуються картки |
| `TAXONOMY_FIELDS` | `subject`, `topic`, `subtopics`, `primary_anchor`, `audience`, `awareness_stage`, `messaging_angle`, `hook_trigger`, `hook_type`, `ad_format`, `notes`, `traffic_flow`, `landing`, `funnel`, `cpp`, `varied_elements`, `manual_launch` | копіюються на картки в момент прогону, у назву не входять |
| `PLANNING_FIELDS` | `priority`, `task_owner_id`, `assignee_id`, `deadline_at`, `execution_complexity`, `creation_complexity` | автоматизація їх для назви не читає (кожна рахує лише свої власні Points) |
| `ALWAYS_EDITABLE_FIELDS` | `test_status`, `var_description` | відповідь із кабінету і нотатки — не входи прогону |
| `AUTOMATION_OWNED_FIELDS` | `drive_url` | Performance: пише тільки автоматизація, у жодному статусі |

### Матриця

`EDITABLE_BY_STATUS`, для `work_stream = Performance`:

| Статус | NAMING | LINEAGE | TAXONOMY | PLANNING | ALWAYS |
| --- | --- | --- | --- | --- | --- |
| `backlog` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `ready_for_production` | — | — | ✓ | ✓ | ✓ |
| `in_progress` | — | — | ✓ | ✓ | ✓ |
| `tech_check` | — | — | ✓ | ✓ | ✓ |
| `creative_check` | — | — | ✓ | ✓ | ✓ |
| `done` | — | — | — | — | ✓ |
| `archived` | — | — | — | — | ✓ |

Поверх матриці — оверлеї, перший збіг виграє (`editable_fields`):

1. роль нижче `creator` → нічого;
2. `deleted_at` заповнений → нічого (вихід один — restore);
3. `Task.automation_running` → нічого (ширше за будь-який рядок: хендлер будує зі знімка);
4. `work_stream != Performance` → все (автоматизація не запускається, розсинхронювати нічого);
5. інакше — рядок матриці.

`locked_fields()` — доповнення до `editable_fields()`, де кожне поле несе причину (`LockReason`:
`status` / `role` / `deleted` / `automation_running` / `automation_owned`). Причина потрібна UI:
«автоматизація володіє цим полем» і «поверни задачу в Backlog» — різні тексти, і друге для
`drive_url` було б тупиком.

### Enforcement

`TasksService._assert_fields_editable` викликається в `update_task` **після** дифу зі збереженою
задачею, поруч з `_assert_drive_link_editable`: клієнт, що відлунює незмінене значення, нічого не
редагує, і `taskUpdateDelta` на фронті теж посилає лише те, що зрушило. Судиться проти **збереженої**
задачі, не проти payload — `status` через цю схему не ходить, а `work_stream` сам лежить у
`NAMING_FIELDS`, тож поза Backlog його не зрушити.

Порушення → `TaskFieldLockedError` → **409** з переліком полів і статусом, у який треба перемістити
задачу. Помилка гучна навмисно: тихо відкинутий ключ лишає інтеграцію в переконанні, що запис
відбувся.

Замок «прогон іде» тут не питається — він стоїть усередині самого запису
(`TasksRepository.update_unless_automation_running`), де читання і перевірка не можуть розійтися.

### Обов'язковість і видимість

`required_fields(work_stream, task_type, has_legacy_remap=...)` — те, що `_validate` перевіряє перед
прогоном, і те, що форма малює зірочкою. Одне джерело замість двох: форма раніше не позначала
`execution_complexity` / `creation_complexity`, і задача без них їхала в `ready_for_production`, щоб за
секунду вернутись у `backlog` з `Missing required fields`.

`number` у наборі навмисно немає, хоч `_validate` його й перевіряє: його призначає
`next_task_number` на створенні, тож зірочка стояла б на полі, яке ніхто не може заповнити.
`count` і поля походження лишають собі власні повідомлення (`"Count is empty or 0"`,
`"No origin creative"`) — «missing» про них сказало б менше.

`hidden_fields(work_stream, task_type)` — `TAXONOMY_FIELDS` без `varied_elements` для
`LOC`/`WHT`/`ADPT`/`RSZ` у Performance: ці типи будують картку з таксономії оригінального креативу,
тож редагувати їх на задачі нічого не дає. Тест стежить, щоб схований набір не перетинався з
обов'язковим — обов'язкове поле, якого форма не малює, це блокер без виходу.

### Що віддається клієнту

| Ендпоінт | Що | Для чого |
| --- | --- | --- |
| `GET /tasks/{id}` → `meta.field_policy` | `TaskFieldPolicy`: `locked` (поле → причина), `required`, `hidden`, `unlock_status` | розв'язано для цієї задачі й **цього викликача** (роль читається з того ж клейма, що й `enforce_write_role`) |
| `GET /tasks/schema` → `field_policy` | `TASK_FIELD_POLICY_SCHEMA` — уся матриця | форма перерозв'язує локально, коли чернетка сидить на статусі, якого відповідь вище ще не бачила |

Друге не дублює перше: обидва генеруються з тієї самої декларації, як `TASK_STATUS_SCHEMA` віддає
`ALLOWED_TRANSITIONS`. Матриця потрібна саме тому, що статус у чернетці змінюється раніше, ніж
приходить перечитування, — без неї користувач має кадр, у який можна друкувати в поле, що вже
закрите. Усі осі перелічені (по типу задачі, з `""` для «ще не обрано»), щоб клієнт робив пошук по
таблиці, а не виводив правило заново — виведене правило і було б другою копією.

Фронтенд-половина — `frontend/src/modules/tasks/model/task-field-policy.ts`: `resolveFieldPolicy()`
(пошук по матриці) плюс мапа `camelCase ⇄ snake_case`, бо форма ключує рядки по-своєму. Своїх правил
там немає; `canEditPerformanceFields`, `lockedSideKeys`, `HIDDEN_FOR_MULTI_ORIGIN` і дзеркало
`requiredEntries` прибрані.


## Автоматизація (`run_automation`)

Запускається при переході в `ready_for_production`. Кроки:

1. Якщо `work_stream != Performance` — хендлер ставить `automation.state = ok` і виходить, статусу не
   торкаючись. Автоматика обробляє лише Performance.
2. Для `VAR`/`VARDES`/`VARAI` — успадкування порожніх полей таксономії з задачі оригінального
   креативу (`_inherit_from_origin`): якщо в задачі поле порожнє, а в оригінала є — копіюється.
3. Валідація обов'язкових полів — набір бере `field_policy.required_fields()` (розділ «Політика
   полів»), плюс `number`, який перевіряється тут: `product_id`, `task_type`, `media_type`,
   `execution_complexity`, `creation_complexity`, `resolution`, `source`. `language` і `geo`
   обов'язкові теж, але не для `WHT`/`ADPT`/`RSZ` (`GEO_LANGUAGE_EXEMPT_TASK_TYPES`): ці типи
   варіюють лише одне поле (`Source` для `WHT`/`ADPT`, `Resolution` для `RSZ` — той самий
   `resolution`, що обов'язковий уже для всіх типів вище), а мову й гео беруть з оригінала.
   `source` натомість обов'язковий завжди — `Task.source` має дефолт (`Source.META`), тож
   порожнім не буває. Для типів із лічильником карток (`NEW`, `VAR`, `NEWDES`, `VARDES`, `DES`,
   `VARAI`, `CLN`) — `count > 0`. Для `LOC`/`WHT`/`ADPT`/`RSZ` — має бути хоч один
   `source_creative`. Для `VAR`/`VARDES`/`VARAI` — має
   бути заданий `Origin Creative` (або legacy remap, що на нього резолвиться) — інакше помилка
   `No origin creative`.
4. Захист від дублів — дві незалежні перевірки: якщо для задачі вже є креативи — помилка (щоб не
   створити дублікати при повторному запуску); якщо серед похідних карток оригінала вже є така сама
   — теж помилка (розділ «Дублікати варіантів»).
5. Побудова `Task Name`, створення креативів (розділ «Неймінг»).
6. Створення папки в Google Drive.
7. Оновлення задачі: `generated_name`, `drive_url`, `drive_folder_id`, `automation.state = ok`.
   Статус не пишеться — задача вже в `ready_for_production`.
8. Для `VAR`/`LOC`/`WHT`/`ADPT`/`RSZ`/`VARAI` — Slack Task Owner про успіх.

Якщо на будь-якому кроці помилка — задача повертається в `backlog` (compare-and-set проти
`ready_for_production`, щоб не затерти ручне переміщення), `automation.state = error` з описом, і
Task Owner отримує Slack.


## Дублікати варіантів (`LOC`/`WHT`/`ADPT`/`RSZ`)

Похідна задача не має вдруге робити те, що для цього оригінала вже зроблено: другу локалізацію на ту
саму пару `Language`+`Geo`, другий вайтенінг чи адаптацію на той самий `Source`, або другий ресайз
у той самий `Resolution`. Вирішує це `find_duplicate_variant` (`creative_factory.py`) — по вже
наявних похідних картках оригінала (`CreativesRepository.list_variants`, запит по `origin_ref._id`).

Вісь порівняння повторює те, що картка насправді варіює:

| Task Type | Дублікат, якщо в оригінала вже є картка з тим самим |
| --- | --- |
| `LOC` | `Language` **і** `Geo` |
| `WHT` | `Source` |
| `ADPT` | `Source` |
| `RSZ` | `Resolution` |

`RSZ` звіряється по `Resolution`, не по `Aspect Ratio`: піксельний розмір — це те, що ресайз
насправді змінює, а дві `RSZ`-картки можуть ділити один `Aspect Ratio` і при цьому бути різного
розміру (`800x800` і `250x250` — обидва `1x1`). `resolution = None` ніколи не збігається сам із
собою — на відміну від `Source`, він не має дефолту на рівні `CreativeVariant` (лише на `Task`),
тож старі `RSZ`-сусіди, зняті до появи цього поля, можуть нести `None`; без цього застереження вони
позначались би дублікатами цілі, якої насправді ще нема.

**`RSZ` додатково**: якщо цільовий `Resolution` збігається з тим, який оригінал уже має сам —
запит відхиляється так само, як дублікат, тільки звірка йде не з сусідньою карткою, а з самим
джерелом (`TasksService._assert_no_duplicate_variants` / `TaskWorkflowHandlers._reject_duplicate_variants`).
Інакше ресайз у той самий розмір минав би цю перевірку, бо в оригінала ще нема жодної `RSZ`-картки,
з якою звіряти.

Порівнюються лише картки того самого `Task Type` — `WHT`-картка ніколи не вважається дублікатом для
`LOC`. Власні попередні картки задачі виключаються (`exclude_task_id`), тож повторне збереження
задачі з тими самими значеннями дублікатом не є.

Перевірка стоїть у двох місцях, і це не дублювання:

- **Рання, у `TasksService`** — на створенні й апдейті задачі. Бачить лише явні
  `source_creative_ids`, зате відповідає синхронно: `DuplicateVariantError` → `409` на
  `POST /api/v1/tasks` і `PATCH /api/v1/tasks/{task_id}`.
- **Авторитетна, у `run_automation`** (`_reject_duplicate_variants` у `handlers.py`) — після
  `_source_origins`, тобто коли набір оригіналів уже повний, включно з тими, що прийшли через
  legacy remap і синхронно не резолвляться. Помилка тут — звичайний `WorkflowError`: задача
  вертається в `backlog` зі `automation.state = error`.

Фронтенд читає той самий набір карток через `GET /api/v1/creatives/variants?origin_ids=...` (до 50
оригіналів за запит), щоб показати конфлікт до сабміту, а не після.


## Неймінг

Реалізовано в `creative_factory.py`. Дефолти не приховуються — сегмент зі значенням показується
завжди. Локаль кодується парою `Geo`+`Language` в кінці назви; ці два сегменти ходять разом і
є єдиними необов'язковими: для `WHT`/`ADPT`/`RSZ` задача їх не має (успадковуються з оригінала), і
тоді вони просто відсутні, а не підставляються літералом `None`.

**`Source` не входить у жодну назву, `Resolution` — тільки в назву креативу.** Задача називається
тим, що вона замовляє; розмір готового файлу — властивість того, що з неї виходить, тож він
належить картці. Саме тому назва креативу більше не є назвою задачі з префіксом: два шаблони
збігаються головою і різняться одним сегментом, і пишуть їх дві функції — `build_task_name` та
`build_creative_name`.

Цим сегментом раніше був `Aspect Ratio`, і він називав форму, а не файл: саме лише `1x1` покриває
`800x800`, `250x250` і `200x200`, тож три картки різного розміру планували три однакові назви, і
дві з них відхиляв `_reject_planned_collisions`. `Resolution` — це сам розмір, тож назва розрізняє
те, чого співвідношення сторін розрізнити не могло.

**Уже видані назви не переписуються.** Назва креативу — це те, як його називає рекламний кабінет
і на що зав'язаний кожен рядок аналітики нижче за течією, тож сегмент `_9x16_` на старих картках
лишається як є; новий сегмент діє для назв, що будуються від цього моменту. Перепланувати назви
задач, які ще не пішли в роботу (Backlog, Ready for Production), уміє
`backend/scripts/rename_to_new_naming_scheme.py`.

**Task Name:**

```
{Task#}_{Product}_{TaskType}_{MediaType}[_{Geo}_{Language}]
52_CNI_NEW_VID_US_EN
```

**Креатив, звичайний (`NEW`, `VAR`, `NEWDES`, `VARDES`, `DES`, `VARAI`):**

```
{Variation#}-{Task#}_{Product}_{TaskType}_{MediaType}_{Resolution}[_{Geo}_{Language}]
1-52_CNI_NEW_VID_1080x1920_US_EN
```

Кількість карток = `count` задачі. Метадані копіюються з задачі, `Assignee` при створенні не
проставляється (його ставлять пізніше на Tech/Creative Check). `VARAI` — те саме, плюс
успадкування кількох полів з оригінала (розділ «Автоматизація», крок 2): один `Origin Creative`,
`count` карток проставляє креативниця (зазвичай 4).

**Креатив `LOC`/`WHT`/`ADPT`/`RSZ` (Case A — оригінал є карткою):**

```
{Variation#, від оригіналу}-{Task#, від оригіналу}_{Product}_{LOC|WHT|ADPT|RSZ}_{MediaType}_{Resolution}_{Geo}_{Language}
1-45_CNI_LOC_VID_1080x1920_FR_FR    ← LOC: Geo+Language → цільова локаль; Resolution — оригіналу
1-45_CNI_WHT_VID_1080x1920_US_EN    ← WHT: Geo+Language і Resolution з оригіналу; платформа в назву не входить
1-45_CNI_ADPT_VID_1080x1920_FR_FR   ← ADPT: решта — з оригіналу; платформа в назву не входить
1-45_CNI_RSZ_VID_800x800_US_EN      ← RSZ: Resolution → цільовий розмір, решта — з оригіналу
```

Одна картка на кожен `source_creative`. `Variation#`/`Task#` у назві беруться від оригінального
креативу (для трасування), `Task Type` — власний (`LOC`/`WHT`/`ADPT`/`RSZ`, а не тип оригінала).
`Product`, `Media Type` — з задачі. `Assignee`/`Task Owner` — з оригінала. Заповнюється `origin_ref`
на оригінал.

**`Resolution` у Case A — оригіналу, а не задачі** (`_delivered_resolution` у
`creative_factory.py`). `LOC`/`WHT`/`ADPT` пікселів не міняють: локалізація, вайтенінг і адаптація —
той самий файл того самого розміру, тож розмір подорожує з оригіналом так само, як `Geo`+`Language`
для `WHT`/`ADPT`. Одна задача може нести кілька оригіналів різного розміру, тому це читається на
кожну картку, а не з єдиного поля задачі. `Task.resolution` лишається фолбеком для оригінала, який
свого розміру не має (легасі-картка або знята до появи поля) — без нього сегмент просто зник би з
назви. `RSZ` — єдиний тип, який бере розмір із задачі, бо зміна розміру і є його роботою; тому
`Resolution` питається (і показується у формі) лише в нього — `RESOLUTION_EXEMPT_TASK_TYPES` у
`kumquat_tasks/enums.py`, `field_policy.required_fields`/`hidden_fields`.

Якщо оригінал сам є scratch-карткою (`NEW`/`VAR`/...), `Variation#`/`Task#` беруться з її власних
полів (`variation_num`, номер її задачі). Якщо ж оригінал сам є нелегасі похідною карткою
(LOC/WHT/ADPT/RSZ, побудованою за тим самим правилом раніше) — своїх `variation_num` вона не має, і
пара `Variation#`-`Task#` парситься назад із префіксу її власної назви (`_borrow_prefix` у
`creative_factory.py`). Оскільки цей префікс зберігається незмінним через увесь ланцюжок, це працює
на будь-якій глибині (`NEW → LOC → WHT → ADPT → RSZ → …`), поки кожна ланка нелегасі.

`LOC`/`WHT` беруть з оригінала `Resolution` і, залежно від типу, `Source` або пару
`Geo`+`Language` (сам `Source` у назві не з'являється, але лишається полем картки) — решта
таксономії (`funnel`, `hypothesis`, `subject`, ...) йде з полів задачі. `ADPT` і `RSZ` ідуть далі: з задачі
береться тільки одне поле — `Source` для `ADPT` (ціль адаптації, теж не в назві), `Resolution`
для `RSZ` (ціль ресайзу — і цей сегмент, на відміну від `Source`, у назві лишається) — а весь
інший контент, включно з `Language`/`Geo` та (для `ADPT`) розміром, копіюється з оригінального
креативу. `WHT` додатково
ставить `is_whitened = true`; `ADPT`/`RSZ` цей прапорець не ставлять.

**Креатив `LOC`/`WHT`/`ADPT`/`RSZ` (Case B — legacy-оригінал, або нема що позичити):**

```
{OriginName}_{Geo}_{Language}   (LOC)   |   {OriginName}_{Source}   (WHT/ADPT)   |   {OriginName}_{Resolution}   (RSZ)
CoinIn_3150_9256_Var_Vid_9x16_Sell_FR_FR    ← LOC: локаль допискою в кінець
CoinIn_legacy_wht_APL                       ← WHT/ADPT: цільова платформа допискою в кінець
CoinIn_legacy_rsz_800x800                   ← RSZ: розмір допискою в кінець
```

Якщо оригінал — легасі (`origin.is_legacy`, задача з `is_legacy=true`) або з його назви взагалі
неможливо розпізнати `{Variation#}-{Task#}` — нова назва не перебудовується, а просто дописує в
кінець існуючої назви оригіналу ту вісь, яку задача змінює: `Geo`+`Language` для `LOC`,
`Source` для `WHT`/`ADPT`, `Resolution` для `RSZ`. **`Source` у структурованій назві (Case A) не
з'являється — але Case B структурованою назвою і не є:** у ній немає й сегмента `TaskType`, тож без
дописки вона була б назвою оригіналу без змін, і кожен вайтенінг чи адаптація легасі-оригінала
стикався б із карткою, з якої робиться. `Resolution`, на відміну від `Source`, лишається сегментом
і в структурованій назві (Case A) — тож RSZ дописує його в Case B з тієї самої причини, з якої він
там лишається: без нього два RSZ від одного легасі-оригінала до різних розмірів спланували б
однакову назву. `is_legacy` завжди переважає Case A, навіть якщо в легасі-оригінала є заповнені
`variation_num`/номер задачі — вони проставлені бекфілом для фільтрації, а не для запозичення в
нову назву.

**Міграція вже згенерованих назв.** Назви, які автоматизація опублікувала до зміни конвенції,
переписує `backend/scripts/rename_to_new_naming_scheme.py` (за замовчуванням dry-run, пише з
`--apply`). Він бере тільки задачі в `Backlog` і `Ready for Production` — саме вони ще нічого не
опублікували назовні: картки з'являються на `Done`, а рекламні кабінети й уся аналітика нижче
пишуться проти назв карток. Для кожної задачі він перебудовує `generated_name` і
`planned_creative_names`, переносить галочки `planned_creatives_done`, перейменовує папку на Drive
і файли в ній (gdrive-sync зв'язує файл із карткою за точним stem). Задачу, чий новий план уже
зайнятий, скрипт пропускає цілком і називає в звіті — назву їй треба дати руками. Скрипт
ідемпотентний: другий запуск нічого не пише.

> **Дві `WHT`- (чи дві `ADPT`-) задачі від одного нелегасі-оригінала на різні платформи планують
> один рядок.** Структурований шаблон Case A платформи не називає, а `TaskType` у них однаковий —
> різниці в назві не лишається. Такий план відхиляється `_reject_planned_collisions` на Ready for
> Production (задача повертається в Backlog з поясненням), а не мінтить дублікат; другій потрібна
> назва руками. Легасі-оригіналів це не стосується — Case B дописує `Source`.

`CLN` автоматично картки не генерує.

**Task #.** Назва завжди починається з номера задачі. Номер призначається автоматично при створенні
задачі (`TasksRepository.next_task_number`) — глобальний атомарний лічильник у колекції `counters`
(документ `_id: "task_number"`), який при першому використанні стартує від поточного максимуму.
Якщо `number` вже задано (наприклад, із синку) — не перезаписується.


## Призначення виконавця (`tech_check` / `creative_check`)

- `tech_check` (NEW/VAR): `Assignee` задачі проставляється на всі креативи задачі, `submitted_at`,
  Slack відповідальному за Tech Check.
- `creative_check` (NEWDES/VARDES): те саме; якщо `Assignee` задачі порожній — береться `Task Owner`
  і записується в `Assignee` задачі. Slack іде Task Owner тільки якщо він не збігається з Assignee.
- `LOC`/`WHT`/`ADPT`/`RSZ`: `Assignee` не чіпається — атрибуція збережена з оригінала ще при створенні.


## Done (`on_done`)

1. Якщо `completed_at` порожній — проставляється (один раз, не перезаписується).
2. Якщо `manual_launch = true` — Slack у UAM-канал. Папка Drive нікуди не переміщується:
   вона лишається там, де її створила автоматизація.


## Slack

Транспорт — `kumquat_tasks/notifications.py`. `WorkflowNotifier` містить таблицю
маршрутизації.

| Подія | Кому |
| --- | --- |
| Помилка валідації / скрипту | Task Owner (DM) |
| Автоматизація успішна (VAR/LOC/WHT/ADPT/RSZ/VARAI) | Task Owner (DM) |
| Tech Check | Відповідальний за Tech Check (DM) |
| Creative Check | Task Owner (DM), якщо він не Assignee |
| Призначення на задачу зі статусом Ready for Production | Новий Assignee (DM) |
| Done + Manual Launch | UAM-канал |
| Немає отримувача | Фолбек у загальний CREATIVE-канал |

Task Owner і Assignee резолвляться по email: Clerk-id → email (колекція `users`) →
`users.lookupByEmail` у Slack → DM. Відповідальний за Tech Check задається напряму як Slack
user id (`SLACK__TECH_CHECK_USER_ID`), без резолву по email.

Єдина подія в цій таблиці, що не прив'язана до переходу статусу — призначення виконавця. Її
пильнує `TasksService.update_task` (`kumquat_tasks/service.py`): якщо `PATCH /tasks/{id}` міняє
`assignee_id` на нове значення, поки задача вже стоїть у `ready_for_production`, сервіс ставить у
Celery-чергу `tasks.io.task_assigned` (`kumquat_api/worker/tasks.py`). Воркер перечитує задачу
заново перед відправкою — якщо її вже перепризначили ще раз або вона встигла зрушити зі статусу,
сповіщення тихо не йде.

**Формат повідомлення.** Кожне сповіщення — один Block Kit блок `card` (`task_message` у
`kumquat_tasks/notifications.py`):

| Поле картки | Вміст | Ліміт |
| --- | --- | --- |
| `title` | назва задачі (`display_name`), обгорнута в посилання | 150 |
| `subtitle` | суть події — перший рядок тексту | 150 |
| `body` | факти по два в рядок: статус, виконавець, дедлайн, продукт, медіа, тип | 200 |
| `subtext` | опційна деталь — решта тексту після першого рядка (цитата коментаря) | 200 |

```
52_CNI_NEW_VID_US_EN                 ← клікабельна назва
🔍 Ready for Tech Check
*Status* 🔧 Tech Check  ·  *Assignee* Denys Koval
*Product* CNI  ·  *Media* VID
*Type* NEW
```

Заголовок веде на `{APP_BASE_URL}/tasks/{id}` і є єдиним переходом. Окремої кнопки картка не
має: посилання в заголовку читається і там, де кнопки не видно — у прев'ю пуш-нотифікації, у
вузькому мобільному клієнті, у згорнутій картці в треді.

**Статус — тег, як на дошці.** На фронтенді статус це підфарбована іконка плюс лейбл
(`optionIcons` поля `status`). Кольорового фону всередині тексту Slack не має, тож тег
переїжджає як емодзі, найближче до тієї іконки, зі збереженим кольором там, де він є:

| Статус | Тег | Статус | Тег |
| --- | --- | --- | --- |
| Backlog | 🟠 Backlog | Creative Check | 👀 Creative Check |
| Ready for Production | 🔵 Ready for Production | Done | ✅ Done |
| In Progress | 🔄 In Progress | Archived | 📦 Archived |
| Tech Check | 📋 Tech Check | | |

Кружечок беруть лише ті два статуси, які й на дошці намальовані кружечками (Backlog і Ready for
Production). Решта — емодзі: у Slack немає кружечка на кожен відтінок, яким фарбує дошка, і
рожевий Creative Check, округлений до найближчого, злився б з іншим статусом в один тег.
Статус без запису в таблиці друкується як є, а не ламає повідомлення.

Назву задачі вводять руками, тож перед вставкою в `<url|назва>` вона екранується (`&`, `<`, `>`)
і обрізається під бюджет `150 - len(url) - 3` — незакритий `>` обірвав би посилання й вивалив
сирий URL у текст.

`body` наповнюється, поки вкладається в 200 символів: рядки додаються попарно й обриваються
цілими, тому довге ім'я виконавця прибирає `Type`, а не ріже значення посередині.

Порожній `APP_BASE_URL` означає «без переходу»: заголовок лишається звичайним текстом. Це
свідомо тихо — краще сповіщення без посилання, ніж зламане.

Кожна подія має свій колір — смужка ліворуч від картки:

| Подія | Колір |
| --- | --- |
| Помилка автоматизації | 🔴 червоний |
| Автоматизація успішна | 🟢 зелений |
| Legacy remap / Manual Launch | 🟡 жовтий |
| Tech / Creative Check / призначення на Ready for Production | 🔵 синій |
| Згадка в коментарі | 🟠 помаранчевий |

Кольорова смужка існує лише в attachments, тому кольорове повідомлення кладе блоки всередину
attachment, а прев'ю — у його `fallback`. Верхньорівневий `text` при цьому не надсилається: поруч
з attachment Slack його **рендерить**, і повідомлення дублювалося б.

Колір задається на рівні транспорту, а не блоків: `task_message` його не приймає, смужку навколо
блоків малює `_payload` у `kumquat_core/integrations/slack.py`.

Імʼя виконавця резолвиться з дзеркала `users` за Clerk-id (одне читання на сповіщення); якщо
виконавця немає — рядок фактів його просто не містить.

Поруч із блоками завжди йде плоский текст (`"{назва}\n{подія}"`) — Slack показує його в
превʼю сповіщення і там, де блоки не рендеряться.

`APP_BASE_URL` — публічний URL фронтенду, різний для кожного середовища (локально
`http://localhost:3000`, у кластері задається через `config.appBaseUrl` у чарті
`kumquat-celery-worker` — саме він виконує обидва Celery-таски, що шлють у Slack).
Якщо змінна порожня — повідомлення йде без переходу, а не з битим посиланням.

Сповіщення про згадку в коментарі має той самий вигляд і додатково цитує сам коментар
(обрізаний до 280 символів).


## Google Drive

Клієнт — `kumquat_tasks/drive.py`. Структура спирається на одну батьківську папку в Shared Drive
(`DRIVE__ROOT_FOLDER_ID`), усередині — `{Product}/{TaskName}`.

- Автоматизація: `{Product}/{TaskName}` (find-or-create, тому повторний прогон перевикористає
  ту саму папку).
- Done: папка лишається на місці — переміщень немає.

Воркфлоу тільки створює папки, ніколи не переміщує й не видаляє. Сервіс-акаунту достатньо прав
content-manager у Shared Drive.


## Конфігурація

Змінні середовища (nested delimiter `__`):

```
APP_BASE_URL
SLACK__ENABLED
SLACK__BOT_TOKEN
SLACK__CREATIVE_CHANNEL
SLACK__UAM_CHANNEL
SLACK__TECH_CHECK_USER_ID
SLACK__BOT_DISPLAY_NAME
SLACK__BOT_ICON_URL
DRIVE__CREDENTIALS_JSON
DRIVE__ROOT_FOLDER_ID
```

Якщо `SLACK__BOT_TOKEN` не задано — використовується `LoggingNotifier` (повідомлення лише в лог).
Якщо `DRIVE__CREDENTIALS_JSON` не задано — `NoopDriveClient` (операції з папками лише в лог).
Це дозволяє ганяти весь воркфлоу локально без реальних інтеграцій.

`SLACK__ENABLED` — вимикач для випадку, коли блок заповнений повністю. `false` лишає
`LoggingNotifier` попри наявний токен; за замовчуванням `true`, тож прод нічого додавати не
мусить. Потрібен там, де секрет спільний з продом: dev тягне ті самі `SLACK__*` з
`ml/dev/kumquat-secrets`, і без прапорця вибір був би між «вирізати чотири значення із
секрету» та «слати DM живим людям». Вимкнений notifier усе одно бачить `creative_channel`,
`uam_channel` і `tech_check_user_id`, тому в лог іде й маршрут, який повідомлення отримало б —
на відміну від повністю знятого блоку, де ці id порожні.

Прапорець сам по собі блоку не створює: `SLACK__ENABLED` без решти `SLACK__*` — це та сама
помилка «Field required» на старті, що й будь-яке інше часткове заповнення.

Слати в Slack можуть лише воркери (`kumquat_api/worker/tasks.py`) — API тільки ставить задачу
в чергу, тому змінна живе в configmap чартів `kumquat-worker` і `kumquat-celery-worker`
(`config.slackEnabled`), а не в `kumquat-api`. У `values-dev.yaml` обох чартів вона вже
виставлена в `"false"`.

`SLACK__BOT_DISPLAY_NAME` / `SLACK__BOT_ICON_URL` — опційні, за замовчуванням порожні. Задані —
підмінюють ім'я й аватар відправника на кожному повідомленні (`username`/`icon_url` у
`chat.postMessage`, `SlackNotifier` у `kumquat_core/integrations/slack.py`); порожні — лишають
брендування самого Slack-застосунку. Підміна працює лише за наявності скоупу
`chat:write.customize` на `SLACK__BOT_TOKEN` — без нього Slack мовчки її ігнорує, без помилки.
