【事例1】ワークフロー承認状況表示

ワークフロー承認状況を、カスタムアプリとして拡張表示する実例を紹介します。

8分
最終更新: 2025年9月9日

ワークフロー承認状況のカスタム表示

1.はじめに

前回の記事では、ERPNextを自分たち用に拡張できる「カスタムアプリ」の仕組みを紹介しました。
今回はその応用として、ERPNextのワークフロー機能をさらに使いやすくする事例を取り上げます。

ERPNextのワークフロー⤴はとても柔軟で、次のような特徴があります。

  • カバー範囲の広さ:見積や受注、経費精算など、ほとんどのドキュメントに承認フローを組み込める
    (例:「50万円を超える発注なら、部長承認を必須にする」)
  • 設定の柔軟さ能:条件分岐や複数ルートを設定できるため、シンプルな承認から複雑な稟議フローまで対応できる
  • 各種処理と一体化:承認が完了すると会計処理や次の工程が自動で始まるように連動させられる

このように便利な仕組みですが、実際の現場では「いま誰が承認しているのか?」「次は誰の順番か?」が分かりにくいという声がありました。
そこで今回は、承認状況をひと目で確認できる表示機能 を、Custom Appとして追加した事例を紹介します。

ワークフローの承認状況 (ワークフローの承認状況、承認予定者を画面末尾に表示)


2-1.実装の狙い

  • 申請者が「誰が承認済みで、誰が未承認か」をすぐ確認できる
  • 承認者が「次に自分の順番が来るか」を一目で把握できる
  • コメントや履歴と並べて確認できるデザインにする

2-2.実装の流れ

今回のカスタマイズは、大きく次の3つのステップに分けられます。

  1. カスタムアプリで環境を用意する
    Custom Appに機能をまとめることで、将来のアップデートに強くなり、チームでの管理もしやすくなります。
    (※アプリの作成方法は前回記事 カスタムアプリ⤴を参照してください)

  2. フロントエンドを拡張する(JavaScript)
    フォームの下部に「承認状況パネル」を差し込みます。
    ERPNext標準の画面に直接手を入れるのではなく、フックを使って安全に追加するのがポイントです。

  3. サーバー側でデータを用意する(Python API)
    どの承認者が済んでいて、誰がまだか、といった情報をサーバーから取得します。
    これにより、最新の承認状況を常にフロント側で表示できるようになります。

次の章では、それぞれのステップを 具体的なコード例 とともに解説していきます。


3.詳細手順

では、実装の詳細を見ていきましょう。 ポイントは

(1) hooks で読み込みを宣言
(2) boot で対象DocTypeを配信
(3) API で状況を返し
(4) JSで描画 ↓

という流れです。


3-1. ディレクトリ構成(抜粋)

my_custom_app/
├─ my_custom_app/
│ ├─ boot.py # Boot の拡張(対象DocTypeを配信)
│ ├─ api/
│ │ └─ workflow.py # ワークフロー状況API
│ ├─ public/
│ │ └─ js/
│ │ └─ workflow_panel.js # 承認状況パネルの描画
│ └─ report/
│ └─ workflow_overview/
│ ├─ workflow_overview.json # Script Report 定義
│ └─ workflow_overview.py # Script Report ロジック
└─ hooks.py

3-2. hooks.py — アセット読み込み & boot登録

作成したJavaScriptをDeskに読み込ませ、セッション起動時に boot_session を呼ぶ。

# hooks.py
app_include_js = [
    "my_custom_app/public/js/workflow_panel.js"
]
 
# Boot の拡張(セッション開始時に呼ばれる)
boot_session = "my_custom_app.boot.boot_session"

3-3. boot.py — 対象DocTypeの一覧を配信

“ワークフローが有効な DocType の一覧” をクライアントへ渡すAPIです。
JS側はこの一覧を見て、対象フォームにだけパネルを描画します。

# my_custom_app/boot.py
import frappe
 
def boot_session(bootinfo):
    """
    現在アクティブな Workflow が紐づく Document Type を配信。
    JS側で対象フォーム判定に使う。
    """
    doctypes = frappe.get_all(
        "Workflow",
        fields=["document_type"],
        filters={"is_active": 1},
        distinct=True,
        as_list=False,
    )
    docnames = sorted({d["document_type"] for d in doctypes if d.get("document_type")})
 
    bootinfo.my_custom_app = bootinfo.get("my_custom_app", {})
    bootinfo.my_custom_app["workflow_doctypes"] = docnames
 
 

3-4. API(Python)— 承認状況を返す

フォーム単位の 現在の承認状態・履歴・未処理アクション を返します。 v15 では Workflow Action に「保留中の承認依頼」が入るため、これを利用します。

# my_custom_app/api/workflow.py
import frappe
from frappe.utils import format_datetime
 
@frappe.whitelist()
def get_status(doctype: str, name: str):
    """対象ドキュメントの承認状況を返す(閲覧可能なユーザーのみ)。"""
    doc = frappe.get_doc(doctype, name)
    doc.check_permission("read")
 
    # 現在のステート(ある場合)
    state = getattr(doc, "workflow_state", None)
 
    # 保留中の承認(Workflow Action)
    pending = frappe.get_all(
        "Workflow Action",
        filters={
            "reference_doctype": doctype,
            "reference_name": name,
            "status": "Pending",
        },
        fields=["user", "creation", "action"],
        order_by="creation asc",
    )
    for p in pending:
        p["creation_fmt"] = format_datetime(p["creation"])
 
    # 履歴(コメントの Workflow ログ)
    history = frappe.get_all(
        "Comment",
        filters={
            "reference_doctype": doctype,
            "reference_name": name,
            "comment_type": "Workflow",
        },
        fields=["content", "owner", "creation"],
        order_by="creation asc",
    )
    for h in history:
        h["creation_fmt"] = format_datetime(h["creation"])
 
    return {
        "state": state,
        "pending": pending,
        "history": history,
    }
 

3-5. フロントエンド(JS)— すべての対象フォームにパネルを描画

対象DocTypeにだけ パネルを差し込みます。
後述のハマリポイントを回避するため、
SPAのルート遷移でも生き残る “ベルト&サスペンダー”方針で実装します。

ベルト&サスペンダー方針とは:
Desk(SPA)では、画面遷移・再接続・非同期処理の“どこででも”ハンドラが失われ得ます。
複数イベントから同一の再描画関数を呼ぶことで、取りこぼしを防ぎつつ重複描画もしない実装方針です。

✅ 冗長化の入口を一本化:debounced_render が共通の再描画口
✅ 重複バインドをブロック:window.__wf_belt_bound フラグ
✅ イベント網羅:router change / socket (re)connect / visibilitychange / after_ajax
✅ 多重実行の抑制:frappe.utils.debounce(…, 80) を標準採用

// my_custom_app/public/js/workflow_panel.js
 
// 通信ゼロ版キャッシュ
const WF_CACHE = {
  loaded: true,
  doctypes: new Set((frappe.boot && frappe.boot.myhatch && frappe.boot.myhatch.wf_doctypes) || [])
};
 
function hasWorkflow(doctype) {
  return WF_CACHE.doctypes.has(doctype);
}
 
function unmount(frm) {
  const el = frm.__wf_panel;
  if (el && el.isConnected) el.remove();
  frm.__wf_panel = null;
}
 
(function () {
  const isCurrentForm = (frm) =>
    !!cur_frm && cur_frm.doctype === frm.doctype && cur_frm.docname === frm.docname;
 
  function waitForFooter(frm, tries = 15) {
    return new Promise((resolve) => {
      (function poll(n) {
        const el = $(frm.wrapper).find(".form-footer")[0];
        if (el) return resolve(el);
        if (n <= 0) return resolve(null);
        setTimeout(() => poll(n - 1), 100);
      })(tries);
    });
  }
 
  // ============== タイムライン用 行パーツ ==============
  // バッジ行(履歴の最上段にある「分岐」などのアイコン行と同じ構造)
  function badgeItem(iconId, innerHTML) {
    return `
      <div class="timeline-item">
        <div class="timeline-badge" title="">
          <svg class="icon icon-sm" aria-hidden="true">
            <use href="#${iconId}"></use>
          </svg>
        </div>
        <div class="timeline-content">
          ${innerHTML}
        </div>
      </div>
    `;
  }
 
  // ドット行(履歴の通常行と同じ構造)
  function dotItem(innerHTML, opts = {}) {
    const indent = opts.indent ? ' style="margin-left:24px;"' : "";
    return `
      <div class="timeline-item">
        <div class="timeline-dot"></div>
        <div class="timeline-content"${indent}>
          ${innerHTML}
        </div>
      </div>
    `;
  }
 
  // ============== マウント処理 ==============
  async function ensureMountedOnce(frm) {
    if (frm.is_new() || !isCurrentForm(frm)) return null;
    if (frm.__wf_panel?.isConnected) return frm.__wf_panel;
 
    const afterSave = $(frm.wrapper).find(".form-footer .after-save")[0];
    if (!afterSave) return null;
 
    const panel = document.createElement("div");
    panel.id = "workflow-progress-panel";
    panel.className = "mt-3 mb-4";
    panel.innerHTML = `
    <div class="new-timeline">
      <div class="timeline-item activity-title">
        <h4>ワークフロー進捗</h4>
        <div class="timeline-items timeline-actions">
          <div class="timeline-item">
            <div class="timeline-content action-buttons">
              <button class="btn btn-xs btn-secondary action-btn" id="wf-refresh-btn">
                <svg class="es-icon es-line icon-xs" aria-hidden="true">
                  <use href="#es-line-refresh"></use>
                </svg>
                更新
              </button>
            </div>
          </div>
        </div>
      </div>
      <div class="timeline-items" id="wf-block"><!-- JSで差し込み --></div>
    </div>
  `;
 
  afterSave.appendChild(panel); // ← これで new-timeline の直後(末尾)に来る
 
    panel.querySelector("#wf-refresh-btn").addEventListener("click", () => refreshPanel(frm));
    frm.__wf_panel = panel;
    return panel;
  }
 
  function render(panel, data) {
    const $block = panel.querySelector("#wf-block");
    const html = [];
 
    // 現在の状態(バッジ行:履歴の先頭風)
    html.push(
      badgeItem(
        "icon-branch", // 履歴で使われる分岐アイコン
        `現在の状態:<strong>${frappe.utils.escape_html(data.current_state || "-")}</strong>`
      )
    );
 
    // 次の承認候補(見出しドット行 + 子ドット行)
    const candidates = Array.from(
      new Set((data.next_candidates || []).flatMap((c) => c.users || []))
    );
    if (candidates.length) {
      html.push(dotItem(`<span class="text-muted">次の承認候補</span>`));
      candidates.forEach((u) =>
        html.push(dotItem(`<strong>${frappe.utils.escape_html(u)}</strong>`, { indent: true }))
      );
    }
 
    // 承認履歴(1行ずつ、履歴と同じドット行)
    const route = Array.isArray(data.route) ? data.route : [];
    if (route.length) {
      route.forEach((r) => {
        const actor = frappe.utils.escape_html(r.by || "-");
        const from = frappe.utils.escape_html(r.from || "-");
        const to = frappe.utils.escape_html(r.to || "-");
        const whenISO = r.when || "";
        const whenPretty = whenISO
          ? `<span class="frappe-timestamp" data-timestamp="${whenISO}" title="${whenISO}">${frappe.datetime.prettyDate(
              whenISO
            )}</span>`
          : "";
 
        html.push(
          dotItem(
            `<strong>${actor}</strong> が <strong>${from}</strong> → <strong>${to}</strong> へ遷移
             <span> · ${whenPretty}</span>`
          )
        );
      });
    } else {
      html.push(dotItem(`<span class="text-muted">進捗データはありません。</span>`));
    }
 
    $block.innerHTML = html.join("");
  }
 
  function refreshPanel(frm) {
    const panel = frm.__wf_panel;
    if (!panel || !isCurrentForm(frm)) return;
 
    const $block = panel.querySelector("#wf-block");
    $block.innerHTML = dotItem(`読み込み中…`);
 
    const token = `${frm.doctype}:${frm.docname}:${Date.now()}`;
    frm.__wf_req_token = token;
 
    frappe
      .call({
        method: "my_custom_app.api.workflow.get_workflow_overview",
        args: { doctype: frm.doctype, name: frm.docname, comments_limit: 0 },
        freeze: false,
      })
      .then((r) => {
        if (frm.__wf_req_token !== token || !isCurrentForm(frm)) return;
        render(panel, r.message || {});
      })
      .catch(() => {
        $block.innerHTML = dotItem(`読み込み失敗`);
      });
  }
 
  function bindHandlers() {
    if (window.__wf_panel_bootstrapped) return;
    window.__wf_panel_bootstrapped = true;
 
    frappe.ui.form.on("*", {
    onload_post_render(frm) {
      if (!hasWorkflow(frm.doctype)) return unmount(frm);
      ensureMountedOnce(frm).then(() => refreshPanel(frm));
    },
    refresh(frm) {
      if (!hasWorkflow(frm.doctype)) return unmount(frm);
      ensureMountedOnce(frm).then(() => refreshPanel(frm));
    },
    after_save(frm) { if (hasWorkflow(frm.doctype)) refreshPanel(frm); },
    on_submit(frm)  { if (hasWorkflow(frm.doctype)) refreshPanel(frm); },
    on_cancel(frm)  { if (hasWorkflow(frm.doctype)) refreshPanel(frm); },
  });
 
    // 念のため Budget を明示
    frappe.ui.form.on("Budget", {
      onload_post_render(frm) {
        ensureMountedOnce(frm).then(() => refreshPanel(frm));
      },
      refresh(frm) {
        ensureMountedOnce(frm).then(() => refreshPanel(frm));
      },
    });
  }
 
  if (window.frappe?.ui?.form?.on) bindHandlers();
  else {
    $(document).on("app_ready", bindHandlers);
    frappe.router && frappe.router.on("change", bindHandlers);
  }
})();
 

【再掲:ベルト&サスペンダー方針】
✅ 重複表示対策:ensure_container() で既存DOMを empty() してから描画
✅ SPA対策:refresh に加え、router change でも再描画
✅ 対象限定:boot で配信した DocType のみに限定(無関係フォームに副作用を出さない)

4.よくあるハマリポイント

4.1 refresh のたびに DOM を足し続けて重複表示

  • 事象:フォームを開き直す/保存する/タブを切り替える度に、承認パネルがもう1枚ずつ増える。

refresh のたびに DOM を足し続けて重複表示

  • 原因refresh はフォーム再描画のたびに呼ばれる。append だけだと、同じ要素を何度も追加してしまう。
  • 対策
    • 単一コンテナに固定#wf-approval-panel などのIDを付ける)
    • 毎回 empty()→再描画増殖を根絶
      const $panel = ensure_container(frm); // なければ作る、あれば empty()
      $panel.empty().html(render(data));
    • さらに念のため フラグ併用(重い処理やレイアウト生成を1回化)
      if (frm.__wf_layout_built) { /* 軽い更新だけ */ } else { /* 骨組み作成 */ frm.__wf_layout_built = true; }

4.2 SPA のルート切替でイベントが消える

  • 事象:最初は動くが、他のドキュメントに移動するとパネル更新が止まる/ハンドラが効かない。
  • 原因:ERPNext Desk は SPA。ルート遷移で フォーム・ソケット・DOMが再初期化 され、前に登録したハンドラが無効化されることがある。コンソールから“今この瞬間の socket/DOM”へ登録すると動くのはこのため。
  • 対策
    • 二重トリガrefresh frappe.router.on("change", ...) の両方で update_panel(frm) を呼ぶ(ベルト&サスペンダー)
    • 再バインド方針:ルート変化ごとに 古い参照に依存しない 形で都度付け直す
      frappe.ui.form.on("*", { refresh: update_panel });
      frappe.router?.on?.("change", () => cur_frm && update_panel(cur_frm));
    • (必要なら)デバウンス で無駄な連続呼び出しを抑制
      const update_panel = frappe.utils.debounce(_update_panel, 80);

4.3 画面生成直後で差し込み先がなく DOMが見つからない

  • 事象:読み込み直後に wrapper が取れず、パネルが出たり出なかったりする。
  • 原因:フォームの DOM構築は非同期refresh のタイミングでも、差し込み先の .form-page が未生成の瞬間がある。
  • 対策
    • ensure_container() で “なければ作る” を徹底(常に差し込み先を自前で用意)
    • 非同期完了を待つ:描画直後は軽く遅延/後段実行で安定化
      frappe.after_ajax(() => update_panel(frm)); // Ajax完了後にもう一度
      // もしくは最小限の遅延
      setTimeout(() => update_panel(frm), 0);
    • 新規ドキュメント除外if (frm.is_new()) return; で “まだ参照名がない状態” を外す

5.まとめ

ワークフローの承認状況

みなさん、うまく実装できたでしょうか!?
今回の事例(承認状況パネル)は、ERPNextのカスタムアプリ(Custom App) が持つ力の“ごく一部”です。
ポイントのおさらいとしては、

  • 🔒アップデートに強い:
    本体と分離(hooks/fixtures)で、核は触らず“外側”から拡張。将来のバージョンアップでも壊れにくい。
  • 🎁一式を“箱詰め”できる:
    UI(JS/HTML/CSS)+API(Python)+レポート+翻訳をひとつのアプリとして配布・再利用。
  • 🌎現場適応が速い:
    小さなUI改善でも“動線の短縮”や“確認コスト削減”に直結。試作→展開が短サイクル。
  • 💡ガバナンスしやすい:
    権限・ログ・監査が標準機能に統合。客先ごとや部門ごとの設定差もアプリ側で吸収。
  • 🔦運用が楽:
    Git/CIで移設・検証・ロールバックが容易。複数サイトへの横展開も手順化できる。

本記事で使ったパターン(Bootで対象配信 → APIで最新状態 → JSで描画)は、
ワークフローに限らず、予算・案件・在庫・品質など多くの領域に“そのまま”応用できます。

MyHatch では、日本企業向けに再利用しやすい Custom Appテンプレ運用レシピ を整備中です。
「まずは現場の見える化から」「小さく始めて早く効く改善を」——そんな導入を伴走します。

まだ疑問が残りますか?

この記事で解決しない疑問は、無料相談でお気軽にご質問ください。ERPNext導入の専門家が直接お答えします。