ТЗ: расширенная карточка объекта (v2)¶
Автор: сессия Perplexity Computer, 11.08.2026 (продолжение) Статус: черновик v0.1, ожидает утверждения приоритетов и открытых вопросов Владелец: Роман Головин Связано с задачами: 51 (parent), 41c (Моё Дело syncBills — становится частью пункта 2), 41b (syncClients — становится частью пункта 2)
Контекст¶
Базовая карточка объекта уже развёрнута в production (задачи 49/50/50a/50b/50c):
clientId, ownCompanyId, code, name, address, objectTypes[],
status, duration, назначения сотрудников, условия оплаты, attendance,
смены, разовые работы, начисления/выплаты, objectDocuments (contract,
invoice, act, project, specification, photo, other).
Владелец переопределил задачу 41c: вместо механического включения syncBills
на пустой CRM — сначала расширить карточку объекта до реального рабочего
инструмента, потом на неё натянуть Моё Дело. Ниже — то, что должно быть в
карточке.
Блок 0 — хранилище файлов (предваряет все остальные)¶
Цель¶
Сейчас в server/routes.ts файлы документов кладутся на локальный диск VPS
(/var/lib/alatyr-service/object-documents/{objectId}/{storageName}). Это
не масштабируется, не бэкапится отдельно от КРМ и не даёт владельцу доступ
к файлам в привычном файл-менеджере. Владелец хочет:
- Хранить файлы в Yandex.Disk (WebDAV) и/или на собственном файловом
сервере на
ragserver(192.168.1.200) — чтобы файлы были доступны из сети, бэкапились вместе с RAG-данными и были доступны без веб-админки. - При создании объекта автоматически создавать структуру папок по типам документов. Когда сотрудник загружает файл, он сразу ложится в нужную папку без случайных UUID-названий.
Предлагаемая архитектура: storage adapter¶
В server/storage-adapters/ вводим абстрактный интерфейс:
export interface FileStorageAdapter {
ensureObjectStructure(objectId: number, code: string): Promise<void>;
putFile(
objectId: number,
docType: ObjectDocumentType,
filename: string,
buffer: Buffer,
mimeType: string,
): Promise<StoredFileRef>;
getFile(ref: StoredFileRef): Promise<{ stream: Readable; size: number }>;
deleteFile(ref: StoredFileRef): Promise<void>;
listObjectFiles(objectId: number): Promise<StoredFileRef[]>;
}
export interface StoredFileRef {
backend: "local" | "yandex_disk" | "ragserver_smb" | "ragserver_webdav";
path: string; // относительный путь внутри backend’a
publicUrl?: string; // есль publish shared link, только опционально
}
Реализации:
- LocalFsAdapter — то, что сейчас (backup / fallback).
- YandexDiskAdapter — WebDAV к https://webdav.yandex.ru/, auth Basic
(логин + application password) или OAuth2 токен Yandex ID. WebDAV
нативно поддерживается Яндексом, без дополнительного API-слоя.
- RagserverAdapter — через WebDAV (nginx + dav_ext_module) или SMB
(samba). VPS ходит к ragserver через AmneziaWG (туннель уже есть
для rag-api.alatyr-service.ru → 10.8.1.12:3000), добавим ещё одну
route для файлового сервера (напр. 10.8.1.12:8081 для WebDAV).
Backend выбирается по ENV FILE_STORAGE_BACKEND=local|yandex_disk|ragserver.
В базе object_documents.storage_backend + storage_path заменяют
текущее storageName (с миграцией данных).
Структура папок объекта (автогенерация при создании)¶
Корень объекта — код объекта (например OBJ-042 или человеко-читаемое
короткое название, так чтобы владелец видел в Яндекс.Диске внятный
список):
/alatyr-service/objects/{OBJ-042}--{slug-названия-объекта}/
├─ 01-договор/
│ ├─ основной/
│ └─ допники/
├─ 02-счета/
│ ├─ выставленные/
│ └─ оплаченные/
├─ 03-акты/
├─ 04-проект/
│ └─ revisions/ # версии: v1_2026-08-11_first.dwg
├─ 05-спецификации/
├─ 06-фото/ # монтаж, объект "до/после"
├─ 07-выписки-мд/ # автоимпорт из Моё Дело
├─ 08-письма/
│ ├─ входящие/
│ └─ исходящие/
├─ 09-чаты/ # автовыгрузка вложений из Telegram/MAX
└─ 99-прочее/
Привязка к objectDocumentTypeEnum:
| Папка | objectDocumentTypeEnum |
|---|---|
| 01-договор/основной | contract |
| 01-договор/допники | contract_annex (новое значение) |
| 02-счета/* | invoice |
| 03-акты | act |
| 04-проект | project |
| 05-спецификации | specification |
| 06-фото | photo |
| 07-выписки-мд | bank_statement (новое значение) |
| 08-письма | letter (новое значение) |
| 09-чаты | chat_attachment (новое значение) |
| 99-прочее | other |
Создание структуры вызывается из POST /api/admin/objects сразу
после insert в таблицу objects. Если создание папок на backend'e
не удалось — объект создаётся (backend down не блокирует работу
менеджеров), но в UI выводится баннер «Хранилище частично
недоступно, retry» и есть кнопка «Создать структуру повторно».
Автоматическая раскладка при загрузке файла¶
При загрузке в UI сотрудник выбирает type: ObjectDocumentType; backend
кладёт файл в соответствующую папку без переименования в UUID —
сохраняет оригинальное имя + при коллизии добавляет суффикс _2, _3
и т.д. На стороне владельца в Яндекс.Диске все файлы читаемые, не мусорные.
Решения по блоку 0 (владелец, 11.08.2026)¶
- 0.1 → Сначала делаем только ragserver-бэкенд. Yandex.Disk откладываем —
вернёмся, когда понадобится внешняя синхронизация или отчуждённый
бэкап.
LocalFsAdapterостаётся как fallback в случае недоступности ragserver'а (туннель AmneziaWG лёг или сам ragserver выключен). - 0.2 → Отложено вместе с Yandex.Disk.
- 0.3 → Обсудим отдельно, когда дойдём до разворачивания файлового сервера на ragserver. Агент должен переспросить в этот момент с развёрнутыми вариантами конфигов (WebDAV vs SMB, TLS vs внутренняя сеть, авторизация).
- 0.4 → На первом этапе только
adminиmanagerвидят все файлы объекта. Гранулярные права по ролям (монтажники/техники видят только проекты/фото/журнал) — отдельная задача на будущее, после того как базовая карточка заработает.
Скоуп 51-storage после решений¶
Из четырёх подзадач остаются три:
- 51-storage-a — abstract
FileStorageAdapterинтерфейс +LocalFsAdapter(рефакторинг текущего кода в 3 точкахserver/routes.ts). - 51-storage-c —
RagserverAdapter(WebDAV или SMB — решаем в 0.3), выход через AmneziaWG, новая route в туннеле до ragserver'а. - 51-storage-d — автогенерация структуры папок при создании
объекта + auto-routing по
docTypeпри загрузке + гард по ролям (admin/manager only).
51-storage-b (Yandex.Disk) выносится в отложенные задачи — вернёмся к
ней после того как ragserver-backend отработает в бое. Интерфейс
FileStorageAdapter с самого начала проектируем без backend-специфики,
чтобы добавить YandexDiskAdapter потом без переделки вышележащего кода.
—
7 функциональных блоков карточки объекта¶
Правило названия и технического идентификатора¶
- В заголовке и паспорте показывается человеческое название объекта.
- Если импорт из МоёДело не вернул название проекта, CRM формирует
Объект заказчика «Название заказчика». - Существующие названия-заглушки вида
Проект МоёДело #IDзаменяются по тому же правилу при запуске приложения. Введённые вручную названия не меняются. - Технический ключ
md-{companyCode}-{projectId}остаётся внутренним полем для идемпотентности импорта и не показывается как название или реквизит паспорта.
1. Договор и допники¶
Что делаем:
- Отдельный раздел «Договор» в карточке объекта, файловый — можно загружать
файлы (PDF/DOCX/сканы) прямо в карточку.
- Внутри этого раздела — подраздел «Допники» с той же логикой: файлы,
привязанные именно к этому договору.
- Поля файла: название, тип (основной договор / допник), дата подписания,
сумма, комментарий, кто загрузил, uploadedAt.
- Отдельно — поле «связь с договором в Моё Дело» (mdContractId, nullable):
если у нас есть договор в МД, можно вручную указать его ID; если нет —
файл всё равно хранится локально и никак не блокируется.
Схема:
- Новая таблица object_contracts (id, objectId, kind: main|annex,
parentContractId nullable, signedAt, amount, mdContractId nullable,
createdBy, createdAt).
- В существующей object_documents расширить type — добавить
contract_annex (или лучше — вынести договоры в отдельную таблицу и
оставить object_documents только для проектов/писем/прочего).
- Файлы кладутся на диск по тому же принципу, что уже работает (/data/...),
либо в S3 — решаем в подзадаче реализации.
Решение 1.1 (11.08.2026): → Выделяем object_contracts. Отдельная
таблица с колонками (id, objectId, kind: main|annex, parentContractId
на self-reference для допников, signedAt, amount, mdContractId nullable,
fileUrl ссылкой в storage adapter, createdBy, createdAt). В object_documents
тип contract остаётся только для уже залитых файлов — новые пишутся
в object_contracts. Миграция старых договоров (если будут к моменту
выкатки) — как часть 51a-миграции.
2. Моё Дело: автоподтягивание счетов и выписок по договору¶
Что делаем:
- В карточке объекта (внутри блока договора или отдельным блоком «Финансы»)
показываем список счетов и актов из МД, привязанных к этому объекту.
- Триггер синка не по расписанию, а по «есть договор с известным
mdContractId / есть клиент с mdCounterpartyId → подтяни всё, что МД
знает по этой связке».
- Полученные счета/акты пишутся в CRM с mdBillId / mdActId, к ним
подшивается PDF из МД (если API отдаёт), показывается статус оплаты.
- Выписки: смотрим, отдаёт ли МД API движения по счёту (/finance/api/v1/*
из postman-коллекции). Если да — тоже подтягиваем в карточку.
Что открывается (заменяет 41c и 41b): - Сначала read-only импорт (kontragents + bills + acts) в CRM, без создания новых счетов из CRM. Только просмотр. - Потом уже создание счетов из CRM в МД — это отдельный этап с feature flag.
Пред-условия:
- Клиенты в CRM должны иметь mdCounterpartyId (это задача 41b — теперь
часть этого блока).
- Объект должен иметь либо mdContractId, либо цепочку client.mdCounterpartyId
+ own_company для однозначной привязки счетов.
Решение 2.1 (11.08.2026, изменено Романом 23.08.2026 после разведки API):
- Счета старше 1 октября 2025 года — не обрабатываются и не учитываются
в impl 51f. В коде зашиваем константу
MOEDELO_BILLS_MIN_DATE = '2025-10-01';listBillsфильтрует поdocAfterDate, чтобы МД не отдавал всю историю; при импорте дополнительно отбрасываем любой счёт с датой выставления< 2025-10-01(защита от сбоя фильтра на стороне МД). - Остальные случаи (клиент есть, объектов несколько, нет клиента в CRM и т.д.) обсуждаются по каждому счёту отдельно в процессе внедрения 51f. В UI — папка «Неразобранные счета МД» на уровне admin-дашборда (вне карточки объекта), где владелец/менеджер вручную вешает счёт на конкретный объект (или помечает как «не по CRM»).
Статус реализации 23.08.2026: PR
alatyr-service#75
merged, production deploy
прошёл. Реализованы read-only preview, staging
moedelo_import_queue, транзакционные решения create/link/reject,
cursor-пагинация и admin-only UI. Доступ разделён двумя флагами:
MOEDELO_IMPORT_ENABLED=true разрешает только GET-импорт для preview, а
MOEDELO_SYNC_ENABLED=true по-прежнему отдельно управляет legacy
sync/push и createBill. Перед первым production preview legacy sync должен
оставаться выключенным. Первый реальный production preview выполнен
23.08.2026 без apply: staging заполнен проектами обоих ИП, суммы счетов и
оплат отображаются. TODO 51f закрыт; ручные решения по строкам очереди
выполняются отдельно по мере разбора.
3. Проекты и их изменения (файлы + история)¶
Что делаем: - Раздел «Проекты» в карточке — файлы проектов (DWG, PDF, PNG). - У каждого проекта — история версий: при загрузке нового файла старый не удаляется, а помечается как предыдущая версия. Обязательные поля: дата изменения, комментарий что именно изменили, кто загрузил.
Схема:
- Новая таблица object_projects (id, objectId, title, currentFileUrl,
currentVersion, createdAt, updatedAt).
- Таблица object_project_revisions (id, projectId, version, fileUrl,
originalName, uploadedAt, uploadedBy, changeSummary).
4. Письма по объекту¶
Что делаем: - Раздел «Письма/переписка» в карточке — файлы (PDF-скан, EML, DOCX) + краткое описание, дата, направление (входящее/исходящее), контрагент.
Схема:
- Таблица object_letters (id, objectId, direction: in|out,
correspondentName, subject, receivedAt, fileUrl, comment, createdBy,
createdAt).
5. Учёт материалов: поставлено vs смонтировано¶
Что делаем: - В карточке — список материалов на этом объекте. Каждая позиция: наименование, единица, количество поставлено, количество смонтировано, дата поставки, дата монтажа, ответственный, ссылка на счёт/накладную. - Показатель «на складе объекта = поставлено − смонтировано» считается автоматом.
Схема:
- Таблица object_materials (id, objectId, name, unit, deliveredQty,
installedQty, deliveredAt, installedAt, invoiceRef, responsibleUserId,
createdAt).
- Возможно, дополнительная таблица движений (object_material_movements)
если понадобится история изменений количества.
Решение 5.1 (11.08.2026): → Гибрид.
- Основной путь — подтягиваем из МойСклад. При добавлении позиции в UI
— автокомплит из номенклатуры (
server/moysklad.tsуже есть), подгрузка лайв по API с кэшем на час. СохраняемmskladProductId, наименование, единицу, артикул. - Запасной путь — ручной ввод, если позиции в МойСклад нет или
API недоступен. Колонка
mskladProductIdnullable, в UI — тоггл «ввести вручную» для таких позиций.
6. Журнал работ¶
Что делаем: - Хронологический журнал в карточке объекта. Каждая запись: дата, автор, текст «что сделано», приложения (фото/файлы), опционально — привязка к сотруднику/смене/задаче. - Читать могут все, кто имеет доступ к объекту; писать — назначенные сотрудники и менеджеры.
Схема:
- Таблица object_worklog (id, objectId, entryDate, authorUserId, text,
attachments JSON, linkedAssignmentId nullable, linkedShiftId nullable,
createdAt).
7. Автоматическое подтягивание из общих рабочих чатов¶
Самый сложный блок. Что делаем:
- Для объекта задаётся «рабочий чат» (или несколько): Telegram-канал,
Telegram-группа, MAX-канал. Хранится chatId + токен бота с доступом.
- Бот слушает сообщения в этих чатах и по правилам (упоминание объекта,
хештег #код_объекта, ответ на закреплённое сообщение, ключевые фразы)
автоматически добавляет запись в журнал работ этого объекта.
- Фото из чата подшиваются как приложения к журнальной записи.
- Пользователь может редактировать/удалять автозаписи, помечать «не по этому
объекту».
Технически:
- Нужен Telegram Bot API webhook. У тебя уже есть server/telegram.ts —
надо посмотреть, что там реализовано.
- Для группового чата бот должен быть добавлен в чат с правами читать сообщения
(по умолчанию в группах у ботов privacy mode ON — они видят только команды и
@mentions; надо выключить через BotFather либо использовать hashtag-триггеры).
- Хранение: та же таблица object_worklog, плюс поле source:
manual|telegram|max и sourceMessageId, sourceChatId.
Решение 7.1 (11.08.2026):
- На первом этапе 51g — только Telegram. MAX остаётся в абстракции
как второй backend
ChatSourceAdapter(как Yandex.Disk в storage) — прикрутим потом без переделки вышележащего кода. - Бота в MAX пока не создавать. Когда понадобится — владелец
создаёт через @MasterBot,
токен добавляем в
/etc/alatyr-service.envкакMAX_BOT_TOKENи в Vaultwarden. - Схема чатов (общая для Telegram сейчас и MAX потом): смесь групповых и личных чатов:
- Групповые — менеджер + монтажники + клиент, бот добавлен
в группу как участник. В Telegram у бота приватность включена
по умолчанию — видит только свои @mention и команды; чтобы
бот видел все сообщения, нужно выключить privacy через
BotFather
/setprivacy → Disable. Сообщения без триггера игнорируются; триггер определяется в подблоке 7.2. - Личные — сотрудник пишет боту команду
/log OBJ-042 текст(или с вложением фото); бот вешает запись на указанный объект. Сотрудника бот узнаёт по Telegramuser_id(храним вusers.telegram_id, при первом взаимодействии требуетсяlink-токен из admin-кабинета).
Решение 7.2: отложено — обсудим перед стартом кодинга 51g
(всё равно это последняа подзадача в списке). Агент переспросит
с готовыми вариантами (хештег vs закреплённый чат vs LLM-классификатор).
На групповые влияет, на личные — нет (там всегда явный
/log OBJ-XXX).
Приоритеты внутри задачи 51 (предложение)¶
Нулевым шагом идёт блок 0 (хранилище), от него зависят 51a/51b/51c/51e. Журнал (51d) и чаты (51g) не зависят в обязательном порядке, но вложения в журнале тоже ложатся в storage adapter.
- 51-storage — storage adapter + Yandex.Disk/ragserver + автоструктура. ш5-7 дней на весь блок.
Дальше — 7 PR по одному на блок, от простого к сложному:
- 51a — Договор и допники (блок 1). Изменение схемы + UI. ~3-5 дней.
- 51b — Проекты с версиями (блок 3). Такая же файловая логика. ~2-3 дня.
- 51c — Письма по объекту (блок 4). ~1-2 дня.
- 51d — Журнал работ (ручной ввод) (блок 6, только пункт «руками»). ~2-3 дня.
- 51e — Материалы: поставлено/смонтировано (блок 5). Здесь возможно решение с МойСклад-sync. ~3-5 дней.
- 51f — Моё Дело: read-only импорт по объекту (блок 2). Заменяет изначальный 41c: сначала kontragents+bills+acts read-only, потом создание. ~5-7 дней с учётом dry-run и пагинации.
- 51g — Автоподтягивание из чатов (блок 7). Самый сложный, требует доработки telegram-бота и решения по MAX. ~7-10 дней.
Общая оценка: ~4-6 рабочих недель одного разработчика, включая тесты и production QA. Часть блоков (51a-51d) можно делать параллельно, если есть руки.
Что делаем прямо сейчас¶
Эта сессия оформляет только ТЗ. Кодинг начинаем со следующей рабочей сессии после того, как ты: 1. Ответишь на оставшиеся 5 открытых вопросов (1.1, 2.1, 5.1, 7.1, 7.2). Блок 0 уточнён 11.08.2026: только ragserver, admin+manager видят всё, WebDAV/SMB обсудим в момент разворачивания. 2. Утвердишь порядок 51-storage → 51a → … → 51g или переставишь приоритеты. 3. Скажешь, начинаем с 51-storage одним PR (его лучше не дробить мельче), или по подблокам a→d.
Задача 41c в TODO остаётся, но переформулирована: она становится подзадачей 51f (пункт 6) — read-only импорт из МД в карточку объекта, не автоматический cron-пуш пустых pending-счетов.