Статус: предложение (draft v1, 2026-07-27), согласуется с владельцем. Примерная архитектура всей системы на backend-фазу. Уровень детализации намеренно эскизный: точные контракты и DDL появляются на PDD-фазе каждого модуля. Исключение — HR-модуль: его схема уже спроектирована целиком и является каноном (
docs/db/hr-schema.sql+ дизайн-док, PR #73). Принятые решения помечены ссылкой на ADR; всё остальное — предложение.
Одна компания (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-джобы с идемпотентными обработчиками).
core) — платформаЯдро уже канонизировано HR-схемой (22 таблицы, PR #73). Все остальные модули строятся поверх него.
| Блок | Таблицы | Что делает |
|---|---|---|
| Tenancy | company (singleton), department | Профиль компании с таймзоной (Asia/Makassar) — она закрывает «бизнес-день»; орг-структура. Single-tenant по контракту (ADR-15), вторая строка запрещена структурно |
| Identity | user_account, session, credential_reset | Логин ≠ e-mail (в ростере есть люди без почты); argon2id; opaque-сессии в Postgres; one-time коды |
| RBAC | role, permission, role_permission, user_role | Три оси разведены: hr.position (должность) ≠ hr.user_type (тир приложения) ≠ core.role (права). Иерархия уровней Super Admin → Admin → Supervisor → Staff — док 11 §3 |
| Files | file | Единый реестр всех бинарников системы (фото чек-инов, аватары, вложения, документы); блобы — в S3-совместимом хранилище за интерфейсом |
| Audit | audit_log | Append-only журнал с маскированием чувствительных полей |
| Notifications | notification_type, notification | Платформенные уведомления (in-app; per-channel delivery-механика отложена до дизайна S49) — модули не заводят свои |
| Integrations | lark_sync_run; lark_id + lark_id_origin на hr.employee | Lark-синк: журнал прогонов + защита вручную заданного Lark ID от перезаписи синком (schema v3: генерическая 5-табличная платформа снята) |
| Settings | setting_definition, setting | Типизированные настройки организации с department-override |
| Migration | legacy_id_map | Мост strangler-миграции (emp-001 → uuid), удаляется после ухода последнего экрана с моков |
Ключевое решение ядра — Account/Employee split: core.user_account (логин, пароль, сессии) и
hr.employee (карточка, синхронизируемая из Lark) — разные сущности со nullable-связью. У сотрудника может
не быть аккаунта; Lark-синк никогда не создаёт и не удаляет аккаунты сам.
Домены данных: схемы core и hr — канон; остальные (property, service, task, issue, report,
project, booking, comms) — предложение по аналогии. Каждый модуль ниже: назначение → ключевые сущности →
потоки → примерные таблицы. Объёмы указаны на горизонте ~3 лет.
Единая точка управления виллой: паспорт, зоны (areas), инвентарь с журналом движений, инженерные системы, дефолтные назначения по департаментам; вкладки Tasks/Issues/History — read-проекции чужих модулей.
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).
Объёмы малые: десятки юнитов, тысячи строк инвентаря.
Конструктор повторяющихся работ: 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. Объёмы: конфигурационные данные, сотни строк.
Задача — самая массовая рабочая сущность системы, 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× от задач — самая
быстрорастущая таблица операционного контура.
Отдельный пункт меню, но не отдельный домен данных: S44 — read-проекция task × исполнители по
сотрудникам/дням с раскраской on-track/overdue. Собственных таблиц нет; нужен составной индекс
(assignee_id, due_at). Drag-to-reschedule намеренно не вводится.
Один отчёт на 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). Объёмы: сотни отчётов/год, тысячи строк-снимков.
Ремонтный проект как воронка: позиции (проблема → решение → калькуляция) → 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/год.
Импорт отзывов по завершённым броням (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).
Единственный модуль с каноничной схемой: 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.
Два контура: 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 — заготовка на потом.
Нижние табы Home / Units / Tasks / Messager / Profile. Собственных сущностей нет — только read-агрегаты чужих доменов + две мутации: check-in/check-out (пишет в HR attendance c фото и геолокацией) и отметки чеклиста (пишут в Tasks). Возможное дополнение на бэке — таблица push-токенов устройств, если бейджи пойдут через push.
Вся асинхронщина — 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 daily | cron 00:01 WITA | На каждого active-сотрудника за вчера: resolve expected (Leave/Holiday > Shift > Schedule, Q-22) → строка attendance_record |
| Lark sync | cron 15–60 мин | Upsert employees/units; lark_id_origin='manual' не перезаписывается; журнал — core.lark_sync_run |
| Notifications worker | по очереди | Фан-аут notification_delivery по каналам |
| Entitlements grant | раз в год | hr.grant_entitlements(year) — грант-снапшоты квот |
| Backup / чистки | nightly | pg_dump-слой (см. док 11), чистка протухших сессий |
Дисциплина времени (ADR-003/ADR-13): все instants — timestamptz; «бизнес-день» — производная в TZ
организации (WITA, UTC+8); cron — в WITA, не UTC; «сейчас» пересекает любую границу явным значением, не
повторным чтением часов; строка attendance несёт свой time_zone.
Гео-иерархия 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.
Все бинарники — через core.file (метаданные) + S3-совместимое хранилище за интерфейсом (у клиента — любое:
MinIO, облачный S3). Крупнейший потребитель — фото чек-инов (2 фото × сотрудник × день): ~305k файлов при
200 сотрудниках за 3 года. Retention — открытый вопрос клиенту №5 (роль staffapp_retention готова,
политики нет).
Серверный рендер двух документов: Maintenance Report и Proposal. Движок не выбран (ADR-seed: WeasyPrint /
Playwright-print / Gotenberg) — решается на PDD-фазе E06. Генерация — только из замороженного снимка
(completed-отчёт), формат сумм 200,000.00 IDR, даты DD/MM/YYYY.
Брони пронизывают полсистемы (триггеры recalc, occupancy юнита, сводка mobile Home, привязка отзывов), но
не имеют ни экрана, ни владельца, ни решённого источника (channel manager / Hostify API vs ручной ввод —
ADR-seed). Это самый крупный незакрытый вход системы: 3 из 6 recalc-триггеров — события броней. Предложение:
завести booking.reservation как импортируемую сущность (по образцу Lark-синка: журнал прогонов + origin-защита),
решение по источнику — приоритетный вопрос клиенту.
Итоговая таблица (горизонт 3 года, ~200 сотрудников, ~50 юнитов — верхние оценки):
| Таблица | Строк | Комментарий |
|---|---|---|
core.file | ~305k (1.1M при 500 сотр.) | Крупнейшая; retention-вопрос открыт |
core.audit_log | ~500k | Append-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: следить после года эксплуатации.
Фазировка backend-строительства (HR-план миграции — §10 дизайн-дока схемы — вкладывается в фазы 1–2):
legacy_id_map + деплой-контур (док 11).property.*-схема, Lark-синк юнитов, решение по броням (§4.5) —
разблокирует recalc.Каждая фаза — отдельные PR со строгим правилом «schema migration в том же PR, что model change»; деплой DEV-first, PROD — по runbook с отдельной авторизацией.
Продуктовые вопросы живут в реестрах 00-client-questions.md (Q-01…) и §11 HR-дизайн-дока. Чисто
архитектурные, требующие решения до соответствующей фазы:
| # | Вопрос | Блокирует |
|---|---|---|
| A-1 | Источник Reservation (Hostify API / channel manager / ручной ввод) | Фазу 3–4 (recalc) |
| A-2 | Механизм ингеста Reviews (API платформ / скрейпинг / ручной) | Фазу 6 |
| A-3 | PDF-движок (WeasyPrint / Playwright / Gotenberg) | Фазу 5 |
| A-4 | Мессенджер: свой vs Lark-интеграция | Таблицы чатов |
| A-5 | Один responsive-клиент vs раздельные desktop/mobile | Mobile-трек |
| A-6 | Payroll-скоуп A/B/C (архитектура payroll-нейтральна: attendance хранит факты) | Расширения HR |
Решение владельца, 2026-07-27. Подключение второй (и последующих) компаний — модель instance-per-company: каждой компании — собственный экземпляр системы целиком (свой compose-стек, своя база данных, свой поддомен, свои бекапы). Row-level multi-tenancy (общие таблицы +
organization_id+ RLS) сознательно не закладывается — она снята в schema v3 (ADR-15) как источник сложности и доказанных багов без единого тенанта в планах; путь возврата к ней — аддитивная миграция, если когда-нибудь понадобится настоящий self-service SaaS на десятки компаний.
Полный стек из дока 11 §4 (caddy → web + api + worker → postgres) + собственные: .env
(имя БД, секреты, бакеты), поддомен staff.<company>.… (Caddy сам получает сертификат; cookie
__Host- работает из коробки), файловый бакет, расписание бекапов. Данные компаний физически не
соприкасаются — класс багов «утечка между тенантами» отсутствует как таковой.
.env (БД, порты, поддомен, бакеты, секреты — свежие).core.company (имя, таймзона, локаль, валюта) и первого Super Admin (seed при установке).Оценка трудоёмкости при готовых runbook'ах — часы, не недели.
Чтобы «дорасти до SaaS», если придёт время, дёшево — каждый инстанс обязан оставаться штампуемым:
core.company;
инфраструктурное — только .env (12-factor). Любой хардкод = регрессия портируемости..env
(S3_BUCKET / S3_PREFIX); ключи объектов не содержат глобальных идентификаторов — перенос
инстанса = перенос бакета..env;
restore-тест гоняется per instance.environment=<company>-<env>, логи с полем instance —
чтобы N стеков не слились в одну кашу.Два пути, оба открыты текущей архитектурой: (а) консолидация в row-level — аддитивная миграция
organization_id по ADR-15 (add column с константным дефолтом → backfill → constraints), оправдана
при десятках-сотнях мелких тенантов с self-service; (б) control plane поверх инстансов —
оркестратор, который штампует и обновляет стеки (модель «managed instances», для B2B-нишевых продуктов
в 2026 чаще выигрывает: изоляция остаётся козырем продукта). Выбор — по факту спроса; правила §8.3
держат оба пути дешёвыми.