В этой статье расскажу, как мы реализовали гибкое многоэтапное согласование в Jira. Особенность подхода - все согласование зациклено в одном статусе, без громоздких схем workflow. Вся логика задается в Assets и управляется через Groovy‑скрипт.

Постановка задачи

Бизнес хотел видеть:

  • несколько последовательных этапов согласования;

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

  • единый прозрачный результат в виде таблицы в задаче;

  • автоматический переход задачи в "Согласовано" или возврат в "В работу".

Инструменты и подход

  • Assets (бывший Insight) - хранение конфигурации согласования (этапы, согласующие, условия).

  • Groovy - основная логика.

  • JSON - два скрытых поля:

    1. ApprovalConfig - структура согласования, формируется скриптом из Assets при старте процесса;

    2. ApprovalResult - динамический результат согласования (кто согласовал/отклонил, на каком этапе).

Архитектура решения

  1. Согласование зациклено в одном статусе:
    Задача не прыгает по workflow - все крутится внутри "Согласование".

  2. Цепочка согласования:

    Выбирается пользователем из списка преднастроеных цепочек через поле справочник Assets object на экране создания задачи или на переходе в согласование

  3. Два скрытых поля:

    • ApprovalConfig (JSON) - конфиг, формируется один раз на старте. Нужен чтобы скрипт переключал этапы.

    • ApprovalResult (JSON) - результаты согласования, обновляются на каждом этапе. Используется для отрисовки таблицы результатов

  4. Работа с пользователями:

    • В поле "Необходимо согласование" заносятся все согласующие текущего этапа.

    • Когда пользователь согласовал он удаляется оттуда и переносится в поле "Согласовано".

    • При переходе на следующий этап список «Необходимо согласование» обновляется.

  5. Условия этапа:

    • "Все должны согласовать" или "достаточно одного". Настраивается в Assets.

  6. Результат на экране:

    • Отдельный 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):

Таблица с результатом согласования - это scripted field
Таблица с результатом согласования - это scripted field
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?

Комментарии (0)