Skip to content

ハウツー: 承認ワークフロー API

FT リファレンス: FT68 (NENE2-FT/approvallog) — 承認ワークフロー API

リクエストが定義された状態(Draft → Submitted → UnderReview → Approved/Rejected)を通過する多段階承認ワークフローを示します。無効な遷移は 409 Conflict を返します。ステートマシンは allowedTransitions() メソッドを使って ApprovalStatus backed enum に直接エンコードされています。


ワークフロー状態

Draft ──submit──▶ Submitted ──review──▶ UnderReview

                                    ┌─approve─┤─reject─┐
                                    ▼                   ▼
                                 Approved            Rejected

                                                    ─rework─▶ Draft
状態説明
draft作成済みだがまだ提出されていない
submittedレビュー割り当て待ち
under_reviewレビュアーが割り当てられてレビュー中
approved最終承認済み
rejected必須の理由とともに却下済み

却下されたリクエストは修正のために再作業(draft に戻す)して再提出できます。承認済みリクエストはそれ以上の遷移がありません。


列挙型にエンコードされた遷移ルール

状態遷移ルールはリポジトリやコントローラーではなく、列挙型の内部に記述します:

php
enum ApprovalStatus: string
{
    case Draft       = 'draft';
    case Submitted   = 'submitted';
    case UnderReview = 'under_review';
    case Approved    = 'approved';
    case Rejected    = 'rejected';

    /** @return list<self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::Draft       => [self::Submitted],
            self::Submitted   => [self::UnderReview],
            self::UnderReview => [self::Approved, self::Rejected],
            self::Approved    => [],
            self::Rejected    => [self::Draft],   // 再作業パス
        };
    }

    public function canTransitionTo(self $target): bool
    {
        return in_array($target, $this->allowedTransitions(), true);
    }
}

canTransitionTo() が遷移が有効かどうかの唯一の情報源です。新しい許可済み遷移を追加するにはこのメソッドのみを更新します。


ルート

メソッドパス説明
POST/requestsドラフトリクエストを作成する
GET/requestsすべてのリクエストを一覧表示する(?status= フィルター)
GET/requests/{id}単一リクエストを取得する
POST/requests/{id}/submitDraft → Submitted
POST/requests/{id}/reviewSubmitted → UnderReview(レビュアーを割り当てる)
POST/requests/{id}/approveUnderReview → Approved
POST/requests/{id}/rejectUnderReview → Rejected(理由が必須)
POST/requests/{id}/reworkRejected → Draft(レビュアー/メモをクリア)

リポジトリでの遷移ガード

リポジトリは UPDATE クエリを実行する前に canTransitionTo() をチェックします:

php
public function submit(int $id, string $now): ?ApprovalRequest
{
    $req = $this->findById($id);

    if ($req === null || !$req->status->canTransitionTo(ApprovalStatus::Submitted)) {
        return null;   // 呼び出し元が null → 409 Conflict にマップする
    }

    $this->db->execute(
        "UPDATE requests SET status = 'submitted', submitted_at = ?, updated_at = ? WHERE id = ?",
        [$now, $now, $id],
    );

    return $this->findById($id);
}

「見つからない」と「無効な遷移」の両方に null を返すのは意図的な簡略化です。本番環境では、型付きの結果を返すかドメイン例外をスローすることで 404(見つからない)と 409(見つかったが無効な遷移)を区別してください。

コントローラーは null → 409 Conflict にマップします:

php
private function submit(ServerRequestInterface $request): ResponseInterface
{
    $id  = (int) ($params['id'] ?? 0);
    $req = $this->repo->submit($id, $now);

    if ($req === null) {
        return $this->problems->create(
            $request,
            'conflict',
            'Request not found or cannot be submitted from its current status.',
            409,
            '',
        );
    }

    return $this->json->create($req->toArray());
}

却下には理由が必要

reject 遷移には reviewernote の両方が必要です:

php
private function reject(ServerRequestInterface $request): ResponseInterface
{
    $reviewer = isset($body['reviewer']) && is_string($body['reviewer']) ? trim($body['reviewer']) : '';
    $note     = isset($body['note']) && is_string($body['note']) ? trim($body['note']) : '';

    if ($reviewer === '' || $note === '') {
        $errors = [];
        if ($reviewer === '') {
            $errors[] = ['field' => 'reviewer', 'code' => 'required', 'message' => 'reviewer is required.'];
        }
        if ($note === '') {
            $errors[] = ['field' => 'note', 'code' => 'required', 'message' => 'note (rejection reason) is required.'];
        }

        return $this->problems->create($request, 'validation-failed', 'Validation Failed', 422, null, compact('errors'));
    }
    // ...
}

理由なしの却下は拒否されます(422)。メモなしの承認は許可されます — note フィールドは承認時にオプションです。


再作業: レビュー状態のクリア

却下されたリクエストが再作業される際、次のレビュアーが新鮮な状態で始められるよう、レビュアーとレビューメモがクリアされます:

php
// リポジトリ: 再作業(Rejected → Draft)
$this->db->execute(
    "UPDATE requests SET status = 'draft', reviewer = NULL, review_note = NULL, reviewed_at = NULL, updated_at = ? WHERE id = ?",
    [$now, $id],
);

submitted_at タイムスタンプは保持されます — これは現在のサイクルではなく、リクエストが最初に提出されたときを記録します。


スキーマ

sql
CREATE TABLE requests (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    title        TEXT    NOT NULL,
    submitter    TEXT    NOT NULL,
    status       TEXT    NOT NULL DEFAULT 'draft',
    reviewer     TEXT,              -- レビュー開始まで NULL
    review_note  TEXT,             -- レビューまで NULL
    submitted_at TEXT,             -- 提出まで NULL
    reviewed_at  TEXT,             -- 承認/却下まで NULL
    created_at   TEXT    NOT NULL,
    updated_at   TEXT    NOT NULL
);

NULL 可カラム(reviewerreview_notesubmitted_atreviewed_at)は再作業時に NULL にクリアされ、rework_count カラムを追加せずにスキーマをクリーンに保ちます。

改善点: enum 値と一致させるために DB レベルのバックストップとして CHECK(status IN ('draft','submitted','under_review','approved','rejected')) を追加する。


一覧エンドポイントでのステータスフィルター

php
private function list(ServerRequestInterface $request): ResponseInterface
{
    $params    = $request->getQueryParams();
    $statusRaw = isset($params['status']) && is_string($params['status']) ? $params['status'] : null;
    $status    = $statusRaw !== null ? ApprovalStatus::tryFrom($statusRaw) : null;

    if ($statusRaw !== null && $status === null) {
        return $this->problems->create($request, 'validation-failed', 'Validation Failed', 422, null, [
            'errors' => [['field' => 'status', 'code' => 'invalid_value', 'message' => 'Invalid status value.']],
        ]);
    }

    $requests = $this->repo->listByStatus($status);
    // ...
}

ApprovalStatus::tryFrom() は未知のステータス文字列に null を返します → 422。$statusRaw === null(フィルターなし)の場合はすべてのリクエストが返されます。


新しい遷移を追加する

任意の非終端状態から到達できる cancelled 状態を追加するには:

  1. ApprovalStatuscase Cancelled = 'cancelled'; を追加する。
  2. DraftSubmittedUnderReviewallowedTransitions() を更新して self::Cancelled を含める。
  3. POST /requests/{id}/cancel ルートとハンドラーを追加する。
  4. リポジトリに DB UPDATE を記述する。
  5. スキーマの CHECK 制約を更新する(追加した場合)。

列挙型が唯一の情報源です — 遷移ガードを追加するために他のファイルを変更する必要はありません。


関連ハウツー

MIT ライセンスの下で公開されています。