Техническая документация: Telegram-бот системы согласований
Техническая документация: Telegram-бот системы согласований
Версия документа: 1.0.
Оглавление
- Структура репозитория (package map)
- Полная модель данных
- Пакет
approvalflow— дерево согласования и реестр тем - Пакет
state— состояние диалога в Redis Handler— инициализация и главное меню- Прохождение дерева —
HandleCallback,HandleTextInput,HandleBackCallback - Цепочка дополнительных шагов —
continueFlowAfterInput - Выбор согласующего (ручной и по роли)
- Наблюдатели
- Делегирование обязанностей
submitApproval— создание заявки, построчно- Решение согласующего —
handleDecision processNextStepOrComplete- Дополнительные согласующие (
add_approver.go) - Задачи исполнителей (
tasks.go) - Отзыв заявок
- Уведомления (
Notifier) - Устойчивость бота (прокси, ретраи)
scheduler— ежедневная сводка- Каталог
callback_data - Каталог ключей Redis
- Журнал багов с кодом до/после
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):
- Копирует
data(собранная картаdata_key → data_value) вtemplateData. - Добавляет
initiator_name,topic_name. addLabelMappings(templateData, data)— старый захардкоженный список соответствий (region: crimea→«Крым» и т.п., branch: f1→«Ф1» и т.п. — полный список хардкода не воспроизведён дословно в этой документации, см. исходный файлDynamicTopic.go).addTreeLabelMappings(templateData, data)— 🆕 перекрывает значения из п.3, если для конкретного(key, value)есть узел дерева сLabel. Для мультивыбора (значение вида"a,b") при отсутствии точного совпадения разбивает по запятой и подписывает каждую часть отдельно, объединяя через", ".- Выполняет 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. Меню и подменю
5.2. getAllowedTopicCodes(ctx, user)
Возвращает map[string]bool. Логика:
- Тянет разрешения (
Permission,action="create") пользователя. - Если разрешений нет вообще — возвращает пустую карту (доступа нет никуда).
- Для каждой темы из реестра ищет категорию по коду; если это код
"absence"(агрегирующий алиас) — отдельно проверяет три дочерние категории (timeoff/vacation/sickleave) — по прямому разрешению на категорию или по разрешению на конкретную тему внутри неё. - Для остальных (не-
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) {
-
Пользователь и тема.
initiator := GetUserByTelegramIDDB;topic := Registry.Get(flowState.TopicCode);topicCode := topic.TopicCodeFromCollected(Collected);approvalTopic := GetApprovalTopicByCodeDB(ctx, topicCode). ЕслиapprovalTopic == nil— сообщение об ошибке пользователю,States.Clear, заявка не создаётся. -
Формирование
ContentиMetadata. ИзflowState.CollectedвApproval.Contentуходит только значение под ключомcontent(если есть). Все остальные парыkey: valueизCollectedуходят вmetadata.extraкак есть (сериализуется в JSON). -
Создание
Approval— статус сразуpending(неdraft),InitiatorID,TopicID = approvalTopic.ID,DepartmentID = initiator.DepartmentID,CreatedAt = now. -
Сохранение файлов (
flowState.Files, если были) —ApprovalFileна каждый,AttachedByID = initiator.ID,TaskID = nil(файлы уровня заявки, не задачи — в отличие от файлов, прикрепляемых исполнителем к конкретной задаче, см. §15). Подгружает файлы обратно вcreated.Filesдля последующих уведомлений (чтобы не делать отдельный запрос). -
createPendingDelegation(ctx, initiator, flowState, created)— см. §10.5. Вызывается безусловно; сама функция не делает ничего, еслиflowState.SelectedDelegateID == 0. -
Определение списка согласующих (
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, если случайно совпал).
- Если
-
Группировка по приоритету (
priorityGroups) — сортировка найденныхUserHierarchyпоPriorityпо возрастанию, элементы с одинаковымPriorityпопадают в одну группу.ApprovalMode == "any_of": все согласующие (из всех групп) помещаются в группу 0 — любой один может решить за всех.- Иначе (
"sequential", по умолчанию): группы обрабатываются по порядку индекса — сначала все из группы 0 параллельно, затем группа 1, и т.д. (в текущей реализации внутри одной группы больше одного согласующего на практике не наблюдалось, но код группировки это допускает).
-
Создание
ApprovalStep— по одному на каждого согласующего. Первая группа (индекс 0) сразу получаетStatus = "pending", остальные группы —"waiting".ApproverNameSnapshotзаполняется сразу по имени назначенного (не делегата — см. §2.5, снапшот переписывается только в момент решения). -
Наблюдатели —
saveSelectedObservers(ctx, created.ID, initiator.ID, flowState.SelectedObserverIDs)(§9.2), если список непуст. -
Уведомление контролёров о создании новой заявки (см. §17.5).
-
Уведомление согласующих первой группы — для каждого: сначала проверка активного делегирования по теме/категории (
GetActiveDelegationForUser-подобная логика, тот же приоритет TopicID → CategoryID, что и в поиске согласующих) — если есть, сообщение о новой заявке уходит делегату, а не исходному согласующему; в остальных случаях — обычному согласующему. -
Сообщение инициатору — подтверждение отправки, с итоговым текстом (через
topic.FormatApproverMessage/аналог), список назначенных согласующих первой группы. -
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()
- Получает всех пользователей (
GetUsersListDB, без фильтра — все, в т.ч. потенциально заблокированные/уволенные — фильтрация по пригодности отправки происходит ниже, по наличиюTelegramID). - Один раз на всю рассылку —
getAbsentEmployeesToday(§19.4). - Для каждого пользователя с непустым
TelegramID—sendReminderToUser. - 🧪 Временный фильтр по конкретному
telegramID— использовался для тестирования на одном аккаунте без риска разослать всем; должен быть удалён из продовой версии.
19.4. getAbsentEmployeesToday(ctx, mskLocation) []AbsentEmployeeInfo
type AbsentEmployeeInfo struct {
Name string
TimeDescription string
Contact string // делегат, если есть активное делегирование, иначе сам сотрудник
}
GetApprovalsListDB(Filtering: {Status: "approved"})— все одобренные заявки компании одним запросом (не по пользователю — так один раз на всю рассылку, а не N запросов).- Для каждой заявки:
- Пропуск, если
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-кода.