---
title: Експорт таблиць у Google Sheets
description: Спільні словники підписів, обхід фіду, Celery-задача і межі Drive.
---

Що бачить користувач — у [Вигрузка креативів і задач](/guide/exports-overview). Тут
про те, звідки беруться підписи колонок і чому експорт живе у воркері.

## Спільний словник

Підписи enum-ів і специфікація колонок лежать у `dictionaries/` у корені репозиторію —
одне джерело для фронтенду й бекенду:

| файл | що містить |
|---|---|
| `enum-labels.json` | 27 мап `значення → підпис` (`in_test` → `In Test`) |
| `creative-columns.json` | 95 колонок креатива: `key`, `label`, `section`, `path`, `kind` |
| `task-columns.json` | 50 колонок задачі |

`path` — це шлях у документі (`vlm.hook.marketing_angles`), `kind` — як значення стає
клітинкою (`rate`, `usd`, `enumList`, `durationMs`, `richtext`, …).

Обидва контексти Docker-збірки (`./frontend`, `./backend`) не бачать кореня репозиторію,
тому кожне дерево тримає **закомічену копію**:

- `frontend/src/core/dictionaries/` — читає `rowValue`/`rowFromColumns`, з яких будуються
  рядки таблиць (`creative-mapping.ts`, `task-mapping.ts`);
- `backend/packages/kumquat-vocabulary/src/kumquat_vocabulary/data/` — читає `render.cell`,
  з якого будуються клітинки експорту.

Копії пише `make dictionaries`; `make check-dictionaries` порівнює їх побайтово і саме це
робить CI (`.github/workflows/dictionaries.yaml`). Редагувати треба оригінал у
`dictionaries/`.

Що це дає: колонка, яку показує таблиця, і колонка, яку віддає експорт, — та сама колонка
з тим самим заголовком. Раніше підписи існували лише у фронтенді (~2500 рядків мап у
чотирьох файлах), і бекенд просто не мав чим їх віддати.

Два тести тримають словник від дрейфу:

- `tests/api/test_field_path_contracts.py` — кожен `path` резолвиться на реальній
  Pydantic-моделі (`Creative`, `Task`);
- `frontend/src/core/dictionaries/index.test.ts` — `label` і `section` кожної колонки
  збігаються з `FieldDef` того ж поля.

Плюс `satisfies Record<TaskStatus, string>` у `task-mapping.ts`: статус, доданий до енама
або зникнувши зі словника, ламає збірку фронтенду.

### Чого у словнику свідомо немає

Поле, на яке експорт не може відповісти, до словника не входить, і `resolve_columns`
відхиляє такий ключ замість того, щоб віддати колонку прочерків. Це, зокрема, роллапи, які
сервер обчислює на кожне читання (`variants`, `points` картки), і `tags`/`description`
задачі — вони живуть на `TaskVariant`, а не на `Task`.

## Обхід фіду

`ExportRows` (`kumquat_api/exports/rows.py`) ходить курсором по тому самому запиту, що й
список: `CreativesRepository.find_page` для креативів, `TasksRepository.find_full_page`
для задач — окремий метод, бо фід дошки трімить документи до `TaskCard`, а експорт пише
цілі документи.

- Сторінка — 500 документів (`PAGE_SIZE`); на кожній батчем резолвляться люди (`user`-колонки).
- Документи валідуються моделлю, а не читаються сирими: інакше поля з ненульовим дефолтом
  (`test_status`, `resolution`, `source`) виходили б порожніми там, де API показує дефолт.
- Імʼя задачі підмінюється на `display_name` — те саме, що робить респонс-шар
  (`_for_display` у `routes/tasks.py`), інакше експорт віддавав би технічну назву картки.
- CSV пишеться на диск потоково, зі `flush` після кожної сторінки.

`query`, `sort`, `search` і `task_id` передаються з клієнта дослівно — це параметри самого
фіду, тому експорт не має другого визначення фільтра.

## Черга і нотифікація

`POST /api/v1/exports/{creatives,tasks}` рахує рядки, віддає `202` з `{entity, rows, columns}`
і ставить `tasks.io.export_sheet` у чергу `io`. Ліміти часу підняті до 1800/1740 с — як у
`execute_graph`, бо загальні 600 с на повну вигрузку не розраховані.

Воркер (`build_export` у `kumquat_api/worker/tasks.py`) пише CSV у тимчасовий файл,
віддає його в Drive, пише рядок аудиту `export.created` і надсилає Slack-DM з посиланням.
Помилка теж іде в DM — інакше людина чекала б вічно. Роль creator+ забезпечує глобальний
`enforce_write_role`: це `POST`, окремого гейта не потрібно.

Ендпоінт віддає `503`, якщо брокер недоступний: нічого не записано, але й обіцяти вигрузку,
яку ніхто не підбере, не можна.

## Drive

Файл створює **сервісний акаунт**, тому він і власник. Складається в
`Exports/{імʼя користувача}/` під `DRIVE__ROOT_FOLDER_ID` і успадковує доступи цієї гілки —
жодного `permissions.create` на конкретного користувача, бо посилання, яке не можна
переслати, це не експорт.

Підпапка на людину — не про приватність (доступи однакові), а про те, що пласка папка за
місяць стає сотнею майже однакових назв. Імʼя папки — `name`, інакше `email`, інакше id;
слеші й контрольні символи вирізаються, довжина ріжеться на 64 (`folder_name`).

Назва файлу — `{Entity|title} {YYYY-MM-DD HH:MM:SS} ({8 символів export_id})`. `export_id`
мінтиться в ендпоінті, а не у воркері: `task_acks_late` + `task_reject_on_worker_lost`
означають, що вбитий под поставить задачу в чергу знову, і id із запиту дає ретраю ту саму
назву замість другого файлу-двійника. Дедуплікації в Drive немає свідомо — це зайвий виклик
на кожен експорт заради випадку, коли под убили посеред аплоаду.

Sheets API не задіяний: `files.create` з `mimeType: application/vnd.google-apps.spreadsheet`
і CSV-медіа — Drive конвертує сам. Один виклик і той самий скоуп, який уже є.

- Аплоад резюмабельний, чанками по 8 МБ: дефолт читає файл у памʼять цілком, а на одному
  `io`-поді може йти вісім експортів одночасно.
- Понад 95 МБ CSV Drive не конвертує — перевіряється до аплоаду (`CsvTooLarge`), щоб людина
  отримала зрозумілу підказку, а не помилку Drive.
- Нових секретів не треба: `DRIVE__*` і `SLACK__*` уже є у `kumquat-celery-worker`.

## Межі

- `ROW_LIMIT` — 50 000 рядків на запит (`kumquat_api/schemas/exports.py`). Це не межа
  Sheets (там 10 млн клітинок), а страховка від вигрузки всієї колекції без фільтра.
- Порожній результат відхиляється `422`.
- Форматування збігається з таблицею, крім двох випадків: порожнє поле — порожня клітинка
  (а не `—`, який сортувався б серед значень), і `datetime` без `T`, щоб Sheets прочитав
  його як дату. Гроші й ставки лишаються рядками (`$1,235`, `1.2%`) — Sheets парсить їх
  назад у числа.
- Кожна вигрузка — новий файл; прибирання старих не автоматизоване.
- `export_id` пишеться і в рядок аудиту, і в назву файлу — по ньому вигрузку можна звести з
  тим, хто і за яким фільтром її замовив.
