1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

児童養護施設の食数管理をWebアプリで解決した話【CakePHP 5 + Docker + GitHub Actions】

1
Last updated at Posted at 2026-07-10

CakePHP 5 × Docker × GitHub Actions で作る児童養護施設の食数管理システム

はじめに

児童養護施設では、毎日の食事(朝食・昼食・夕食・弁当)の提供数を事前に把握し、食材の発注や調理計画を立てる必要があります。これまで紙や口頭で行っていた食数の管理を、Webアプリで効率化するために shokusuu1(食数予約管理システム)を開発しました。

本記事では、CakePHP 5 × Docker × GitHub Actions を使った開発・運用の設計思想と実装のポイントを紹介します。


システム概要

項目 内容
対象施設 児童養護施設
主な利用者 入所児童・職員・ブロック長・管理者・システム管理者
目的 食数の予約・集計・Excel出力で食事提供を効率化

主な機能

  • 食数予約(個人・一括) — 日別・週単位で食事予約を登録。職員による部屋単位のまとめ予約にも対応
  • カレンダー表示 — FullCalendar で月間の予約状況を視覚的に確認。カレンダー上から直接予約変更も可能
  • ダッシュボード — 今週/来週/再来週の予約状況を一覧表示、食数報告アラート付き
  • 食数集計・Excel出力 — 期間・部屋別・食事種別の集計をExcelエクスポート
  • 承認フロー — 実食報告をブロック長→管理者の2段階で承認・差し戻し
  • 監査ログ — ログイン・予約変更・権限変更など全重要操作を記録
  • AIアシスタント — ロール別システムプロンプト付きのAIチャット(SSEストリーミング)
  • 統計AI — 直近の食数・承認・利用率データをコンテキストに持つ管理者専用AIチャット
  • 部屋異動スケジュール — 入退所・部屋移動を事前に登録し、指定日に自動反映
  • 管理者機能 — ユーザー・部屋情報の管理、CSVインポート、年齢一括更新

技術スタック

バックエンド  : CakePHP 5.x / PHP 8.3
データベース  : MySQL
インフラ      : Docker(開発)/ さくらのVPS(本番)/ OCI(ステージング)
CI/CD         : GitHub Actions(8ワークフロー)
フロントエンド: FullCalendar / Bootstrap / Vanilla JS / Playwright(E2E)
外部API       : OpenRouter(AI推論)/ Resend(メール配信)

CakePHP 5 を選んだ理由

  • PHP の老舗フレームワークで、Convention over Configuration による素早い開発
  • ORM が強力で複雑な予約クエリも簡潔に記述できる
  • バージョン5でPHP 8.x の型機能(declare(strict_types=1))と相性が良い

アーキテクチャの工夫

サービス層の分割

コントローラーが肥大化しないよう、責務ごとに サービスクラス を細かく分割しました。

src/Service/
├── DashboardService.php               # ダッシュボード用ビューデータ生成
├── ReservationCalendarService.php     # カレンダー表示ロジック
├── ReservationAddService.php          # 予約追加処理
├── ReservationWriteService.php        # 予約書き込み(更新・削除含む)
├── ReservationQueryService.php        # 予約検索クエリ
├── ReservationReportService.php       # 食数レポート集計
├── ReservationBulkService.php         # 一括予約処理
├── ReservationCopyService.php         # 予約コピー処理
├── ReservationDatePolicy.php          # 予約可能日の判定ポリシー
├── ReservationChangeEditService.php   # 直前変更処理
├── ReservationRoomDetailService.php   # 部屋別詳細取得
├── ReservationViewService.php         # 予約ビュー構築
├── ApprovalService.php                # 承認フロー処理
├── ActualMealManagementService.php    # 実食確認管理
├── AuditLogService.php                # 監査ログ記録
├── NotificationService.php            # 通知管理
├── RoomAccessService.php              # 部屋アクセス権限
├── RoomService.php                    # 部屋管理
├── RoomTransferScheduleService.php    # 部屋異動スケジュール
├── RoomUsageService.php               # 部屋別利用率集計
├── FeatureUsageSummaryService.php     # 機能利用サマリ(管理者向け)
├── MealCountGridService.php           # 食数グリッド表示
├── MealReportingService.php           # 食数報告処理
├── MealSummaryExportService.php       # 食数集計エクスポート
├── UserCreateService.php              # ユーザー作成
├── UserEditService.php                # ユーザー編集
├── UserDeletionService.php            # ユーザー削除(論理削除)
├── UserRestoreService.php             # ユーザー復元
├── UserPermissionService.php          # 権限変更
├── UserRoomAssignmentService.php      # 部屋割り当て
├── UserBulkImportService.php          # CSVインポート
├── BulkReservationFormService.php     # 一括予約フォーム
└── ContactService.php                 # お問い合わせ処理

例えば ReservationDatePolicy は「何日前まで予約できるか」「直前変更の期限はいつか」といったビジネスルールを一箇所に集約しています。ポリシー変更があっても1ファイルの修正で済みます。

// ReservationDatePolicy.php(抜粋イメージ)
class ReservationDatePolicy
{
    // 通常予約は15日以上先から
    public function isNormalReservationDate(\DateTimeImmutable $date): bool
    {
        $threshold = new \DateTimeImmutable('+15 days', new \DateTimeZone('Asia/Tokyo'));
        return $date >= $threshold;
    }
}

Controller の分割

当初は予約コントローラーが肥大化していましたが、機能ごとに 専用コントローラー へ分離しました。

src/Controller/
├── ReservationBaseController.php      # 共通の初期化・依存取得を担う基底クラス
├── TReservationInfoController.php     # カレンダー・個人予約CRUD(コア)
├── ReservationBulkController.php      # 一括予約
├── ReservationCopyController.php      # 予約コピー
├── ReservationReportController.php    # 食数レポート
├── ReservationActualMealController.php# 実食確認
├── ReservationToggleController.php    # 予約トグル(カレンダークリック)
├── ApprovalController.php             # 承認フロー
├── AuditLogController.php             # 監査ログ閲覧
├── AiAssistantController.php          # AIアシスタント
├── FeatureUsageSummaryController.php  # 機能利用サマリ
├── RoomUsageController.php            # 部屋別利用率
└── ...

共通処理は ReservationBaseController にまとめ、各コントローラーは薄く保っています。

ドメイン ValueObject によるロール定義

ユーザーロールのマジックナンバーが各所に散在していたため、UserRole 値オブジェクトに一元化しました。

// src/Domain/ValueObject/UserRole.php
final class UserRole
{
    public const GENERAL      = 0;
    public const ADMIN        = 1;
    public const BLOCK_LEADER = 2;
    public const SYSTEM_ADMIN = 3;

    public static function isAdmin(int $value): bool
    {
        return in_array($value, [self::ADMIN, self::SYSTEM_ADMIN], true);
    }
}

ポリシークラス(TReservationInfoPolicyApprovalPolicy 等)もこの定数を参照しており、ロール体系の変更が1ファイルで完結します。


ダッシュボード:今週〜来週の週表示

ダッシュボードでは「今週・来週・再来週」と「通常予約可能な最初の週」を表示します。DashboardService がこの週の計算を担います。

class DashboardService
{
    public function buildHomeContext($user): array
    {
        $today = new \DateTimeImmutable('now', new \DateTimeZone('Asia/Tokyo'));

        // 今週の月曜日(PHP の modify は ISO 週準拠)
        $thisWeekMonday = $today->modify('monday this week');
        $nextWeekMonday = $thisWeekMonday->modify('+7 days');
        $nextNextWeekMonday = $thisWeekMonday->modify('+14 days');

        // 通常予約は今日+15日以降の最初の月曜日から
        $threshold = $today->modify('+15 days');
        $firstNormalWeekMonday = $threshold->modify('monday this week');
        if ($firstNormalWeekMonday < $threshold) {
            $firstNormalWeekMonday = $firstNormalWeekMonday->modify('+7 days');
        }

        // 画面表示用に「2/23(月) 〜 2/27(金)」形式を返すクロージャ
        $dow = ['日', '月', '火', '水', '木', '金', '土'];
        $fmtWeekRange = function (\DateTimeImmutable $monday) use ($dow): string {
            $friday = $monday->modify('+4 days');
            return $monday->format('n/j') . '(' . $dow[(int)$monday->format('w')] . ')'
                 . ' 〜 '
                 . $friday->format('n/j') . '(' . $dow[(int)$friday->format('w')] . ')';
        };

        return compact(
            'thisWeekMonday', 'nextWeekMonday', 'nextNextWeekMonday',
            'firstNormalWeekMonday', 'fmtWeekRange',
        );
    }
}

カレンダーUI:FullCalendar の活用

フロントエンドには FullCalendar を採用し、月間カレンダー上に予約状況をイベントとして表示します。日本の祝日は japaneseholiday ライブラリで自動判定しています。

// FullCalendar の初期化例
const calendar = new FullCalendar.Calendar(calendarEl, {
    initialView: 'dayGridMonth',
    locale: 'ja',
    events: '/reservations/calendar-events.json',
    eventClick: function(info) {
        // 予約詳細モーダルを表示
        openReservationModal(info.event);
    }
});

バックエンドからはJSON APIでイベントを返すため、画面遷移なしにカレンダーの予約状況を確認できます。また、カレンダー上のイベントをクリックすると自分の予約をインライン編集できるモーダルが開き、1タップで予約トグルが可能です。


AIアシスタント

OpenRouter API(google/gemma-4-31b-it:free)を使ったAIチャット機能を搭載しています。

ロール別システムプロンプト

施設の操作マニュアルやよくある質問はロールによって参照すべき画面が異なります。インターフェイスと実装を分離し、ロールに応じたURLリストを動的に組み込む設計としました。

// src/Application/AI/SystemPromptProviderInterface.php(依存逆転)
interface SystemPromptProviderInterface
{
    public function get(string $role): string;
}

// src/Infrastructure/AI/SystemPromptProvider.php(実装)
final class SystemPromptProvider implements SystemPromptProviderInterface
{
    public function get(string $role): string
    {
        $base    = $this->readFile(self::BASE_FILE);                // ai_assistant.md
        $urlList = $this->readFile(self::URLS_DIR . $role . '.md'); // ロール別URL一覧
        return str_replace('{{URL_LIST}}', $urlList, $base);
    }
}

SSEストリーミングとマルチターン会話

回答を逐次表示するためにSSE(Server-Sent Events)を採用しています。バックエンド側では cURL の CURLOPT_WRITEFUNCTION でチャンクを受け取り、フロントエンドの ReadableStream で逐次描画します。会話履歴は sessionStorage に保存するためリロードしても継続でき、タブを閉じると自動消去されます(共用PCでの情報漏洩防止)。

統計AI(管理者専用)

直近4週間と今後1週間の食数・部屋別利用率・承認状況をシステムプロンプトに組み込んだ、管理者専用の統計分析AIを実装しています。

プライバシー保護のため、外部AIへ送信されるデータはすべてHMAC-SHA256でハッシュ化し、氏名・部屋名をトークンに置き換えます。AI回答内のトークン([U:abc123][R:def456])は、画面側のJavaScriptでのみ元の名前に戻す仕組みです。

// 氏名・部屋名をHMACトークンに変換してからAIへ送信
private function maskPersonalNames(string $content): string
{
    foreach ($this->nameToToken as $name => $token) {
        $content = str_replace($name, '[U:' . $token . ']', $content);
    }
    foreach ($this->roomToToken as $name => $token) {
        $content = str_replace($name, '[R:' . $token . ']', $content);
    }
    return $content;
}

承認フロー

実食確認は「未承認 → ブロック長承認済 → 管理者承認済」の2段階承認フローを実装しています。差し戻し機能もあり、差し戻し理由を入力して前段階へ戻せます。ApprovalService に承認ロジックを集約し、ApprovalPolicy でアクション単位の権限チェックを行います。


監査ログ

カテゴリ: user / reservation / actual_meal / approval / master / system

静的メソッド AuditLogService::record() でアプリのどこからでもDIなしに記録でき、書き込み失敗はメイン処理を妨げません。ログイン試行・予約変更・権限変更・AIへの問い合わせ内容まで一元管理します。管理者はWeb画面からフィルタ・CSVエクスポートできます。


GitHub Actions による自動化

現在8つのワークフローで開発・運用を自動化しています。

デプロイフロー(本番・ステージング分離)

ブランチ 環境 インフラ
main 本番 さくらのVPS
develop ステージング OCI(Oracle Cloud)

ステージングdevelop push のたびに自動デプロイ。Dockerfileが変わった場合のみ ghcr.io へイメージをビルド・プッシュし、変わらない場合はプルのみでデプロイ時間を短縮しています。

# Dockerfile が変わった場合のみビルド
check-dockerfile:
  outputs:
    changed: ${{ steps.filter.outputs.dockerfile }}

build:
  needs: check-dockerfile
  if: needs.check-dockerfile.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'

composer.lock のSHA256ハッシュをキャッシュし、変更があった場合のみ composer install を実行することでデプロイ時間をさらに短縮しています。

CI/CD ワークフロー一覧

deployment.yml          本番デプロイ(main → さくらのVPS)
deploy-staging.yml      ステージングデプロイ(develop → OCI)
phpunit.yml             PHPUnit(PR・develop push 時に自動実行)
playwright.yml          Playwright E2E(毎朝1時・手動実行)
room_transfer_apply.yml 部屋異動スケジュール自動反映(毎日0時)
age_update.yml          年齢一括更新(毎年4月1日 JST)
db_backup.yml           DBバックアップ
cert-renew.yml          SSL証明書更新

本番デプロイのポイント

name: Deploy to Sakura VPS
on:
  push:
    branches: [ main ]

concurrency:
  group: deploy-to-vps
  cancel-in-progress: true  # 多重デプロイを防止

jobs:
  deploy:
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.2.2
        with:
          script: |
            set -euo pipefail
            # 初回は clone、以降は fetch + reset
            if [ ! -d .git ]; then
              git clone --branch main "$REPO_URL" .
            else
              git fetch origin --prune
              git reset --hard origin/main
            fi
            # マイグレーション・キャッシュクリア等
            echo "✅ Deployed: $(git rev-parse --short HEAD)"

全デプロイ結果はSlackへ通知されます(成功は ✅、失敗は担当者メンション付き ❌)。

業務系 cron:部屋異動の自動反映

入退所・部屋移動は MRoomTransferSchedule テーブルに事前登録しておき、毎日0時(JST)に GitHub Actions の cron が自動適用します。手動実行も可能です。

# room_transfer_apply.yml
on:
  schedule:
    - cron: '0 15 * * *'   # 毎日 JST 0:00(UTC 15:00)
  workflow_dispatch:         # 手動実行も可能

テスト戦略

PHPUnit(サービス層・ポリシー層)

サービスクラス全23件・ポリシークラス全12件にユニットテストを整備しています。PR作成時と develop へのpush時にCIで自動実行されます。

vendor/bin/phpunit tests/TestCase/Policy tests/TestCase/Service

Playwright E2E テスト

ステージング環境に対して毎朝1時(JST)にスモークテストを実行し、予約フローの動作を自動確認しています。ブラウザとベースURLは環境変数で指定でき、手動実行時は任意のURLを指定できます。

# playwright.yml
on:
  schedule:
    - cron: '0 16 * * *'  # 毎朝 1:00 JST
  workflow_dispatch:
    inputs:
      base_url: { required: true }  # 手動実行時は任意URLを指定可能

開発で苦労したポイント

1. CakePHP の Date クラスと DateTimeInterface

CakePHP 5 の Cake\I18n\DateDateTimeInterface を実装していないため、PHPネイティブの日付関数と組み合わせる際に型エラーが発生しました。

// NG: Cake\I18n\Date は DateTimeInterface ではない
function doSomething(\DateTimeInterface $date) { ... }

// OK: \DateTimeImmutable に変換してから渡す
$nativeDate = new \DateTimeImmutable($cakeDate->format('Y-m-d'));
doSomething($nativeDate);

2. 排他制御と予約の競合

同じ日・同じユーザーへの二重登録を防ぐため、DBレベルのユニーク制約とアプリレベルの排他ロジックを組み合わせています。直前変更期限(前日17時まで)などのビジネスルールは ReservationDatePolicy に集約し、コントローラーとサービス双方から参照できるようにしました。

3. タイムゾーン統一

サーバー・DB・アプリのタイムゾーンを Asia/Tokyo に統一することで、日付ずれバグを防止。GitHub ActionsのUbuntu環境(UTC)で実行されるcronジョブでも、スクリプト内で明示的に TZ=Asia/Tokyo を設定しています。

4. SSEストリーミングの行バッファリング

cURL の CURLOPT_WRITEFUNCTION コールバックは、SSEの1行分が複数回に分割されて届くことがあります。コールバックをまたいだ行バッファを保持し、改行を受け取ったタイミングで初めて処理する実装にしました。

$lineBuffer = '';
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, string $data) use (&$lineBuffer): int {
    $lineBuffer .= $data;
    $lines      = explode("\n", $lineBuffer);
    $lineBuffer = (string)array_pop($lines);  // 未完行を次回へ持ち越す
    foreach ($lines as $line) {
        // 完結した行を処理
    }
    return strlen($data);
});

5. AIへの個人情報漏洩防止

統計AIでは施設の利用者氏名・部屋名が集計データに含まれますが、そのまま外部APIへ送るわけにはいきません。Security.salt を使ったHMAC-SHA256でトークン化し、AI側には一切の素性が分からない状態で送信します。ブラウザ側でのみ [U:ハッシュ] → 氏名 の変換マップを保持し、表示時に復元します。


まとめ

設計判断 採用したアプローチ
Controller 肥大化防止 サービス層 + 専用Controller分割
ビジネスルールの集約 ReservationDatePolicy クラス
ロール定数の一元管理 UserRole 値オブジェクト
フロントのカレンダーUI FullCalendar + JSON API + インライン編集モーダル
AIアシスタント OpenRouter API + SSEストリーミング + ロール別プロンプト
統計AI のプライバシー HMAC-SHA256 トークン化(氏名・部屋名を外部AI非送信)
本番デプロイの自動化 GitHub Actions + appleboy/ssh-action
多重デプロイ防止 concurrency.cancel-in-progress
ステージング環境 OCI + ghcr.io Docker イメージキャッシュ
テスト自動化 PHPUnit(サービス/ポリシー全件)+ Playwright E2E(毎朝スモーク)
運用自動化 部屋異動・年齢更新・DBバックアップを GitHub Actions cron で自動化

CakePHP 5 のConvention over Configurationを活かしつつ、サービス層・ポリシークラス・ValueObjectでビジネスロジックを整理することで、機能追加・バグ修正がしやすいコードベースを維持できています。

福祉施設向けの業務システムという特性上、職員の負担を減らすUXと、誤操作を防ぐ堅牢なバリデーションの両立が重要な課題でした。引き続き改善を続けていきます。


参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?