承認ワークフローのDB設計は、「申請」「承認ルート定義」「承認ステップ」「承認履歴」の4つに分けるのが基本です。 申請には「いまの状態」と「いま何段目か」を持たせ、誰がいつ何をしたかは履歴に追記だけしていきます。こうすると、段数が増えても列を足さずに済み、差し戻しや代理承認も行の更新だけで表せます。

この記事では、CREATE TABLE 一式と、申請・承認・差し戻し(1段戻し/申請者まで戻し)・取り下げ・代理承認・合議のSQLを、実行結果と一緒に示します。ステータスの値の決め方や状態遷移図の書き方そのものは扱わず、承認ルートの定義と、承認者をどう決めるかに絞ります。題材は架空の備品購入申請で、金額はすべて説明用の例です。

確認環境(2026-10-06):Ubuntu 24.04、PostgreSQL 16.15(psql)。掲載したSQLと実行結果はすべてこの環境で流したものです。Mermaid の図は @mermaid-js/mermaid-cli 12.0.0(mermaid 12.1.0)と Chromium 141 で描画できることを確かめました。行ロックと外部キーの説明は PostgreSQL 16 のドキュメントを同日に開いて確認しています。

この記事の結論

  • テーブルは4つに分けます。 申請(状態と現在の段)、承認ルート定義(どの申請が何段を通るか)、承認ステップ(申請ごとに決まった承認者)、承認履歴(追記のみ)。
  • 「いま何段目か」は申請に1列で持ちます。 履歴から毎回計算せず、承認待ち一覧をすぐ引けるようにします。
  • 承認者は申請した時点で解決して、承認ステップに写します。 ルート定義には「上長」「部門長」「総務」のような決め方だけを書きます。
  • 差し戻しは2種類。 1段戻しは現在の段を1つ前に戻すだけ、申請者まで戻しは状態を REMANDED にして再申請でルートを選び直します。
  • 合議は承認者の行を人数分持ち、「全員」は未処理の行が無くなったら、「誰か1人」は1人が承認したら次へ進めます。

テーブル構成の早見表

テーブル 役割 1行が表すもの 更新の仕方
request(申請) 状態と現在の段を持つ 申請1件 状態と段を上書き
approval_route(ルート定義・見出し) どの申請がどのルートを通るか 申請種別×金額帯 管理者が保守
approval_route_step(ルート定義・明細) ルートの何段目を誰が承認するか ルート×段 管理者が保守
request_step(承認ステップ) 申請ごとに解決した承認者と結果 申請×段×承認者 結果を上書き。再申請で作り直す
approval_history(承認履歴) 誰がいつ何をしたか 操作1回 追記のみ
app_user(利用者) 上長と役割 利用者1人 人事情報に合わせて保守
delegation(代理) 代わりに承認できる人と期間 代理元×代理先×期間 本人または管理者が登録

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「承認ワークフローのDB設計|多段承認と差し戻しのテーブル例」

ER図(Mermaid)

erDiagram
    app_user ||--o{ request : "申請する"
    approval_route ||--|{ approval_route_step : "段を持つ"
    approval_route ||--o{ request : "適用される"
    request ||--o{ request_step : "承認者を写す"
    request ||--o{ approval_history : "追記する"
    app_user ||--o{ request_step : "承認する"
    app_user ||--o{ delegation : "代理を立てる"
    request {
        int request_id PK
        text request_type
        int amount
        int applicant_id FK
        int route_id FK
        text status
        int current_step_no
    }
    approval_route {
        int route_id PK
        text request_type
        int amount_from
        int amount_to
    }
    approval_route_step {
        int route_id PK
        int step_no PK
        text approver_type
        text approver_role
        text rule
    }
    request_step {
        int request_id PK
        int step_no PK
        int approver_id PK
        text rule
        text result
        int acted_by
    }
    approval_history {
        bigint history_id PK
        int request_id FK
        text action
        int actor_id FK
        int on_behalf_of
        text to_status
    }
    delegation {
        int delegator_id PK
        int delegate_id PK
        date valid_from PK
        date valid_to
    }

CREATE TABLE 一式(PostgreSQL 16)

-- 利用者(上長と役割を持つ)
CREATE TABLE app_user (
  user_id    integer PRIMARY KEY,
  user_name  text    NOT NULL,
  dept_code  text    NOT NULL,
  manager_id integer REFERENCES app_user (user_id),
  role_code  text                      -- 'DEPT_HEAD'(部門長), 'GA'(総務)など
);

-- 代理(delegator の代わりに delegate が承認できる期間)
CREATE TABLE delegation (
  delegator_id integer NOT NULL REFERENCES app_user (user_id),
  delegate_id  integer NOT NULL REFERENCES app_user (user_id),
  valid_from   date    NOT NULL,
  valid_to     date    NOT NULL,
  PRIMARY KEY (delegator_id, delegate_id, valid_from),
  CHECK (valid_from <= valid_to),
  CHECK (delegator_id <> delegate_id)
);

-- 承認ルート定義(見出し):どの申請がどのルートを通るか
CREATE TABLE approval_route (
  route_id     integer PRIMARY KEY,
  route_name   text    NOT NULL,
  request_type text    NOT NULL,
  amount_from  integer NOT NULL DEFAULT 0,   -- この金額以上
  amount_to    integer,                      -- この金額未満(NULL は上限なし)
  is_active    boolean NOT NULL DEFAULT true
);

-- 承認ルート定義(明細):ルートの何段目を、誰が、どの条件で承認するか
CREATE TABLE approval_route_step (
  route_id         integer NOT NULL REFERENCES approval_route (route_id),
  step_no          integer NOT NULL CHECK (step_no >= 1),
  step_name        text    NOT NULL,
  approver_type    text    NOT NULL CHECK (approver_type IN ('MANAGER', 'DEPT_ROLE', 'ROLE', 'USER')),
  approver_role    text,
  approver_user_id integer REFERENCES app_user (user_id),
  rule             text    NOT NULL DEFAULT 'ALL' CHECK (rule IN ('ALL', 'ANY')),
  PRIMARY KEY (route_id, step_no),
  CHECK ((approver_type IN ('DEPT_ROLE', 'ROLE')) = (approver_role IS NOT NULL)),
  CHECK ((approver_type = 'USER') = (approver_user_id IS NOT NULL))
);

-- 申請:状態と「いま何段目か」を持つ
CREATE TABLE request (
  request_id      integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  request_type    text    NOT NULL,
  title           text    NOT NULL,
  amount          integer NOT NULL DEFAULT 0,
  applicant_id    integer NOT NULL REFERENCES app_user (user_id),
  route_id        integer REFERENCES approval_route (route_id),
  status          text    NOT NULL DEFAULT 'DRAFT'
                  CHECK (status IN ('DRAFT', 'IN_REVIEW', 'REMANDED', 'APPROVED', 'REJECTED', 'WITHDRAWN')),
  current_step_no integer,
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),
  FOREIGN KEY (route_id, current_step_no) REFERENCES approval_route_step (route_id, step_no),
  CHECK ((status = 'IN_REVIEW') = (current_step_no IS NOT NULL))
);

-- 承認ステップ:申請ごとに承認者を解決して写した行(result が NULL の行が未処理)
CREATE TABLE request_step (
  request_id  integer NOT NULL REFERENCES request (request_id),
  step_no     integer NOT NULL,
  approver_id integer NOT NULL REFERENCES app_user (user_id),
  rule        text    NOT NULL CHECK (rule IN ('ALL', 'ANY')),
  result      text    CHECK (result IN ('APPROVED', 'SKIPPED')),
  acted_by    integer REFERENCES app_user (user_id),
  acted_at    timestamptz,
  PRIMARY KEY (request_id, step_no, approver_id)
);
CREATE INDEX request_step_pending_idx ON request_step (approver_id) WHERE result IS NULL;

-- 承認履歴:追記のみ
CREATE TABLE approval_history (
  history_id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  request_id   integer NOT NULL REFERENCES request (request_id),
  action       text    NOT NULL CHECK (action IN
               ('SUBMIT', 'APPROVE', 'REMAND_PREV', 'REMAND_APPLICANT', 'REJECT', 'WITHDRAW')),
  step_no      integer,                -- 操作した時点の段(申請・取り下げは NULL)
  actor_id     integer NOT NULL REFERENCES app_user (user_id),
  on_behalf_of integer REFERENCES app_user (user_id),   -- 代理承認のときの本来の承認者
  to_status    text    NOT NULL,
  to_step_no   integer,
  comment      text,
  acted_at     timestamptz NOT NULL DEFAULT now()
);

CREATE FUNCTION approval_history_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  RAISE EXCEPTION 'approval_history は追記のみです(% は行えません)', TG_OP;
END $$;
CREATE TRIGGER approval_history_no_change
  BEFORE UPDATE OR DELETE ON approval_history
  FOR EACH ROW EXECUTE FUNCTION approval_history_append_only();

設計の要点は4つです。

  • request の (route_id, current_step_no) に複合外部キーを張り、ルートに存在しない段を指せないようにしています。current_step_no が NULL の間は検査されません(外部キーの既定の動作)。
  • CHECK ((status = 'IN_REVIEW') = (current_step_no IS NOT NULL)) で、「承認待ちなのに段が無い」「承認済みなのに段が残っている」行を作れなくしています。
  • request_step の result は NULL が未処理です。部分インデックス WHERE result IS NULL で、承認待ちの行だけを小さく索引にします。
  • approval_history はトリガーで UPDATE と DELETE を止めています。 運用では、アプリ用のロールに INSERT と SELECT だけを与える方法も併用できます。

承認ルート定義の記入例

申請種別と金額帯でルートを1つ選び、ルートごとに段を並べます。

route_id ルート名 申請種別 金額(以上) 金額(未満)
1 備品購入(5万円未満) PURCHASE 0 50000
2 備品購入(5万円以上30万円未満) PURCHASE 50000 300000
3 備品購入(30万円以上) PURCHASE 300000 (上限なし)
4 備品持ち出し TAKEOUT 0 (上限なし)
route_id 段 段の名前 承認者の決め方 役割 合議の条件
1 1 上長承認 MANAGER ALL
2 1 上長承認 MANAGER ALL
2 2 部門長承認 DEPT_ROLE DEPT_HEAD ALL
3 1 上長承認 MANAGER ALL
3 2 部門長承認 DEPT_ROLE DEPT_HEAD ALL
3 3 総務合議 ROLE GA ALL
4 1 総務確認 ROLE GA ANY

表計算ソフトで保守するときは、次のタブ区切りを貼って使えます。

route_id    route_name  request_type    amount_from amount_to
1   備品購入(5万円未満) PURCHASE    0   50000
2   備品購入(5万円以上30万円未満)   PURCHASE    50000   300000
3   備品購入(30万円以上)    PURCHASE    300000  
4   備品持ち出し  TAKEOUT 0   
route_id    step_no step_name   approver_type   approver_role   approver_user_id    rule
1   1   上長承認    MANAGER         ALL
2   1   上長承認    MANAGER         ALL
2   2   部門長承認   DEPT_ROLE   DEPT_HEAD       ALL
3   1   上長承認    MANAGER         ALL
3   2   部門長承認   DEPT_ROLE   DEPT_HEAD       ALL
3   3   総務合議    ROLE    GA      ALL
4   1   総務確認    ROLE    GA      ANY

承認者の決め方4種

ルート定義に利用者IDを直接書くと、異動のたびに定義を直すことになります。「誰か」ではなく「どう決めるか」を書くのが要点です。

approver_type 解決の仕方 向く段
MANAGER 申請者の manager_id 直属の上長
DEPT_ROLE 申請者と同じ部門で、指定の役割を持つ人 部門長
ROLE 指定の役割を持つ全員 総務・情報システムなど横断の担当
USER 指定した利用者1人 特定の責任者

登録に使ったデータは次のとおりです。

INSERT INTO app_user (user_id, user_name, dept_code, manager_id, role_code) VALUES
  (3, '上田(開発部長)', 'DEV', NULL, 'DEPT_HEAD'),
  (2, '井上(開発課長)', 'DEV', 3,    NULL),
  (1, '青木(開発)',     'DEV', 2,    NULL),
  (6, '加藤(開発)',     'DEV', 2,    NULL),
  (4, '江口(総務)',     'GA',  NULL, 'GA'),
  (5, '小野(総務)',     'GA',  NULL, 'GA');

INSERT INTO approval_route (route_id, route_name, request_type, amount_from, amount_to) VALUES
  (1, '備品購入(5万円未満)',       'PURCHASE', 0,      50000),
  (2, '備品購入(5万円以上30万円未満)', 'PURCHASE', 50000,  300000),
  (3, '備品購入(30万円以上)',      'PURCHASE', 300000, NULL),
  (4, '備品持ち出し',              'TAKEOUT',  0,      NULL);

INSERT INTO approval_route_step (route_id, step_no, step_name, approver_type, approver_role, approver_user_id, rule) VALUES
  (1, 1, '上長承認',   'MANAGER',   NULL,        NULL, 'ALL'),
  (2, 1, '上長承認',   'MANAGER',   NULL,        NULL, 'ALL'),
  (2, 2, '部門長承認', 'DEPT_ROLE', 'DEPT_HEAD', NULL, 'ALL'),
  (3, 1, '上長承認',   'MANAGER',   NULL,        NULL, 'ALL'),
  (3, 2, '部門長承認', 'DEPT_ROLE', 'DEPT_HEAD', NULL, 'ALL'),
  (3, 3, '総務合議',   'ROLE',      'GA',        NULL, 'ALL'),
  (4, 1, '総務確認',   'ROLE',      'GA',        NULL, 'ANY');

-- 井上(2)の代理を加藤(6)が務める期間
INSERT INTO delegation (delegator_id, delegate_id, valid_from, valid_to)
VALUES (2, 6, current_date - 1, current_date + 5);

状態遷移図と、ステータス×操作の遷移表

stateDiagram-v2
    [*] --> DRAFT
    DRAFT --> IN_REVIEW: 申請
    IN_REVIEW --> IN_REVIEW: 承認(次の段へ)・1段戻し
    IN_REVIEW --> APPROVED: 最終段を承認
    IN_REVIEW --> REMANDED: 申請者まで戻し
    IN_REVIEW --> REJECTED: 却下
    IN_REVIEW --> WITHDRAWN: 取り下げ
    REMANDED --> IN_REVIEW: 再申請
    REMANDED --> WITHDRAWN: 取り下げ
    APPROVED --> [*]
    REJECTED --> [*]
    WITHDRAWN --> [*]

次の表は、6つの状態それぞれで6つの操作を実際に呼び出し、移った先の状態を記録したものです(1段だけのルートで実行。「—」はエラーになり、状態が変わらなかった組み合わせ)。

いまの状態 申請・再申請 承認 1段戻し 申請者まで戻し 却下 取り下げ
DRAFT(下書き) IN_REVIEW — — — — —
IN_REVIEW(承認待ち) — APPROVED REMANDED REMANDED REJECTED WITHDRAWN
REMANDED(差し戻し中) IN_REVIEW — — — — WITHDRAWN
APPROVED(承認済み) — — — — — —
REJECTED(却下) — — — — — —
WITHDRAWN(取り下げ) — — — — — —
いまの状態   申請・再申請  承認  1段戻し    申請者まで戻し 却下  取り下げ
DRAFT   IN_REVIEW   —   —   —   —   —
IN_REVIEW   —   APPROVED    REMANDED    REMANDED    REJECTED    WITHDRAWN
REMANDED    IN_REVIEW   —   —   —   —   WITHDRAWN
APPROVED    —   —   —   —   —   —
REJECTED    —   —   —   —   —   —
WITHDRAWN   —   —   —   —   —   —

段が2つ以上あるルートでは、承認は「次の段の IN_REVIEW」、2段目以降での1段戻しは「前の段の IN_REVIEW」になります。1段目での1段戻しは戻り先の段が無いので、申請者まで戻しと同じ REMANDED です。誰が操作できるかは、申請・再申請・取り下げが申請者本人、それ以外が現在の段の承認者(または代理)です。

操作のSQL(申請・承認・差し戻し・却下・取り下げ)

どの操作も「申請の行をロックする → 状態を確かめる → 承認ステップを更新する → 申請を更新する → 履歴に追記する」の順です。1つの操作が途中で止まっても半端に残らないよう、PL/pgSQL の関数にまとめています。

-- 申請・再申請:ルートを選び、承認者を解決して1段目に置く
CREATE FUNCTION wf_submit(p_request_id integer, p_actor integer) RETURNS text
LANGUAGE plpgsql AS $$
DECLARE
  r       request;
  v_route integer;
  v_empty text;
BEGIN
  SELECT * INTO r FROM request WHERE request_id = p_request_id FOR UPDATE;
  IF r.applicant_id <> p_actor OR r.status NOT IN ('DRAFT', 'REMANDED') THEN
    RAISE EXCEPTION '申請できません(status=%)', r.status;
  END IF;

  -- 1) 申請種別と金額からルートを1つ選ぶ
  SELECT route_id INTO STRICT v_route FROM approval_route
   WHERE request_type = r.request_type AND is_active
     AND amount_from <= r.amount AND (amount_to IS NULL OR r.amount < amount_to);

  -- 2) 承認者を解決して写す(申請者本人は承認者にしない)
  DELETE FROM request_step WHERE request_id = p_request_id;
  INSERT INTO request_step (request_id, step_no, approver_id, rule)
  SELECT p_request_id, s.step_no, u.user_id, s.rule
    FROM approval_route_step s
    JOIN app_user a ON a.user_id = r.applicant_id
    JOIN app_user u ON (s.approver_type = 'MANAGER'   AND u.user_id = a.manager_id)
                    OR (s.approver_type = 'DEPT_ROLE' AND u.dept_code = a.dept_code AND u.role_code = s.approver_role)
                    OR (s.approver_type = 'ROLE'      AND u.role_code = s.approver_role)
                    OR (s.approver_type = 'USER'      AND u.user_id = s.approver_user_id)
   WHERE s.route_id = v_route AND u.user_id <> r.applicant_id;

  -- 3) 承認者が1人もいない段があれば止める
  SELECT string_agg(s.step_no || ':' || s.step_name, ', ' ORDER BY s.step_no) INTO v_empty
    FROM approval_route_step s
   WHERE s.route_id = v_route
     AND NOT EXISTS (SELECT 1 FROM request_step x
                      WHERE x.request_id = p_request_id AND x.step_no = s.step_no);
  IF v_empty IS NOT NULL THEN
    RAISE EXCEPTION '承認者を解決できない段があります: %', v_empty;
  END IF;

  UPDATE request SET route_id = v_route, status = 'IN_REVIEW', current_step_no = 1, updated_at = now()
   WHERE request_id = p_request_id;
  INSERT INTO approval_history (request_id, action, actor_id, to_status, to_step_no)
  VALUES (p_request_id, 'SUBMIT', p_actor, 'IN_REVIEW', 1);
  RETURN format('route=%s step=1', v_route);
END $$;

-- 承認:本人または代理が、現在の段の自分の行を承認済みにする
CREATE FUNCTION wf_approve(p_request_id integer, p_actor integer, p_comment text DEFAULT NULL) RETURNS text
LANGUAGE plpgsql AS $$
DECLARE
  r        request;
  v_behalf integer;
  v_next   integer;
BEGIN
  SELECT * INTO r FROM request WHERE request_id = p_request_id FOR UPDATE;
  IF r.status <> 'IN_REVIEW' THEN
    RAISE EXCEPTION '承認待ちではありません(status=%)', r.status;
  END IF;

  -- 1) 自分の行、または代理元の行を承認済みにする
  UPDATE request_step s SET result = 'APPROVED', acted_by = p_actor, acted_at = now()
   WHERE s.request_id = p_request_id AND s.step_no = r.current_step_no AND s.result IS NULL
     AND (s.approver_id = p_actor
          OR s.approver_id IN (SELECT d.delegator_id FROM delegation d
                                WHERE d.delegate_id = p_actor
                                  AND current_date BETWEEN d.valid_from AND d.valid_to))
  RETURNING NULLIF(s.approver_id, p_actor) INTO v_behalf;
  IF NOT FOUND THEN
    RAISE EXCEPTION '利用者 % が承認できる行がありません(step=%)', p_actor, r.current_step_no;
  END IF;

  -- 2) 「誰か1人」の段は、残りの承認者を SKIPPED にする
  UPDATE request_step SET result = 'SKIPPED'
   WHERE request_id = p_request_id AND step_no = r.current_step_no AND rule = 'ANY' AND result IS NULL;

  -- 3) 未処理の行が残っていれば同じ段にとどまる。無ければ次の段へ、最後なら承認済みへ
  IF EXISTS (SELECT 1 FROM request_step
              WHERE request_id = p_request_id AND step_no = r.current_step_no AND result IS NULL) THEN
    v_next := r.current_step_no;
  ELSE
    SELECT min(step_no) INTO v_next FROM approval_route_step
     WHERE route_id = r.route_id AND step_no > r.current_step_no;
  END IF;

  UPDATE request SET current_step_no = v_next,
         status = CASE WHEN v_next IS NULL THEN 'APPROVED' ELSE 'IN_REVIEW' END, updated_at = now()
   WHERE request_id = p_request_id;
  INSERT INTO approval_history (request_id, action, step_no, actor_id, on_behalf_of, to_status, to_step_no, comment)
  VALUES (p_request_id, 'APPROVE', r.current_step_no, p_actor, v_behalf,
          CASE WHEN v_next IS NULL THEN 'APPROVED' ELSE 'IN_REVIEW' END, v_next, p_comment);
  RETURN CASE WHEN v_next IS NULL THEN 'APPROVED' ELSE 'IN_REVIEW step=' || v_next END;
END $$;

-- 差し戻し・却下:p_action は 'REMAND_PREV'(1段戻し), 'REMAND_APPLICANT'(申請者まで戻し), 'REJECT'(却下)
CREATE FUNCTION wf_return(p_request_id integer, p_actor integer, p_action text, p_comment text) RETURNS text
LANGUAGE plpgsql AS $$
DECLARE
  r        request;
  v_prev   integer;
  v_status text;
BEGIN
  SELECT * INTO r FROM request WHERE request_id = p_request_id FOR UPDATE;
  IF r.status <> 'IN_REVIEW' OR NOT EXISTS (
       SELECT 1 FROM request_step s
        WHERE s.request_id = p_request_id AND s.step_no = r.current_step_no AND s.result IS NULL
          AND (s.approver_id = p_actor
               OR s.approver_id IN (SELECT d.delegator_id FROM delegation d
                                     WHERE d.delegate_id = p_actor
                                       AND current_date BETWEEN d.valid_from AND d.valid_to))) THEN
    RAISE EXCEPTION '利用者 % はこの申請を差し戻し・却下できません', p_actor;
  END IF;

  IF p_action = 'REMAND_PREV' THEN
    SELECT max(step_no) INTO v_prev FROM approval_route_step
     WHERE route_id = r.route_id AND step_no < r.current_step_no;
  END IF;

  IF v_prev IS NOT NULL THEN
    -- 1段戻し:戻り先の段と現在の段の結果を未処理に戻す
    UPDATE request_step SET result = NULL, acted_by = NULL, acted_at = NULL
     WHERE request_id = p_request_id AND step_no >= v_prev;
    v_status := 'IN_REVIEW';
  ELSE
    -- 申請者まで戻し(1段目での1段戻しも同じ)/却下
    v_status := CASE p_action WHEN 'REJECT' THEN 'REJECTED' ELSE 'REMANDED' END;
  END IF;

  UPDATE request SET status = v_status, current_step_no = v_prev, updated_at = now()
   WHERE request_id = p_request_id;
  INSERT INTO approval_history (request_id, action, step_no, actor_id, to_status, to_step_no, comment)
  VALUES (p_request_id, p_action, r.current_step_no, p_actor, v_status, v_prev, p_comment);
  RETURN v_status || COALESCE(' step=' || v_prev, '');
END $$;

-- 取り下げ:申請者本人が、承認待ちか差し戻し中の申請を引っ込める
CREATE FUNCTION wf_withdraw(p_request_id integer, p_actor integer, p_comment text DEFAULT NULL) RETURNS text
LANGUAGE plpgsql AS $$
BEGIN
  UPDATE request SET status = 'WITHDRAWN', current_step_no = NULL, updated_at = now()
   WHERE request_id = p_request_id AND applicant_id = p_actor AND status IN ('IN_REVIEW', 'REMANDED');
  IF NOT FOUND THEN
    RAISE EXCEPTION '取り下げできません(申請者本人で、承認待ちか差し戻し中のときだけ行えます)';
  END IF;
  INSERT INTO approval_history (request_id, action, actor_id, to_status, comment)
  VALUES (p_request_id, 'WITHDRAW', p_actor, 'WITHDRAWN', p_comment);
  RETURN 'WITHDRAWN';
END $$;

各関数の最初の SELECT ... FOR UPDATE が、同じ申請への操作を1つずつに並べます。合議で2人が同時に承認しても、後から来た側は先の処理が終わるのを待ってから、最新の状態を見て判定します。

実行結果:申請から承認済みまで

申請する(12万円 → 2段のルートが選ばれる)

INSERT INTO request (request_type, title, amount, applicant_id)
VALUES ('PURCHASE', '検証用モニター 4台', 120000, 1) RETURNING request_id, status;
SELECT wf_submit(1, 1);
SELECT step_no, approver_id, rule, result FROM request_step WHERE request_id = 1 ORDER BY step_no;
 request_id | status 
------------+--------
          1 | DRAFT
(1 row)

INSERT 0 1
   wf_submit    
----------------
 route=2 step=1
(1 row)

 step_no | approver_id | rule | result 
---------+-------------+------+--------
       1 |           2 | ALL  | (null)
       2 |           3 | ALL  | (null)
(2 rows)

申請者(青木)の上長である井上(2)が1段目、同じ部門の部門長である上田(3)が2段目に解決されました。結果の (null) は psql の \pset null (null) による表示です。

1段目を承認し、2段目から1段戻しする

SELECT wf_approve(1, 2, '内容を確認しました');
SELECT wf_return(1, 3, 'REMAND_PREV', '台数の根拠を課長と確認してください');
SELECT request_id, status, current_step_no FROM request WHERE request_id = 1;
SELECT step_no, approver_id, result, acted_by FROM request_step WHERE request_id = 1 ORDER BY step_no;
    wf_approve    
------------------
 IN_REVIEW step=2
(1 row)

    wf_return     
------------------
 IN_REVIEW step=1
(1 row)

 request_id |  status   | current_step_no 
------------+-----------+-----------------
          1 | IN_REVIEW |               1
(1 row)

 step_no | approver_id | result | acted_by 
---------+-------------+--------+----------
       1 |           2 | (null) |   (null)
       2 |           3 | (null) |   (null)
(2 rows)

1段戻しでは状態は IN_REVIEW のまま、current_step_no が 2 から 1 に戻り、1段目の承認結果が未処理に戻ります。 井上の承認があったことは履歴に残っています。

代理承認する

井上(2)の代理として登録された加藤(6)が承認します。

SELECT wf_approve(1, 6, '井上の代理で承認');
SELECT step_no, approver_id, result, acted_by FROM request_step WHERE request_id = 1 ORDER BY step_no;
    wf_approve    
------------------
 IN_REVIEW step=2
(1 row)

 step_no | approver_id |  result  | acted_by 
---------+-------------+----------+----------
       1 |           2 | APPROVED |        6
       2 |           3 | (null)   |   (null)
(2 rows)

本来の承認者は approver_id、実際に操作した人は acted_by に分けて残ります。履歴にも on_behalf_of として本来の承認者が入ります。

申請者まで戻し、内容を直して再申請する(36万円 → 3段のルートに変わる)

SELECT wf_return(1, 3, 'REMAND_APPLICANT', '8台に増やして出し直してください');
SELECT request_id, status, current_step_no FROM request WHERE request_id = 1;

UPDATE request SET title = '検証用モニター 8台', amount = 360000, updated_at = now()
 WHERE request_id = 1 AND status = 'REMANDED';
SELECT wf_submit(1, 1);
SELECT step_no, approver_id, rule, result FROM request_step WHERE request_id = 1 ORDER BY step_no, approver_id;
 wf_return 
-----------
 REMANDED
(1 row)

 request_id |  status  | current_step_no 
------------+----------+-----------------
          1 | REMANDED |          (null)
(1 row)

UPDATE 1
   wf_submit    
----------------
 route=3 step=1
(1 row)

 step_no | approver_id | rule | result 
---------+-------------+------+--------
       1 |           2 | ALL  | (null)
       2 |           3 | ALL  | (null)
       3 |           4 | ALL  | (null)
       3 |           5 | ALL  | (null)
(4 rows)

金額が30万円以上になったので、再申請でルートが 2 から 3 に選び直され、3段目に総務の2人が加わりました。申請者まで戻したときにルートと承認者を作り直すのは、このように内容が変わるからです。

合議(全員の承認が必要)

SELECT wf_approve(1, 2);
SELECT wf_approve(1, 3);
SELECT wf_approve(1, 4, '総務1人目');
SELECT wf_approve(1, 5, '総務2人目');
    wf_approve    
------------------
 IN_REVIEW step=2
(1 row)

    wf_approve    
------------------
 IN_REVIEW step=3
(1 row)

    wf_approve    
------------------
 IN_REVIEW step=3
(1 row)

 wf_approve 
------------
 APPROVED
(1 row)

3段目は1人目が承認しても step=3 のままで、2人目の承認で APPROVED になりました。

合議(誰か1人の承認でよい)

INSERT INTO request (request_type, title, applicant_id)
VALUES ('TAKEOUT', 'プロジェクター持ち出し', 1) RETURNING request_id;
SELECT wf_submit(2, 1);
SELECT wf_approve(2, 5);
SELECT step_no, approver_id, rule, result, acted_by FROM request_step WHERE request_id = 2 ORDER BY approver_id;
 request_id 
------------
          2
(1 row)

INSERT 0 1
   wf_submit    
----------------
 route=4 step=1
(1 row)

 wf_approve 
------------
 APPROVED
(1 row)

 step_no | approver_id | rule |  result  | acted_by 
---------+-------------+------+----------+----------
       1 |           4 | ANY  | SKIPPED  |   (null)
       1 |           5 | ANY  | APPROVED |        5
(2 rows)

rule = 'ANY' の段は、1人が承認した時点で残りの行を SKIPPED にします。こうしておくと、「全員」と「誰か1人」のどちらも「未処理の行が無くなったら次へ」という1つの判定で進められます。

取り下げる

INSERT INTO request (request_type, title, amount, applicant_id)
VALUES ('PURCHASE', 'キーボード', 8000, 1) RETURNING request_id;
SELECT wf_submit(3, 1);
SELECT wf_withdraw(3, 1, '在庫が見つかったため');
SELECT wf_withdraw(1, 1);   -- 承認済みの申請
 request_id 
------------
          3
(1 row)

INSERT 0 1
   wf_submit    
----------------
 route=1 step=1
(1 row)

 wf_withdraw 
-------------
 WITHDRAWN
(1 row)

ERROR:  取り下げできません(申請者本人で、承認待ちか差し戻し中のときだけ行えます)
CONTEXT:  PL/pgSQL function wf_withdraw(integer,integer,text) line 6 at RAISE

通らない操作と、そのときのエラー

設計が効いているかは、通らないはずの操作を流して確かめます。

操作 結果
承認済みの申請をもう一度承認する ERROR: 承認待ちではありません(status=APPROVED)
承認者でない人が承認する ERROR: 利用者 1 が承認できる行がありません(step=1)
履歴を UPDATE する ERROR: approval_history は追記のみです(UPDATE は行えません)
履歴を DELETE する ERROR: approval_history は追記のみです(DELETE は行えません)
ルートに無い段を直接セットする ERROR: insert or update on table "request" violates foreign key constraint "request_route_id_current_step_no_fkey"
段を残したまま APPROVED にする ERROR: new row for relation "request" violates check constraint "request_check"

部門長自身が申請すると、承認者がいない段ができる

INSERT INTO request (request_type, title, amount, applicant_id)
VALUES ('PURCHASE', '会議用マイク', 60000, 3) RETURNING request_id;
SELECT wf_submit(5, 3);
SELECT request_id, status, route_id, current_step_no FROM request WHERE request_id = 5;
 request_id 
------------
          5
(1 row)

INSERT 0 1
ERROR:  承認者を解決できない段があります: 1:上長承認, 2:部門長承認
CONTEXT:  PL/pgSQL function wf_submit(integer,integer) line 36 at RAISE
 request_id | status | route_id | current_step_no 
------------+--------+----------+-----------------
          5 | DRAFT  |   (null) |          (null)
(1 row)

部門長の上田には上長が登録されておらず、部門長承認の段は本人しか該当しないので、どちらの段も承認者が0人になりました。関数がエラーで終わると途中の INSERT も取り消されるので、申請は DRAFT のまま残ります。承認者が0人の段をそのまま通すと、誰の画面にも出ない申請ができて止まります。 申請の時点で止めて、役員用のルートを別に定義する、上位の部門の長へ付け替える、といった決まりをルート定義に足します。

履歴の表示と、自分の承認待ち一覧

申請1件の履歴

SELECT h.history_id AS id, h.action, h.step_no AS step, u.user_name AS actor, b.user_name AS on_behalf_of,
       h.to_status, h.to_step_no AS to_step, h.comment
  FROM approval_history h
  JOIN app_user u ON u.user_id = h.actor_id
  LEFT JOIN app_user b ON b.user_id = h.on_behalf_of
 WHERE h.request_id = 1 ORDER BY h.history_id;
 id |      action      |  step  |      actor       |   on_behalf_of   | to_status | to_step |              comment               
----+------------------+--------+------------------+------------------+-----------+---------+------------------------------------
  1 | SUBMIT           | (null) | 青木(開発)     | (null)           | IN_REVIEW |       1 | (null)
  2 | APPROVE          |      1 | 井上(開発課長) | (null)           | IN_REVIEW |       2 | 内容を確認しました
  3 | REMAND_PREV      |      2 | 上田(開発部長) | (null)           | IN_REVIEW |       1 | 台数の根拠を課長と確認してください
  4 | APPROVE          |      1 | 加藤(開発)     | 井上(開発課長) | IN_REVIEW |       2 | 井上の代理で承認
  5 | REMAND_APPLICANT |      2 | 上田(開発部長) | (null)           | REMANDED  |  (null) | 8台に増やして出し直してください
  6 | SUBMIT           | (null) | 青木(開発)     | (null)           | IN_REVIEW |       1 | (null)
  7 | APPROVE          |      1 | 井上(開発課長) | (null)           | IN_REVIEW |       2 | (null)
  8 | APPROVE          |      2 | 上田(開発部長) | (null)           | IN_REVIEW |       3 | (null)
  9 | APPROVE          |      3 | 江口(総務)     | (null)           | IN_REVIEW |       3 | 総務1人目
 10 | APPROVE          |      3 | 小野(総務)     | (null)           | APPROVED  |  (null) | 総務2人目
(10 rows)

承認ステップの行は差し戻しや再申請で書き換わりますが、履歴には1回目の承認も差し戻しもすべて残ります。

自分の承認待ち一覧(代理の分も含める)

加藤(6)の画面に出す一覧です。本人あての行に加えて、代理期間中の代理元あての行も拾います。

SELECT r.request_id, r.title, a.user_name AS applicant, s.step_no,
       CASE WHEN s.approver_id = 6 THEN '本人' ELSE '代理(' || p.user_name || ')' END AS as_who
  FROM request_step s
  JOIN request r  ON r.request_id = s.request_id AND r.status = 'IN_REVIEW' AND r.current_step_no = s.step_no
  JOIN app_user a ON a.user_id = r.applicant_id
  JOIN app_user p ON p.user_id = s.approver_id
 WHERE s.result IS NULL
   AND (s.approver_id = 6
        OR s.approver_id IN (SELECT delegator_id FROM delegation
                              WHERE delegate_id = 6 AND current_date BETWEEN valid_from AND valid_to))
 ORDER BY r.request_id;
 request_id |  title   |  applicant   | step_no |          as_who          
------------+----------+--------------+---------+--------------------------
          6 | 椅子 2脚 | 青木(開発) |       1 | 代理(井上(開発課長))
(1 row)

r.current_step_no = s.step_no の条件が要点です。これが無いと、まだ順番が来ていない後ろの段の承認者にも表示されます。

同時に承認されたときの動き

合議(全員)の段で、江口(4)の承認を2秒かけて確定させている間に、小野(5)が別の接続から承認しました。

接続A: BEGIN; SELECT wf_approve(43, 4);  → IN_REVIEW step=3(応答した時刻 58.544秒)、2秒後に COMMIT
接続B: SELECT wf_approve(43, 5);         → APPROVED(応答した時刻 00.551秒=接続Aの約2.0秒後)

接続Bは接続Aの COMMIT を待ってから動き、江口の承認が済んだ状態を見て APPROVED と判定しました。PostgreSQL のドキュメントにあるとおり、SELECT ... FOR UPDATE は同じ行をロックしようとする他のトランザクションを、こちらが終わるまで待たせます。このロックが無いと、2人とも「相手がまだ未処理」と判定して段が進まないことがあります。

3つの方式の違いと選び方

方式 持ち方 段数 履歴 合議・代理 現在の状態の求め方
ステータス1カラム方式 申請の status に「課長承認待ち」「部長承認待ち」などを持つ 固定 残らない(最終の承認者と日時の列を足す程度) 表しにくい 列を読むだけ
ステップ別テーブル方式 申請×段×承認者の行を持つ(この記事の request_step) 可変 最新の結果だけ 行を増やすだけ 行を読むだけ
イベント追記方式 操作を追記し、状態は記録から求める(この記事の approval_history) 可変 すべて残る 表せる 集計が必要

この記事の設計は、ステップ別テーブル方式で現在の状態を持ち、イベント追記方式で履歴を残す組み合わせです。どれを選ぶかは次の表で決められます。

条件 選ぶもの
段数が1〜2段で固定、誰がいつ承認したかは最終分だけ分かればよい ステータス1カラム方式
金額や申請種別で段数が変わる ステップ別テーブル方式
合議・代理承認・1段戻しがある ステップ別テーブル方式
差し戻しや再申請の経過を後から確かめたい イベント追記方式を足す
承認待ち一覧を速く出したい 現在の状態を行で持つ(イベントだけにしない)
ルートを利用者が画面で組み替える ステップ別テーブル方式+ルート定義を画面から保守

差し戻し・却下・取り下げの違い

混同しやすい4つの操作を並べます。

操作 行う人 移る先 その後
1段戻し 現在の段の承認者 前の段(IN_REVIEW のまま) 前の段の承認者がもう一度判断する
申請者まで戻し 現在の段の承認者 REMANDED 申請者が直して再申請する(ルートを選び直す)
却下 現在の段の承認者 REJECTED 終わり。必要なら新しい申請を作る
取り下げ 申請者本人 WITHDRAWN 終わり。必要なら新しい申請を作る

確かめて分かったこと

  • 「全員」と「誰か1人」は、SKIPPED を使うと同じ判定にまとまります。 1人が承認した時点で残りを SKIPPED にすれば、どちらも「未処理の行が無くなったら次へ」で進められました。
  • 申請者本人を承認者から外すと、承認者が0人の段ができます。 部門長自身の申請では2つの段がどちらも0人になりました。申請の時点で検査して止める処理が欠かせません。
  • 再申請でルートが変わることがあります。 12万円を36万円に直した再申請では、2段のルートから3段のルートに切り替わりました。差し戻し前の承認ステップを使い回さず、作り直すのが確実です。
  • 複合外部キーは、NULL を含む行を検査しません。 current_step_no が NULL の下書きや承認済みの行は通り、値が入ったときだけ「ルートに存在する段か」を確かめてくれます。
  • 履歴を追記のみにしても、承認ステップ側は書き換えてかまいません。 1段戻しで1段目の結果を未処理に戻しても、最初の承認は履歴の2行目に残っていました。
  • 連番には飛びができます。 遷移表を作るために試して取り消した INSERT の分だけ request_id が進み、次の申請は 43 番になりました。申請番号を欠番なく振りたいときは、連番の列とは別に採番します。

よくある質問

Q. 承認ワークフローのテーブル設計で、最低限必要なテーブルは? A. 申請、承認ルート定義、承認ステップ、承認履歴の4つです。段数が1〜2段で固定なら、申請のステータス1列と承認者・承認日時の列だけでも始められます。

Q. 差し戻しと却下の違いは? A. 差し戻しは直してもう一度進められる状態、却下はその申請を終わらせる状態です。差し戻しには、1つ前の承認者に戻す1段戻しと、申請者まで戻す2種類があります。

Q. 承認者は申請のたびに解決するのと、表示のたびに解決するのと、どちらがよいですか? A. 申請の時点で解決して承認ステップに写すのが扱いやすいです。誰の承認待ちかを行で引けるうえ、あとで異動があっても「そのとき誰に回したか」が残ります。異動で承認者が不在になった場合は、代理の登録か管理者による付け替えで対応します。

Q. 多段承認の段数が申請ごとに変わるときは、どう設計しますか? A. 承認ルート定義を「見出し(申請種別と金額帯)」と「明細(段)」に分け、申請の時点で条件に合うルートを1つ選びます。段の数だけ明細の行を持つので、列を足さずに段を増減できます。

Q. 承認履歴を書き換えられないようにするには? A. 履歴のテーブルで UPDATE と DELETE を止めます。この記事ではトリガーで例外を出す方法を使い、どちらも「追記のみです」というエラーになることを確かめました。アプリ用のロールに INSERT と SELECT だけを与える方法も併用できます。

まとめ

  • 申請・承認ルート定義・承認ステップ・承認履歴の4つに分け、段数が変わっても列を足さない形にします。
  • 現在の段は申請に持ち、複合外部キーと CHECK 制約で「存在しない段」「状態と段の食い違い」を防ぎます。
  • 承認者は申請の時点で解決して写し、承認者が0人の段があれば申請を止めます。
  • 1段戻しは段を戻すだけ、申請者まで戻しは再申請でルートと承認者を作り直します。
  • 合議は承認者ごとに行を持ち、「誰か1人」は残りを SKIPPED にして判定を1つにまとめます。
  • 履歴は追記のみにし、代理承認では本来の承認者と実際に操作した人の両方を残します。
  • 操作の最初に申請の行をロックして、同時の承認を1つずつに並べます。

参考・出典

  • PostgreSQL 16 Documentation「13.3. Explicit Locking」(行レベルロック FOR UPDATE)https://www.postgresql.org/docs/16/explicit-locking.html
  • PostgreSQL 16 Documentation「5.4. Constraints」(外部キーと NULL の扱い)https://www.postgresql.org/docs/16/ddl-constraints.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「承認ワークフローのDB設計|多段承認と差し戻しのテーブル例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?