- 設計
承認ワークフローのDB設計|多段承認と差し戻しのテーブル例
承認ワークフローの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期連続の黒字決算。