
В этой статье расскажу, как мы реализовали гибкое многоэтапное согласование в Jira. Особенность подхода - все согласование зациклено в одном статусе, без громоздких схем workflow. Вся логика задается в Assets и управляется через Groovy‑скрипт.
Постановка задачи
Бизнес хотел видеть:
- несколько последовательных этапов согласования; 
- возможность задавать количество этапов, согласующих, и условия этапа ("все" или "любой"); 
- единый прозрачный результат в виде таблицы в задаче; 
- автоматический переход задачи в "Согласовано" или возврат в "В работу". 
Инструменты и подход
- Assets (бывший Insight) - хранение конфигурации согласования (этапы, согласующие, условия). 
- Groovy - основная логика. 
- 
JSON - два скрытых поля: - ApprovalConfig- структура согласования, формируется скриптом из Assets при старте процесса;
- ApprovalResult- динамический результат согласования (кто согласовал/отклонил, на каком этапе).
 
Архитектура решения
- Согласование зациклено в одном статусе: 
 Задача не прыгает по workflow - все крутится внутри "Согласование".
- 
Цепочка согласования: Выбирается пользователем из списка преднастроеных цепочек через поле справочник Assets object на экране создания задачи или на переходе в согласование 
- 
Два скрытых поля: - ApprovalConfig(JSON) - конфиг, формируется один раз на старте. Нужен чтобы скрипт переключал этапы.
- ApprovalResult(JSON) - результаты согласования, обновляются на каждом этапе. Используется для отрисовки таблицы результатов
 
- 
Работа с пользователями: - В поле "Необходимо согласование" заносятся все согласующие текущего этапа. 
- Когда пользователь согласовал он удаляется оттуда и переносится в поле "Согласовано". 
- При переходе на следующий этап список «Необходимо согласование» обновляется. 
 
- 
Условия этапа: - "Все должны согласовать" или "достаточно одного". Настраивается в Assets. 
 
- 
Результат на экране: - Отдельный UI‑блок в задаче — табличка: Этап | Пользователь | Статус («Согласовано» / «Отказано»). 
 
Реализация
1. Формирование цепочки и этапов согласования
Настройки в Assets (этапы, согласующие, условие ALL/ANY)


2. Формирование конфига
Groovy-скрипт берет данные из Assets и строит JSON-конфиг.
def getAllStepsData(){
    def result = [steps: []]
    def stepCount = 1
    //OBJECTID глобальная переменная, значение поля справочник Assets object 
    if (!OBJECTID) return null
    //STEP_ATTR_IDS список из ID этапов на вкладке "цепочка согласования" в Assets
    STEP_ATTR_IDS.each { stepAttrId ->
        //assets - наша внутренняя библиотека реализующая    
        //методы пакета com.riadalabs.jira.plugins.insight
        //в данном случае assets.getAttributeValue()
        //соответствует методу ObjectFacade.loadObjectAttributeBean()
        def stepIds = assets.getAttributeValue(OBJECTID, stepAttrId)
        if (!stepIds) return
        stepIds.each { stepId ->
            def type = assets.getAttributeValue(stepId, 26627)?.getAt(0)
            def status = assets.getAttributeValue(stepId, 26628)?.getAt(0)
            if (status != 1) return // обрабатываем только активные
            def approversList = []
            def customApprover = assets.getAttributeValue(stepId, 26632)?.getAt(0)
            def approvers = assets.getAttributeValue(stepId, 26626)
            def stepName = assets.getAttributeValue(stepId, 26617)?.getAt(0)
            if (customApprover) {
                if (customApprover == "reporter") {
                    approversList << issue.reporter.key
                }
            } else if (approvers) {
                approversList = approversList.plus(approvers)
            }
            if (approversList) {
                def stageStatus = (result.steps.isEmpty())?"in progress":"awaiting"
                result.steps << [
                        "step $stepCount": [
                                "approvers"      : approversList,
                                "stepName"       : stepName,
                                "type"           : type,
                                "stepStatus"     : stageStatus
                        ]
                ]
            }
            stepCount++
        }
    }
    return result
}
Итоговая структура записывается в поле ApprovalConfig
{
  "steps": [
    {
      "step 1": {
        "approvers": [
          "fyulgushev",
          "iivanov"
        ],
        "stepName": "Согласующие первого этапа",
        "type": "Все",
        "stepStatus": "in progress"
      }
    },
    {
      "step 2": {
        "approvers": [
          "fyulgushev",
          "ppetrov"
        ],
        "stepName": "Согласующие второго этапа",
        "type": "Любой",
        "stepStatus": "awaiting"
      }
    },
    {
      "step 3": {
        "approvers": [
          "fyulgushev",
          "ssidorov"
        ],
        "stepName": "Согласующие третьего этапа",
        "type": "Все",
        "stepStatus": "awaiting"
      }
    },
    {
      "step 4": {
        "approvers": [
          "iivanov",
          "ppetrov"
        ],
        "stepName": "Согласующие четвертого этапа",
        "type": "Все",
        "stepStatus": "awaiting"
      }
    }
  ]
}3. Инициализация согласования
При старте заполняется поле "Необходимо согласование" пользователями из первого этапа.
 В ApprovalResult создается структура для отрисовки таблицы согласования.
{
  "steps": [
    {
      "approverName": "fyulgushev",
      "dep": "Согласующие первого этапа",
      "status": "moved"
    },
    {
      "approverName": "iivanov",
      "dep": "Согласующие первого этапа",
      "status": "moved"
    }    
  ]
}4. Обработка согласования
Когда пользователь нажимает "Согласовать" или "Отклонить":
- его убираем из поля "Необходимо согласование"; 
- переносим в поле "Согласовано" (если согласовал) или в поле "Отказано" (если отказал); 
- обновляем - ApprovalResult.
def successJsonData(user){
    JSONDATA["steps"].find{it["approverName"] == user.name && it.status == "moved"}.status = "success"
    //NEEDAPPROVE глобальная переменная со значением поля "Необходимо согласование"
    JSONDATA["steps"].removeIf { !(it["approverName"] in  NEEDAPPROVE.collect{it.name}) && it["status"] == "moved"}
    //updateJsonField записывает структуру в поле ApprovalResult
    updateJsonField(ApprovalResultFieldID, JSONDATA)
}5. Проверка условий этапа
- Если "ALL" → ждем всех согласующих. 
- Если "ANY" → достаточно одного согласия. 
- Если условие выполнено → этап закрывается, и переходим к следующему. 
6. Переходы задачи
- Если все этапы пройдены → заявка переводится в статус "Согласовано". 
- Если хоть один отказал → заявка возвращается в статус "В работе". 
Визуализация результата
В задаче пользователи видят таблицу (рендерится из ApprovalResult):

JSONDATA = new JsonSlurper().parseText(DATA)
def jsonSteps = JSONDATA.steps
String html = """
<table class='aui myTable'>
"""
if (jsonSteps){
    for (item in jsonSteps){
        html += userData(item.approverName,item.dep,getStatus(item.status))
    }
}
html += "</table>"
return html
def userData(userName,dep,status){
    def user = getUser(userName)
    AvatarService avatarService = ComponentAccessor.getAvatarService()
    def userIconUrl = avatarService.getAvatarURL(ComponentAccessor.jiraAuthenticationContext.getLoggedInUser(), user.name).toString()
    def urlUser = baseURL+"/secure/ViewProfile.jspa?name="
    def tr = "<tr>"
    tr += """
<th class="myTr">
<div>
<div style="float: left;">
<img src=${userIconUrl} style="border-radius:50%;vertical-align:middle; width="32"; height="32";">
</div>
<div style="margin-bottom: 10px; margin-left: 40px;">
<a href="${urlUser+user.name}">${user.displayName}</a>
</div>
</div>
</th>
"""
    tr += """<th class="myTr">${dep}</th>"""
    tr += """<th class="myTr">${status}</th>"""
    tr += "</tr>"
    return tr
}
def getStatus (key) {
    def map = [
            success : "<span style=\"font-size: 12px;\" class=\"aui-lozenge aui-lozenge-success\">Согласовано</span>",
            moved : "<span style=\"font-size: 12px;\" class=\"aui-lozenge aui-lozenge-default\">Ожидает согласования</span>",
            error : "<span style=\"font-size: 12px;\" class=\"aui-lozenge aui-lozenge-removed\">Отклонено</span>",
            dialog : "<span style=\"font-size: 12px;\" class=\"aui-lozenge aui-lozenge-moved\">Обсуждение</span>"
    ]
    return map.get(key)
}
Результат
- Убрали громоздкий workflow. 
- Все согласование реализовано в одном статусе. 
- Настройки выносятся в Assets, администратор может управлять согласованием без переписывания скриптов. 
- Пользователи видят удобную табличку прогресса. 
Ограничения и подводные камни
- Придется поддерживать Groovy-код (проверка условий, обновление JSON, перезапуск согласования, обновление согласующих). 
- Визуализация требует кастомных скриптов. 
Что можно улучшить
- Объединить - ApprovalConfig и ApprovalResultв единую структуру
- Хранить JSON не в custom fields, а в issue properties 
А как у вас организовано многоэтапное согласование? Используете Groovy/Assets или сторонние плагины с Marketplace?
 
           
 
Irreversib1e
Достаточно стандартный вариант реализации, если честно. Делали такое лет 5 назад.(Столкнулись с тем, что кастомные скрипты долго работали и грузили систему). Расскажите кстати про обработку варианта, когда все согласовали, а кто один вернул вопрос на доработку.
FerazYulgushev Автор
Хорошо, что такой подход получает распространение. Нередко встречал концепцию, где «этап = статус»
Идея цикличного статуса у нас применялась еще до появления Insight. Тогда мы использовали плагин, позволявший задавать метаданные прямо в значения опций select-листа. Решение выходило полностью бесплатным: и Groovy был не от ScriptRunner, и сам кастомный select-лист тоже бесплатный. Минус был только в том, что конфиг приходилось сразу прописывать в JSON, что было неудобно при передаче управления заказчику. А основная цель всегда оставалась одна - минимизировать затраты на поддержку. Сейчас мы начали использовать assets. Переписали старые скрипты от чего и родилась идея для статьи. При этом проблем с производительностью такие реализации никогда не вызывали. Было бы интересно провести профилирование подобного кода.
Что касается вашего вопроса: если на каком-то этапе происходит отклонение, то задача возвращается из согласования инициатору. Он прорабатывает отказ и при необходимости запускает согласование заново. Для наших заказчиков этого было достаточно, хотя при желании логику можно сделать более сложной - но нам в этом просто не было необходимости