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

ТЗ: расширенная карточка объекта (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}). Это не масштабируется, не бэкапится отдельно от КРМ и не даёт владельцу доступ к файлам в привычном файл-менеджере. Владелец хочет:

  1. Хранить файлы в Yandex.Disk (WebDAV) и/или на собственном файловом сервере на ragserver (192.168.1.200) — чтобы файлы были доступны из сети, бэкапились вместе с RAG-данными и были доступны без веб-админки.
  2. При создании объекта автоматически создавать структуру папок по типам документов. Когда сотрудник загружает файл, он сразу ложится в нужную папку без случайных 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-cRagserverAdapter (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 недоступен. Колонка mskladProductId nullable, в 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 текст (или с вложением фото); бот вешает запись на указанный объект. Сотрудника бот узнаёт по Telegram user_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.

  1. 51-storage — storage adapter + Yandex.Disk/ragserver + автоструктура. ш5-7 дней на весь блок.

Дальше — 7 PR по одному на блок, от простого к сложному:

  1. 51a — Договор и допники (блок 1). Изменение схемы + UI. ~3-5 дней.
  2. 51b — Проекты с версиями (блок 3). Такая же файловая логика. ~2-3 дня.
  3. 51c — Письма по объекту (блок 4). ~1-2 дня.
  4. 51d — Журнал работ (ручной ввод) (блок 6, только пункт «руками»). ~2-3 дня.
  5. 51e — Материалы: поставлено/смонтировано (блок 5). Здесь возможно решение с МойСклад-sync. ~3-5 дней.
  6. 51f — Моё Дело: read-only импорт по объекту (блок 2). Заменяет изначальный 41c: сначала kontragents+bills+acts read-only, потом создание. ~5-7 дней с учётом dry-run и пагинации.
  7. 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-счетов.