🍊

Pleasanter でのワークフロー機能実現のためのアーキテクチャ

2022/12/25に公開

2022 個人アドベントカレンダー の記事です。

目的

「プロセス」機能を使って「ワークフロー」を実現するためのアーキテクチャの提案

また間にあわず…

現状の課題感

FAQやこれの参考実装であるデモサイトの"稟議申請の例"では、テーブルに機能を盛り込んでいる。

FAQ や Web での記事を確認しても、基本的に特定目的のテーブルを作成し、そのテーブルにコードや設定を作り込んで機能実現を図っている。

テーブル内にロジックを盛り込んでいくと、技術負債化してしまうこと以上に、ワークフロー的機能を実現するにあたって次の問題がある

  • プロセスの一括実行は、同じ名前のプロセスがあると実用性がない
    • 複数回の「承認」があると、「承認」が複数出る
  • プロセスの条件(ビューも同じだが)はユーザ属性に基づく動的な内容を右辺値にできない
    • つまり「自分チェック」を担当者/管理者以外には使えない
      • ^ 複数の承認がある場合、担当者や管理者をデータ変更機能で入れ換えるなどの対応を要する
    • 自組織、はないので、レコードのアクセス制御で閲覧/更新権限を分類型に基づいて設定することで、コントロールすることになる
      • 承認が誰に回覧されているかと、承認を待っている内容そのもののアクセス権限が混在してしまう
        • 権限管理が煩雑になる要因でもあり、ワークフローの進み具合を概観したりすることが困難になる

オンライン化するにあたって業務を見直して無駄な承認ステップを減らす、といったことは前提としても、いざどうしても多段階の承認をしようとしたとき、紙(や Excel ファイル)を回覧していたイメージに固まり、システム化の恩恵を得られていない。

今回検討するアーキテクチャ

ここでは、承認依頼内容そのものと承認行為を別テーブルにするアーキテクチャを検討する

具体的に嬉しくなる要素として

  • 承認行為と承認内容の分離
    • 承認を得るべき点(決裁)と、記載内容における書式チェックは本質的に独立している。これらを独立に承認・チェックできる仕組みが実現できる
  • 一括承認の実用的な実行
    • 承認をするテーブルでは、自分が更新権があるレコードに対して「承認」または「却下」だけが選択できるようにする。
    • また、依頼内容によらず、まとめての承認が可能となる
  • ワークフローの承認パス設計とユーザインターフェイスの独立(UI の一貫性)
    • ワークフローを行う内容ごとに、承認回数や方式(誰かひとりでも承認すればいいのか、多数決なのか、全員が認めるのか、強権的な権限があるのか)が異なり得るが、そうした要素によって、一括承認のようなユーザビリティが変動しないようにする。

実装案

テーブル設計

ワークフローの起案内容をもつテーブル群と承認だけをするテーブルの 2 パターンに分ける。
起案テーブルは、そこに記載された承認依頼先に応じたレコードを、承認テーブルに作成する。
なお、複数の起案テーブルが共通の承認テーブルにレコード作成したり、特定の起案テーブルが、依頼先(人か組織か)によって複数の承認テーブルにレコードを作成したりしてもよさそう。

  • 承認テーブル

承認依頼内容を参照させるため、テーブルのリンクを設定する想定。
ただ、承認テーブルにレコードを作成するのはスクリプトでしか実行できないので、あえて機能としてのリンクテーブルを作らなくてもよい。
この場合、承認テーブルにはサマリとして文字として情報を渡し、さらに分類型でアンカーを有効にして依頼へのリンクをつける、といった対応が想定できる。

このテーブルは、管理者や担当者、分類型項目を使って、レコードのアクセス権限(読取/更新)を設定し、承認できるものしか見えない、かたちにする。

  • 起案テーブル

承認フローのビジネスロジックをスクリプトとして保持する。このスクリプトにより、承認テーブルのレコードを作成したり、その内容によってステータスをかえたりする。

このため、承認を誰またはどの組織にするかの情報や承認テーブルのレコードの情報を保持しておく(レコードは保持しなくて、探すことも可能ではあるが…)

子テーブル(承認)

  • サイトのアクセス制御で新規作成のみ許可
  • ユーザ/組織項目を用意 → 承認者
    • ユーザは管理者、担当者を使う
    • 組織やグループは分類を使う
  • レコードのアクセス制御で、承認者に読取と更新権限を付与
  • 状況による制御で、承認行為が可能でない場合、読取専用とする
    • 他にもエディタで全ての項目を読取専用に(内容などを意見欄として有効化するのは可能だろう)
  • プロセスで、承認と却下ボタンを用意 → 完了にする
    • プロセスの検証でコメントを必須にしてもよいかもしれない
  • サーバースクリプトで、更新後に親を Update する

サイトパッケージとしては下記のような想定

{
  "HeaderInfo": {
    "AssemblyVersion": "1.3.26.1",
    "BaseSiteId": 7174854,
    "Server": "https://demo.pleasanter.org",
    "CreatorName": "テナント管理者",
    "PackageTime": "2022-12-24T16:14:58.6661352+00:00",
    "Convertors": [
      {
        "SiteId": 7174854,
        "SiteTitle": "承認テーブル",
        "ReferenceType": "Results",
        "IncludeData": false
      }
    ],
    "IncludeSitePermission": true,
    "IncludeRecordPermission": true,
    "IncludeColumnPermission": false,
    "IncludeNotifications": false,
    "IncludeReminders": false
  },
  "Sites": [
    {
      "TenantId": 13658,
      "SiteId": 7174854,
      "Title": "承認テーブル",
      "SiteName": "",
      "SiteGroupName": "",
      "Body": "",
      "GridGuide": "",
      "EditorGuide": "",
      "ReferenceType": "Results",
      "ParentId": 7094335,
      "InheritPermission": 7174854,
      "SiteSettings": {
        "Version": 1.017,
        "ReferenceType": "Results",
        "GridView": 1,
        "AllowViewReset": false,
        "LinkTableView": 1,
        "GridColumns": [
          "ResultId",
          "Title",
          "Status",
          "Manager",
          "Owner",
          "ClassC"
        ],
        "EditorColumnHash": {
          "General": [
            "ResultId",
            "ClassA",
            "ClassB",
            "Title",
            "Status",
            "Manager",
            "Owner",
            "ClassC"
          ]
        },
        "LinkColumns": ["ResultId", "Title", "Status", "Manager"],
        "Columns": [
          {
            "ColumnName": "ClassA",
            "LabelText": "p",
            "ChoicesText": "[[7174853]]",
            "Hide": true,
            "Link": true,
            "SearchType": "PartialMatch"
          },
          {
            "ColumnName": "Status",
            "ChoicesText": "0,未対応,未\n900,承認,承\n990,却下,却",
            "NotInsertBlankChoice": true,
            "DefaultInput": "0",
            "EditorReadOnly": true
          },
          {
            "ColumnName": "Title",
            "AutoNumberingFormat": "[yyyyMM]-[NNNN]",
            "AutoNumberingResetType": "Month",
            "ValidateRequired": false,
            "EditorReadOnly": true
          },
          {
            "ColumnName": "ClassB",
            "LabelText": "p2",
            "ChoicesText": "[[7178328]]",
            "Anchor": true,
            "AnchorFormat": "{Value}",
            "Hide": true,
            "Link": true,
            "SearchType": "PartialMatch"
          },
          {
            "ColumnName": "ClassC",
            "LabelText": "組織",
            "ChoicesText": "[[Depts]]",
            "EditorReadOnly": true,
            "SearchType": "PartialMatch"
          },
          {
            "ColumnName": "Manager",
            "EditorReadOnly": true
          },
          {
            "ColumnName": "Owner",
            "EditorReadOnly": true
          }
        ],
        "Links": [
          {
            "ColumnName": "ClassA",
            "SiteId": 7174853
          },
          {
            "ColumnName": "ClassB",
            "SiteId": 7178328
          }
        ],
        "Processes": [
          {
            "Id": 1,
            "Name": "承認",
            "DisplayName": "承認",
            "CurrentStatus": 0,
            "ChangedStatus": 900,
            "ConfirmationMessage": "承認しますか",
            "SuccessMessage": "承認しました",
            "AllowBulkProcessing": true,
            "View": {
              "Id": 0,
              "ApiColumnKeyDisplayType": 0,
              "ApiColumnValueDisplayType": 0,
              "ApiDataType": 0
            }
          },
          {
            "Id": 2,
            "Name": "却下",
            "DisplayName": "却下",
            "CurrentStatus": 0,
            "ChangedStatus": 990,
            "ConfirmationMessage": "却下しますか",
            "SuccessMessage": "却下しました",
            "AllowBulkProcessing": true,
            "View": {
              "Id": 0,
              "ApiColumnKeyDisplayType": 0,
              "ApiColumnValueDisplayType": 0,
              "ApiDataType": 0
            }
          }
        ],
        "StatusControls": [
          {
            "Id": 1,
            "Name": "完了時読取",
            "Status": -1,
            "ReadOnly": true,
            "View": {
              "Id": 0,
              "ColumnFilterHash": {
                "Status": "[\"900\",\"990\"]"
              },
              "ApiColumnKeyDisplayType": 0,
              "ApiColumnValueDisplayType": 0,
              "ApiDataType": 0
            }
          }
        ],
        "ViewLatestId": 1,
        "Views": [
          {
            "Id": 1,
            "Name": "a",
            "DefaultMode": "Index",
            "ColumnFilterHash": {
              "Status": "[\"\\t\",\"0\",\"100\",\"900\"]"
            },
            "ColumnSorterHash": {
              "ResultId": "asc"
            },
            "ApiColumnKeyDisplayType": 0,
            "ApiColumnValueDisplayType": 0,
            "CalendarTimePeriod": "Monthly",
            "CalendarFromTo": "DateA",
            "CrosstabGroupByX": "Status",
            "CrosstabGroupByY": "Owner",
            "CrosstabAggregateType": "Total",
            "CrosstabTimePeriod": "Monthly",
            "ApiDataType": 0
          }
        ],
        "ServerScripts": [
          {
            "Title": "afterEdit",
            "Name": "afterEdit",
            "AfterUpdate": true,
            "Body": "try {\n  if (context.ControlId.startsWith('Process')) {\n    const parentId = model.ClassA || model.ClassB;\n    const parents = items.Get(parentId);\n    if (parents.Length === 1) {\n      const parent = parents[0];\n      parent.Update();\n    }\n  }\n} catch (e) {\n  context.Log(e.message);\n}\n",
            "Id": 1
          }
        ],
        "NoDisplayIfReadOnly": false,
        "PermissionForCreating": {
          "ClassC": 5,
          "Manager": 5,
          "Owner": 5
        },
        "PermissionForUpdating": {
          "ClassC": 5,
          "Manager": 5,
          "Owner": 5
        }
      },
      "Publish": false,
      "DisableCrossSearch": false,
      "Comments": []
    }
  ],
  "Data": [],
  "Permissions": [
    {
      "SiteId": 7174854,
      "Permissions": [
        {
          "ReferenceId": 7174854,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": -1,
          "PermissionType": 2
        },
        {
          "ReferenceId": 7174854,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 511
        },
        {
          "ReferenceId": 7186549,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186549,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275519,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186550,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186550,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275527,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186551,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186551,
          "DeptId": 123132,
          "GroupId": 0,
          "UserId": 0,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186557,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186557,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275524,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186558,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186558,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275520,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186559,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186559,
          "DeptId": 123136,
          "GroupId": 0,
          "UserId": 0,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186563,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186563,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275512,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186564,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186564,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275528,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186565,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186565,
          "DeptId": 123139,
          "GroupId": 0,
          "UserId": 0,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186567,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186567,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275513,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186568,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186568,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275514,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186569,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186569,
          "DeptId": 123134,
          "GroupId": 0,
          "UserId": 0,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186571,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186571,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275527,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186572,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186572,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275519,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186573,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186573,
          "DeptId": 123133,
          "GroupId": 0,
          "UserId": 0,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186575,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186575,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275518,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186576,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186576,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275525,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186577,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186577,
          "DeptId": 123137,
          "GroupId": 0,
          "UserId": 0,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186579,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186579,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275525,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186580,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186580,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275518,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186581,
          "DeptId": 0,
          "GroupId": 0,
          "UserId": 275509,
          "PermissionType": 5
        },
        {
          "ReferenceId": 7186581,
          "DeptId": 123137,
          "GroupId": 0,
          "UserId": 0,
          "PermissionType": 5
        }
      ]
    }
  ],
  "PermissionIdList": {
    "DeptIdList": [
      {
        "DeptId": 123132,
        "DeptCode": "000000"
      },
      {
        "DeptId": 123133,
        "DeptCode": "000010"
      },
      {
        "DeptId": 123134,
        "DeptCode": "000110"
      },
      {
        "DeptId": 123136,
        "DeptCode": "000310"
      },
      {
        "DeptId": 123137,
        "DeptCode": "000410"
      },
      {
        "DeptId": 123139,
        "DeptCode": "000610"
      }
    ],
    "GroupIdList": [],
    "UserIdList": [
      {
        "UserId": 275509,
        "LoginId": "Tenant13658_User1"
      },
      {
        "UserId": 275512,
        "LoginId": "Tenant13658_User4"
      },
      {
        "UserId": 275513,
        "LoginId": "Tenant13658_User5"
      },
      {
        "UserId": 275514,
        "LoginId": "Tenant13658_User6"
      },
      {
        "UserId": 275518,
        "LoginId": "Tenant13658_User10"
      },
      {
        "UserId": 275519,
        "LoginId": "Tenant13658_User11"
      },
      {
        "UserId": 275520,
        "LoginId": "Tenant13658_User12"
      },
      {
        "UserId": 275524,
        "LoginId": "Tenant13658_User16"
      },
      {
        "UserId": 275525,
        "LoginId": "Tenant13658_User17"
      },
      {
        "UserId": 275527,
        "LoginId": "Tenant13658_User19"
      },
      {
        "UserId": 275528,
        "LoginId": "Tenant13658_User20"
      }
    ]
  }
}

親テーブル(起案)

  • プロセスで依頼ボタン
  • サーバースクリプトでフロー制御
    • フローチャートを表現したオブジェクトにより、どの状況で何をやるかを定義
    • 子が親を Update しにきたときにも発動させる
  • 状況は読取専用だが、本テーブルは特に状況による制御をかけない想定
    • 例えば、購入申請でも送付先の変更(別事業所へ)などは、発注まで変更して差し支えない、など、決裁を受けるべき部分と管理上必要な情報を分けて、後者は随時編集できていい。申請書を概念的に分けることができるのはオンラインシステムのメリットとなり得る

サイトパッケージは下記のような想定

{
  "HeaderInfo": {
    "AssemblyVersion": "1.3.26.1",
    "BaseSiteId": 7174853,
    "Server": "https://demo.pleasanter.org",
    "CreatorName": "テナント管理者",
    "PackageTime": "2022-12-24T16:26:36.2077475+00:00",
    "Convertors": [
      {
        "SiteId": 7174853,
        "SiteTitle": "起案テーブル",
        "ReferenceType": "Results",
        "IncludeData": false
      }
    ],
    "IncludeSitePermission": false,
    "IncludeRecordPermission": false,
    "IncludeColumnPermission": false,
    "IncludeNotifications": false,
    "IncludeReminders": false
  },
  "Sites": [
    {
      "TenantId": 13658,
      "SiteId": 7174853,
      "Title": "起案テーブル",
      "SiteName": "",
      "SiteGroupName": "",
      "Body": "",
      "GridGuide": "",
      "EditorGuide": "",
      "ReferenceType": "Results",
      "ParentId": 7094335,
      "InheritPermission": 7094335,
      "SiteSettings": {
        "Version": 1.017,
        "ReferenceType": "Results",
        "GridColumns": ["ResultId", "Title", "Status", "Manager"],
        "EditorColumnHash": {
          "General": [
            "ResultId",
            "Title",
            "Status",
            "ClassX",
            "ClassY",
            "ClassZ",
            "ClassW"
          ]
        },
        "LinkColumns": ["ResultId", "Title", "Body"],
        "Columns": [
          {
            "ColumnName": "ClassX",
            "LabelText": "承認依頼ユーザ1",
            "ChoicesText": "[[Users*]]",
            "SearchType": "PartialMatch"
          },
          {
            "ColumnName": "ClassY",
            "LabelText": "承認依頼ユーザ2",
            "ChoicesText": "[[Users*]]",
            "SearchType": "PartialMatch"
          },
          {
            "ColumnName": "ClassZ",
            "LabelText": "承認依頼組織",
            "ChoicesText": "[[Depts]]",
            "SearchType": "PartialMatch"
          },
          {
            "ColumnName": "Status",
            "ChoicesText": "100,未着手,未,status-new\n150,準備,準,status-preparation\n200,実施中,実,status-inprogress\n250,2準備,準,status-preparation\n300,レビュー,レ,status-review\n350,3準備,準,status-preparation\n900,完了,完,status-closed\n910,保留,留,status-rejected",
            "EditorReadOnly": true
          },
          {
            "ColumnName": "ClassW",
            "LabelText": "初期ステータス",
            "ChoicesText": "0",
            "DefaultInput": "0",
            "Hide": true,
            "EditorReadOnly": true,
            "SearchType": "PartialMatch"
          }
        ],
        "Processes": [
          {
            "Id": 1,
            "Name": "承認依頼",
            "DisplayName": "承認依頼",
            "CurrentStatus": 100,
            "ChangedStatus": 100,
            "View": {
              "Id": 0,
              "ApiColumnKeyDisplayType": 0,
              "ApiColumnValueDisplayType": 0,
              "ApiDataType": 0
            }
          }
        ],
        "StatusControls": [
          {
            "Id": 1,
            "Name": "読取",
            "Status": -1,
            "View": {
              "Id": 0,
              "ColumnFilterHash": {
                "Status": "[\"900\",\"910\"]"
              },
              "ApiColumnKeyDisplayType": 0,
              "ApiColumnValueDisplayType": 0,
              "ApiDataType": 0
            }
          }
        ],
        "ServerScripts": [
          {
            "Title": "beforeEdit",
            "Name": "beforeEdit",
            "BeforeUpdate": true,
            "Body": "const handler = (param, stat) => {\n  const route = param[stat];\n  if (route === undefined) {\n    return;\n  }\n  switch (route['action']) {\n    case 'request':\n      requester(route['param']);\n      modifier(route['modify']);\n      return;\n    case 'check':\n      checker(route['param']);\n      modifier(route['modify']);\n      return;\n    default:\n      return;\n  }\n};\nconst requester = (params) => {\n  const childId = 7174854;\n  const src = model;\n  const newRequest = (params) => {\n    const newRecord = items.New();\n    params.forEach((e) => {\n      newRecord[e.dst] = src[e.src];\n    });\n    context.Log(newRecord.Status);\n    return newRecord;\n  };\n  params.forEach((param) => items.Create(childId, newRequest(param)));\n};\nconst checker = (params) => {\n  const childId = 7174854;\n  const counter = (params) => {\n    return items.Get(childId, params.replace('%ThisId%', context.Id)).Length;\n  };\n  for (const param of params) {\n    const cnt = counter(param['condition']);\n    if (cnt >= param['threshold']) {\n      modifier(param['modify']);\n      return;\n    }\n  }\n};\nconst modifier = (param) => {\n  if (param === undefined) {\n    return;\n  }\n  const self = model;\n  Object.keys(param).forEach((k) => (self[k] = param[k]));\n};\n\ntry {\n  //  model.Body = `${context.Id} ${model.ResultId} ${context.Controller}${context.UrlReferrer}${context.Url}${context.SiteId}`;\n  if (selfId === context.SiteId && context.ControlId === 'Process_1') {\n    let stat = model.Status;\n    do {\n      stat = model.Status;\n      handler(seq /*para*/, stat);\n    } while (stat !== model.Status);\n  } else if (childId === context.SiteId) {\n    let stat = model.Status;\n    do {\n      stat = model.Status;\n      handler(seq /*para*/, stat);\n    } while (stat !== model.Status);\n  }\n} catch (e) {\n  context.Log(e.message);\n}\n",
            "Id": 1
          },
          {
            "Title": "defs",
            "Name": "defs",
            "Shared": true,
            "Body": "const selfId = 7174853;\nconst childId = 7174854;\nconst seq = {\n  100: {\n    action: 'request',\n    param: [\n      [\n        { src: 'ClassX', dst: 'Manager' },\n        { src: 'ResultId', dst: 'ClassA' },\n        { src: 'ClassW', dst: 'Status' },\n      ],\n    ],\n    modify: { Status: 150 },\n  },\n  150: {\n    action: 'check',\n    param: [\n      {\n        threshold: 1,\n        condition: `{\"View\":{\"ColumnFilterHash\":{\"Status\":\"900\", \"ClassA\":\"[\\\\\"%ThisId%\\\\\"]\"},\"ColumnFilterSearchTypes\":{\"Status\":\"ExactMatch\",\"ClassA\":\"ExactMatchMultiple\"}}}`,\n        modify: { Status: 200 },\n      },\n    ],\n  },\n  200: {\n    action: 'request',\n    param: [\n      [\n        { src: 'ClassY', dst: 'Manager' },\n        { src: 'ResultId', dst: 'ClassA' },\n        { src: 'ClassW', dst: 'Status' },\n      ],\n    ],\n    modify: { Status: 250 },\n  },\n  250: {\n    action: 'check',\n    param: [\n      {\n        threshold: 2,\n        condition: `{\"View\":{\"ColumnFilterHash\":{\"Status\":\"900\", \"ClassA\":\"[\\\\\"%ThisId%\\\\\"]\"},\"ColumnFilterSearchTypes\":{\"Status\":\"ExactMatch\",\"ClassA\":\"ExactMatchMultiple\"}}}`,\n        modify: { Status: 300 },\n      },\n    ],\n  },\n  300: {\n    action: 'request',\n    param: [\n      [\n        { src: 'ClassZ', dst: 'ClassC' },\n        { src: 'ResultId', dst: 'ClassA' },\n        { src: 'ClassW', dst: 'Status' },\n      ],\n    ],\n    modify: { Status: 350 },\n  },\n  350: {\n    action: 'check',\n    param: [\n      {\n        threshold: 3,\n        condition: `{\"View\":{\"ColumnFilterHash\":{\"Status\":\"900\", \"ClassA\":\"[\\\\\"%ThisId%\\\\\"]\"},\"ColumnFilterSearchTypes\":{\"Status\":\"ExactMatch\",\"ClassA\":\"ExactMatchMultiple\"}}}`,\n        modify: { Status: 900 },\n      },\n      {\n        threshold: 1,\n        condition: `{\"View\":{\"ColumnFilterHash\":{\"Status\":\"990\", \"ClassA\":\"[\\\\\"%ThisId%\\\\\"]\"},\"ColumnFilterSearchTypes\":{\"Status\":\"ExactMatch\",\"ClassA\":\"ExactMatchMultiple\"}}}`,\n        modify: { Status: 910 },\n      },\n    ],\n  },\n};\n\nconst para = {\n  100: {\n    action: 'request',\n    param: [\n      [\n        { src: 'ClassX', dst: 'Manager' },\n        { src: 'ResultId', dst: 'ClassA' },\n        { src: 'ClassW', dst: 'Status' },\n      ],\n      [\n        { src: 'ClassY', dst: 'Manager' },\n        { src: 'ResultId', dst: 'ClassA' },\n        { src: 'ClassW', dst: 'Status' },\n      ],\n      [\n        { src: 'ResultId', dst: 'ClassA' },\n        { src: 'ClassZ', dst: 'ClassC' },\n        { src: 'ClassW', dst: 'Status' },\n      ],\n    ],\n    modify: { Status: 150 },\n  },\n  150: {\n    action: 'check',\n    param: [\n      {\n        threshold: 2,\n        condition: `{\"View\":{\"ColumnFilterHash\":{\"Status\":\"900\", \"ClassA\":\"[\\\\\"%ThisId%\\\\\"]\"},\"ColumnFilterSearchTypes\":{\"Status\":\"ExactMatch\",\"ClassA\":\"ExactMatchMultiple\"}}}`,\n        modify: { Status: 900 },\n      },\n      {\n        threshold: 2,\n        condition: `{\"View\":{\"ColumnFilterHash\":{\"Status\":\"990\", \"ClassA\":\"[\\\\\"%ThisId%\\\\\"]\"},\"ColumnFilterSearchTypes\":{\"Status\":\"ExactMatch\",\"ClassA\":\"ExactMatchMultiple\"}}}`,\n        modify: { Status: 910 },\n      },\n    ],\n  },\n};\n",
            "Id": 2
          }
        ],
        "NoDisplayIfReadOnly": false
      },
      "Publish": false,
      "DisableCrossSearch": false,
      "Comments": []
    }
  ],
  "Data": [],
  "Permissions": [],
  "PermissionIdList": {
    "DeptIdList": [],
    "GroupIdList": [],
    "UserIdList": []
  }
}

補足:フローを表現するオブジェクト

承認テーブルに記載の 2 つのフローを表現するオブジェクトについて補足する

2 パターン定義しており、順次癩と承認を進めていく seq と同時依頼で多数決方式の para を検討した。

const seq = {
  100: {
    action: 'request',
    param: [
      [
        { src: 'ClassX', dst: 'Manager' },
        { src: 'ResultId', dst: 'ClassA' },
        { src: 'ClassW', dst: 'Status' },
      ],
    ],
    modify: { Status: 150 },
  },
  150: {
    action: 'check',
    param: [
      {
        threshold: 1,
        condition: `{"View":{"ColumnFilterHash":{"Status":"900", "ClassA":"[\\"%ThisId%\\"]"},"ColumnFilterSearchTypes":{"Status":"ExactMatch","ClassA":"ExactMatchMultiple"}}}`,
        modify: { Status: 200 },
      },
    ],
  },
  200: {
    action: 'request',
    param: [
      [
        { src: 'ClassY', dst: 'Manager' },
        { src: 'ResultId', dst: 'ClassA' },
        { src: 'ClassW', dst: 'Status' },
      ],
    ],
    modify: { Status: 250 },
  },
  250: {
    action: 'check',
    param: [
      {
        threshold: 2,
        condition: `{"View":{"ColumnFilterHash":{"Status":"900", "ClassA":"[\\"%ThisId%\\"]"},"ColumnFilterSearchTypes":{"Status":"ExactMatch","ClassA":"ExactMatchMultiple"}}}`,
        modify: { Status: 300 },
      },
    ],
  },
  300: {
    action: 'request',
    param: [
      [
        { src: 'ClassZ', dst: 'ClassC' },
        { src: 'ResultId', dst: 'ClassA' },
        { src: 'ClassW', dst: 'Status' },
      ],
    ],
    modify: { Status: 350 },
  },
  350: {
    action: 'check',
    param: [
      {
        threshold: 3,
        condition: `{"View":{"ColumnFilterHash":{"Status":"900", "ClassA":"[\\"%ThisId%\\"]"},"ColumnFilterSearchTypes":{"Status":"ExactMatch","ClassA":"ExactMatchMultiple"}}}`,
        modify: { Status: 900 },
      },
      {
        threshold: 1,
        condition: `{"View":{"ColumnFilterHash":{"Status":"990", "ClassA":"[\\"%ThisId%\\"]"},"ColumnFilterSearchTypes":{"Status":"ExactMatch","ClassA":"ExactMatchMultiple"}}}`,
        modify: { Status: 910 },
      },
    ],
  },
};

const para = {
  100: {
    action: 'request',
    param: [
      [
        { src: 'ClassX', dst: 'Manager' },
        { src: 'ResultId', dst: 'ClassA' },
        { src: 'ClassW', dst: 'Status' },
      ],
      [
        { src: 'ClassY', dst: 'Manager' },
        { src: 'ResultId', dst: 'ClassA' },
        { src: 'ClassW', dst: 'Status' },
      ],
      [
        { src: 'ResultId', dst: 'ClassA' },
        { src: 'ClassZ', dst: 'ClassC' },
        { src: 'ClassW', dst: 'Status' },
      ],
    ],
    modify: { Status: 150 },
  },
  150: {
    action: 'check',
    param: [
      {
        threshold: 2,
        condition: `{"View":{"ColumnFilterHash":{"Status":"900", "ClassA":"[\\"%ThisId%\\"]"},"ColumnFilterSearchTypes":{"Status":"ExactMatch","ClassA":"ExactMatchMultiple"}}}`,
        modify: { Status: 900 },
      },
      {
        threshold: 2,
        condition: `{"View":{"ColumnFilterHash":{"Status":"990", "ClassA":"[\\"%ThisId%\\"]"},"ColumnFilterSearchTypes":{"Status":"ExactMatch","ClassA":"ExactMatchMultiple"}}}`,
        modify: { Status: 910 },
      },
    ],
  },
};

seq では状態を遷移させることで、ある段階では依頼を作成、ある段階では条件が満たされるか待つ、を順次に実行できるようにしている。
こちら、却下を全ては作り込んでいないのだが 350 では、却下が 1 つでもあれば、保留とするようにして、拒否権的な実装となっている。
状況と条件を作り込んでいけば、差戻しは可能になりそう(単に、申請者に差し戻すのであれば、状況 0 を作るだけでよさそうだが、1 つ戻すとなると、事前の承認依頼レコードを消すか、条件ブロックを上手く作らないといけないので結構大変)
ただ、状況とそのとき何をするか、はプロセス機能で単一テーブル内で実現する場合もかなり手間なので、設計面での大変さはあまりかわらない。

para ではレコードの初期ステータスで、依頼を 3 件発行し、過半数が承認もしくは却下となるようにした。
同時的に依頼をすることで特定の誰かがボトルネックにならないことが期待される。
こうした、同時での発行や多数決もしくは合議での依頼は、プロセス機能で単一テーブルで承認する方法では単純に実現できず、スクリプトそのものにロジックを組み込まないといけない(しかも、途中の段階では状況を使いにくい(1/3 承認、みたいな機械的な状況を使うならともかく)ので、定義に解消しづらい)

他にも、例えば"代理承認"みたいな対応を後載せしやすい。つまり、起案テーブルのユーザ項目にルックアップをつけて、特定の部長を選択すると部長代理が隣りにうまる、といった構造をしていれば、それを手掛りに承認レコードが作れる。同様のことはシステムの「グループ」ではない「特定事業ごとのチームと責任者」を管理する別テーブルを使って、選択肢から何人かのユーザにマッピングする、といった対応も可能となる。
これらは、システムのグループという、テナント管理権限のない場所で、ユーザ部門にメンテしてもらえる、方法ともなり得る。

このとき、複数の承認者がいること、子レコードのレコードの数としても、子レコードのカラムの数としても表現できる柔軟性があり、前者は先着 n 人ができる一方で未対応レコードが残る(もし気にするなら)が、後者は多数決はできないが未対応のままのものが残らない、といった使い分けが考えられる。

また、想定したものの他にも、独裁者(鶴の一声)を作ったり、途中までは多数決としつつ最後は特定のユーザの関門を経由させたり、といったこともできそう。

つまったところ

  • 状況の制御で状況が(未設定)のとき、読取専用とすると、既定値で値を指定していても、レコードを新規作成できなくなる
  • サーバスクリプトで items.New とすると Status が 100 になっている。
    指定していないはずの、担当者も埋まった。
    NewIssue という関数で初期化されているのだが、具体的な値はまだまだ深い先で初期化されているようで、探索を諦めた。
    作り的に Create で SiteId を渡す前に初期化されるため、作成したいサイトの既定値が利用できない構造。辛すぎる。
  • items.Get の第二引数(view)で Status が 0 がヒットしない。(ExactMatch でも ExactMatchMultiple でも。)
    数値だけかえたら他のステータスにはヒットするので何かおかしい。
    • view の書きかたまじでしんどすぎる。Status は ExactMatch も ExactMatchMultiple も動作してそうなのに、分類は ExactMatch だと上手く動かず ExactMatchMultiple だと動く、といった一見して謎な挙動を示す
      • もちろん Multiple か否かで配列記載するか否かが異なるので、記載ミスかもしれないが、調査が険しい

さいごに

実装面で、基本的にプリザンターはワークフローシステムではないので、どうやって実現するにせよ実装上のつらさは回避できない。
また、現状の仕組み的に、別テーブルを操作するプロセスや、プロセスの条件で右辺値に変数を入れる、は結構実現が遠そうなので、今回のようなことをするとなると、まだまだスクリプトに頼らざるを得ないと考えた。

あらためて課題として思うのは、プリザンターはテーブルの数を増やすことに制限がないので 1 テーブルに閉じないことで使いやすく、また機能を合理的に作ったり、使ったりできるのではないか、ということ。
(Excel だって、データを作るシートと見せるシートは分ける、というのは基本な気がする)

今回の例で最大のメリットは、フローが複雑化しても利用者の行動は変動せず、単に承認テーブルに来て、見えているものの採否を判断するだけ、そのときには押すべきボタンさえ変化しない、というところにあると考えている。
あまり無理して寄せるのはそれはそれで悪手だが、作りによっては、承認作業は全て 1 つのテーブルに集約する、ということだってできそう。

利用局面から見たテーブル設計の参考例がもっと増えてくれたらいいな。

GitHubで編集を提案

Discussion