ハウツー: 承認ワークフロー 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 に戻す)して再提出できます。承認済みリクエストはそれ以上の遷移がありません。
列挙型にエンコードされた遷移ルール
状態遷移ルールはリポジトリやコントローラーではなく、列挙型の内部に記述します:
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}/submit | Draft → Submitted |
POST | /requests/{id}/review | Submitted → UnderReview(レビュアーを割り当てる) |
POST | /requests/{id}/approve | UnderReview → Approved |
POST | /requests/{id}/reject | UnderReview → Rejected(理由が必須) |
POST | /requests/{id}/rework | Rejected → Draft(レビュアー/メモをクリア) |
リポジトリでの遷移ガード
リポジトリは UPDATE クエリを実行する前に canTransitionTo() をチェックします:
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 にマップします:
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 遷移には reviewer と note の両方が必要です:
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 フィールドは承認時にオプションです。
再作業: レビュー状態のクリア
却下されたリクエストが再作業される際、次のレビュアーが新鮮な状態で始められるよう、レビュアーとレビューメモがクリアされます:
// リポジトリ: 再作業(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 タイムスタンプは保持されます — これは現在のサイクルではなく、リクエストが最初に提出されたときを記録します。
スキーマ
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 可カラム(reviewer、review_note、submitted_at、reviewed_at)は再作業時に NULL にクリアされ、rework_count カラムを追加せずにスキーマをクリーンに保ちます。
改善点: enum 値と一致させるために DB レベルのバックストップとして
CHECK(status IN ('draft','submitted','under_review','approved','rejected'))を追加する。
一覧エンドポイントでのステータスフィルター
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 状態を追加するには:
ApprovalStatusにcase Cancelled = 'cancelled';を追加する。Draft、Submitted、UnderReviewのallowedTransitions()を更新してself::Cancelledを含める。POST /requests/{id}/cancelルートとハンドラーを追加する。- リポジトリに DB UPDATE を記述する。
- スキーマの
CHECK制約を更新する(追加した場合)。
列挙型が唯一の情報源です — 遷移ガードを追加するために他のファイルを変更する必要はありません。
関連ハウツー
content-draft-lifecycle.md— draft → publish ライフサイクル(シンプルなステートマシン)media-watchlist.md—tryFrom()による backed enum バリデーションadd-custom-route.md— POST アクションエンドポイントパターンmulti-step-workflow.md— 汎用的な多段階ワークフローパターン