Перейти к основному контенту

Новая страница

Техническая документация: Telegram-бот системы согласований 

Версия документа: 1.0.


Оглавление

  1. Структура репозитория (package map)
  2. Полная модель данных
  3. Пакет approvalflow — дерево согласования и реестр тем
  4. Пакет state — состояние диалога в Redis
  5. Handler — инициализация и главное меню
  6. Прохождение дерева — HandleCallback, HandleTextInput, HandleBackCallback
  7. Цепочка дополнительных шагов — continueFlowAfterInput
  8. Выбор согласующего (ручной и по роли)
  9. Наблюдатели
  10. Делегирование обязанностей
  11. submitApproval — создание заявки, построчно
  12. Решение согласующего — handleDecision
  13. processNextStepOrComplete
  14. Дополнительные согласующие (add_approver.go)
  15. Задачи исполнителей (tasks.go)
  16. Отзыв заявок
  17. Уведомления (Notifier)
  18. Устойчивость бота (прокси, ретраи)
  19. scheduler — ежедневная сводка
  20. Каталог callback_data
  21. Каталог ключей Redis
  22. Журнал багов с кодом до/после

1. Структура репозитория (package map)

telegram_service/
├── approvalflow/
│   ├── FlowNode / BuildTree / FindNode         — модель дерева
│   ├── DynamicTopic.go                          — тема категории, шаблоны, подписи
│   └── registry.go                              — Registry, LoadFromDB, алиасы
├── state/
│   └── state.go                                 — ApprovalFlowState, StateManager (Redis)
├── keyboard/
│   ├── *.go (BuildApprovalMenu, BuildApprovalStep, BuildMultiSelectStep, ...)
│   └── delegation_keyboard.go                   — 🆕 клавиатуры делегирования
├── handlers/approval/
│   ├── handler.go                                — Handler, NewHandler, HandleMenu*, HandleCallback, HandleTextInput, HandleBackCallback, continueFlowAfterInput
│   ├── decision.go                                — HandleDecision, handleDecision, processNextStepOrComplete
│   ├── add_approver.go                            — доп. согласующие: добавление, список категорий/заявок
│   ├── tasks.go                                   — задачи исполнителей, отзыв связанных задач
│   ├── delegation.go                              — 🆕 делегирование обязанностей
│   ├── helpers.go (submitApproval и прочие помощники — по смыслу; фактическое имя файла не фиксировано в этой сессии)
│   └── revoke.go (performActualRevoke, HandleRevokeWithComment — по смыслу)
├── lib/internally/redis/                          — обёртка над Redis-клиентом
└── ...

core/lib/external/tgkit/
└── client.go                                      — HTTP-клиент бота, прокси, ретраи

proxy/
└── main.go                                        — внешний прокси-сервис к Telegram Bot API

scheduler/
└── daily_reminder.go                               — ежедневная сводка (9:00 МСК, будни)

2. Полная модель данных

Ниже — все поля, встретившиеся за сессию, по каждой структуре. Где структура не была показана целиком — явно отмечено «частично известна».

2.1. ApprovalTopicCategory

type ApprovalTopicCategory struct {
    ID          *uint64
    Name        *string
    Code        *string
    Description *string
    IsActive    *bool
    CreatedAt   *time.Time
    UpdatedAt   *time.Time
}

Примеры реальных категорий, встретившихся в данных: sickleave («Больничный», ID=3), vacation («Отпуск», ID=2), timeoff («Отгул», ID=1). Алиас absence в Registry объединяет все три для целей проверки прав доступа в главном меню.

2.2. ApprovalTopic (частично известна, поля из реального использования)

type ApprovalTopic struct {
    ID           *uint64
    CategoryID   *uint64
    Name         *string
    Code         *string
    ApprovalMode *string   // "sequential" | "any_of"

    DurationType          *string   // single_day | multi_day | date_range | sl_single_day | sl_date_range
    TimeType              *string   // full_day | partial
    PaymentType           *string   // own_expense | vacation_account | official
    Template              *string   // текст-подсказка формата ввода, напр. "22.02.2022"
    FirstApproverRoleCode *string

    AllowsFile             *bool
    AllowApproverSelection *bool
    AllowObserverSelection *bool
    // 🆕 добавлено в этой сессии, по паттерну AllowObserverSelection:
    AllowDelegationSelection *bool

    RequireApproverComment   *bool
    RequiresFile             *bool
    RequireObserverSelection *bool

    IsActive     *bool
    CreatedAt    *time.Time
    UpdatedAt    *time.Time
    Instructions *string
    Category     *ApprovalTopicCategory   // populated-связь
}

Реальный пример из БД (тема id=19, категория «Больничный»):

{
  "id": 19, "category_id": 3,
  "name": "Больничный — диапазон официальный",
  "code": "sickleave_date_range_full_day_official",
  "ApprovalMode": "sequential",
  "duration_type": "date_range", "time_type": "full_day",
  "payment_type": "official",
  "template": "22.02.2022 - 25.02.2022",
  "allows_file": true, "requires_file": false,
  "allow_approver_selection": false, "allow_observer_selection": false,
  "require_approver_comment": false, "require_observer_selection": false,
  "is_active": true
}

2.3. ApprovalFlowNode (дерево согласования)

type ApprovalFlowNode struct {
    ID              *uint64
    ParentID        *uint64   // nil = корневой узел
    CategoryID      *uint64
    Key             string    // технический ключ, участвует в callback_data
    Label           string    // видимый текст кнопки / источник {{.key_label}}
    DataKey         string    // "" если не кнопка с данными
    DataValue       string
    IsInput         bool
    InputKey        string    // обычно "content"
    InputHint       string
    IsMultiSelect   bool
    MultiSelectKey  string
    MultiSelectValue string
    TopicCode       string    // опционально: ограничение видимости узла по правам
    MessageTemplate *string   // только на корневом узле
    CodeTemplate    *string   // только на корневом узле
    Children        []*ApprovalFlowNode  // заполняется BuildTree()
}

Инвариант: Key корневого узла (ParentID == nil) обязан совпадать с ApprovalTopicCategory.Code — иначе HandleCallback не найдёт начало дерева при нажатии кнопки раздела в главном меню (симптом в логах: node not found).

2.4. Approval

type Approval struct {
    ID            *uint64
    InitiatorID   *uint64
    TopicID       *uint64
    DepartmentID  *uint64
    Content       *string           // свободный текст, финальный ввод сотрудника
    Status        *string           // draft|pending|approved|rejected|revoked|revision
    Metadata      *string           // JSON: {"extra": {...}}
    RevisionCount int
    RevokedByID   *uint64
    RevokedAt     *time.Time
    CreatedAt     *time.Time
    UpdatedAt     *time.Time

    // populated-связи (не всегда присутствуют, зависит от запроса)
    Initiator  *User
    Topic      *ApprovalTopic
    Department *Department
    Steps      []*ApprovalStep
    Files      []*ApprovalFile
    RevokedBy  *User
}

Metadata — JSON вида {"extra": {"time_type": "full_day", "absence_type": "sickleave", "payment_type": "official", "duration_type": "sl_date_range", "open_date": "11.07.2026"}}. Обратите внимание: open_date присутствует не всегда и не является надёжным источником полного диапазона дат — единственный надёжный источник дат — свободнотекстовое поле Content (см. раздел про разбор дат в scheduler, §19).

2.5. ApprovalStep

type ApprovalStep struct {
    ID                    *uint64
    ApprovalID            *uint64
    ApproverID            *uint64
    StepOrder             *int
    Status                *string   // waiting|pending|approved|rejected|cancelled_any_of|cancelled
    ApproverNameSnapshot  *string   // ФИО на момент назначения — не меняется при делегации
    ActedAt               *time.Time
    Comment                *string
    CreatedAt              *time.Time
    UpdatedAt               *time.Time
}

ApproverNameSnapshot заполняется реальным исполнившим действие пользователем (actualExecutor.FullName) в момент решения — то есть после одобрения делегатом в снапшоте окажется имя делегата, а не исходного согласующего.

2.6. ApprovalAdditionalApprover

type ApprovalAdditionalApprover struct {
    ID          *uint64
    ApprovalID  *uint64
    ApproverID  *uint64
    AddedByID   *uint64
    Status      *string    // pending|approved|rejected
    Comment     *string
    ActedAt     *time.Time
}

Независим от ApprovalStep — у доп. согласующего нет собственной строки в ApprovalStep (источник одного из ключевых багов, §22).

2.7. ApprovalTask и ApprovalTopicExecutor

type ApprovalTask struct {
    ID              *uint64
    ApprovalID      *uint64
    ExecutorID      *uint64
    TaskText        *string
    Status          *string  // pending|waiting|completed|cancelled
    StepOrder       *int
    RequireFile     *bool
    TopicExecutorID *uint64
    CreatedAt       *time.Time
    CompletedAt     *time.Time
}

type ApprovalTopicExecutor struct {
    ID            *uint64
    TopicID       *uint64
    UserID        *uint64
    TargetSystem  *string   // "all" | "okb" | "1c" — фильтр по выбранной сотрудником системе
    TaskText      *string
    RequireFile   *bool
}

createTasksForApproval создаёт по одной задаче на каждую (система × исполнитель) комбинацию, где TargetSystem совпадает ("all" matчит любую систему). Первая созданная задача сразу pending, остальные waiting — активируются последовательно по мере выполнения предыдущей (activateNextTask).

2.8. UserHierarchy

type UserHierarchy struct {
    ID              *uint64
    SubordinateID   *uint64
    ApproverID      *uint64
    DepartmentID    *uint64
    TopicCategoryID *uint64   // взаимоисключающе с TopicID
    TopicID         *uint64
    Priority        int
}

Область действия правила определяется по заполненным полям: TopicID задан → правило только для этой темы; иначе TopicCategoryID задан → для всей категории; оба nil → глобальное правило («для всех тем согласований»).

2.9. ApprovalDelegation (реальная структура, прислана пользователем)

type ApprovalDelegation struct {
    ID          *uint64
    DelegatorID *uint64    // not null
    DelegateID  *uint64    // not null
    CategoryID  *uint64    // nullable
    TopicID     *uint64    // nullable
    ApprovalID  *uint64    // 🆕 nullable — привязка к заявке-отсутствию
    ValidUntil  *time.Time // not null
    IsActive    *bool      // not null, default true
    NotifiedDay *bool      // not null, default false
    Notified2h  *bool      // not null, default false
    CreatedAt   *time.Time
    UpdatedAt   *time.Time

    Delegator *User
    Delegate  *User
    Category  *ApprovalTopicCategory
    Topic     *ApprovalTopic
}

NotifiedDay/Notified2h указывают на существование отдельного, незадействованного в этой сессии механизма напоминаний об истечении делегирования (за день / за 2 часа до ValidUntil) — реализация этого механизма не входила в объём данной сессии.

2.10. Прочие сущности (кратко, по факту использования)

  • ApprovalComment { ApprovalID, UserID, Comment }
  • ApprovalObserver / ApprovalAdditionalObserver { ApprovalID, UserID, AddedByID }
  • ApprovalFile { ApprovalID, TaskID(nullable), FileBase64, FileName, AttachedByID }
  • User { ID, TelegramID, Username, FullName, LanguageCode, DepartmentID, IsBlocked, IsFired }
  • Department { ID, Name, Code, Description, IsActive }

3. Пакет approvalflow

3.1. FlowNode.BuildTree() / FindNode(tree, key)

Registry хранит плоский список узлов на категорию; BuildTree() превращает его в дерево по ParentID. FindNode — рекурсивный поиск по Key внутри уже построенного дерева (используется и в HandleCallback, и в HandleBackCallback, и в HandleTextInput при переходе к следующему уровню после текстового ввода).

3.2. DynamicTopic

type DynamicTopic struct {
    category *typescore.ApprovalTopicCategory
    nodes    []*typescore.ApprovalFlowNode

    codeTemplate    string
    messageTemplate string

    // 🆕 dataValueLabels: "data_key\x00data_value" -> Label узла с такими
    // DataKey/DataValue (или MultiSelectKey/MultiSelectValue).
    dataValueLabels map[string]string
}

NewDynamicTopic(category, nodes) — единственный проход по всем узлам: если узел корневой — забирает CodeTemplate/MessageTemplate; независимо от этого (без break, в отличие от исходной версии до правки) — если у узла есть Label и DataKey+DataValue (или MultiSelectKey+MultiSelectValue) — кладёт подпись в dataValueLabels под составным ключом data_key + "\x00" + data_value.

applyMessageTemplate(tmpl, initiatorName, data):

  1. Копирует data (собранная карта data_key → data_value) в templateData.
  2. Добавляет initiator_name, topic_name.
  3. addLabelMappings(templateData, data) — старый захардкоженный список соответствий (region: crimea→«Крым» и т.п., branch: f1→«Ф1» и т.п. — полный список хардкода не воспроизведён дословно в этой документации, см. исходный файл DynamicTopic.go).
  4. addTreeLabelMappings(templateData, data) — 🆕 перекрывает значения из п.3, если для конкретного (key, value) есть узел дерева с Label. Для мультивыбора (значение вида "a,b") при отсутствии точного совпадения разбивает по запятой и подписывает каждую часть отдельно, объединяя через ", ".
  5. Выполняет Go-шаблон (text/template), при ошибке парсинга/выполнения — defaultMessage(initiatorName, data) (запасной вариант).

TopicCodeFromCollected(collected) — если code_template непустой, выполняет его как Go-шаблон над collected; иначе — запасной вариант: сортирует ключи collected алфавитно и склеивает значения через _.

3.3. Registry

type Registry struct {
    topics             map[string]Topic       // код темы → DynamicTopic
    order               []string
    topicCodeToCategory map[string]string      // алиасы
}

NewRegistry(ipc) — единственное место, где заполняются алиасы:

r.topicCodeToCategory["timeoff"]   = "absence"
r.topicCodeToCategory["vacation"]  = "absence"
r.topicCodeToCategory["sickleave"] = "absence"

LoadFromDB(ctx) — пересобирает topics/order с нуля, не трогает topicCodeToCategory — алиасы, заданные в NewRegistry, переживают любое количество перезагрузок автоматически.

watchRegistryReload() (горутина, запускается в NewHandler) — опрашивает Redis-ключ approval_flow:version каждые 5 секунд; при изменении значения вызывает LoadFromDB. Админ-панель обязана вызывать bumpFlowVersion() после любой мутации дерева/тем/категорий — иначе бот продолжит работать со старой версией дерева до перезапуска.


4. Пакет state

4.1. ApprovalFlowState (реальная структура, прислана пользователем)

type ApprovalFlowState struct {
    TopicCode               string
    NodeKey                 string
    Collected               map[string]string
    AwaitInput               bool
    InputKey                 string
    History                  []string
    AwaitFileInput            bool
    Files                     []FileAttachment
    FileStatusMsgID           int
    FilesStepCompleted        bool
    MultiSelected             map[string][]string
    AwaitMultiSelectConfirm   bool
    MultiSelectNodeKey        string

    AwaitFirstApproverSelection bool
    SelectedFirstApproverID     uint64

    AwaitApproverSelection bool
    SelectedApproverID     uint64

    AwaitObserverSelection bool
    ObserverSelectionDone  bool
    SelectedObserverIDs    []uint64

    // Делегирование
    AwaitDelegationChoice   bool
    AwaitDelegateSelection  bool
    DelegationSelectionDone bool
    SelectedDelegateID      uint64
}

type FileAttachment struct {
    Base64    string
    FileName  string
    MessageID int
}

4.2. StateManager

  • Redis-ключ: approval_flow:{telegramID}.
  • TTL: 30 минут (stateTTL) — если пользователь бросил диалог дольше этого времени, состояние теряется, при попытке продолжить бот просит начать заново («⚠️ Состояние утеряно. Начните заново.»).
  • Get трактует отсутствие ключа как штатную ситуацию (nil, nil), не как ошибку.
  • Помимо ApprovalFlowState, StateManager управляет и другими, отдельными видами состояний, встретившимися по коду: AddApproverFlowState (флоу добавления доп. согласующего, ключи SetAddApprover/GetAddApprover/ ClearAddApprover), AttachmentSession (сессия прикрепления файла к задаче, поля ApprovalID, TaskID, Files, Stage), состояния комментариев (GetCommentFlow/GetCommentReply, Stage: "await_text") — их полные структуры не входили в состав присланных в этой сессии файлов, здесь зафиксирован только факт существования и используемые поля.

5. Handler — инициализация

type Handler struct {
    ipc      *types.InternalProviderControl
    Registry *Registry
    States   *state.StateManager
}

func NewHandler(ipc *types.InternalProviderControl) *Handler {
    h := &Handler{
        ipc:      ipc,
        Registry: NewRegistry(ipc),
        States:   state.NewStateManager(ipc.RedisClient),
    }
    ctx, cancel := context.WithTimeout(context.Background(), variables.ContextTimeoutLong)
    if err := h.Registry.LoadFromDB(ctx); err != nil {
        logrus.Errorf("Failed to load topics from DB: %v", err)
    }
    cancel()

    go h.watchRegistryReload()   // см. §3.3
    return h
}

Обратите внимание: ошибка первичной загрузки дерева только логируется, не приводит к остановке бота — при полностью пустом/ неудачно загруженном реестре бот запустится, но не сможет провести ни одного согласования до успешной перезагрузки через watchRegistryReload.

5.1. Меню и подменю

  • HandleMenu(bot, chatID, telegramID) → HandleMenuPage(..., page=0, prevMessageID=0).
  • HandleMenuPage: находит пользователя, вызывает getAllowedTopicCodes, фильтрует h.Registry.All() по разрешённым кодам, строит клавиатуру keyboard.BuildApprovalMenu(visibleTopics, page). Если разрешённых тем нет — сообщение «У вас нет доступных тем для согласования» без клавиатуры.
  • HandleInitiatorSubmenu / HandleApproverSubmenu / HandleObserverSubmenu — статичные подменю без БД-логики, просто разные клавиатуры (BuildInitiatorSubmenu/BuildApproverSubmenu/BuildObserverSubmenu).

5.2. getAllowedTopicCodes(ctx, user)

Возвращает map[string]bool. Логика:

  1. Тянет разрешения (Permission, action="create") пользователя.
  2. Если разрешений нет вообще — возвращает пустую карту (доступа нет никуда).
  3. Для каждой темы из реестра ищет категорию по коду; если это код "absence" (агрегирующий алиас) — отдельно проверяет три дочерние категории (timeoff/vacation/sickleave) — по прямому разрешению на категорию или по разрешению на конкретную тему внутри неё.
  4. Для остальных (не-absence) тем — обычная проверка: разрешение на категорию целиком, либо на конкретную тему.

6. Прохождение дерева

6.1. HandleCallback(bot, cq, topicCode, nodeKey)

Формат входящего callback_data: appr:{topicCode}:{nodeKey}.

1. Отвечает на callback, удаляет предыдущее сообщение с кнопками.
2. topic := Registry.Get(topicCode)  — если не найдено, warn-лог и return.
3. flowState := States.Get(telegramID)
   isNew := flowState == nil
   если isNew — создаёт новый ApprovalFlowState{TopicCode, NodeKey, Collected: {}}
4. tree := topic.BuildTree(); node := FindNode(tree, nodeKey)
   если nil — warn-лог "node not found", return (см. инвариант §2.3 про Key корня)
5. если НЕ isNew — History = append(History, flowState.NodeKey)  (пуш текущего перед переходом)
6. если node.DataKey != "" — Collected[node.DataKey] = node.DataValue
7. если у node нет детей И это не IsInput — warn "leaf node without IsInput", return
   (защита от некорректно построенного дерева)
8. Особый случай: у узла РОВНО один ребёнок, и это IsInput —
   пропускает промежуточный экран, сразу переходит в режим ожидания
   текста: AwaitInput=true, InputKey=inputNode.InputKey, NodeKey=nodeKey (!),
   показывает InputHint, клавиатура BuildApprovalInputPrompt(topicCode).
   (NodeKey указывает на РОДИТЕЛЯ input-узла, не на сам input — важно
   для последующей логики в HandleTextInput, см. §6.2)
9. Иначе: NodeKey = nodeKey, State.Set(...)
   visibleChildren := filterNodesByAccess(ctx, telegramID, node.Children)
   если пусто после фильтрации — "⚠️ У вас нет доступа к подкатегориям
   этого раздела.", States.Clear (!) — весь диалог сбрасывается.
10. Детект мультивыбора: если у первого видимого ребёнка IsMultiSelect —
    переходит в режим AwaitMultiSelectConfirm, MultiSelectNodeKey=nodeKey,
    клавиатура BuildMultiSelectStep(..., nil, nil).
11. Иначе — обычная клавиатура BuildApprovalStep(topicCode, visibleChildren).

6.2. HandleTextInput(bot, msg) bool

Вызывается на любое текстовое сообщение от пользователя (не callback), возвращает bool — обработано ли сообщение этим хендлером (если false — сообщение не относится к диалогу согласования, дальше по цепочке обработчиков может пойти что-то другое).

1. Проверка Redis-ключа appr_approve_comment_pending:{telegramID} —
   если есть, это ввод ОБЯЗАТЕЛЬНОГО комментария к решению (см. §12.4),
   не к созданию заявки. Удаляет ключ, вызывает HandleDecisionWithChat
   с approved=true и введённым текстом. return true.
2. Проверка состояния комментариев к заявке (GetCommentFlow) —
   делегирует в HandleCommentTextInput, если Stage == "await_text".
3. Проверка состояния ответа на комментарий (GetCommentReply) —
   аналогично, HandleCommentReplyInput.
4. flowState := States.Get(telegramID) — если nil или !AwaitInput —
   return false (не наш случай).
5. Collected[flowState.InputKey] = msg.Text;  AwaitInput = false
6. Особый случай "текст → сразу следующий уровень кнопок, минуя лишний
   Set+показ":
     topic := Registry.Get(flowState.TopicCode)
     parentNode := FindNode(tree, flowState.NodeKey)  ← это тот самый
       родитель input-узла, сохранённый в п.8 §6.1
     если у parentNode ровно один ребёнок IsInput и у ЭТОГО inputNode
     ЕСТЬ дети — сразу показывает их (с фильтрацией по правам,
     NodeKey переключается на inputNode.Key), return true.
7. Иначе (input — реальный лист дерева, дальше веток нет):
     States.Set(...); continueFlowAfterInput(...) — переход к
     дополнительным шагам (§7). return true.

6.3. HandleBackCallback(bot, cq, topicCode)

  • Если flowState.AwaitFileInput — сбрасывает режим файлового ввода, удаляет статусное сообщение (FileStatusMsgID), если было.
  • Если flowState.AwaitInput — сбрасывает режим текстового ввода.
  • Если History пуста — полный сброс состояния, HandleMenu (возврат к выбору темы).
  • Иначе — pop() из History, FindNode по этому ключу, показывает его детей (с фильтрацией по правам). Важно: при возврате назад фильтр мультивыбора/input повторно не применяется — всегда показывает обычную клавиатуру кнопок, даже если по факту это узел с одним input-ребёнком. (Асимметрия с вперёд-навигацией, зафиксирована как наблюдение, не как заведомый баг — не тестировалось отдельно в этой сессии.)

7. continueFlowAfterInput — цепочка дополнительных шагов

Единая точка, куда сходятся: конец ввода текста (§6.2), любой из дополнительных шагов ниже (каждый в конце сам вызывает эту функцию снова, чтобы проверить следующий шаг в цепочке).

func (h *Handler) continueFlowAfterInput(bot, ctx, chatID, telegramID, flowState) {
    topic := Registry.Get(flowState.TopicCode)
    если topic == nil { States.Clear(); return }

    topicCode, err := topic.TopicCodeFromCollected(flowState.Collected)
    если err != nil { warn-лог; States.Clear(); return }

    approvalTopic := GetApprovalTopicByCodeDB(ctx, topicCode)
    // approvalTopic может остаться nil при ошибке — часть проверок ниже
    // построена как `approvalTopic != nil && approvalTopic.X != nil && *approvalTopic.X`,
    // то есть при ошибке получения темы бот тихо пропускает ВСЕ
    // дополнительные шаги и уходит сразу к финализации.

    // --- Шаг 1: файл ---
    если approvalTopic != nil && !flowState.FilesStepCompleted:
        requiresFile := RequiresFile
        allowsFile   := AllowsFile
        isOfficial   := Collected["payment_type"] == "official"
        needFileStep := requiresFile || allowsFile || isOfficial
        если needFileStep && !AwaitFileInput:
            AwaitFileInput = true; FilesStepCompleted = false
            States.Set(...)
            showFileStatus(bot, chatID, telegramID, flowState, required=requiresFile||isOfficial)
            return

    // --- Шаг 2: первый согласующий по роли ---
    если approvalTopic != nil && FirstApproverRoleCode не пусто
        && SelectedFirstApproverID == 0 && !AwaitFirstApproverSelection:
        AwaitFirstApproverSelection = true; States.Set(...)
        показать список пользователей с этой ролью (showFirstApproverSelection)
        return

    // --- Шаг 3: ручной выбор согласующего ---
    если approvalTopic != nil && AllowApproverSelection
        && SelectedApproverID == 0 && !AwaitApproverSelection:
        AwaitApproverSelection = true; States.Set(...)
        showApproverSelection(...)   // §8
        return

    // --- Шаг 4: наблюдатели ---
    если approvalTopic != nil && AllowObserverSelection && !ObserverSelectionDone:
        ObserverSelectionDone = true; AwaitObserverSelection = true; States.Set(...)
        showObserverSelection(...)   // §9
        return

    // --- Шаг 5 (🆕): делегирование ---
    если approvalTopic != nil && AllowDelegationSelection && !DelegationSelectionDone:
        AwaitDelegationChoice = true; States.Set(...)
        showDelegationChoice(...)    // §10
        return

    // --- Финализация ---
    States.Clear(ctx, telegramID)
    finalizeAndSubmit(bot, ctx, chatID, telegramID, flowState)
}

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


8. Выбор согласующего

8.1. Ручной выбор (showApproverSelection)

getAvailableApproversForTopic(ctx, userID, departmentID, approvalTopic):
    hierarchies := GetUserHierarchyListDB(Filtering: {SubordinateID: userID, DepartmentID: departmentID})
    approverIDs := {}
    для каждой hier:
        если hier.TopicID == approvalTopic.ID → approverIDs[hier.ApproverID] = true; continue
        если hier.TopicCategoryID == approvalTopic.CategoryID → approverIDs[hier.ApproverID] = true
    подгружает User по каждому approverID

⚠️ Обратите внимание: здесь фильтрация по иерархии идёт только по TopicID/TopicCategoryID — глобальные правила иерархии (оба поля nil в UserHierarchy) не учитываются этой функцией. Это согласуется с общей логикой (глобальное правило — это правило «по умолчанию для всех тем», а не «выбор из списка вариантов»), но стоит иметь в виду при диагностике «почему в списке выбора согласующего никого нет», если для сотрудника настроено только глобальное правило.

Если список пуст — «⚠️ Нет доступных согласующих для этой темы.», States.Clear.

HandleApproverSelectionCallback(bot, cq, approverID) — сохраняет SelectedApproverID, AwaitApproverSelection=false, показывает подтверждение с ФИО, вызывает continueFlowAfterInput.

HandleApproverSelectionPageCallback — пагинация того же списка, перестраивает GetApprovalTopicByCodeDB заново по актуальному TopicCodeFromCollected (не кэширует).

8.2. Первый согласующий по роли (showFirstApproverSelection)

Упоминается в continueFlowAfterInput, вызывается с параметрами (bot, ctx, chatID, telegramID, roleCode, roleLabel) — полная реализация не входила в присланные в этой сессии файлы; известно, что результат сохраняется в flowState.SelectedFirstApproverID, и что при последующей сборке approvers в submitApproval (§11) этот согласующий вставляется первым в списке, то есть шагом 0 в последовательности, перед автоматически определёнными через иерархию.


9. Наблюдатели

9.1. showObserverSelection

observers := getAvailableObservers(ctx)   // все пользователи с ролью "observer"
isRequired := RequireObserverSelection == true

если observers пуст:
    если isRequired:
        "⚠️ Для этой темы обязательно выбрать наблюдателя, но в системе
         нет пользователей с ролью наблюдателя. Обратитесь к администратору."
        AwaitObserverSelection=false; ObserverSelectionDone=false (!) — специально
        НЕ помечается выполненным, чтобы не позволить создать заявку в
        нарушение обязательного требования; States.Set; return (диалог
        застревает здесь до вмешательства администратора)
    иначе:
        AwaitObserverSelection=false; ObserverSelectionDone=true
        States.Clear(); finalizeAndSubmit(...)   — пропускает шаг, сразу отправляет
    return

иначе показывает мультивыбор (BuildObserverSelectionMenu(observers,
SelectedObserverIDs, page=0, isRequired)), текст различается в
зависимости от isRequired (для обязательного — с уточнением
«Выберите менеджера, чья это отгрузка», это доменная формулировка,
специфичная для конкретных тем с обязательными наблюдателями).

9.2. Остальные хендлеры наблюдателей

  • HandleObserverToggleCallback — toggle одного ID в SelectedObserverIDs (добавить, если не было; убрать, если было), перерисовывает клавиатуру через EditMessageReplyMarkup (не новое сообщение).
  • HandleObserverPageCallback — пагинация, та же логика isRequired, пересчитанная заново по текущему flowState.
  • HandleObserverConfirmCallback — если isRequired и выбор пуст — показывает alert через callback («⚠️ Выберите хотя бы одного наблюдателя»), не закрывает экран выбора. Иначе — States.Clear, finalizeAndSubmit.
  • getAvailableObservers — GetUsersByRoleCodeDB(ctx, "observer").
  • saveSelectedObservers(ctx, approvalID, addedByID, observerIDs) — вызывается из submitApproval после создания заявки, создаёт по одной строке ApprovalAdditionalObserver на каждого выбранного.
  • getAdditionalObserversForApproval(ctx, approvalID) — используется админкой/карточкой заявки для отображения списка наблюдателей.

10. Делегирование обязанностей (детально)

10.1. showDelegationChoice

Простой экран Да/Нет (keyboard.BuildDelegationChoiceMenu(), callback_data appr_deleg_yes/appr_deleg_no), с пояснением, что делегирование активируется только после согласования этой заявки и отменяется при её отзыве.

10.2. HandleDelegationChoiceCallback(bot, cq, wantsDelegate)

  • wantsDelegate == false: DelegationSelectionDone=true, сообщение «обязанности не делегируются», continueFlowAfterInput.
  • wantsDelegate == true:
    • getAvailableDelegateCandidates — все активные сотрудники (не заблокированы, не уволены, есть TelegramID), кроме самого пользователя.
    • Если кандидатов нет — сообщение «Нет доступных сотрудников для делегирования», DelegationSelectionDone=true, идёт дальше без делегирования.
    • Иначе — AwaitDelegateSelection=true, показывает BuildDelegateSelectionMenu(candidates, page=0).

10.3. HandleDelegateSelectionCallback(bot, cq, delegateID)

Важно: здесь запись в БД НЕ создаётся. Только сохраняет выбор (SelectedDelegateID), показывает предварительный расчёт delegationValidUntil для UX («Ориентировочно до: ...») и вызывает continueFlowAfterInput. Реальная запись появляется позже, вместе с самой заявкой (§11.4) — потому что flowState в Redis живёт всего 30 минут (§4.2), а решение по заявке может занять дни, поэтому долгосрочные данные (делегирование) не могут храниться только в flowState — они переносятся в постоянное хранилище (БД) при первой возможности, то есть в момент отправки заявки.

10.4. delegationValidUntil(collected map[string]string) (time.Time, bool)

Проходит по всем значениям flowState.Collected (не только по ключу "content" — на случай нестандартной настройки дерева, где дата могла осесть под другим ключом), ищет через regexp (\d{1,2})[.,](\d{1,2})\.(\d{4}) (запятая вместо точки после дня учтена специально — реальная опечатка встречалась в проде). Берёт максимальную найденную дату, время 23:59:59. Если дат не найдено — возвращает now + 30 дней и false (вызывающий код логирует warning).

10.5. createPendingDelegation / activatePendingDelegation / deactivatePendingDelegation

См. документ 1 (версия 1.0), раздел 5 — логика не изменилась, здесь фиксируется дополнительно точная последовательность вызовов:

Функция Вызывается из Момент
createPendingDelegation submitApproval сразу после создания Approval и сохранения файлов, до создания ApprovalStep
activatePendingDelegation processNextStepOrComplete в ветке финального approved (НЕ в ветке передачи следующему шагу)
deactivatePendingDelegation performActualRevoke между UpdateApprovalDB (статус → revoked) и handleRevokeCancellationTask

Каждая из активации/деактивации сначала делает GetApprovalDelegationsListDB(Filtering: {ApprovalID: approval.ID}) — если пусто, тихо ничего не делает (делегирование не настраивалось при подаче этой заявки).


Техническая документация: Telegram-бот (детальная версия) — часть 3

Продолжение частей 1–2. Разделы 11–23.


11. submitApproval построчно

func (h *Handler) submitApproval(bot, ctx, chatID, telegramID, flowState) {
  1. Пользователь и тема. initiator := GetUserByTelegramIDDB; topic := Registry.Get(flowState.TopicCode); topicCode := topic.TopicCodeFromCollected(Collected); approvalTopic := GetApprovalTopicByCodeDB(ctx, topicCode). Если approvalTopic == nil — сообщение об ошибке пользователю, States.Clear, заявка не создаётся.

  2. Формирование Content и Metadata. Из flowState.Collected в Approval.Content уходит только значение под ключом content (если есть). Все остальные пары key: value из Collected уходят в metadata.extra как есть (сериализуется в JSON).

  3. Создание Approval — статус сразу pending (не draft), InitiatorID, TopicID = approvalTopic.ID, DepartmentID = initiator.DepartmentID, CreatedAt = now.

  4. Сохранение файлов (flowState.Files, если были) — ApprovalFile на каждый, AttachedByID = initiator.ID, TaskID = nil (файлы уровня заявки, не задачи — в отличие от файлов, прикрепляемых исполнителем к конкретной задаче, см. §15). Подгружает файлы обратно в created.Files для последующих уведомлений (чтобы не делать отдельный запрос).

  5.  createPendingDelegation(ctx, initiator, flowState, created) — см. §10.5. Вызывается безусловно; сама функция не делает ничего, если flowState.SelectedDelegateID == 0.

  6. Определение списка согласующих (var approvers []*User):

    • Если flowState.SelectedApproverID != 0 (ручной выбор, §8.1) — единственный согласующий, весь дальнейший блок иерархии пропускается.
    • Иначе: GetUserHierarchyListDB(Filtering: {SubordinateID: initiator.ID, DepartmentID: initiator.DepartmentID}), из них отбираются подходящие под тему записи (по тому же принципу TopicID → TopicCategoryID → глобальное — в этой ветке уже включая глобальные правила, в отличие от §8.1, где глобальные не участвуют).
    • Если flowState.SelectedFirstApproverID != 0 (§8.2) — вставляется первым элементом списка, перед определёнными через иерархию (дедупликация по ID, если случайно совпал).
  7. Группировка по приоритету (priorityGroups) — сортировка найденных UserHierarchy по Priority по возрастанию, элементы с одинаковым Priority попадают в одну группу.

    • ApprovalMode == "any_of": все согласующие (из всех групп) помещаются в группу 0 — любой один может решить за всех.
    • Иначе ("sequential", по умолчанию): группы обрабатываются по порядку индекса — сначала все из группы 0 параллельно, затем группа 1, и т.д. (в текущей реализации внутри одной группы больше одного согласующего на практике не наблюдалось, но код группировки это допускает).
  8. Создание ApprovalStep — по одному на каждого согласующего. Первая группа (индекс 0) сразу получает Status = "pending", остальные группы — "waiting". ApproverNameSnapshot заполняется сразу по имени назначенного (не делегата — см. §2.5, снапшот переписывается только в момент решения).

  9. Наблюдатели — saveSelectedObservers(ctx, created.ID, initiator.ID, flowState.SelectedObserverIDs) (§9.2), если список непуст.

  10. Уведомление контролёров о создании новой заявки (см. §17.5).

  11. Уведомление согласующих первой группы — для каждого: сначала проверка активного делегирования по теме/категории (GetActiveDelegationForUser-подобная логика, тот же приоритет TopicID → CategoryID, что и в поиске согласующих) — если есть, сообщение о новой заявке уходит делегату, а не исходному согласующему; в остальных случаях — обычному согласующему.

  12. Сообщение инициатору — подтверждение отправки, с итоговым текстом (через topic.FormatApproverMessage/аналог), список назначенных согласующих первой группы.

  13. States.Clear — на этом этапе уже вызван из continueFlowAfterInput перед вызовом finalizeAndSubmit (см. §7), повторного вызова здесь не требуется.


12. handleDecision (решение согласующего)

12.1. Определение контекста вызова

func (h *Handler) handleDecision(bot, ctx, chatID, telegramID, approvalID, approved bool, comment string) {
    approval := GetApprovalByIDDB(ctx, approvalID)
    currentUser := GetUserByTelegramIDDB(ctx, telegramID)

    // Проверка: это доп. согласующий?
    additionalRecord := найти pending ApprovalAdditionalApprover для
        (ApprovalID: approvalID, ApproverID: currentUser.ID)
    isAdditionalApprover := additionalRecord != nil

    mainStep := GetActiveStepDB(ctx, approvalID)  // текущий активный ОСНОВНОЙ шаг, вне зависимости от того, кто сейчас решает

12.2. Ветка «дополнительный согласующий»

если isAdditionalApprover:
    если approved:
        additionalRecord.Status = "approved"; UpdateApprovalAdditionalApproverDB
        actualExecutorID := currentUser.ID  // 🩹 см. §22, п.3 — раньше сюда
                                              // ошибочно попадал не тот ID
        если allAdditionalApproved(ctx, approvalID) И mainStep.Status == "approved":
            processNextStepOrComplete(ctx, bot, approval, mainStep, actualExecutorID)
            // 🩹 §22 п.1: раньше вместо mainStep сюда передавался результат
            // findStepForCurrentApprover(currentUserID) — всегда nil для
            // доп. согласующего (у него нет строки в ApprovalStep, есть
            // только в ApprovalAdditionalApprover) → заявка «зависала»
        иначе:
            // ждём либо основного согласующего, либо остальных доп.
            уведомление доп. согласующему "Ваше решение принято, заявка
            продолжит движение после решения остальных участников"
    иначе (rejected):
        additionalRecord.Status = "rejected"; UpdateApprovalAdditionalApproverDB
        approval.Status = "rejected"; UpdateApprovalDB
        // отклонение ЛЮБОГО доп. согласующего — блокирующее для всей заявки,
        // в отличие от основной последовательности, где при any_of
        // достаточно одного одобрения
        уведомления инициатору, остальным согласующим (заявка закрыта)
    return

12.3. Ветка «основной согласующий»

иначе (не доп. согласующий):
    если approved:
        если RequireApproverComment == true И mainStep.Comment пуст И comment == "":
            // запрашиваем комментарий отдельным сообщением, СОХРАНЯЕМ
            // решение "в подвешенном" виде через Redis-ключ
            // appr_approve_comment_pending:{telegramID} = approvalID
            // (см. §6.2, п.1 — HandleTextInput ловит следующий текстовый
            // ввод и вызывает HandleDecisionWithChat(approved=true, text))
            return

        mainStep.Status = "approved"; mainStep.Comment = comment;
        mainStep.ActedAt = now; mainStep.ApproverNameSnapshot = currentUser.FullName
        UpdateApprovalStepDB

        actualExecutorID := currentUser.ID
        если allAdditionalApproved(ctx, approvalID):
            processNextStepOrComplete(ctx, bot, approval, mainStep, actualExecutorID)
        иначе:
            // основной шаг одобрен, но есть неотвеченные доп. согласующие —
            // заявка НЕ двигается дальше, ждём их
    иначе (rejected):
        mainStep.Status = "rejected"; ...; UpdateApprovalStepDB
        если approval ApprovalMode == "any_of":
            // отменяет ОСТАЛЬНЫЕ шаги той же группы приоритета
            для каждого другого step в той же priority-группе со
            статусом pending/waiting:
                step.Status = "cancelled_any_of"; UpdateApprovalStepDB
                уведомление этому согласующему "участие больше не требуется"
        approval.Status = "rejected"; UpdateApprovalDB
        уведомления инициатору, наблюдателям, директорам

12.4. Обязательный комментарий (детали флоу)

Redis-ключ appr_approve_comment_pending:{telegramID} хранит approvalID (строкой). При следующем текстовом сообщении от этого пользователя (независимо от того, есть ли активный ApprovalFlowState — проверка в HandleTextInput идёт первой, до проверки flowState, см. §6.2 п.1) — ключ удаляется, вызывается HandleDecisionWithChat(bot, chatID, telegramID, approvalID, approved=true, comment=msg.Text), которая и приводит к обычной ветке §12.3 уже с непустым comment.


13. processNextStepOrComplete

func (h *Handler) processNextStepOrComplete(ctx, bot, approval, currentStep, actualExecutorID uint64) {
    nextStep := следующий ApprovalStep этой заявки с наименьшим
                StepOrder среди тех, что ещё Status == "waiting"
                (то есть первый шаг следующей приоритетной группы)

    если nextStep != nil:
        // --- Заявка ПЕРЕДАНА следующему согласующему, ещё НЕ одобрена целиком ---
        nextStep.Status = "pending"; UpdateApprovalStepDB
        // проверка делегирования следующего согласующего — та же логика,
        // что при первичной отправке (§11 п.11)
        уведомление следующему согласующему (или его делегату)
        return  // ⚠️ activatePendingDelegation НЕ вызывается здесь —
                // делегирование активируется только при полном
                // одобрении, см. ниже

    // --- nextStep == nil: заявка ПОЛНОСТЬЮ одобрена ---
    approvedStatus := "approved"
    approval.Status = &approvedStatus
    UpdateApprovalDB(ctx, nil, approval)

    // 🆕 Активация делегирования — см. §10.5
    activatePendingDelegation(ctx, approval)

    lastStepIsDelegate := currentStep.ApproverID != nil &&
                           *currentStep.ApproverID != actualExecutorID
    // ^ используется для формулировки уведомлений: "решение принято
    // делегатом Х вместо назначенного Y" против обычного "одобрено Y"

    createTasksForApproval(ctx, bot, approval)   // §14, ApprovalTopicExecutor → ApprovalTask
    уведомление инициатору (одобрено)
    уведомления директорам, подписанным на категорию
    уведомления наблюдателям
}

14. Дополнительные согласующие (add_approver.go)

14.1. HandleAddApproverFromApproval(bot, chatID, telegramID, approvalID)

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

14.2. getApproverCategoriesWithPendingApprovals (🩹 переписан в этой сессии)

Было: проверяло только topic-scoped иерархию (пропускало правила, заданные на уровне категории или глобально), и если у пользователя была хоть одна pending-запись где угодно — ошибочно помечало сразу все категории как имеющие ожидающие заявки (некорректная агрегация).

Стало: переписано по образцу соседней рабочей функции getPendingApprovalsByCategory — категории определяются напрямую из фактических pending-шагов пользователя (ApprovalStep + ApprovalAdditionalApprover со статусом pending, у которых сама заявка ещё pending), без обращения к иерархии вообще — иерархия для этой задачи была лишним, ошибочным уровнем косвенности.


15. Задачи исполнителей (tasks.go)

Полный список хендлеров и их назначение:

Функция Назначение
HandleMyTasks Список pending-задач текущего исполнителя (проверка роли через IsExecutorDB)
sendTaskCard Карточка одной задачи: инициатор, тема, текст заявки, дата, текст задачи, прикреплённые именно к этой задаче файлы (TaskID совпадает), секция комментариев
HandleCompleteTask Проверка RequireFile (если файл обязателен и не прикреплён — блокирует завершение, просит прикрепить), Status → "completed", CompletedAt = now, activateNextTask, уведомление инициатору (включая текст самой задачи — 🩹 см. §22 последняя строка)
activateNextTask Следующая задача этой же заявки с StepOrder = завершённая.StepOrder + 1 и статусом waiting → переводит в pending, уведомляет её исполнителя
handleRevokeCancellationTask Вызывается при отзыве заявки: отменяет pending/waiting ApprovalStep/ApprovalAdditionalApprover; по каждой задаче — если completed → createReversalTask, если pending → askExecutorAboutChanges, если waiting → просто cancelled с уведомлением
createReversalTask Новая задача «Обратить изменения по заявке #N», тот же исполнитель, Status = pending сразу
askExecutorAboutChanges Вопрос исполнителю Да/Нет «вносили ли уже изменения» через BuildRevokeTaskConfirmation
HandleRevokeTaskNoChanges / HandleRevokeTaskHasChanges Обработка ответа: первое — просто cancelled; второе — создаёт reversal-задачу (§ выше)
HandleExecutorAttachStart Начало сессии прикрепления файла к задаче (state.AttachmentSession)

16. Отзыв заявок

  • HandleRevokeWithComment — точка входа с комментарием причины отзыва.
  • Правила, кто может отозвать:
    • pending/revision — инициатор или директор.
    • approved, отзывает не-директор-инициатор — startApprovalRevocationRequest — отдельный флоу запроса согласия у всех, кто участвовал в согласовании (упоминается как существующий механизм; полная реализация не входила в объём этой сессии).
    • approved, отзывает директор — сразу, без запроса согласия.
  • performActualRevoke — единственное место, где Approval.Status реально становится "revoked":
    approval.Status = "revoked"; RevokedByID = revoker.ID; RevokedAt = nowUpdateApprovalDBdeactivatePendingDelegation(ctx, approval)   // 🆕 §10.5handleRevokeCancellationTask(bot, ctx, approval)  // §15уведомления инициатору, директорам, согласующим, наблюдателям
    

17. Уведомления

Список типов уведомлений, встретившихся по коду (точные сигнатуры Notifier-методов не были показаны как единый файл в этой сессии — здесь зафиксированы смысловые точки отправки, привязанные к конкретным функциям):

# Кому Когда Откуда
1 Согласующему первой группы (или его делегату) Заявка создана submitApproval
2 Инициатору Заявка отправлена (подтверждение) submitApproval
3 Контролёрам категории Заявка создана submitApproval
4 Следующему согласующему (или делегату) Предыдущий шаг одобрен, есть следующий processNextStepOrComplete
5 Инициатору Заявка полностью одобрена processNextStepOrComplete
6 Директорам, подписанным на категорию Заявка полностью одобрена processNextStepOrComplete
7 Наблюдателям Заявка полностью одобрена processNextStepOrComplete
8 Инициатору, наблюдателям, директорам Заявка отклонена handleDecision (rejected-ветки)
9 Остальным согласующим той же any_of-группы Их участие больше не требуется handleDecision
10 Исполнителю следующей задачи Предыдущая задача выполнена activateNextTask
11 Инициатору Исполнитель завершил задачу (включая текст задачи) HandleCompleteTask
12 Исполнителю Заявка отозвана — просьба обратить/подтвердить изменения handleRevokeCancellationTask
13 Инициатору, директорам, согласующим, наблюдателям Заявка отозвана performActualRevoke

18. Устойчивость бота

  • buildHTTPClient — сборка http.Client для Telegram Bot API, подключает CustomAPIProxyTransport, если задан proxyCfg (🩹 ранее конфиг прокси передавался, но фактически не использовался при сборке транспорта — исправлено).
  • Ретраи на старте — при недоступности Telegram API во время запуска бот повторяет попытку подключения вместо немедленного падения (конкретное число попыток/интервал не зафиксированы в объёме сессии).
  • Отдельный proxy/main.go — вынесенный прокси-сервис перед реальным Telegram Bot API, с собственной логикой ретраев на запросы (doWithRetry — упоминается как существующий, детали реализации не входили в присланные файлы).

19. scheduler/daily_reminder.go — построчно

19.1. Start()

func (dr *DailyReminder) Start() {
    go dr.sendReminders()   // 🧪 см. журнал — временная строка для
                              // тестирования, должна быть удалена после
                              // проверки в проде (немедленный запуск при
                              // старте сервиса, а не ожидание 9:00 МСК)
    go dr.scheduleLoop()
}

19.2. scheduleLoop()

Бесконечный цикл: вычисляет ближайшие 9:00 по Europe/Moscow (fallback — фиксированная зона UTC+3, если LoadLocation не удался), переносит на понедельник, если выпадает на субботу/воскресенье (skipWeekends), спит до этого момента, вызывает sendReminders(), повторяет.

19.3. sendReminders()

  1. Получает всех пользователей (GetUsersListDB, без фильтра — все, в т.ч. потенциально заблокированные/уволенные — фильтрация по пригодности отправки происходит ниже, по наличию TelegramID).
  2. Один раз на всю рассылку — getAbsentEmployeesToday (§19.4).
  3. Для каждого пользователя с непустым TelegramID — sendReminderToUser.
  4. 🧪 Временный фильтр по конкретному telegramID — использовался для тестирования на одном аккаунте без риска разослать всем; должен быть удалён из продовой версии.

19.4. getAbsentEmployeesToday(ctx, mskLocation) []AbsentEmployeeInfo

type AbsentEmployeeInfo struct {
    Name            string
    TimeDescription string
    Contact         string   // делегат, если есть активное делегирование, иначе сам сотрудник
}
  1. GetApprovalsListDB(Filtering: {Status: "approved"}) — все одобренные заявки компании одним запросом (не по пользователю — так один раз на всю рассылку, а не N запросов).
  2. Для каждой заявки:
    • Пропуск, если Metadata/Content пусты, или metadata.extra.absence_type пуст (не заявка на отсутствие — поле есть только у отгула/отпуска/больничного), или инициатор уже встречен ранее (дедупликация — сотрудник с несколькими пересекающимися заявками попадёт в сводку один раз).
    • parseAbsenceDates(content) — все совпадения regex (\d{1,2})[.,](\d{1,2})\.(\d{4}). Если ни одной даты не найдено — warning в лог с ID заявки и содержимым, пропуск.
    • absenceCoversDate(dates, content, today) — если дат ровно 2 и в тексте есть дефис — трактует как диапазон (с меньшей по большую дату, независимо от порядка написания); иначе — сверяет точное совпадение с любой из распознанных дат (список отдельных дней).
    • Формулировка TimeDescription по absence_type:
      • timeoff + time_type == "partial" → парсит время через (\d{1,2}):(\d{2}).*?(\d{1,2}):(\d{2}) (строго через двоеточие, не точку — специально, чтобы не путать с датами), формат "отгул с HH:MM до HH:MM"; при неудаче парсинга времени — "отгул весь день".
      • timeoff (не partial) → "отгул весь день".
      • vacation → "отпуск до " + maxDate(dates).
      • sickleave → "больничный до " + maxDate(dates).
    • Contact — поиск активных ApprovalDelegation по DelegatorID = InitiatorID, IsActive = true, с дополнительной проверкой ValidUntil (подстраховка на случай, если фоновая задача деактивации по времени ещё не отработала) — первая подходящая даёт имя делегата, иначе — имя самого сотрудника.

19.5. sendReminderToUser

Считает 4 персональных счётчика (доработки/задачи/согласования, плюс неявный «есть ли вообще что показать»), формирует текст, добавляет блок «Сотрудники в отгуле/больничном/отпуске» если absentToday непуст. Отправляет, если есть хоть один личный пункт или список отсутствующих непуст (осознанное решение — раньше отправляли только при личных пунктах).


20. Каталог callback_data

Префикс/формат Обработчик Назначение
appr:{topicCode}:{nodeKey} HandleCallback Переход по дереву
appr_back:{topicCode} HandleBackCallback Кнопка «Назад»
appr_decision:{approvalID}:1|0 handleDecision (через диспетчер) Одобрить/отклонить
appr_approver_sel:{userID} HandleApproverSelectionCallback Выбор согласующего вручную
appr_approver_pg:{page} HandleApproverSelectionPageCallback Пагинация списка согласующих
appr_obs_toggle:{userID} HandleObserverToggleCallback Toggle наблюдателя
appr_obs_pg:{page} HandleObserverPageCallback Пагинация наблюдателей
appr_obs_confirm HandleObserverConfirmCallback Подтверждение выбора наблюдателей
appr_deleg_yes / appr_deleg_no HandleDelegationChoiceCallback 🆕 Да/Нет делегирования
appr_deleg_sel:{userID} HandleDelegateSelectionCallback 🆕 Выбор делегата
appr_deleg_pg:{page} HandleDelegateSelectionPageCallback 🆕 Пагинация делегатов
add_appr_to_approval:{approvalID} HandleAddApproverFromApproval Добавление доп. согласующего (кнопка сейчас закомментирована)
revoke_task_no_changes:{taskID}:{approvalID} HandleRevokeTaskNoChanges Подтверждение отсутствия изменений при отзыве
revoke_task_has_changes:{taskID}:{approvalID} HandleRevokeTaskHasChanges Подтверждение наличия изменений

⚠️ Все ключи держатся в пределах 64 байт вручную (лимит Telegram) — особенно важно для Key узлов дерева (§2.3), которые входят в appr:{topicCode}:{nodeKey}.


21. Каталог ключей Redis

Ключ Формат/TTL Назначение
approval_flow:{telegramID} JSON ApprovalFlowState, TTL 30 мин Состояние диалога создания заявки
approval_flow:version Произвольное значение, без TTL Триггер перезагрузки Registry (опрос раз в 5 сек)
appr_approve_comment_pending:{telegramID} approvalID строкой Ожидание обязательного комментария к одобрению
(без точного имени) — AddApproverFlowState, AttachmentSession, comment-flow состояния JSON, управляются через StateManager Прочие второстепенные диалоговые состояния (§4.2)

22. Полный журнал багов с кодом до/после

22.1. handleDecision: тема не найдена для timeoff/vacation/sickleave

Симптом: при попытке согласующего принять решение по заявке категории «Отгул»/«Отпуск»/«Больничный» — ошибка «unknown topic code».

Причина: topicCodeToCategory в Registry инициализировалась пустой картой, алиасы никогда не заполнялись.

Исправление — в NewRegistry:

r := &Registry{
    ipc: ipc, topics: make(...), order: nil,
    topicCodeToCategory: map[string]string{
        "timeoff":   "absence",
        "vacation":  "absence",
        "sickleave": "absence",
    },
}

22.2. 64-байтный лимит callback_data — падение конструктора/бота

Причина: r.Route("/api/approvals/{id}", ...) в chi перекрывал уже существующий GET-хендлер базового пути /api/approvals — новые под-маршруты (/comments, /additional-approvers и т.п.) логически переносили туда весь роутинг, ломая существующий список заявок.

Исправление: заменено на r.Group(...) с полными абсолютными путями вместо вложенного r.Route.

22.3. Заявка «зависает» после одобрения доп. согласующим

Причина: ветка доп. согласующего в handleDecision искала «свой» шаг через findStepForCurrentApprover(ctx, approvalID, currentUserID) — эта функция ищет запись в ApprovalStep, а у доп. согласующего такой записи никогда не было (он есть только в ApprovalAdditionalApprover) → всегда nil → заявка молча переставала двигаться дальше после его одобрения, при этом никакой ошибки не логировалось.

Было:

step := findStepForCurrentApprover(ctx, approvalID, currentUserID)
processNextStepOrComplete(ctx, bot, approval, step, currentUserID)
// step == nil здесь всегда для доп. согласующего

Стало: переиспользование уже корректно полученного mainStep (результат GetActiveStepDB, взятого в начале функции, см. §12.1):

processNextStepOrComplete(ctx, bot, approval, mainStep, currentUserID)

Заодно исправлен передаваемый actualExecutorID — раньше туда уходил ID самого доп. согласующего, из-за чего в processNextStepOrComplete (lastStepIsDelegate проверка, §13) он ошибочно трактовался как «делегат основного согласующего», хотя это два независимых, не связанных друг с другом человека.

22.4. Прокси бота фактически не использовался

Причина: buildHTTPClient принимал proxyCfg аргументом, но не подключал его к транспорту http.Client.

Исправление: подключён CustomAPIProxyTransport, добавлены ретраи на подключение при старте.

22.5. getApproverCategoriesWithPendingApprovals — неверные категории

См. §14.2 — полностью переписана функция.

22.6. Уведомление о выполненной задаче не содержало текст задачи

Было: сообщение инициатору формировалось только из approval.Content (текст всей заявки).

Стало: добавлена отдельная строка 🎯 Задача: {task.TaskText} — конкретно то, что исполнитель фактически делал.

22.7. DynamicTopic — подписи {{.key_label}} только для захардкоженных ключей

Причина: addLabelMappings содержала фиксированный список соответствий «ключ+значение → подпись», не расширяемый без правки кода.

Исправление: добавлен dataValueLabels, заполняемый при NewDynamicTopic из Label любого узла дерева с соответствующими DataKey/DataValue, и метод addTreeLabelMappings, вызываемый после addLabelMappings (приоритет — у дерева). Теперь переименование узла в конструкторе автоматически меняет и текст в шаблоне сообщения, без правки Go-кода.