Dev Specs

BetterPlace Staff App — Specification · 10 System Architecture

Статус: предложение (draft v1, 2026-07-27), согласуется с владельцем. Примерная архитектура всей системы на backend-фазу. Уровень детализации намеренно эскизный: точные контракты и DDL появляются на PDD-фазе каждого модуля. Исключение — HR-модуль: его схема уже спроектирована целиком и является каноном (docs/db/hr-schema.sql + дизайн-док, PR #73). Принятые решения помечены ссылкой на ADR; всё остальное — предложение.

1. Большая картина

Одна компания (Better Place, Бали), несколько десятков вилл, ~50–150 сотрудников (схема рассчитана с запасом до ~500). Два клиента — desktop back-office и mobile staff app — работают с одним API и одной базой данных.

                        ┌──────────────────────── host (client infra) ───────────────────────┐
   Users                │                                                                    │
 ┌─────────────┐ HTTPS  │  ┌───────┐      ┌──────────────┐       ┌───────────────────────┐   │
 │ Desktop     │───────▶│  │ Caddy │─────▶│ web (Next.js)│       │ postgres (core, hr,   │   │
 │ back-office │        │  │ edge  │      └──────────────┘       │  property, task, ...) │   │
 └─────────────┘        │  │ TLS   │      ┌──────────────┐       └──────────▲────────────┘   │
 ┌─────────────┐        │  │       │─────▶│ api (FastAPI)│──────────────────┤                │
 │ Mobile      │───────▶│  └───────┘/api/*└──────────────┘       ┌──────────┴────────────┐   │
 │ staff app   │        │                                        │ worker (pgqueuer:     │   │
 └─────────────┘        │   S3-compatible file store ◀───────────│  recalc, attendance,  │   │
                        │   (за интерфейсом, у клиента любое)    │  Lark sync, notify)   │   │
                        │                                        └───────────────────────┘   │
                        └────────────────────────────────────────────────────────────────────┘
 External: Lark/Feishu (identity units+employees) · OTA/Hostify (reservations, reviews) · Sentry EU

Принятое направление (ADR-002, технический roadmap): прагматичный модульный монолит — FastAPI + SQLAlchemy 2 async + Pydantic v2, PostgreSQL (ADR-002 целится в 18; HR-схема верифицирована на чистом PG16), очередь и cron внутри Postgres (pgqueuer, без Redis/RabbitMQ), собственный auth (~400 строк, argon2id + opaque-сессии), docker compose, портируемость без cloud lock-in — система передаётся на инфраструктуру клиента. Микросервисы отклонены: один оператор, 300 пользователей максимум, независимого масштабирования нет.

Внутри монолита — vertical slices: каждый модуль = свой пакет (domain → application → infrastructure → api), прямые импорты между модулями запрещены (import-linter в CI), кросс-модульное общение — через domain events (pgqueuer-джобы с идемпотентными обработчиками).

2. Ядро системы (core) — платформа

Ядро уже канонизировано HR-схемой (22 таблицы, PR #73). Все остальные модули строятся поверх него.

БлокТаблицыЧто делает
Tenancycompany (singleton), departmentПрофиль компании с таймзоной (Asia/Makassar) — она закрывает «бизнес-день»; орг-структура. Single-tenant по контракту (ADR-15), вторая строка запрещена структурно
Identityuser_account, session, credential_resetЛогин ≠ e-mail (в ростере есть люди без почты); argon2id; opaque-сессии в Postgres; one-time коды
RBACrole, permission, role_permission, user_roleТри оси разведены: hr.position (должность) ≠ hr.user_type (тир приложения) ≠ core.role (права). Иерархия уровней Super Admin → Admin → Supervisor → Staff — док 11 §3
FilesfileЕдиный реестр всех бинарников системы (фото чек-инов, аватары, вложения, документы); блобы — в S3-совместимом хранилище за интерфейсом
Auditaudit_logAppend-only журнал с маскированием чувствительных полей
Notificationsnotification_type, notificationПлатформенные уведомления (in-app; per-channel delivery-механика отложена до дизайна S49) — модули не заводят свои
Integrationslark_sync_run; lark_id + lark_id_origin на hr.employeeLark-синк: журнал прогонов + защита вручную заданного Lark ID от перезаписи синком (schema v3: генерическая 5-табличная платформа снята)
Settingssetting_definition, settingТипизированные настройки организации с department-override
Migrationlegacy_id_mapМост strangler-миграции (emp-001 → uuid), удаляется после ухода последнего экрана с моков

Ключевое решение ядра — Account/Employee split: core.user_account (логин, пароль, сессии) и hr.employee (карточка, синхронизируемая из Lark) — разные сущности со nullable-связью. У сотрудника может не быть аккаунта; Lark-синк никогда не создаёт и не удаляет аккаунты сам.

3. Модули системы (по меню приложения)

Домены данных: схемы core и hr — канон; остальные (property, service, task, issue, report, project, booking, comms) — предложение по аналогии. Каждый модуль ниже: назначение → ключевые сущности → потоки → примерные таблицы. Объёмы указаны на горизонте ~3 лет.

3.1 Properties (Units) — реестр объектов

Единая точка управления виллой: паспорт, зоны (areas), инвентарь с журналом движений, инженерные системы, дефолтные назначения по департаментам; вкладки Tasks/Issues/History — read-проекции чужих модулей.

  • Источник истины по юнитам — Lark (bulk-импорт + push при смене статуса): lark_id + lark_status отдельно от внутреннего статуса. Occupied/Vacant — не статус-машина, а деривация из броней.
  • Инвентарь — только через журнал: amount предмета = баланс последнего движения; правка задним числом запрещена, ошибка гасится компенсирующим move (void), как в HR-леджере коррекций.
  • Чувствительные поля (wifi_password, keybox_code) не попадают в list-DTO и маскируются по ролям.

Таблицы (~8): unit, unit_area, inventory_item, inventory_move (append-only), infra_item, assignment_default → перерастает в hierarchy_node + assignment + assignment_target (ADR-001, см. §4.2). Объёмы малые: десятки юнитов, тысячи строк инвентаря.

3.2 Services & Task Templates — фабрика задач

Конструктор повторяющихся работ: Service (когда: триггер Time / Reservation / Vacancy + подключённые юниты) + TaskTemplate (что: дерево Sections → Room|System-группы → строки чеклиста). Recalc-движок (§4.1) материализует эту связку в задачи. Два вида шаблона (Q-08): Task (выполнил/не выполнил) и Inspection (per-item да/нет + Score; ответ «нет» рождает Issue автоматически).

Статус-машина сервиса Draft → Active ↔ Inactive; активация и любая правка активного сервиса — событие пересчёта. Триггер храним как JSONB с дискриминатором (типы триггера фиксированы, код по ним ветвится — кандидат на child-таблицы при реализации, по образцу гибридного payload HR-заявок).

Таблицы (~6): service, service_property (M:N), task_template, template_section, template_group, template_task. Объёмы: конфигурационные данные, сотни строк.

3.3 Tasks — центральный операционный модуль

Задача — самая массовая рабочая сущность системы, 4 источника рождения: Direct (вручную), Schedule (recalc-движок), Issue (эскалация инцидента), Reservation (заезд/выезд). Жизненный цикл Planned → New → In Progress → Finished (Finished — терминальный, reopen нет). locked-флаг защищает начатую работу от перезаписи движком. Каждая мутация пишет task_event (append-only история). Auto-assignment — по иерархии most-specific-wins (§4.2).

Мобильное исполнение (E04) — не отдельный модуль данных, а write-path тех же таблиц: чеклист с фото и таймером от wall-clock (total_time = finished_at − started_at), пер-пунктовый прогресс.

Таблицы (~9): task, task_assignee (M:N), task_requirement, task_requirement_item (пер-пунктовый done + фото — нужен мобильному чеклисту), task_cost (IDR, типы Task|Hour|Items|Supplies), task_event, task_comment, task_attachment. Объёмы: ~20–70k задач/год; task_event ≈ 5–10× от задач — самая быстрорастущая таблица операционного контура.

3.4 Schedule — календарь задач

Отдельный пункт меню, но не отдельный домен данных: S44 — read-проекция task × исполнители по сотрудникам/дням с раскраской on-track/overdue. Собственных таблиц нет; нужен составной индекс (assignee_id, due_at). Drag-to-reschedule намеренно не вводится.

3.5 Maintenance Reports — месячная отчётность

Один отчёт на Unit × Year × Month: авто-компиляция из finished-задач, инцидентов и area-фото за месяц → проверка координатором → draft → completed (заморозка) → PDF владельцу. Ключевое решение — строки отчёта это снимки (snapshot), не live-join: PDF воспроизводим и не плывёт задним числом. Include/exclude строк — вручную, дефолт excluded (Q-01). По решению K-2 отчёт — про факт работ, не финансовый документ: финблок S26 уходит в Projects.

Таблицы (~7): maintenance_report, area_report, report_task_line, report_issue_line, report_issue_item, report_vendor_cost (+ связки area↔lines). Объёмы: сотни отчётов/год, тысячи строк-снимков.

3.6 Projects / Proposals — сметы и согласование крупного

Ремонтный проект как воронка: позиции (проблема → решение → калькуляция) → Proposed → Approved (владелец) → Paid (Accounting) → порождение project-задач в Tasks → completion report (механизм E06) → PDF. Деньги живут здесь (K-2): Project Costs = Σ позиций + Σ charges (Handling Fee — обычная строка charges, не авто-процент, Q-02). Тоталы — derived (SQL-агрегат), не хранятся.

Таблицы (~7): proposal, proposal_item, proposal_item_cost, proposal_charge, proposal_history, charge_preset; связь с задачами — FK proposal_id в task. Судьба Tasks-вкладки (и её снимок-таблицы) — Q-25, open: удаление приторможено до явного «да» заказчика. Объёмы: десятки proposals/год.

3.7 Reviews — качество глазами гостя

Импорт отзывов по завершённым броням (Airbnb — шкала 5, Booking — шкала 10, частичный набор под-рейтингов). Правила: хранить сырой рейтинг + шкалу, нормализация к 5-балльной — на чтении; отсутствующий под-рейтинг — NULL, не 0. Атрибуция сотрудников (heat-grid Employees × Units) — вся команда кластера виллы (Q-04), заполняется бэкендом. Экраны read-only, мутаций не порождают.

Таблицы (~3): review (+ 6 nullable колонок под-рейтингов), review_employee_attribution (M:N), возможно review_request. Объёмы: сотни–тысячи отзывов/год. Открыт главный вопрос — механизм ингеста (§6).

3.8 HR — люди, время, отпуска (схема готова)

Единственный модуль с каноничной схемой: 48 таблиц (17 core + 31 hr), 5 security_invoker-view, 2 rollup-функции (schema v3) — дизайн-док 2026-07-27-hr-postgres-schema-design.md, DDL прогнан на чистом PG16 с батареей инвариант-проб. Полный справочник таблиц и полей — док 12. Пять доменов: Platform (ядро, §2) · People & Scheduling (темпоральный контракт employment с EXCLUDE-защитой) · Leave & Holidays (политика квот как данные, пер-датный леджер leave_day) · Requests & Approvals (единый конверт hr.request, data-driven статус-машина, маршруты как данные) · Attendance (строка на сотрудника×день, GENERATED-статус с приоритетом holiday > day_off > leave > absent > late > present, коррекции только парой с append-only леджером).

Точка входа для остальных модулей: hr.employee — ось идентичности (assignment задач, участники чатов, календарь), core.department — scope доступа.

Примечание (overengineering-ревью 2026-07-27 — применено в v3). 4-линзовое ревью схемы v2 подтвердило: доменное ядро (employment/leave/attendance/request) — right-sized; перетяжелённый платформенный слой сокращён в schema v3 (59 → 48 таблиц, владелец делегировал решение): снята multi-tenancy-ось (single-tenant по контракту, ADR-15), 4-табличный approval-движок заменён на single-hop approver_role_id (ADR-16), 5-табличная integration-платформа свёрнута до lark_id+журнала синка, механика полудневных отпусков убрана. Permission-каталог сохранён как данные — этого требует иерархия ролей владельца (док 11 §3, ADR-18). Всё верифицировано на чистом PG16 с батареей проб; попутно исправлен унаследованный v2-баг (INSERT сотрудника падал на COMMIT). Полные вердикты — docs/superpowers/specs/2026-07-27-hr-schema-overengineering-review.md.

3.9 Announcements / Communications — объявления и мессенджер

Два контура: Announcements (односторонние объявления: desktop-авторинг «как админка блога», витрина на mobile Home; lifecycle Draft → New → Archive) и Messenger (мобильные личные/групповые чаты + вкладка Notifications). Уведомления модуль не хранит сам — переиспользует core.notification с фан-аутом. Мессенджер — отложенный трек: компания живёт в Lark, вопрос «свой чат vs интеграция» не решён; проектировать таблицы чатов до продуктового ответа не нужно.

Таблицы (~4 сейчас): announcement, announcement_target (композиция аудитории в стиле K-1), announcement_read; chat/chat_participant/message — заготовка на потом.

3.10 Mobile shell — дашборды поля

Нижние табы Home / Units / Tasks / Messager / Profile. Собственных сущностей нет — только read-агрегаты чужих доменов + две мутации: check-in/check-out (пишет в HR attendance c фото и геолокацией) и отметки чеклиста (пишут в Tasks). Возможное дополнение на бэке — таблица push-токенов устройств, если бейджи пойдут через push.

4. Кросс-модульные механизмы

4.1 Recalc engine и фоновые джобы

Вся асинхронщина — pgqueuer (очередь + cron внутри Postgres, ADR-002). Джобы:

ДжобРасписание/триггерЧто делает
Recalc engine (UC-1)cron 00:01 WITA + события (service изменён, бронь new/edit/delete)Горизонт [завтра … +30]: delete не-locked service-задач → развернуть occurrences триггера → материализовать Task(Planned) + чеклист; идемпотентно (delete-before-create)
Attendance dailycron 00:01 WITAНа каждого active-сотрудника за вчера: resolve expected (Leave/Holiday > Shift > Schedule, Q-22) → строка attendance_record
Lark synccron 15–60 минUpsert employees/units; lark_id_origin='manual' не перезаписывается; журнал — core.lark_sync_run
Notifications workerпо очередиФан-аут notification_delivery по каналам
Entitlements grantраз в годhr.grant_entitlements(year) — грант-снапшоты квот
Backup / чисткиnightlypg_dump-слой (см. док 11), чистка протухших сессий

Дисциплина времени (ADR-003/ADR-13): все instants — timestamptz; «бизнес-день» — производная в TZ организации (WITA, UTC+8); cron — в WITA, не UTC; «сейчас» пересекает любую границу явным значением, не повторным чтением часов; строка attendance несёт свой time_zone.

4.2 Иерархическое авто-назначение (ADR-001)

Гео-иерархия Region > Area > SubArea > Cluster > Unit — adjacency list (parent_id + явный level, глубина не зашита). Назначение — отдельная сущность на узле иерархии с полиморфными targets[] (user|group|role|cluster|department; анти-паттерн «два nullable-поля user_id/group_id» запрещён). Резолюция — на чтение, most-specific-wins: подняться от юнита по предкам, взять первое заданное, группы развернуть в людей. ReBAC-движки (OpenFGA/SpiceDB) отклонены как overkill.

4.3 Файлы и медиа

Все бинарники — через core.file (метаданные) + S3-совместимое хранилище за интерфейсом (у клиента — любое: MinIO, облачный S3). Крупнейший потребитель — фото чек-инов (2 фото × сотрудник × день): ~305k файлов при 200 сотрудниках за 3 года. Retention — открытый вопрос клиенту №5 (роль staffapp_retention готова, политики нет).

4.4 PDF-экспорт

Серверный рендер двух документов: Maintenance Report и Proposal. Движок не выбран (ADR-seed: WeasyPrint / Playwright-print / Gotenberg) — решается на PDD-фазе E06. Генерация — только из замороженного снимка (completed-отчёт), формат сумм 200,000.00 IDR, даты DD/MM/YYYY.

4.5 Reservation — сущность без владельца ⚠️

Брони пронизывают полсистемы (триггеры recalc, occupancy юнита, сводка mobile Home, привязка отзывов), но не имеют ни экрана, ни владельца, ни решённого источника (channel manager / Hostify API vs ручной ввод — ADR-seed). Это самый крупный незакрытый вход системы: 3 из 6 recalc-триггеров — события броней. Предложение: завести booking.reservation как импортируемую сущность (по образцу Lark-синка: журнал прогонов + origin-защита), решение по источнику — приоритетный вопрос клиенту.

5. Объёмы и масштаб

Итоговая таблица (горизонт 3 года, ~200 сотрудников, ~50 юнитов — верхние оценки):

ТаблицаСтрокКомментарий
core.file~305k (1.1M при 500 сотр.)Крупнейшая; retention-вопрос открыт
core.audit_log~500kAppend-only, purge защищён
hr.attendance_record~219k (~75 MB)Rollup-функциями, без materialized views
task + task_event~60–200k + ×5–10Самый быстрый рост операционного контура
Всё остальноетысячи–десятки тысячКонфигурация и документы

Вывод (подтверждён ревью HR-схемы): партиционирование, materialized views, брокеры сообщений и кэш-слои не оправданы — обычные B-tree-индексы и range-запросы держат эти объёмы с запасом. Единственный watch-item — task_event и core.file: следить после года эксплуатации.

6. Порядок реализации

Фазировка backend-строительства (HR-план миграции — §10 дизайн-дока схемы — вкладывается в фазы 1–2):

  1. Фундамент: FastAPI-каркас + alembic (миграция №1 = HR DDL v2) + auth + seed из моков через legacy_id_map + деплой-контур (док 11).
  2. HR-модуль по фазам дизайн-дока: справочники → employees+access → leave+requests → attendance-движок → Lark-sync.
  3. Properties + Reservation-источник: property.*-схема, Lark-синк юнитов, решение по броням (§4.5) — разблокирует recalc.
  4. Services + Tasks + recalc engine: фабрика задач, статус-машина, mobile execution write-path.
  5. Reports + Projects: снимки, PDF-движок.
  6. Reviews + Announcements: ингест отзывов, объявления; мессенджер — по продуктовому решению.

Каждая фаза — отдельные PR со строгим правилом «schema migration в том же PR, что model change»; деплой DEV-first, PROD — по runbook с отдельной авторизацией.

7. Открытые архитектурные вопросы (сводка)

Продуктовые вопросы живут в реестрах 00-client-questions.md (Q-01…) и §11 HR-дизайн-дока. Чисто архитектурные, требующие решения до соответствующей фазы:

#ВопросБлокирует
A-1Источник Reservation (Hostify API / channel manager / ручной ввод)Фазу 3–4 (recalc)
A-2Механизм ингеста Reviews (API платформ / скрейпинг / ручной)Фазу 6
A-3PDF-движок (WeasyPrint / Playwright / Gotenberg)Фазу 5
A-4Мессенджер: свой vs Lark-интеграцияТаблицы чатов
A-5Один responsive-клиент vs раздельные desktop/mobileMobile-трек
A-6Payroll-скоуп A/B/C (архитектура payroll-нейтральна: attendance хранит факты)Расширения HR

8. Масштабирование на новые компании: instance-per-company

Решение владельца, 2026-07-27. Подключение второй (и последующих) компаний — модель instance-per-company: каждой компании — собственный экземпляр системы целиком (свой compose-стек, своя база данных, свой поддомен, свои бекапы). Row-level multi-tenancy (общие таблицы + organization_id + RLS) сознательно не закладывается — она снята в schema v3 (ADR-15) как источник сложности и доказанных багов без единого тенанта в планах; путь возврата к ней — аддитивная миграция, если когда-нибудь понадобится настоящий self-service SaaS на десятки компаний.

8.1 Что такое «инстанс»

Полный стек из дока 11 §4 (caddy → web + api + worker → postgres) + собственные: .env (имя БД, секреты, бакеты), поддомен staff.<company>.… (Caddy сам получает сертификат; cookie __Host- работает из коробки), файловый бакет, расписание бекапов. Данные компаний физически не соприкасаются — класс багов «утечка между тенантами» отсутствует как таковой.

8.2 Подключение новой компании (процедура)

  1. Скопировать compose-стек с новым .env (БД, порты, поддомен, бакеты, секреты — свежие).
  2. DNS + поддомен → Caddy получает сертификат автоматически.
  3. Прогнать миграции на пустую БД (raw-SQL alembic, ADR-17).
  4. Засидить core.company (имя, таймзона, локаль, валюта) и первого Super Admin (seed при установке).
  5. Развести бекапы: отдельный бакет/префикс, отдельный restore-тест в расписании.

Оценка трудоёмкости при готовых runbook'ах — часы, не недели.

8.3 SaaS-readiness: правила, обязательные уже сейчас

Чтобы «дорасти до SaaS», если придёт время, дёшево — каждый инстанс обязан оставаться штампуемым:

  1. Никаких фактов о компании в коде. Имя, таймзона, локаль, валюта — только строка core.company; инфраструктурное — только .env (12-factor). Любой хардкод = регрессия портируемости.
  2. Файловое хранилище — bucket/prefix per instance. Бакет (или префикс) задаётся в .env (S3_BUCKET / S3_PREFIX); ключи объектов не содержат глобальных идентификаторов — перенос инстанса = перенос бакета.
  3. Бекапы и retention параметризованы инстансом: пути, бакеты, расписания — из .env; restore-тест гоняется per instance.
  4. Каждый новый справочник проходит вопрос «это данные инстанса или продукта?» Продуктовые справочники (статусы, типы заявок) сидируются миграциями и одинаковы во всех инстансах; инстансные (департаменты, политики отпусков) — обычные данные. Смешивание — главный источник боли при будущей консолидации.
  5. Все инстансы на одной версии образа (GHCR по SHA); раскатка по очереди — первая компания выступает canary.
  6. Наблюдаемость с тегом инстанса: Sentry environment=<company>-<env>, логи с полем instance — чтобы N стеков не слились в одну кашу.

8.4 Если придёт время SaaS

Два пути, оба открыты текущей архитектурой: (а) консолидация в row-level — аддитивная миграция organization_id по ADR-15 (add column с константным дефолтом → backfill → constraints), оправдана при десятках-сотнях мелких тенантов с self-service; (б) control plane поверх инстансов — оркестратор, который штампует и обновляет стеки (модель «managed instances», для B2B-нишевых продуктов в 2026 чаще выигрывает: изоляция остаётся козырем продукта). Выбор — по факту спроса; правила §8.3 держат оба пути дешёвыми.