ТЗ: 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"),
});
Ключевые связи и правила¶
clients1 → Nobjects— у клиента может быть много объектовobjects1 → Ncontracts— объект не меняется при появлении нового договора или допсоглашенияcontracts1 → Ncontract_stages— гибкий графикcontracts1 → Nobject_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с FKown_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(),
});
Правила автоматизации:
- У объекта явно задаются разрешённые Telegram/MAX-чаты; сообщение из чужого чата не считается подтверждением присутствия.
- Кнопка
Пришёлили первое подтверждённое событие дня может открыть смену. - Кнопка
Ушёлзакрывает смену. Фотоотчёт подтверждает присутствие в момент снимка, но не назначает время ухода автоматически. - Если смена не закрыта, она получает
needs_review; часы и деньги не подтверждаются автоматически. - Ручная отметка содержит автора, причину и исходное/новое время в аудите.
- Геолокация и распознавание лица не требуются для 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), назначение и расчётный период; - всего выплачено;
- остаток:
подтверждённые начисления − выплаты; - спорные, неподтверждённые и отменённые записи показываются отдельно и не входят в итог к выплате.
Итоги доступны в двух разрезах:
- По объектам: объект → часы → начислено → выплачено → остаток.
- По времени: день/неделя/месяц → смены → начисления → выплаты.
Формула итогов должна выполняться сервером. UI не пересчитывает деньги самостоятельно.
Обязательные бизнес-правила¶
objects.codeуникален и после создания доступен только для чтения.- Один объект может иметь несколько договоров, счетов и актов.
- Номер документа не считается уникальным без
ownCompanyIdиmdId. - Ставка копируется в смену/начисление в момент закрытия; прошлые расчёты не пересчитываются автоматически.
- Закрытая смена создаёт черновик начисления, который подтверждает менеджер или прораб.
- Выплата не меняет смену: она уменьшает остаток долга перед сотрудником.
- Смены, начисления, выплаты и финансовые документы не удаляются физически; исправление выполняется отменой или корректирующей записью с аудитом.
- Секреты API «Моё Дело» и Telegram не хранятся в БД CRM и не попадают в логи.
- Любое начисление связано с условиями назначения, сменой, разовой работой или подтверждённым завершением; свободное начисление требует текстового основания.
- Отсутствие отметки не создаёт штраф автоматически: оно создаёт только
проблему табеля
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.
/admin/objects— список объектов с фильтрами и поиском/admin/objects/new— форма создания/admin/objects/:id— карточка объекта (табы: Общее, Договор, Этапы, Платежи, Акты, Документы, История)/admin/clients— список клиентов/admin/clients/:id— карточка клиента + все его объекты/admin/dashboards/objects— дашборд: активные / зависшие / финансы / cashflow/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 + описание) → пуш в gitalatyr-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, положить в.envVPS (без коммита в 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,handleTodohandleVerify,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 по подтверждённым начислениям?
Что делать сейчас¶
- На production вручную проверить авторизованный сценарий: заявка → счёт → оплата → объект.
- Отдельно проверить импорт счетов с каждым токеном Моё Дело и только после этого
включить
MOEDELO_SYNC_ENABLED=true. - Добавить пагинацию импорта Моё Дело свыше первых 100 счетов.
- Добавить отдельный backup каталога
/var/lib/alatyr-service/object-documents. - Перед реальными выплатами определить правило контроля: запрещать выплату сверх утверждённого остатка или разрешать с отдельным override и аудитом.
Не берём в первую итерацию: - Генерация PDF актов и счетов - Автоматическое включение production-sync Моё Дело без ручной проверки - Экспорт в RAG - Роль "прораб"
Это добавим позже, когда основа заработает.