Перейти к содержанию

ТЗ: CRM-модуль "Объекты" для alatyr-service

Расширение существующего Node.js/Express + React приложения alatyr-service для управления крупными объектами (стройка + ОПС), которые приходят по P2P/партнёрским каналам. quick_orders остаётся входящим контуром переговоров, а objects становится производственным контуром после фактического начала работ.

Автор ТЗ: сессия Perplexity Computer, 2026-08-05 Статус: производственный контур задач 49/50 развёрнут в production: объекты, сотрудники, назначения, условия оплаты, attendance, смены, разовые работы, начисления/выплаты и отдельные страницы сущностей


Контекст и бизнес-задача

Оборот направления: ~60 млн ₽/год Активных объектов: до 20 одновременно, 20-30 новых в год Средний чек: от 500 тыс до 10+ млн Тип клиентов: ИП, ООО (реже физлица), часто с несколькими объектами

Ключевые вопросы, на которые CRM должна отвечать в любой момент: 1. Где сейчас каждый объект? (лид / в работе / пауза / сдан / закрыт) 2. Если завис — где именно и почему? 3. Финансы: сумма договора — получено = остаток 4. Дедлайн — успеваем или горит? 5. У какого заказчика сколько активных объектов и на какую сумму?


Отличие от quick_orders

quick_orders (есть) objects (новое)
Источник Форма на сайте P2P, наработанные партнёры, старые клиенты, действующие объекты
Клиент Обычно физлицо, регистрация на сайте Юрлица (ИП/ООО), реквизиты, договорные отношения
Сумма Сотни тысяч Сотни тысяч — десятки миллионов
Срок Дни-недели Месяцы-годы
Договор Опционально Всегда
Смета/этапы Простая или нет Гибкий график, часто ежемесячные акты
Оплата Обычно 1 платёж Аванс + этапы + финал
Каталог МойСклад Может быть индивидуальная смета вне каталога
Управление Быстро закрывается Ведётся месяцами

Связь: заявка → объект (кнопка «В объект» открывает короткую форму запуска работ, копирует контакт и данные заявки).

Утверждённая логика входящих заявок

  • quick_orders хранит обращения, пока идут переговоры и работа ещё не началась.
  • Канал ввода хранится отдельно как entrySource: website|manager; повторное обращение старого клиента отмечается независимым флагом isRepeat, а подробности — в sourceNote.
  • После преобразования заявка исчезает из активного списка, но физически не удаляется: объект сохраняет связь через objects.sourceOrderId, а администратор может запросить историю с includeConverted=true.
  • Статус выполнения работ, ответственные и назначения сотрудников не задаются в заявке. Эти поля появляются только после создания объекта.
  • Менеджер может создать заявку вручную; заявки с публичной формы автоматически получают источник website.

Коммерческий цикл заявки

  • В заявке отдельно отображаются статусы подготовки счёта, договора и оплаты. Рабочий статус объекта в заявке не используется.
  • Счёт проходит состояния не подготовлен → подготовлен → выставлен; договор — не требуется → готовится → отправлен → подписан; оплата — не оплачено → частично → оплачено.
  • Сотрудник создаёт бухгалтерский счёт в Моём Деле. Сервис периодически подтягивает его и связывает с клиентом и заявкой.
  • Счёт, сформированный сайтом, сначала создаётся в заявке, затем отправляется в Моё Дело через API. Ошибка синхронизации не должна терять локальный счёт.
  • Автоматическая привязка импортированного счёта к заявке разрешена только при однозначном совпадении собственного ИП, клиента Моего Дела и одной активной заявки. Неоднозначные счета попадают в состояние требует привязки.
  • Полная оплата, полученная из Моего Дела или платёжного webhook, автоматически преобразует заявку в объект только если заполнены собственное ИП, CRM-клиент, название, адрес и тип объекта. При нехватке данных заявка остаётся видимой с причиной блокировки.
  • Нажатие на клиента в списке заявок открывает полную карточку с редактированием, описанием, счетами, договором, историей изменений и готовностью к переходу в объект.
  • Отдельный рабочий раздел Счета скрывается: счета показываются в карточке заявки, а после преобразования — в карточке объекта.
  • Отдельный рабочий раздел Абонентки скрывается. Абонентское обслуживание является типом объекта наряду с мелким монтажом, крупным монтажом и продажей оборудования; у объекта может быть несколько типов.

Модель данных

Все новые таблицы в существующем shared/schema.ts (SQLite + Drizzle ORM). Существующие таблицы НЕ меняем в первой итерации, только новые.

1. clients — юридические заказчики

Отдельно от users (клиенты сайта), т.к. большинство "объектных" клиентов не имеют аккаунта — им не нужен личный кабинет.

export const clients = sqliteTable("clients", {
  id: integer("id").primaryKey({ autoIncrement: true }),

  // ── Идентификация ──
  displayName: text("display_name").notNull(),      // "ООО Иванов и Ко" или "Иванов И.И."
  clientType: text("client_type").notNull(),        // 'ooo' | 'ip' | 'individual'
  inn: text("inn"),                                  // ИНН (10 или 12 цифр)
  kpp: text("kpp"),                                  // КПП (только для ООО)
  ogrn: text("ogrn"),                                // ОГРН/ОГРНИП

  // ── Контакты ──
  contactName: text("contact_name").notNull(),      // ФИО основного контакта
  contactPhone: text("contact_phone").notNull(),
  contactEmail: text("contact_email"),
  contactRole: text("contact_role"),                // "директор", "прораб", "снабженец"

  // ── Юридический адрес ──
  legalAddress: text("legal_address"),
  // ── Фактический адрес (если отличается) ──
  actualAddress: text("actual_address"),

  // ── Банковские реквизиты (для формирования счетов) ──
  bankName: text("bank_name"),
  bankBik: text("bank_bik"),
  bankAccount: text("bank_account"),                // р/с
  bankCorrAccount: text("bank_corr_account"),       // к/с

  // ── Источник и канал ──
  source: text("source").notNull(),                 // 'p2p' | 'partner' | 'inherited' | 'website' | 'other'
  sourceNote: text("source_note"),                  // "рекомендация от Петрова" и т.п.

  // ── Связь с существующими сущностями ──
  msCounterpartyId: text("ms_counterparty_id"),     // uuid контрагента в МойСклад
  userId: integer("user_id"),                        // если у клиента есть аккаунт на сайте → users.id

  // ── Заметки ──
  notes: text("notes"),                              // произвольные внутренние заметки

  createdAt: integer("created_at").notNull(),
  updatedAt: integer("updated_at"),
  createdBy: integer("created_by"),                  // users.id менеджера/админа
});

2. objects — физические объекты

export const objects = sqliteTable("objects", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  clientId: integer("client_id").notNull(),          // clients.id

  // ── Идентификация ──
  code: text("code").notNull().unique(),             // неизменяемый бизнес-ключ "2026-015-ivanov-himki"
  name: text("name").notNull(),                      // "Склад Иванова в Химках — СКУД+видео"
  address: text("address").notNull(),                // фактический адрес объекта

  // ── Направление(я) работ ──
  // Массив в JSON: ['skud', 'video', 'fire', 'network', 'general', 'other']
  directions: text("directions").notNull().default('[]'),

  // ── Статус жизненного цикла ──
  // 'lead'       — переговоры, договора нет
  // 'contract'   — договор подписан, работа не начата
  // 'in_progress'— идёт работа/поставки/монтаж
  // 'paused'     — на паузе (см. pauseReason)
  // 'delivered'  — сдан по акту, но есть незакрытые обязательства (гарантия, недоплата)
  // 'closed'     — полностью закрыт, гарантия истекла или отсутствует
  // 'cancelled'  — отменён
  status: text("status").notNull().default('lead'),
  pauseReason: text("pause_reason"),                 // если status='paused' — что мешает

  // ── Классификация ──
  // 'short' — до месяца, единая смета, обычно 1 акт
  // 'long' — длинный, с ежемесячными актами
  duration: text("duration").notNull().default('short'),

  // ── Даты ──
  startedAt: integer("started_at"),                  // unix seconds, фактический старт
  plannedDeadline: integer("planned_deadline"),      // плановый дедлайн
  actualFinishedAt: integer("actual_finished_at"),   // факт сдачи
  warrantyUntil: integer("warranty_until"),          // до какого числа гарантия

  // ── Финансы (суммы в копейках) ──
  contractSum: integer("contract_sum").notNull().default(0),   // сумма договора (может меняться допсоглашениями)
  paidSum: integer("paid_sum").notNull().default(0),           // получено всего (пересчитывается)
  // Себестоимость (опционально, для маржинальности)
  costSum: integer("cost_sum"),                                 // расходы: материалы, субподряд

  // ── Ответственный ──
  managerId: integer("manager_id"),                  // users.id основного менеджера

  // ── Заметки и связь с внешними системами ──
  description: text("description"),                  // краткое описание работ
  notes: text("notes"),                              // внутренние заметки

  // ── Ссылки на документы (внешние URL) ──
  // JSON: [{ label, url, type: 'contract'|'act'|'photo'|'other' }]
  documents: text("documents").notNull().default('[]'),

  // ── Метки ──
  tags: text("tags").notNull().default('[]'),        // JSON-массив строк

  // ── Связь с существующими сущностями ──
  sourceOrderId: integer("source_order_id"),         // quick_orders.id если конвертирован из заявки

  createdAt: integer("created_at").notNull(),
  updatedAt: integer("updated_at"),
  createdBy: integer("created_by"),
});

3. contracts — договоры

Один объект может иметь несколько договоров, если меняется юрлицо, исполнитель или состав работ. Одновременно у объекта может быть один основной активный договор на каждое ownCompanyId; допсоглашения храним отдельными строками со ссылкой на родительский договор.

export const contracts = sqliteTable("contracts", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  objectId: integer("object_id").notNull(),

  contractNo: text("contract_no").notNull(),         // "2026-015" или полный номер
  signedAt: integer("signed_at").notNull(),          // дата подписания
  amount: integer("amount").notNull(),               // сумма договора в копейках
  vatIncluded: integer("vat_included", { mode: "boolean" }).notNull().default(false),
  vatRate: integer("vat_rate"),                       // ставка НДС в процентах (20, 10, 0) если vatIncluded

  // Тип оплаты
  paymentType: text("payment_type").notNull(),       // 'bank' | 'cash' | 'mixed'

  // Плательщик и получатель (могут отличаться от clients)
  payerName: text("payer_name"),                     // если платит третье лицо
  payerInn: text("payer_inn"),

  // Файл договора (URL или локальный путь)
  fileUrl: text("file_url"),

  // Ссылка на "родительский" договор (для допсоглашений)
  parentContractId: integer("parent_contract_id"),
  kind: text("kind").notNull().default('main'),      // 'main' | 'addendum'

  status: text("status").notNull().default('active'), // 'draft' | 'active' | 'closed' | 'cancelled'

  notes: text("notes"),

  createdAt: integer("created_at").notNull(),
  updatedAt: integer("updated_at"),
});

4. contract_stages — этапы (гибкий график)

Ключевая гибкость: список этапов — свободный, каждый со своим типом. Пользователь сам расписывает по каждому договору.

export const contractStages = sqliteTable("contract_stages", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  contractId: integer("contract_id").notNull(),

  // Порядок отображения
  orderIndex: integer("order_index").notNull(),

  // Тип этапа
  // 'advance'   — аванс (обычно первый)
  // 'delivery'  — поставка оборудования
  // 'work'      — выполнение работ (может быть N штук)
  // 'monthly_act' — ежемесячный акт (для длинных договоров)
  // 'final_act' — финальный акт
  // 'other'     — прочее
  kind: text("kind").notNull(),

  title: text("title").notNull(),                    // "Аванс 30%", "Акт за апрель", "Финальный акт"
  description: text("description"),

  plannedDate: integer("planned_date"),              // плановая дата
  actualDate: integer("actual_date"),                // факт

  // Сумма этапа в копейках
  amount: integer("amount").notNull().default(0),
  paidAmount: integer("paid_amount").notNull().default(0),

  // Статус
  // 'planned' — запланирован
  // 'active' — в работе / выполняется
  // 'ready' — готов к оплате (акт подписан)
  // 'paid' — оплачен
  // 'waiting_payment' — акт подписан, ждём деньги
  // 'skipped' — пропущен (например аванс если решили без него)
  status: text("status").notNull().default('planned'),

  // Для длинных договоров: год-месяц периода
  periodYear: integer("period_year"),
  periodMonth: integer("period_month"),

  notes: text("notes"),

  createdAt: integer("created_at").notNull(),
  updatedAt: integer("updated_at"),
});

5. object_payments — платежи по объектам

Отдельно от розничных payments (те завязаны на invoices/orders). Здесь платежи всегда идут через банковский перевод / нал / смешанное, ЮKassa обычно не задействован.

export const objectPayments = sqliteTable("object_payments", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  objectId: integer("object_id").notNull(),
  contractId: integer("contract_id"),                // может быть привязан к договору
  stageId: integer("stage_id"),                      // может быть привязан к этапу

  amount: integer("amount").notNull(),               // копейки
  method: text("method").notNull(),                  // 'bank' | 'cash' | 'card_transfer' | 'other'
  paidAt: integer("paid_at").notNull(),              // unix seconds

  // Счёт из банка (номер платёжки), если банковский перевод
  paymentDocNo: text("payment_doc_no"),
  paymentDocDate: integer("payment_doc_date"),

  // Плательщик (если отличается от клиента)
  payerName: text("payer_name"),

  note: text("note"),
  createdBy: integer("created_by"),
  createdAt: integer("created_at").notNull(),
});

6. object_acts — акты выполненных работ

Для длинных договоров каждый месяц выпускается акт. Также — финальный акт, промежуточные акты по этапам.

export const objectActs = sqliteTable("object_acts", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  objectId: integer("object_id").notNull(),
  contractId: integer("contract_id").notNull(),
  stageId: integer("stage_id"),                      // если акт по конкретному этапу

  actNo: text("act_no").notNull(),                   // "АКТ-2026-015-04" (за апрель)
  actDate: integer("act_date").notNull(),
  amount: integer("amount").notNull(),

  // Период для ежемесячных
  periodYear: integer("period_year"),
  periodMonth: integer("period_month"),

  status: text("status").notNull().default('draft'), // 'draft' | 'sent' | 'signed' | 'rejected'
  signedAt: integer("signed_at"),
  sentAt: integer("sent_at"),

  fileUrl: text("file_url"),                         // ссылка на файл акта
  notes: text("notes"),

  createdBy: integer("created_by"),
  createdAt: integer("created_at").notNull(),
  updatedAt: integer("updated_at"),
});

Ключевые связи и правила

  • clients 1 → N objects — у клиента может быть много объектов
  • objects 1 → N contracts — объект не меняется при появлении нового договора или допсоглашения
  • contracts 1 → N contract_stages — гибкий график
  • contracts 1 → N object_acts — акты
  • object_payments может ссылаться на stageId (какой этап оплачен) для автопересчёта paidSum

Автоматика (в БД или в приложении): - Изменение object_payments → пересчёт objects.paidSum и contract_stages.paidAmount - Подпись акта (objectActs.signedAt) → соответствующий contract_stages.status = 'waiting_payment' - Полная оплата этапа → stages.status = 'paid' - Все этапы paid + все акты signed → предложить закрыть объект


Утверждённая структура объекта и производственного учёта (2026-08-09)

Главный идентификатор объекта

Объект ведётся по внутреннему коду CRM, а не по номеру договора, счёта или акта:

  • objects.id — технический первичный ключ БД, пользователю не показывается;
  • objects.code — уникальный неизменяемый бизнес-ключ, например 2026-015-ivanov-himki;
  • objects.name — свободное понятное название, его можно менять;
  • номер договора, счёта или акта — реквизит связанного документа, но не идентификатор объекта.

Рекомендуемый формат кода: YYYY-NNN-shortname-location, где NNN — сквозной номер объекта в году. Код создаётся один раз и не меняется при смене названия, адреса, клиента, договора или исполнителя. В интерфейсе везде показывается сочетание: 2026-015 · Склад Иванова в Химках.

Почему не номер договора или счёта:

  • у одного объекта может быть несколько договоров и допсоглашений;
  • по одному договору бывает много счетов и актов;
  • номера могут повторяться у двух собственных ИП;
  • объект может появиться раньше договора;
  • смена юрлица не должна создавать новый физический объект.

Карта связей

client
  └── object (единый внутренний code)
        ├── contracts
        │     ├── stages
        │     └── moedelo_document_refs
        ├── object_assignments
        │     └── worker_shifts
        ├── worker_accruals
        ├── worker_payments
        ├── tasks
        └── photos / notes / files

Ссылки на документы «Моё Дело»

В CRM не копируем бухгалтерскую первичку целиком. Храним только устойчивую связь и оперативный снимок, чтобы карточка объекта показывала связанные счета и акты без поиска по номеру.

export const moedeloDocumentRefs = sqliteTable("moedelo_document_refs", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  objectId: integer("object_id").notNull(),
  contractId: integer("contract_id"),
  stageId: integer("stage_id"),
  ownCompanyId: text("own_company_id").notNull(),

  documentType: text("document_type").notNull(),      // 'bill' | 'act' | 'invoice' | 'other'
  mdId: text("md_id").notNull(),                      // устойчивый ID документа в Моё Дело
  documentNo: text("document_no"),
  documentDate: integer("document_date"),
  amount: integer("amount"),                          // снимок суммы в копейках
  status: text("status"),                             // оперативный снимок статуса
  mdUpdatedAt: integer("md_updated_at"),
  syncedAt: integer("synced_at").notNull(),
});

Уникальность: (ownCompanyId, documentType, mdId). Источник правды по содержимому и бухгалтерскому статусу документа — «Моё Дело»; CRM хранит связь с объектом и последний синхронизированный снимок.

Сотрудники и назначения на объект

Системная роль и рабочая должность — разные поля:

  • users.role определяет права входа: admin | manager | master | client;
  • employee_profiles.position определяет должность в производстве: installer | brigadier | foreman | itr | employee;
  • object_assignments.role определяет функцию человека на конкретном объекте и может отличаться от основной должности.

Например, пользователь с системной ролью master и должностью installer на одном объекте может быть назначен brigadier. Изменение должности не должно автоматически давать доступ к финансам или административным функциям.

export const employeeProfiles = sqliteTable("employee_profiles", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  userId: integer("user_id").notNull().unique(),      // users.id
  position: text("position").notNull(),               // 'installer' | 'brigadier' | 'foreman' | 'itr' | 'employee'
  personnelNo: text("personnel_no"),                  // внутренний табельный номер, опционально
  active: integer("active", { mode: "boolean" }).notNull().default(true),
  hiredAt: integer("hired_at"),
  dismissedAt: integer("dismissed_at"),
  defaultRateType: text("default_rate_type"),         // 'hourly' | 'shift' | 'piece' | 'percent'
  defaultRateValue: integer("default_rate_value"),    // копейки или basis points
  notes: text("notes"),
  createdAt: integer("created_at").notNull(),
  updatedAt: integer("updated_at"),
});

Подписи в интерфейсе:

Код Должность
installer Монтажник
brigadier Бригадир
foreman Прораб
itr ИТР
employee Сотрудник

Состав бригады задаётся назначениями:

export const objectAssignments = sqliteTable("object_assignments", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  objectId: integer("object_id").notNull(),
  workerId: integer("worker_id").notNull(),           // users.id
  role: text("role").notNull().default("installer"),  // installer/brigadier/foreman/itr/employee

  plannedFrom: integer("planned_from"),
  plannedUntil: integer("planned_until"),
  active: integer("active", { mode: "boolean" }).notNull().default(true),

  defaultRateType: text("default_rate_type").notNull(), // 'hourly' | 'shift' | 'piece' | 'percent'
  defaultRateValue: integer("default_rate_value").notNull(), // копейки или basis points для percent
  notes: text("notes"),

  createdAt: integer("created_at").notNull(),
  updatedAt: integer("updated_at"),
});

Назначение отвечает на вопросы: кто работает на объекте, в какой роли, в какой период и по какой ставке по умолчанию. Один сотрудник может одновременно быть назначен на несколько объектов. Основная должность берётся из employee_profiles, а роль на объекте — из object_assignments.

Условия труда и оплаты задаются на назначении

Резервная ставка в employee_profiles используется только как подсказка при создании назначения. Источником правды является конкретная договорённость сотрудник × объект, потому что один человек может работать:

  • на одном объекте почасово;
  • на другом сдельно;
  • почасово с отдельной премией за результат;
  • за фиксированную сумму после завершения объекта или этапа;
  • по разовой согласованной сумме за работу вне своей обычной компетенции.

Для назначения добавляется версия условий оплаты:

export const assignmentCompensationTerms = sqliteTable("assignment_compensation_terms", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  assignmentId: integer("assignment_id").notNull(),
  scheme: text("scheme").notNull(),                    // 'hourly' | 'shift' | 'piece' | 'fixed_completion' | 'mixed'
  hourlyRate: integer("hourly_rate"),                  // копейки/час
  shiftRate: integer("shift_rate"),                    // копейки/смену
  fixedCompletionAmount: integer("fixed_completion_amount"),
  bonusMode: text("bonus_mode"),                       // null | 'fixed' | 'percent' | 'manual'
  bonusValue: integer("bonus_value"),                  // копейки или basis points
  effectiveFrom: integer("effective_from").notNull(),
  effectiveUntil: integer("effective_until"),
  conditions: text("conditions"),                      // что именно считается выполнением
  createdBy: integer("created_by").notNull(),
  createdAt: integer("created_at").notNull(),
});

Условия не перезаписываются задним числом. При изменении создаётся новая версия, а смена или начисление хранит снимок применённых условий.

Разовая работа хранится отдельно от основной ставки:

export const workerObjectTasks = sqliteTable("worker_object_tasks", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  objectId: integer("object_id").notNull(),
  workerId: integer("worker_id").notNull(),
  assignmentId: integer("assignment_id"),
  title: text("title").notNull(),
  description: text("description"),
  outsidePrimaryCompetence: integer("outside_primary_competence", { mode: "boolean" })
    .notNull().default(false),
  agreedAmount: integer("agreed_amount").notNull(),    // сумма согласуется до начала
  status: text("status").notNull().default("offered"), // offered | accepted | in_progress | submitted | approved | rejected | cancelled
  offeredBy: integer("offered_by").notNull(),
  acceptedAt: integer("accepted_at"),
  approvedBy: integer("approved_by"),
  approvedAt: integer("approved_at"),
  createdAt: integer("created_at").notNull(),
});

После approved создаётся одно сдельное начисление. Повторное начисление по той же задаче запрещается уникальной связью worker_accruals.worker_object_task_id.

Состояние реализации на 10.08.2026

Первый серверный срез реализован в alatyr-service, ветка feat/crm-employees-assignments, коммит 5424b8c:

  • runtime-миграция SQLite создаёт employee_profiles и object_assignments;
  • добавлены ограничения должностей, типов ставок, дат, неотрицательных сумм и внешние ключи на users;
  • частичный уникальный индекс запрещает два активных назначения одного сотрудника на один объект;
  • добавлены Drizzle-типы, Zod-валидация, storage CRUD и admin API:
  • GET /api/admin/employees;
  • GET /api/admin/employees/:userId;
  • PUT /api/admin/users/:userId/employee-profile;
  • GET /api/admin/object-assignments;
  • POST /api/admin/object-assignments;
  • PATCH /api/admin/object-assignments/:id.

Этап коммита 5424b8c не развёрнут в production. На этом промежуточном шаге object_assignments.object_id оставался без внешнего ключа до реализации таблицы objects; ограничение снято следующим коммитом.

Продолжение от 10.08.2026, коммит fa8d0c3:

  • добавлена runtime-миграция SQLite таблицы objects с неизменяемым уникальным code, статусами, сроками, финансовыми агрегатами и JSON-полями;
  • добавлены Drizzle-типы, Zod-валидация, storage CRUD и маршруты GET/POST/PATCH /api/admin/objects;
  • object_assignments.object_id связан внешним ключом с objects.id;
  • API запрещает назначать сотрудника на несуществующий объект;
  • production по-прежнему не развёрнут;
  • на этом промежуточном шаге objects.client_id и objects.own_company_id ещё хранились без FK.

Продолжение от 10.08.2026, коммит a6fa35f:

  • перенесены SQLite/Drizzle-таблицы own_companies и clients;
  • добавлены Zod-схемы, storage CRUD и admin API собственных компаний и CRM-клиентов;
  • objects.client_id связан FK с clients.id, objects.own_company_id связан FK с own_companies.id;
  • внешние ID МойСклад, Моё Дело и связь CRM-клиента с аккаунтом защищены частичными уникальными индексами;
  • создание и изменение объекта проверяет существование CRM-клиента и собственной компании;
  • новый объект нельзя создать для неактивной собственной компании;
  • production не развёрнут.

Продолжение задачи 40a в той же feature-ветке, коммит b728236:

  • добавлен script/seed-own-companies.ts с двумя сверенными ИП и upsert по неизменяемому code;
  • повторный запуск сохраняет id, mdCompanyId и ручной флаг isActive;
  • seed запускается в deploy workflow после сборки и до рестарта сервиса;
  • production не читает приватный KB и не требует отдельного GitHub-токена;
  • на этапе b728236 основной счёт korobochka — ВТБ, а дополнительные счета ещё не были перенесены.

Продолжение от 10.08.2026, коммит 627b6d2:

  • добавлена own_company_accounts с FK own_company_id → own_companies.id;
  • расчётный счёт уникален, а частичный индекс разрешает только один активный основной счёт на ИП;
  • основной счёт обязан быть активным, банковские реквизиты валидируются по длине;
  • добавлены storage CRUD и admin API просмотра, создания и изменения счетов;
  • seed создаёт один счёт Романа и три счёта Артёма, сохраняя ID при повторном запуске;
  • старые банковские поля в own_companies временно оставлены для обратной совместимости;
  • production не развёрнут.

Продолжение от 10.08.2026, PR alatyr-service#4, merge-коммит 815801b:

  • во вкладке «Заявки» добавлено действие «В объект»;
  • перед запуском работ менеджер указывает обязательные адрес объекта и собственную компанию, название предзаполнено из услуги и имени заказчика;
  • преобразование выполняется одной SQLite-транзакцией: CRM-клиент переиспользуется по аккаунту или телефону либо создаётся автоматически, затем создаётся объект со статусом in_progress и ссылкой source_order_id;
  • сумма, оплата, комментарий и заметка заявки переносятся в оперативные поля объекта, дата начала ставится на момент преобразования;
  • заявка сохраняется как история переговоров и получает статус in_progress; в списке вместо повторной кнопки показывается «Объект #ID»;
  • частичный уникальный индекс objects.source_order_id запрещает создать из одной заявки два объекта;
  • проверено производственной сборкой, транзакционным тестом на временной SQLite и браузерной QA;
  • GitHub Actions deploy 31339783027 завершён успешно, функциональность развёрнута в production.

Задачи 49/50 завершены в PR alatyr-service#7, коммит 8b5418d, merge-коммит 58543f4, deploy 31370190443:

  • добавлены versioned compensation terms и выбор версии по времени события;
  • реализованы attendance, смены, разовые работы, начисления и выплаты;
  • state machine разовой работы не допускает пропуск этапов и повторное утверждение; утверждённая работа создаёт ровно одно начисление;
  • ручные премии, штрафы и корректировки создаются как draft с обязательным основанием и отдельным approve/reject с аудитом;
  • worker endpoints разрешают сотруднику только собственные check-in/out и переходы собственной задачи до submitted;
  • добавлены отдельные страницы заявки, объекта и сотрудника с собственными URL;
  • пройдены typecheck, production build, 29 интеграционных API-проверок и desktop/mobile browser QA; production health вернул HTTP 200.

Telegram/MAX ingestion событий присутствия в этот этап не входит: доступен готовый web/manual контур, а канальные адаптеры остаются будущим расширением.

Смены, ставки и начисления

Каждая закрытая смена хранит снимок условий расчёта: rateType, rateValue, payAmount. Изменение ставки в профиле или назначении не пересчитывает прошлые смены.

Поддерживаемые модели:

  • hourly — часы × ставка;
  • shift — фиксированная сумма за закрытую смену;
  • piece — сдельное начисление, подтверждаемое менеджером;
  • percent — процент от согласованной базы; база и сумма фиксируются отдельным начислением.
  • fixed_completion — согласованная сумма после приёмки объекта или этапа;
  • mixed — почасовая/сменная база плюс премия или отдельные сдельные работы.

Для следующего производственного блока P0: hourly, shift, piece, fixed_completion и почасовая ставка с фиксированной ручной премией. Процентные формулы остаются P1.

Учёт присутствия на объекте

Смена строится из журнала событий присутствия. Поддерживаемые источники:

  • кнопки Пришёл / Ушёл в Telegram;
  • сообщение или фотоотчёт в привязанном Telegram-канале объекта;
  • аналогичные события MAX после подтверждения доступного API/бот-интеграции;
  • ручная отметка менеджера или прораба с обязательной причиной;
  • исправление времени после проверки.
export const workerAttendanceEvents = sqliteTable("worker_attendance_events", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  workerId: integer("worker_id").notNull(),
  objectId: integer("object_id").notNull(),
  shiftId: integer("shift_id"),
  eventType: text("event_type").notNull(),             // check_in | check_out | photo_report | manual_adjustment
  source: text("source").notNull(),                    // telegram | max | web | manual
  occurredAt: integer("occurred_at").notNull(),
  externalChatId: text("external_chat_id"),
  externalMessageId: text("external_message_id"),
  evidenceFileId: text("evidence_file_id"),            // ссылка на защищённое вложение, не публичный URL
  note: text("note"),
  verificationStatus: text("verification_status")
    .notNull().default("pending"),                     // pending | verified | rejected
  verifiedBy: integer("verified_by"),
  verifiedAt: integer("verified_at"),
  createdAt: integer("created_at").notNull(),
});

Правила автоматизации:

  1. У объекта явно задаются разрешённые Telegram/MAX-чаты; сообщение из чужого чата не считается подтверждением присутствия.
  2. Кнопка Пришёл или первое подтверждённое событие дня может открыть смену.
  3. Кнопка Ушёл закрывает смену. Фотоотчёт подтверждает присутствие в момент снимка, но не назначает время ухода автоматически.
  4. Если смена не закрыта, она получает needs_review; часы и деньги не подтверждаются автоматически.
  5. Ручная отметка содержит автора, причину и исходное/новое время в аудите.
  6. Геолокация и распознавание лица не требуются для MVP.

Начисление после завершения и корректировки

  • Фиксированная сумма за объект или этап создаётся только после статуса approved/completed и подтверждения менеджером.
  • На одно назначение и один триггер завершения создаётся не более одного начисления; повторное открытие страницы не дублирует деньги.
  • Премия создаётся отдельным положительным начислением с основанием.
  • Штраф/удержание создаётся отдельной отрицательной корректировкой, а не уменьшает или удаляет уже подтверждённую смену.
  • Для отрицательной корректировки обязательны причина, сумма, объект, автор и подтверждающий; автоматические штрафы за отсутствие события запрещены.
  • Перед применением штрафа интерфейс показывает сотруднику/менеджеру исходное начисление, корректировку и новый остаток. Правомерность удержания зависит от оформленных условий сотрудничества и проверяется вне автоматического расчёта.

Отдельная таблица начислений нужна для премий, штрафов, сдельных работ и ручных корректировок:

export const workerAccruals = sqliteTable("worker_accruals", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  workerId: integer("worker_id").notNull(),
  objectId: integer("object_id").notNull(),
  shiftId: integer("shift_id"),
  assignmentId: integer("assignment_id"),
  workerObjectTaskId: integer("worker_object_task_id"),
  completionTriggerId: text("completion_trigger_id"), // idempotency для оплаты за объект/этап
  kind: text("kind").notNull(),                       // 'shift' | 'piece' | 'bonus' | 'penalty' | 'adjustment'
  amount: integer("amount").notNull(),                // копейки; штраф может быть отрицательным
  basis: text("basis"),                               // основание расчёта
  status: text("status").notNull().default("draft"),  // 'draft' | 'approved' | 'cancelled'
  approvedBy: integer("approved_by"),
  approvedAt: integer("approved_at"),
  createdAt: integer("created_at").notNull(),
});

Фактическая выплата не смешивается с начислением:

export const workerPayments = sqliteTable("worker_payments", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  workerId: integer("worker_id").notNull(),
  objectId: integer("object_id"),                     // null для общей выплаты за несколько объектов
  kind: text("kind").notNull(),                       // 'salary' | 'advance' | 'bonus' | 'reimbursement'
  amount: integer("amount").notNull(),                // копейки
  paidAt: integer("paid_at").notNull(),
  method: text("method"),                             // 'bank' | 'cash' | 'card_transfer'
  periodFrom: integer("period_from"),
  periodUntil: integer("period_until"),
  note: text("note"),
  createdBy: integer("created_by").notNull(),
  createdAt: integer("created_at").notNull(),
});

Отчёт по сотруднику за период показывает: часы, смены, начислено, выплачено, остаток. Отчёт по объекту показывает трудозатраты и фонд оплаты труда для расчёта фактической маржи.

Персональный расчёт сотрудника

Для каждого сотрудника CRM должна строить детальную расшифровку за произвольный период:

  • на каких объектах работал;
  • даты и продолжительность каждой смены;
  • всего часов и смен по каждому объекту;
  • ставка-снимок и начисление по каждой смене;
  • премии, штрафы, сдельные и ручные корректировки;
  • всего начислено;
  • каждая выплата: дата, сумма, способ (bank | cash | card_transfer), назначение и расчётный период;
  • всего выплачено;
  • остаток: подтверждённые начисления − выплаты;
  • спорные, неподтверждённые и отменённые записи показываются отдельно и не входят в итог к выплате.

Итоги доступны в двух разрезах:

  1. По объектам: объект → часы → начислено → выплачено → остаток.
  2. По времени: день/неделя/месяц → смены → начисления → выплаты.

Формула итогов должна выполняться сервером. UI не пересчитывает деньги самостоятельно.

Обязательные бизнес-правила

  1. objects.code уникален и после создания доступен только для чтения.
  2. Один объект может иметь несколько договоров, счетов и актов.
  3. Номер документа не считается уникальным без ownCompanyId и mdId.
  4. Ставка копируется в смену/начисление в момент закрытия; прошлые расчёты не пересчитываются автоматически.
  5. Закрытая смена создаёт черновик начисления, который подтверждает менеджер или прораб.
  6. Выплата не меняет смену: она уменьшает остаток долга перед сотрудником.
  7. Смены, начисления, выплаты и финансовые документы не удаляются физически; исправление выполняется отменой или корректирующей записью с аудитом.
  8. Секреты API «Моё Дело» и Telegram не хранятся в БД CRM и не попадают в логи.
  9. Любое начисление связано с условиями назначения, сменой, разовой работой или подтверждённым завершением; свободное начисление требует текстового основания.
  10. Отсутствие отметки не создаёт штраф автоматически: оно создаёт только проблему табеля needs_review.

Приёмочные критерии P0

  • Given создан объект, when добавляются второй договор и несколько счетов, then все документы остаются в одной карточке с одним objects.code.
  • Given монтажник назначен на объект, when он закрывает почасовую или фиксированную смену, then CRM сохраняет часы, снимок ставки, сумму и черновик начисления.
  • Given ставка монтажника изменена, when открывается прошлая смена, then её ставка и сумма не изменяются.
  • Given сотруднику предложена разовая работа вне основной компетенции, when он принимает сумму, выполняет работу и менеджер подтверждает результат, then создаётся одно сдельное начисление на согласованную сумму.
  • Given назначение оплачивается после завершения объекта, when объект принят и закрыт, then CRM создаёт одно начисление и не дублирует его при повторном сохранении.
  • Given сотрудник отправил фото в привязанный чат объекта, when событие сопоставлено с сотрудником и объектом, then оно появляется в табеле как доказательство присутствия; незакрытая смена требует ручной проверки.
  • Given менеджер назначает штрафную корректировку, when отсутствуют причина или подтверждающий, then CRM не сохраняет операцию.
  • Given менеджер фиксирует аванс или зарплату, when строится отчёт за период, then отдельно видны начислено, выплачено и остаток.
  • Given документ синхронизирован из «Моё Дело», when пользователь открывает объект, then видит номер, дату, сумму и статус, а бухгалтерские данные остаются первичными в «Моё Дело».

API-эндпоинты (примерный список)

Auth: используется существующая, роли admin и manager имеют доступ.

Clients

  • GET /api/objects/clients — список с поиском/фильтром
  • POST /api/objects/clients — создать
  • GET /api/objects/clients/:id — детали (с полным списком его объектов)
  • PATCH /api/objects/clients/:id — обновить
  • DELETE /api/objects/clients/:id — soft-delete (только если нет объектов)

Objects

  • GET /api/objects — список с фильтрами (status, direction, clientId, managerId)
  • POST /api/objects — создать
  • GET /api/objects/:id — карточка с этапами, платежами, актами
  • PATCH /api/objects/:id — обновить
  • POST /api/objects/:id/convert-from-order/:orderId — конвертировать заявку в объект

Contracts

  • POST /api/objects/:objectId/contracts — создать
  • PATCH /api/contracts/:id — обновить
  • POST /api/contracts/:id/addendum — добавить допсоглашение

Stages

  • POST /api/contracts/:contractId/stages — добавить этап
  • PATCH /api/stages/:id — обновить (даты, суммы, статус)
  • POST /api/stages/:id/generate-monthly — для длинных: сгенерировать 12 ежемесячных этапов

Payments

  • POST /api/objects/:objectId/payments — зафиксировать платёж
  • PATCH /api/object-payments/:id — правка
  • DELETE /api/object-payments/:id — удаление

Acts

  • POST /api/objects/:objectId/acts — создать акт
  • PATCH /api/acts/:id — правка (статус, дата подписания)
  • POST /api/acts/:id/generate-pdf — сгенерировать PDF из шаблона (Этап 3, позже)

Сотрудники, смены и расчёты

  • GET /api/workers — сотрудники с должностями, назначениями и агрегатами за период
  • GET /api/workers/:id — карточка сотрудника
  • GET /api/objects/:objectId/assignments — состав бригады и условия назначений
  • POST /api/objects/:objectId/assignments — назначить монтажника
  • PATCH /api/object-assignments/:id — изменить роль, период или ставку по умолчанию
  • GET /api/timesheet — смены с фильтрами по сотруднику, объекту и периоду
  • POST /api/shifts/:id/close — закрыть смену и создать черновик начисления
  • POST /api/worker-accruals/:id/approve — подтвердить начисление
  • POST /api/worker-payments — зафиксировать выплату или аванс
  • GET /api/workers/:id/settlements?from=&to=&groupBy=object — объекты, часы, начислено, выплачено и остаток
  • GET /api/workers/:id/shifts?from=&to=&objectId= — детализация смен
  • GET /api/workers/:id/accruals?from=&to=&objectId= — детализация заработка
  • GET /api/workers/:id/payments?from=&to=&method= — история выплат и способов оплаты

Dashboards / отчёты

  • GET /api/reports/active-objects — активные с фильтрами
  • GET /api/reports/financials — сводка: договоров всего, получено, дебиторка
  • GET /api/reports/stuck — где что застряло
  • GET /api/reports/monthly-cashflow?year=2026 — денежный поток по месяцам

UI-страницы (React + Radix UI + Tailwind)

Все страницы в существующей админке /admin/.... Роли admin, manager.

Подробный дизайн карточки объекта, экрана монтажников, табеля, начислений и выплат зафиксирован в crm-object-workers-ui-spec.md.

  1. /admin/objects — список объектов с фильтрами и поиском
  2. /admin/objects/new — форма создания
  3. /admin/objects/:id — карточка объекта (табы: Общее, Договор, Этапы, Платежи, Акты, Документы, История)
  4. /admin/clients — список клиентов
  5. /admin/clients/:id — карточка клиента + все его объекты
  6. /admin/dashboards/objects — дашборд: активные / зависшие / финансы / cashflow
  7. /admin/reports/monthly — отчёт за месяц

Этапы реализации (roadmap)

Этап 1: Фундамент (~1-2 дня)

  • Написать миграцию Drizzle (все 6 новых таблиц)
  • Обновить storage.ts — CRUD-методы для новых сущностей
  • API: базовые CRUD для clients, objects, contracts (без сложной логики)
  • UI: список объектов + карточка + форма создания (без табов, только основное)
  • Развернуть на VPS, протестировать

Этап 2: Гибкие этапы и платежи (~2-3 дня)

  • Этапы договора: добавление, редактирование, автогенерация ежемесячных
  • Платежи по объекту с привязкой к этапам
  • Автопересчёт paidSum, статусов этапов
  • UI: табы "Этапы" и "Платежи" в карточке объекта

Этап 3: Акты и документы (~1-2 дня)

  • Таблица object_acts + CRUD
  • Прикрепление файлов актов (URL или локальное хранилище)
  • Генерация номера акта автоматически
  • UI: таб "Акты"

Этап 4: Дашборды и отчёты (~1-2 дня)

  • Дашборд активных объектов с фильтрами
  • Финансовая сводка
  • Cashflow по месяцам
  • Отчёт "где что зависло"

Этап 5: Интеграции и автоматизация (~2-3 дня)

  • Импорт клиентов из МойСклад (для тех, у кого уже есть msCounterpartyId)
  • Импорт из Excel Яндекс.Диска (парсер XLSX)
  • Импорт из Моё Дело API (если есть)
  • Cron: за N дней до дедлайна этапа — уведомление в Telegram
  • Cron: не подписанные акты > 14 дней — уведомление
  • Cron: 1-го числа каждого месяца — предложение создать ежемесячные акты для длинных договоров

Этап 6: Экспорт в KB для RAG (~0.5 дня)

  • Скрипт: экспорт всех объектов в .md файлы (frontmatter + описание) → пуш в git alatyr-infra-kb/objects/
  • Cron еженедельно
  • Теперь LLM через RAG может отвечать "покажи мои активные объекты у клиента N"

Итого: ~10-14 рабочих дней. Каждый этап — рабочая версия, можно останавливаться и использовать.


Интеграция с Моё Дело

Роли и границы ответственности

Моё Дело — источник правды для: - Реквизитов контрагентов (ИНН/КПП/ОГРН/банк) - Финализированных счетов, актов, накладных - Банковской выписки (платежи по безналу) - Кассовых операций - Всей первичной бухгалтерии

alatyr-service CRM — источник правды для: - Объектов и их статусов - Этапов договоров (расписание и связь "этап → счёт → платёж") - Внутренних заметок, документов производственного процесса - Оперативной аналитики (cashflow по объектам, зависшие суммы) - Уведомлений менеджерам

Схема обмена

                    ┌─────────────────────────────┐
                    │   Моё Дело (restapi.moedelo)│
                    │  ✓ выставленные счета       │
                    │  ✓ акты                     │
                    │  ✓ платежи (банк.выписка)   │
                    │  ✓ контрагенты              │
                    └───────┬─────────────────────┘
                            │ REST API + токен
              ┌─────────────┴──────────────┐
              │                            │
         ┌────▼─────┐                ┌─────▼──────┐
         │ PULL     │                │ PUSH       │
         │ (чтение) │                │ (создание) │
         └────┬─────┘                └─────┬──────┘
              │                            │
              │  cron 02:00 ежедневно      │  по кнопке в UI
              │  + кнопка "Обновить"       │  "Выставить счёт"
              │                            │  "Сформировать акт"
              │                            │
              └────────────┬───────────────┘
                    ┌──────▼──────────┐
                    │  alatyr-service │
                    │      CRM        │
                    └─────────────────┘

Направление 1: PULL (Моё Дело → CRM)

Cron sync-moedelo в 02:00 MSK + ручной триггер через кнопку "Обновить из МД":

Что читаем Эндпоинт МД (примерный) Куда пишем в CRM
Контрагенты GET /kontragents clients.mdCounterpartyId + обновление реквизитов
Счета GET /sales/bill mdInvoices + попытка авто-линка к contract_stages
Акты GET /sales/act object_acts + попытка авто-линка
Платежи GET /bankOperation или /cashFlow object_payments + попытка авто-линка

Правило работы с существующими записями: - Если запись из МД уже есть в CRM (по mdId) → обновляем поля. - Если нет и автоматически удаётся привязать (совпадает клиент+сумма+период) → создаём запись, статус linked. - Если нет и не удаётся привязать однозначно → создаём запись в отдельной таблице moedelo_unassigned со статусом needs_review.

Направление 2: PUSH (CRM → Моё Дело)

Только по действиям менеджера в UI:

Кнопка "Выставить счёт по этапу": 1. Менеджер открывает объект → этап → жмёт кнопку 2. CRM собирает данные: контрагент (уже слинкован с МД), сумма, назначение платежа 3. POST /sales/bill в МД со статусом draft 4. МД возвращает billId → сохраняем в contract_stages.mdInvoiceId 5. Бухгалтер в МД дорабатывает счёт (реквизиты, комментарии), финализирует и отправляет клиенту

Кнопка "Сформировать акт": Аналогично — черновик в МД, финализация и отправка бухгалтером.

Направление 3: Разбор существующих незакрытых данных

Это отдельный интерактивный процесс на старте, не автоматический импорт.

Новая таблица moedelo_unassigned:

export const moedeloUnassigned = pgTable('moedelo_unassigned', {
  id: varchar('id').primaryKey(),
  type: varchar('type', { length: 20 }).notNull(),  // invoice | act | payment | counterparty
  mdId: varchar('md_id').notNull(),                  // id в Моё Дело
  mdData: json('md_data').notNull(),                 // полный JSON из МД для анализа
  status: varchar('status', { length: 20 }).default('needs_review'),
  // needs_review — новый, ждёт менеджера
  // linked — привязан к объекту
  // ignored — помечен как "не создавать объект" (разовая работа/ошибка)
  // archived — из закрытых периодов, оставили в МД без CRM

  suggestedClientId: varchar('suggested_client_id'), // догадка автоматики
  suggestedObjectId: varchar('suggested_object_id'),
  resolvedAt: timestamp('resolved_at'),
  resolvedBy: varchar('resolved_by'),
  notes: text('notes'),

  createdAt: timestamp('created_at').defaultNow(),
});

Экран "Требуют разбора" (/admin/moedelo-review): - Список незакрытых счетов/актов/платежей из МД без привязки в CRM - По каждой записи: реквизиты клиента, сумма, дата, назначение - 4 кнопки действий: - "Это существующий объект в CRM" → выбор объекта из dropdown → привязка + создание клиента если новый - "Создать новый объект" → форма создания объекта, запись линкуется к новому этапу - "Разовая работа, объект не нужен" → status=ignored, храним справочно - "Ошибка/дубль" → status=archived, скрываем

Порядок первого разбора (рекомендую): 1. Первый запуск синхронизации тянет только незакрытые счета и акты (status ≠ paid и/или act не подписан). 2. Менеджер прогоняет их через экран разбора. 3. Заодно создаются объекты для действующих "P2P/наследных" клиентов. 4. Потом включаем cron ежедневной синхронизации на будущее.

Технические детали интеграции

Аутентификация: - Моё Дело API работает по токену (Authorization: Bearer <token>) - Токен получаем в интерфейсе МД: Настройки → Интеграция с партнёрами → включить и скопировать - Токен храним в .env как MOEDELO_API_TOKEN (никогда не в git) - Также MOEDELO_COMPANY_ID — id вашей организации в МД

Файл server/moedelo.ts (новый модуль):

// Основные функции:
export async function mdGetCounterparties(): Promise<MdCounterparty[]>
export async function mdGetInvoices(fromDate?: Date, status?: 'open' | 'all'): Promise<MdInvoice[]>
export async function mdGetActs(fromDate?: Date): Promise<MdAct[]>
export async function mdGetPayments(fromDate?: Date): Promise<MdPayment[]>

export async function mdCreateInvoiceDraft(payload: {
  counterpartyId: string;
  amount: number;
  vatRate: number;
  items: MdInvoiceItem[];
  comment?: string;
}): Promise<{ mdId: string; url: string }>

export async function mdCreateActDraft(payload: {
  counterpartyId: string;
  invoiceId?: string;
  amount: number;
  workDescription: string;
}): Promise<{ mdId: string; url: string }>

Обработка ошибок и лимитов: - МД API имеет rate limit — реализуем retry с экспоненциальным backoff - Ошибки 401/403 → бросаем в лог + уведомление админу (токен протух) - Ошибки 5xx → повтор через 30 сек, если не ушло — оставляем в очереди на следующий cron

Что делать если МД API недоступно (например тариф не поддерживает): - Fallback-режим: CSV-импорт вручную (менеджер выгружает из МД CSV, загружает в CRM через /admin/moedelo-import) - Логика разбора остаётся та же, меняется только источник

Cron-задачи для интеграции

// server/cron.ts — новые задачи

// Ежедневно 02:00 MSK — полная синхронизация
cron.schedule('0 2 * * *', async () => {
  await syncMoedeloCounterparties();
  await syncMoedeloInvoices({ status: 'all', since: last14days });
  await syncMoedeloActs({ since: last14days });
  await syncMoedeloPayments({ since: last14days });
  await autoLinkNewMoedeloRecords();
  await notifyManagerIfUnassignedCount();
}, { timezone: 'Europe/Moscow' });

// По кнопке — endpoint POST /api/admin/moedelo/sync-now
// делает то же самое, но синхронно с прогресс-баром в UI

Что нужно решить перед стартом реализации

  • Проверить, что ваш тариф МД поддерживает REST API (в личном кабинете Настройки → Интеграция с партнёрами)
  • Если не поддерживает — обсудить апгрейд тарифа или fallback на CSV
  • Получить MOEDELO_API_TOKEN и MOEDELO_COMPANY_ID, положить в .env VPS (без коммита в git)
  • Определить дату "водораздела" — с какого числа МД считается источником правды для CRM (например 2026-01-01 или 2025-01-01)

Telegram-модуль: задачи + табель

Зачем это нужно

Монтажники всегда с телефоном, привыкли к Telegram, не будут учиться новому приложению. Задачи и табель — через бота в TG, всё остальное — в CRM. Бот у нас уже есть (server/telegram.ts), расширяем его.

Структура чатов (гибрид)

Общий чат Alatyr · Общий — вся команда (менеджер, старшие, монтажники). Сюда: - Мелкие короткие объекты (1-3 дня) - Объявления, общие вопросы - Смены (/start и /end) - Задачи по мелким объектам с тегом #obj42

Отдельные чаты — только для крупных длинных объектов (месяц+). Создаётся через кнопку в CRM "Создать чат в TG" на карточке объекта: - Формат имени: [Obj#42] Ленина 10 · офис 4 этаж - Бот автодобавляется как админ - Менеджер приглашает вручную нужных сотрудников - При закрытии объекта (status=closed) бот архивирует чат (выйти всем из чата) через 14 дней

В CRM в карточке объекта поле tgChatId — куда бот постит. Если null → постит в общий чат с тегом.

Модуль 1: Задачи по объектам

Новая таблица object_tasks:

export const objectTasks = pgTable('object_tasks', {
  id: varchar('id').primaryKey(),
  objectId: varchar('object_id').references(() => objects.id).notNull(),
  section: varchar('section', { length: 20 }),  // skud/video/fire/network/general
  location: varchar('location', { length: 200 }), // "Кабинет 305", "3 этаж"
  title: varchar('title', { length: 200 }).notNull(),
  description: text('description'),

  status: varchar('status', { length: 20 }).default('open'),
  // open — открыта, ждёт выполнения
  // in_progress — взята в работу
  // done — выполнена, есть фото-доказательство
  // verified — проверена старшим группы
  // reopened — откачено назад (плохо сделано)

  priority: varchar('priority', { length: 10 }).default('normal'), // low/normal/high/urgent
  assignedTo: varchar('assigned_to').references(() => users.id),
  createdBy: varchar('created_by').references(() => users.id).notNull(),

  // Telegram привязка
  tgChatId: bigint('tg_chat_id', { mode: 'number' }),
  tgMessageId: bigint('tg_message_id', { mode: 'number' }), // где бот постил задачу

  // Фото (в виде JSON списка)
  photosBefore: json('photos_before').default([]), // как было
  photosAfter: json('photos_after').default([]),   // как стало

  completedAt: timestamp('completed_at'),
  verifiedAt: timestamp('verified_at'),
  verifiedBy: varchar('verified_by').references(() => users.id),

  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

Сценарий создания задачи (менеджер): 1. Открывает карточку объекта → вкладка "Задачи" → "+ Новая задача" 2. Заполняет: раздел (СКД/видео/ОПС), место ("Кабинет 305"), описание ("Ригель не установлен"), приоритет, кому (старший или конкретный монтажник) 3. Может прикрепить фото "как есть" 4. CRM создаёт запись в object_tasks 5. Бот постит в чат объекта (или общий с тегом):

🔴 Задача #142 · Объект № 42
«Ригель не установлен»
📍 Кабинет 305
🔧 Раздел: СКД
👥 Назначен: @ivan_montazhnik
⚡ Приоритет: высокий

[📷 Фото "как есть"]

⬇️ Ответьте на это сообщение фото + /done когда готово

Сценарий закрытия задачи (монтажник): 1. Приходит на кабинет, делает работу 2. Reply на сообщение бота + 1-N фото + текст /done (или кнопка "Готово" под сообщением) 3. Бот: - Скачивает фото → сохраняет в /opt/alatyr-service/files/tasks/142/ - Пишет в photosAfter список URL - Меняет статус задачи: open → done - Обновляет исходное сообщение: 🔴 → ✅ - Постит ответом: ✅ Задача #142 закрыта · @ivan_montazhnik · 3 фото 4. Старший группы получает уведомление в личке: "Проверьте задачу #142" 5. Старший может либо /verify 142 (перевод в verified), либо /reopen 142 <причина> (возврат)

Сценарий "что готово по кабинету":

Команды бота в чате объекта: - /status — общая сводка по объекту по всем разделам - /kab 305 — только по кабинету 305 - /section СКД — только по разделу СКД - /todo — мои задачи на сегодня

Ответ бота на /kab 305:

📍 Кабинет 305 · Объект № 42

✅ СКД (2/2 задач)
✅ Видео (1/1)
🟡 ОПС (0/2) — @petya, @vasya
⚪️ Сеть (не начато)

Модуль 2: Табельный учёт человеко-часов

Новая таблица worker_shifts:

export const workerShifts = pgTable('worker_shifts', {
  id: varchar('id').primaryKey(),
  workerId: integer('worker_id').references(() => users.id).notNull(),
  objectId: integer('object_id').references(() => objects.id).notNull(),
  assignmentId: integer('assignment_id').references(() => objectAssignments.id),

  // Начало смены
  startedAt: timestamp('started_at').notNull(),
  startPhotoUrl: varchar('start_photo_url'),        // локальный путь
  startTgChatId: bigint('start_tg_chat_id', { mode: 'number' }),
  startTgMessageId: bigint('start_tg_message_id', { mode: 'number' }),

  // Конец смены
  endedAt: timestamp('ended_at'),
  endPhotoUrl: varchar('end_photo_url'),
  endTgChatId: bigint('end_tg_chat_id', { mode: 'number' }),
  endTgMessageId: bigint('end_tg_message_id', { mode: 'number' }),

  // Расчёт
  hoursWorked: decimal('hours_worked', { precision: 5, scale: 2 }),  // 8.50 = 8в8 30мин
  breakMinutes: integer('break_minutes').default(0),                 // вычет обеда
  rateType: varchar('rate_type', { length: 20 }).notNull(),          // hourly/shift/piece/percent
  rateValue: decimal('rate_value', { precision: 12, scale: 2 }),     // снимок ставки на момент смены
  payAmount: decimal('pay_amount', { precision: 10, scale: 2 }),     // вычисляемое

  status: varchar('status', { length: 20 }).default('active'),
  // active — смена открыта
  // closed — закрыта корректно
  // disputed — старший оспорил
  // auto_closed — автозакрыта через 12ч без /end

  disputedBy: integer('disputed_by').references(() => users.id),
  disputedReason: text('disputed_reason'),

  notes: text('notes'),

  createdAt: timestamp('created_at').defaultNow(),
});

Сценарий начала смены: 1. Монтажник пришёл, переоделся, в чате объекта (или общем с тегом) отправляет: фото + /start (или /start #obj42 в общем чате) 2. Бот: - Определяет workerId по tg_user_id → users.telegramUserId - Определяет objectId по чату или тегу - Сохраняет фото → startPhotoUrl - Создаёт worker_shifts со статусом active, startedAt=now - Отвечает: ✅ @ivan_montazhnik начал смену на Объекте № 42 · 09:15

Сценарий конца смены: 1. Монтажник: фото + /end 2. Бот: - Находит активную смену этого монтажника - Считает hoursWorked = endedAt - startedAt - breakMinutes/60 - Стандартный обед: 60 мин если смена > 6ч, 0 если меньше - Для hourly: payAmount = hoursWorked × rateValue - Для shift: payAmount = rateValue - Копирует условия в смену и создаёт черновик worker_accruals - Статус → closed - Отвечает: ✅ Смена закрыта · 8ч 30мин · @ivan_montazhnik

Автозакрытие забытых смен: - Cron ежечасно: если смена активна > 12ч → статус auto_closed, hoursWorked=null → старший группы видит в табеле и проставляет вручную

Экран /admin/timesheet: - Фильтры: монтажник, объект, месяц - Таблица смен: дата, старт, конец, часы, сумма, статус - Клик на смену → модалка с фото начала/конца + кнопка "Оспорить" для старшего - Итого за месяц: часы, сумма к выплате, в том числе disputed - Кнопка "Экспорт в Excel" для бухгалтерии

Привязка к Telegram-аккаунту

Новые поля в users:

telegramUserId: bigint('telegram_user_id', { mode: 'number' }).unique(),
telegramUsername: varchar('telegram_username', { length: 50 }),
telegramLinkedAt: timestamp('telegram_linked_at'),

Должность и резервная ставка хранятся в employee_profiles, а ставка конкретного объекта — в object_assignments.

Механика привязки: 1. Менеджер в CRM → карточка монтажника → "Пригласить в Telegram" 2. CRM генерит одноразовый код вида LINK-a7f3 (ттл 24ч) 3. Монтажник открывает бота (делимся ссылкой t.me/alatyr_worker_bot), шлёт /start LINK-a7f3 4. Бот сохраняет его tg_user_id в users.telegramUserId, код гасится 5. С этого момента бот знает, кто пишет в чатах

Без привязки: бот игнорирует команды и тихо пишет в личку менеджеру: "Неизвестный пользователь @unknown пишет в чате Объекта 42".

План реализации (единый этап)

Этап 4.5 (между "Дашборды" и "Интеграции"): Telegram-модуль — ~3-4 дня

  • Миграция: новые поля в users, таблицы object_assignments, object_tasks, worker_shifts, worker_accruals, worker_payments
  • server/telegram.ts — расширение существующего:
  • handleStart (с кодом привязки)
  • handleTaskDone (reply на задачу + фото)
  • handleShiftStart (/start в чате + фото)
  • handleShiftEnd (/end в чате + фото)
  • handleStatus, handleKab, handleSection, handleTodo
  • handleVerify, handleReopen (для старшего)
  • Модуль хранения фото: скачивание через Telegram API, сохранение в /opt/alatyr-service/files/
  • API endpoints в server/routes.ts:
  • POST /api/objects/:id/tasks (создание задачи → пост в TG)
  • PATCH /api/tasks/:id (верификация/откат)
  • POST /api/users/:id/tg-invite (генерация кода привязки)
  • GET /api/timesheet (табель за период)
  • POST /api/shifts/:id/dispute (старший оспаривает)
  • POST /api/worker-accruals/:id/approve (подтверждение начисления)
  • POST /api/worker-payments (выплата/аванс)
  • UI: вкладка "Задачи" в карточке объекта
  • UI: экран /admin/timesheet
  • UI: кнопка "Пригласить в TG" в карточке монтажника
  • Cron: автозакрытие смен > 12ч
  • Cron: напоминание старшему о непроверенных задачах (вечером)

Что откладываем на потом

  • OCR заводских табличек/кабельных подписей по фото (можно Vision API + LLM латер)
  • Гео-верификация (вы выбрали не нужна)
  • Автораспознавание кабинета/объекта по фото (лента CV)
  • Автоматический расчёт сдельных и процентных выплат (поля в схеме предусмотрены; в MVP доступна ручная запись начисления)
  • Авто-создание группы TG через API (бот не может, нужен userbot — сложно). На первое время — вручную

Источники данных и учёт (решено 2026-08-06)

Мультитенантность: 2 ИП в одной CRM

Ведём два ИП в одном инстансе CRM:

  • ИП Головин Роман (стройка + ОПС, основной оборот)
  • ИП Головин Артём Романович (сын, направление "Коробочка" — локальные услуги)

Владелец (Роман) видит и ведёт оба ИП.

Логика разграничения (какой договор на какое ИП)

Критерий ИП Головин Роман ИП Головин Артём (Коробочка)
Тип работ Крупная стройка, ОПС, коммерческие объекты Мелкие частные заказы, локальные услуги
Тип клиента Юрлица (ИП/ООО), редко физлица Физлица, самозанятые
Сумма От 500 тыс до 10+ млн До ~500 тыс
Срок Месяцы–годы Дни–недели
Сложность/риск Высокая (субподряд, сложные системы) Низкая — простые безопасные действия
Разделы skud, video, fire, network, construction, low_voltage мелкие части skud/video/network + локальные услуги (печать и т.д.)
Договор Всегда Опционально или обезличенный чек
Форма работ Этапы, аванс + акты Целиком за 1-2 выезда
Поток в CRM Через objects (основной модуль) Через quick_orders с сайта или ручной ввод

Правило маршрутизации по умолчанию (default routing): 1. Заявка с сайта (quick_orders) < 500 тыс и клиент-физлицо → Коробочка (Менеджер видит подсказку "Рекомендуемо: ИП Коробочка", может переопределить) 2. Новый object без явного выбора → Головин (как основное ИП) 3. При создании объекта — обязательный селектор ownCompanyId в UI

Почему так: Артём ведёт собственное ИП, не искушён в крупных контрактах и субподряде. На его ИП — только мелкие безопасные действия (низкий чек, короткие сроки, малая ответственность). Крупные стройки — на ИП Головин Роман, который несёт основной риск и оборот.

Структура в БД

Новая таблица own_companies:

export const ownCompanies = pgTable('own_companies', {
  id: varchar('id').primaryKey(),
  code: varchar('code', { length: 20 }).unique().notNull(),  // 'golovin', 'korobochka'
  legalName: varchar('legal_name', { length: 200 }).notNull(),
  ownerName: varchar('owner_name', { length: 200 }).notNull(),
  inn: varchar('inn', { length: 12 }).notNull(),
  ogrnip: varchar('ogrnip', { length: 15 }),
  addressLegal: text('address_legal'),
  bankName: varchar('bank_name', { length: 200 }),
  bankBik: varchar('bank_bik', { length: 9 }),
  bankAccount: varchar('bank_account', { length: 20 }),
  bankCorrAccount: varchar('bank_corr_account', { length: 20 }),
  mdCompanyId: varchar('md_company_id'),  // ID компании в Моё Дело
  active: boolean('active').default(true),
  createdAt: timestamp('created_at').defaultNow(),
});

Поле ownCompanyId добавляется в: - contracts (каждый договор от лица одного из ИП) - object_payments (платежи приходят на счёт конкретного ИП) - object_acts (акт выставляется от конкретного ИП)

Фильтр в UI: переключатель "Все / ИП Головин / ИП Коробочка" в шапке дашборда. Отчёты можно строить по любому ИП или по обоим.

Налоговая логика: каждое ИП имеет свой лимит по УСН/ПСН (60 млн→150 млн/год в 2026). Коробочка как автономный ИП с мелкими заказами — разгружает оборот Головина, не конкурирует с крупными клиентами.

Источники данных (кто первичен для чего)

Тип данных Первичный источник Куда попадает Как синхронизируется
Мои реквизиты (2 ИП) alatyr-infra-kb/my-companies.md + синхронный snapshot в коде RAG + build-time seed в own_companies При деплое идемпотентный upsert по code, без доступа production к KB
Реквизиты контрагентов Моё Дело API Таблица clients в БД CRM cron 02:00 sync-moedelo + ручной триггер
Справочник услуг/разделов alatyr-infra-kb/services-catalog.md RAG + build-time seed в service_categories При деплое CRM
Объекты и их внутренние коды CRM (первичный источник) objects Код создаётся один раз, экспорт агрегатов в RAG еженедельно
Договоры, этапы и управленческие платежи CRM (первичный источник) БД CRM Ручной ввод + сверка с Моё Дело
Назначения, смены, ставки, начисления и выплаты монтажникам CRM (первичный источник) БД CRM Telegram + админка; исторические ставки хранятся снимками
Первичка (счета, акты в МД) Моё Дело moedelo_document_refs хранит связь и оперативный снимок Синхронизация по ownCompanyId + mdId; содержимое первично в МД
Банковская выписка Моё Дело Читается через API для сверки платежей Не дублируем

Ключевой принцип: реляционные данные (клиенты, объекты, договоры) — в БД CRM. В RAG только: 1. Статичные справочники (мои ИП, услуги, разделы) 2. Агрегаты по объектам (текстовое описание для семантического поиска)

Попытка сложить контрагентов в RAG как markdown-файлы приведёт к рассинхрону и невозможности делать JOIN'ы "все объекты клиента с ИНН X".

Финансовый учёт: уровни

Решено вести уровень B (управленческий по объектам) в этой CRM.

  • A (бухгалтерский): только Моё Дело, не трогаем
  • B (управленческий): CRM — cashflow по объектам, зависшие деньги, маржа, готовность к сдаче
  • C (личный кэш): не в этой CRM, отдельно (Дзен-Мани или отдельный модуль позже)

Дашборд "Финансы" показывает: - Общий cashflow (сумма договоров / получено / остаток / просрочено) - Разбивка по ИП (Головин / Коробочка / оба) - По статусам объектов (в работе / пауза / сдан) - Топ клиентов по обороту - Зависшие суммы (акт подписан > 14 дней, оплаты нет)

API-токены и секреты

Все токены хранятся в Vaultwarden, никогда в git и никогда в чат Perplexity.

Секрет Где живёт Куда попадает на VPS
MD_TOKEN_GOLOVIN Vaultwarden /etc/alatyr-service.env
MD_TOKEN_KOROBOCHKA Vaultwarden /etc/alatyr-service.env
MOEDELO_SYNC_ENABLED Не секрет; feature flag /etc/alatyr-service.env, только после production-проверки
TELEGRAM_BOT_TOKEN Vaultwarden /etc/alatyr-service.env (уже есть)

Порядок получения токенов МД: 1. Войти в личный кабинет МД под каждым ИП 2. Настройки → Интеграция с партнёрами → API-токен → сгенерировать 3. Записать в Vaultwarden как отдельные записи Моё Дело API — ИП Головин и Моё Дело API — ИП Коробочка 4. Скопировать в .env на VPS через SSH 5. Проверять API только локально в SSH-сессии, передавая токен в заголовке md-api-key; значение токена не писать в историю shell, git или чат.


Открытые вопросы

  • Первая версия хранит файлы локально на VPS в /var/lib/alatyr-service/object-documents, вне git и каталога деплоя. Метаданные находятся в objects.documents; доступ к upload/download/delete только у manager/admin, лимит 25 МБ, права каталогов 700, файлов 600. Отдельный backup и перенос в S3/объектное хранилище остаются последующим инфраструктурным этапом.
  • Какие системные права дать должностям brigadier, foreman и itr по умолчанию? Должность сама по себе доступ не предоставляет.
  • Нужен ли учёт себестоимости (материалы, субподряд) в первой версии, или это Этап 5+?
  • Работа с проходными платежами (клиент оплачивает субподрядчику напрямую)?
  • Автоматическое формирование счёта на этап для отправки заказчику (PDF)?
  • Обязательна ли двойная система учёта (мы для себя vs что заказчик подписывает)?
  • Кто подтверждает начисления по сменам: только admin/manager или назначенный прораб с отдельным permission?
  • Как распределять одну общую выплату сотруднику по нескольким объектам: вручную или FIFO по подтверждённым начислениям?

Что делать сейчас

  1. На production вручную проверить авторизованный сценарий: заявка → счёт → оплата → объект.
  2. Отдельно проверить импорт счетов с каждым токеном Моё Дело и только после этого включить MOEDELO_SYNC_ENABLED=true.
  3. Добавить пагинацию импорта Моё Дело свыше первых 100 счетов.
  4. Добавить отдельный backup каталога /var/lib/alatyr-service/object-documents.
  5. Перед реальными выплатами определить правило контроля: запрещать выплату сверх утверждённого остатка или разрешать с отдельным override и аудитом.

Не берём в первую итерацию: - Генерация PDF актов и счетов - Автоматическое включение production-sync Моё Дело без ручной проверки - Экспорт в RAG - Роль "прораб"

Это добавим позже, когда основа заработает.