# Архитектура рабочего сервиса Статус: предложение к утверждению. Требования — `spec.md`; решения — `adr/`; открытые продуктовые развилки — `open-questions.md`. Схемы — `diagrams.html` (самодостаточная страница с SVG, без внешних скриптов). ## Основание и совместимость со STT-215 ТЗ §30: INT 01 — адаптер сущности проектов; INT 02 — неразрушающий синк; INT 03 — один мастер назначений; INT 04 — серверные секреты. NFR 01 — область доступа до чтения данных; NFR 02 — транзакции и журнал; NFR 03 — наблюдаемость и согласуемые восстановление/ёмкость. STT-215 прочитана 13.10.2026: Nuxt 4/Nitro, TypeScript, Node 22, pnpm, Postgres/Drizzle, PGlite без DATABASE_URL, zod, vitest/Playwright. Её `SphereClient` и интервал SPHERE_SYNC_MINUTES=15 — общий контракт, пока не доказано наличие готовой библиотеки. Не объявляем скопированный интерфейс переиспользованным кодом: при реализации сначала найти реализацию и контрактные фикстуры. Модель Team, степени access и предупреждение FTE>1 из «Итераций» сюда не переносятся: ТЗ стаффинга требует блокировку перегрузки и роли РП/РМ. Фейковый вход STT-215 по SPHERE_FAKE сужен здесь до NODE_ENV=test. Репозиторий service создан отдельно, remote не задан по условию задачи; до правок каталог отсутствовал. Проверены локальные ветки prototype-v2: main, t-12345-data/screens/shell, архитектурной ветки нет; remote отсутствует, поиск MR неприменим. История STT-232 и STT-215 пуста. Это новая архитектурная постановка, не исправление неудачной реализации. ## C4 L1: контекст ```mermaid flowchart LR PM[Руководитель проекта] -->|Заявка, изменение, свои отчёты| S[Сервис ресурсного планирования] RM[Ресурсный менеджер] -->|Решения, общий план| S S -->|Чтение справочников и проектов| B[Сфера / Bitrix24] S -->|Чтение фактических часов| T[Time Лоции] S -->|Чтение календаря и возможных ставок| C[1С] S -->|Вход и проверка личности| I[Bitrix24 OAuth или корпоративный SSO] O[Технический оператор] -->|Состояние синка, восстановление| S ``` Пользователи не обращаются к интеграционным API напрямую. OAuth обмен и служебный REST-синк — разные подключения. Time не считается стандартным методом Bitrix24. Ни один поток не пишет в Сферу (рекомендация по INT 03). ## C4 L2: контейнеры ```mermaid flowchart TB U[Браузер: Nuxt UI] -->|HTTPS, cookie, общие DTO| A[Nitro API: доступ и модули] A -->|Транзакции и проекции| D[(Postgres)] W[Фоновый Node worker: тот же пакет модулей] -->|Задания, lease, checkpoint| D A -->|Ручной запуск: запись задания| D W -->|Порты чтения| E[Bitrix24 / Time / 1С] A -->|Порт идентификации| I[OAuth / SSO] A --> L[Логи, метрики, Sentry] W --> L D --> B[Резервные копии и проверка восстановления] ``` Один репозиторий и один модульный монолит. В production API и worker — два процесса одного релиза с Postgres. Worker выбран вместо experimental Nitro scheduledTasks (ADR-0002); Redis и брокер сообщений не нужны. Scheduler создаёт задания в БД, worker обрабатывает их с lease и повтором после падения. При локальной PGlite только один владелец БД — API-процесс со встроенным ручным runner, без второго процесса и cron; отдельный worker требует DATABASE_URL. Это предотвращает совместный доступ процессов к embedded-файлу. ## C4 L3: глубокие модули ```mermaid flowchart LR H[HTTP handlers] --> A[Доступ] H --> R[Заявки и согласование] H --> P[Назначения и ресурсный план] H --> V[Отчёты] R --> P R --> C[Расчёты] P --> C V --> C R --> A P --> A V --> A W[Worker / ручной запуск] --> S[Справочники и синк] S --> X[SphereClient / ProjectSource / TimeSource / OneCSource] R --> DB[(Транзакционная БД)] P --> DB V --> DB S --> DB A --> DB ``` Модули сами владеют запросами к своим таблицам, а не цепочкой generic repository/service/controller. Общий UnitOfWork — инфраструктурная граница транзакции, не отдельный бизнес-модуль. Доступ обеспечивает policy-функции и scoped query predicates; ни один публичный метод чтения не принимает произвольный SQL scope от клиента. Сигнатуры ниже — эскиз контракта TypeScript, не код сервиса. `Result` — дискриминированный результат `{ok:true,value:T}` либо `{ok:false,error:DomainError}`; `Promise` только для I/O. DTO определяются один раз в `shared/contracts/`, схемы zod выводят типы. `ActorContext` создаётся сервером через Доступ. ID — номинальные строковые типы; Decimal — точное число, сериализуемое строкой. `Tx` выдаётся только UnitOfWork; наружу HTTP не выходит. ### Заявки и согласование — Requests ```ts interface Requests { create(actor: ActorContext, cmd: CreateRequest): Promise>; proposeChange(actor: ActorContext, cmd: ChangeAssignment): Promise>; decide(actor: ActorContext, cmd: DecisionCommand): Promise>; get(actor: ActorContext, id: RequestId): Promise>; list(actor: ActorContext, query: RequestQuery): Promise>>; } // Команды содержат expectedVersion и commandId; DecisionCommand — approve/return/reject. ``` Прячет: конечный автомат, validation ролей/ставок, idempotency, проверку версии, снимки первичной заявки, журнал решений и одну транзакцию изменения назначений. Вызывает: Nitro API. Проверяется: L1 автомат и правила решения; L2 через create/decide с настоящей БД; L4 права и ошибки, L5 заявка→план. Requests владеет Request/ResourceLine/AssignmentChange/DecisionLog/CommandReceipt. Вызов Assignments.applyDecision происходит внутри его Tx, а не отдельным HTTP-запросом. ### Назначения и ресурсный план — Assignments ```ts interface Assignments { previewChange(actor: ActorContext, cmd: ChangeDraft): Promise>; getPlan(actor: ActorContext, query: PlanQuery): Promise>; list(actor: ActorContext, query: AssignmentQuery): Promise>>; applyDecision(tx: Tx, decision: ApprovedDecision): Promise>; } ``` Прячет: дневные интервалы, состояния active/ended, единственное ожидающее изменение, блокировки общей ёмкости людей, версии назначений и согласованную проекцию плана. Вызывает: API для чтения/превью, Requests для решения; ApprovedDecision — внутренний тип модуля, клиент его не создаёт. Проверяется: L1 дневные преобразования; L2 смена версии без двойного учёта и откат; Postgres-проверка конкурентного решения; L4 область РП; L5 замена. Владеет Assignment/AssignmentVersion/AssignmentDay; изменение существующей версии запрещено, текущий указатель меняется транзакционно. ### Расчёты — Calculations ```ts interface Calculations { plannedHours(plan: MonthPlan, calendar: CalendarMonth): Decimal; changePlan(plan: AssignmentPlan, change: ChangeDraft, calendar: Calendar): ChangePreview; overloads(plans: AssignmentPlan[], calendar: Calendar): Overload[]; finance(items: FinancialItem[]): FinanceTotals; utilization(periods: PeriodTotals[]): UtilizationTotals; releaseDate(plan: AssignmentPlan, calendar: Calendar): LocalDate | null; } ``` Прячет: формулы §1, приоритет days, взвешивание, допуски 0,00001 FTE / 0,001 п.п., отсутствие данных и правила точности. Вызывают: Requests/Assignments/Reports. Проверяется: L1 таблицы AC и property-based fast-check, без БД/сети. База — функции calc.js; глобальные imports seed.js из costRate/viewScope заменяются аргументами. Clock нужен вызывающему модулю для проверки даты, Calculations получает уже определённую дату. Никаких Date.now(), fetch или process.env. ### Отчёты — Reports ```ts interface Reports { get(actor: ActorContext, query: ReportQuery): Promise>; exportCsv(actor: ActorContext, query: ExportQuery): Promise>; updateLocalData(actor: ActorContext, cmd: LocalReportDataCommand): Promise>; } ``` Прячет: безопасную область и агрегации, категории проектов, actual coverage, отсутствие факта, текущие кадровые атрибуты, проекцию ставок по viewCost, экранирование CSV. Вызывают API и UI через API. Проверяется: L1 примеры AC 41–66 в Calculations; L2 real DB фильтры/полнота; L4 матрица прав и отсутствие утечек в CSV; L5 отчёт. Владеет только локальными overrides/категориями/ручными actuals/vacations; импортированные snapshots принадлежат Sync и читаются, не редактируются. Итоги собираются одной согласованной версией снимков, версии возвращаются в метаданных. ### Справочники и синк — DirectorySync ```ts interface DirectorySync { enqueue(actor: OperatorContext, source: SourceKind): Promise>; run(input: SyncInput): Promise>; status(actor: ActorContext, source: SourceKind): Promise>; resolveIdentity(actor: OperatorContext, cmd: IdentityMapping): Promise>; } // SyncInput: source, runId, asOf, mode=incremental|reconcile; часы/ID внедряются фабрикой. ``` Прячет: постраничное чтение, staging, checkpoint, дедупликацию, полные сверки, мягкую деактивацию, карантин неподтверждённых сопоставлений и атомарную активацию снимка. Вызывают worker и защищённый операторский API; run доступен программно без scheduler. Проверяется L2 повтор, обрыв, восстановление; L3 адаптеры на фикстурах; L4 только оператор запускает; L5 устаревание. Владеет справочниками, ExternalIdentity, SyncRun/SyncCheckpoint, ImportSnapshot, ActualEntry/Coverage, CalendarVersion/Day, CostRateVersion. Детерминизм: одинаковые входы+Clock+IdGenerator → одинаковые изменения доменных данных; технический runId может отличаться. ### Доступ — Access ```ts interface Access { beginLogin(input: LoginStart): Promise>; finishLogin(input: LoginCallback): Promise>; authenticate(input: SessionInput): Promise>; authorize(actor: ActorContext, operation: Operation, object: ObjectScope): Result; scope(actor: ActorContext, resource: ResourceKind): AuthorizedScope; revoke(principalId: PrincipalId): Promise; } ``` Прячет: OAuth/OIDC flow, связь external subject→principal, сессии, права и scope, отзыв после деактивации. Вызывают все публичные модули и handlers. Проверяется L1 policy, L2 session/grants, L3 IdentityProvider, L4 полная матрица и production-negative fake-login, L5 вход в тесте. Владеет Principal, Session, AccessGrant; AccessGrant не редактируется Requests. ActorContext нельзя передать через JSON как доверенный объект. ## Порты и адаптеры ```ts interface SphereClient { // совместимо по именам и смыслу со STT-215 getTasks(ids: string[]): Promise; listGroupTasks(groupId: string, options: {openOnly: boolean}): Promise; searchGroups(q: string): Promise; getGroupMembers(groupId: string): Promise; listDepartments(): Promise; searchUsers(query: {q?: string; departmentId?: string; ids?: string[]}): Promise; } interface ProjectSource { read(cursor?: string): Promise> } interface TimeSource { read(query: TimeWindow, cursor?: string): Promise> } interface OneCSource { calendar(year: number): Promise>; costRates(cursor?: string): Promise>; } interface IdentityProvider { begin(input: LoginStart): Promise; verify(input: LoginCallback): Promise } interface Clock { now(): Instant; today(zone: 'Europe/Moscow'): LocalDate } interface IdGenerator { next(): string } interface CurrentUser { get(): Promise> } ``` SphereUser: id/name/position/departmentIds/active; SphereDepartment: id/name/parentId; SphereTask: id/title/stageName/closed/groupId/responsibleId/deadline; SphereGroup: id/name/membersCount. Методы, не нужные стаффингу сейчас, не вызываются ради заполнения данных. Поскольку searchUsers по умолчанию ищет только активных, сверка сохранённых ID выполняется явно через ids с получением ACTIVE=false; отсутствие не доказывает увольнение. SourcePage содержит items/nextCursor/completeness/sourceVersion; общий транспорт не притворяется, что пагинация у всех систем одинаковая. ProjectSource реализуется GroupProjectSource, DealProjectSource или SmartProcessProjectSource, без проверки типа сущности внутри домена. TimeSource и OneCSource — проектируемые контракты: фактические endpoint и схема ещё не известны. Для каждого — HTTP-адаптер и Fake-адаптер. В e2e фейковые HTTP-серверы проверяют ещё и транспорт; модульные тесты используют in-memory fake порты. ## Модель хранения и транзакции ```mermaid erDiagram REQUEST ||--|{ RESOURCE_LINE : contains REQUEST ||--o{ DECISION_LOG : records RESOURCE_LINE ||--o{ ASSIGNMENT : creates ASSIGNMENT ||--|{ ASSIGNMENT_VERSION : versions ASSIGNMENT ||--o{ ASSIGNMENT_CHANGE : changes EMPLOYEE ||--o{ ASSIGNMENT : assigned PROJECT ||--o{ REQUEST : requested PROJECT ||--o{ PROJECT_MANAGER : grants EMPLOYEE ||--o{ ACTUAL_ENTRY : worked PROJECT ||--o{ ACTUAL_ENTRY : measured CALENDAR_VERSION ||--|{ CALENDAR_DAY : defines ``` Таблицы нормализованы: нет одной JSON-записи State/main, которая блокирует весь сервис. JSON допустим для неизменяемого before/after в DecisionLog и валидированного сырого staging с ограниченным сроком хранения; фильтруемые поля и внешние ключи — колонки. - Уникальность `(source, entityType, externalId)`; FK не позволяют связать запись с неизвестным человеком/проектом. Роли и имена имеют локальные ID. - Версия Request защищает заявку, Assignment.version защищает исходное назначение. Частичный unique index запрещает два pending-change одного assignmentId. - Для решения транзакция сначала блокирует стабильные строки Employee всех затронутых людей в порядке ID (`FOR UPDATE`), затем Request и Assignment; перечитывает их версии и весь согласованный дневной план этих людей. Все решения используют один порядок. Проверка общей загрузки видит назначения всех проектов независимо от видимости актору, ошибка наружу не раскрывает чужие проекты/ставки. - Read Committed с блокировками Employee достаточен при обязательном использовании того же протокола всеми writer; вставка назначения без этого модуля запрещена. Конкурентность проверяется именно на Postgres, не заменяется последовательным тестом PGlite. - Проверка active/closed, разрешений и rateVersion повторяется в транзакции. Sync обновляет затрагиваемые employee-строки тем же порядком блокировок; при изменении ставки после превью возвращается RATE_CHANGED и требуется новое подтверждение, а не скрытое принятие убытка. - Requests создаёт DecisionLog и новые версии Assignments в одной транзакции; сбой журнала откатывает решение. CommandReceipt уникален по principalId+commandId, хранит hash команды и безопасную ссылку результата; повтор с иным payload → COMMAND_KEY_REUSED. Проверка текущих прав выполняется и при повторе. - В назначении/превью сохраняется calendarVersionId, в отчёте возвращается версия выбранного календаря. Автоматический пересчёт прошлых периодов выключен до Q-06; изменение версии требует явной ревизии и сопоставления результата. - Журнал append-only для приложения, с маскированием C и производных значений при чтении без viewCost. Нельзя утечь through before/after. Административное удаление/обезличивание по NFR 03 — отдельный регламент после Q-09, не обычный UPDATE истории. - Заявки сохраняют исходного РП; project_manager даёт доступ к проекту, но не автоматически ко всем заявкам других РП. Передача портфеля — Q-08, отдельная аудируемая операция. ## API и отличие от прототипа `/api/v1` — предложение нового контракта, а не обещание бинарной совместимости с прототипом. Старые action=create/approve/return/reject/change сохраняют бизнес-смысл; manager из строки заменён проверенной личностью, lineIndex — стабильным ID, global revision — версиями агрегатов. Старый GET State не переносится в рабочий API, чтобы не отдавать данные всех проектов. Если нужен импорт прототипа, он делается отдельной проверяемой операцией (Q-10). | Метод / путь | Модуль / контракт | Версии и ответ | | --- | --- | --- | | GET /api/v1/requests; GET /api/v1/requests/:id | Requests.list/get | 200 scoped Page/RequestView | | POST /api/v1/requests | Requests.create | 201 RequestView; commandId; projectId/lines/comment | | POST /api/v1/requests/:id/decisions | Requests.decide | 200; expectedVersion, commandId, action, lines только для первичного approve, comment, allowLoss, expectedRateVersions | | GET /api/v1/assignments; GET /api/v1/plan | Assignments.list/getPlan | 200; period/filter/cursor | | POST /api/v1/assignments/:id/preview | Assignments.previewChange | 200; изменение без записи | | POST /api/v1/assignments/:id/changes | Requests.proposeChange | 201; expectedAssignmentVersion, mode/date/FTE/employeeId/reason | | GET /api/v1/reports/:kind; GET /api/v1/reports/:kind.csv | Reports | 200 JSON/CSV; только известные kind, одна область | | PATCH /api/v1/report-data | Reports.updateLocalData | 200; expectedVersion, commandId; только локальные поля | | GET /api/v1/sync/status; POST /api/v1/sync/:source | DirectorySync | 200 статус для РМ; 202 runId оператору запуска | | GET /auth/sphere; GET /auth/sphere/callback; POST /auth/logout | Access | redirect/cookie; logout отозвает сессию | | GET /api/health/live; GET /api/health/ready | Инфраструктура | 200 live; ready 503 при недоступной БД/несовместимой схеме | Общая ошибка `{error:{code,message,details?},requestId}`: 400 INVALID_REQUEST; 401 UNAUTHORIZED; 403 FORBIDDEN; 404 NOT_FOUND для чужого объекта; 409 VERSION_CONFLICT/CHANGE_PENDING/RATE_CHANGED/COMMAND_KEY_REUSED; 422 OVERLOAD/LOSS_CONFIRMATION_REQUIRED/INVALID_TRANSITION/UNMAPPED_REFERENCE; 503 SOURCE_UNAVAILABLE/STORAGE_UNAVAILABLE. Тексты локализуются, тесты проверяют коды. Область прав применяется до агрегации и пагинации; стабильная сортировка `(createdAt,id)` или `(name,id)`, cursor, ограниченный размер страниц. Подробности ошибки не раскрывают скрытые ставки. ## Правила проекта и зачем | Правило | Зачем | | --- | --- | | TypeScript strict, Node 22 LTS, pnpm и lockfile; Nuxt 4 app/ + server/; @nuxt/eslint | Единый предсказуемый инструментальный контур со STT-215; версии патчей фиксируются при старте реализации. | | domain/ — чистый TS; handlers = zod → module → HTTP | Формулы и решения проверяются без Nuxt/браузера. | | shared/contracts/ с выводом TS из zod, серверные модели отдельно | Не допускает ручного расхождения DTO клиента и сервера и утечки служебных полей. | | Компоненты отображают; composables управляют UI; глобальный state только с описанным владельцем | Состояние формы не перемешивается с кешем всей компании. | | Postgres + Drizzle; миграции в репо, без ручного DDL | Любой разработчик воспроизводит схему и историю обновлений. | | Нет DATABASE_URL → PGlite; каждый тест начинает с чистой схемы миграций | Локальная работа без Docker/внешней БД; production readiness требует Postgres. | | DB выбирается composition root; деньги decimal/копейки, не float | Домен не зависит от драйвера и не теряет точность финансов. | | Bitrix24, Time, 1С, Clock, IdGenerator, CurrentUser — внедряемые порты | Воспроизводимые тесты и замена внешнего источника без переписывания правил. | | Нет fetch/Date.now/process.env в домене | Побочные эффекты видны на границах и подменяются. | | Builders и сценарные seed на основе seed.js — один источник для L1–L5 | Числовые примеры одинаковы в тестах, UI и стенде. | | Тестовый вход компилируется только при NODE_ENV=test; prod негативный тест обязателен | Флаг fake-интеграций не открывает вход под чужой ролью. | | Синк имеет явный run; scheduler только инициирует; повтор идемпотентен | Оператор и тест могут повторить сбой без ожидания cron. | | Fake HTTP-серверы источников запускаются со стендом; без боевых credentials | Демонстрация и e2e не зависят от рабочих систем. | | Секреты — только private runtimeConfig/env, .env.example содержит лишь имена | Сборка клиента и репозиторий не раскрывают доступ. | | Стабильная сортировка и типизированные ошибки | Пагинация и автоматические проверки воспроизводимы. | | Структурные логи + Sentry + health + UI последнего синка | Оператор видит источник и стадию сбоя, не читая весь код. | | ID требований в именах тестов; scripts/req-coverage; покрытие домена по testing.md | Потерянные бизнес-требования обнаруживаются в CI. | Будущая структура: `app/{components,composables,pages}`, `shared/contracts`, `server/{api,auth,composition}`, `domain/{requests,assignments,calculations,reports,access,sync}`, `adapters/{sphere,time,onec,identity,db}`, `worker`, `db/migrations`, `tests/{domain,module,contract,api,e2e,fixtures,builders}`. Это ориентир ответственности, не scaffolding в этой задаче. SQL и adapters конкретного модуля можно держать рядом с ним; не создавать интерфейс на каждый вызов ORM. ## Эксплуатация и NFR 03 Логи: requestId/runId, source, duration, resultCode, checkpoint; без токенов, webhook URL, ставок и полных ответов API. Метрики: sync_last_success_age, sync_errors, pending_jobs, quarantined_records, API latency/error rate, backup/restore age. Alert по порогам Q-09; source outage не делает live=false и не уничтожает кеш. UI показывает stale с временем и отдельную ошибку, а не зелёный статус по наличию старого кеша. Операторский порядок: проверить health/schema → source status и runId → исправить секрет/маппинг → явный reconcile → проверить изменённые/карантинные строки. Cursor не сбрасывать вручную в SQL. Резервирование Postgres и ключей отдельно, шифрованные backup, восстановление в изолированную БД с проверкой FK/версий/журнала и smoke. Частота, RPO/RTO, retention, удаление и нагрузка остаются Q-09. Откат релиза только на совместимую схему; миграции expand/contract, перед необратимой миграцией проверенная копия. ## Проверенные технические источники На 13.10.2026 [Nitro tasks](https://nitro.build/docs/tasks) помечены experimental — это аргумент ADR-0002, а не запрет будущего перехода. [Drizzle PGlite](https://orm.drizzle.team/docs/connect-pglite) подтверждает отдельный драйвер. [PGlite socket](https://pglite.dev/docs/pglite-socket) описывает single-connection ограничение: блокировки конкурентных транзакций отдельно проверяем на Postgres. Конкретные версии Nuxt/Nitro/Drizzle нужно закрепить lockfile и smoke в П1; текущая документация Nitro может относиться к другой основной версии, конфиг отсюда дословно не копируется.