0
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?

Python × Dify × RAGで学ぶ業務システム開発入門【第6回 研修管理編】

0
Last updated at Posted at 2026-06-18

はじめに

前回は、社員管理のCRUD(一覧・登録・更新・削除・バリデーション)を実装しました。

前回の記事:Python × Dify × RAGで学ぶ業務システム開発入門【第5回 社員管理CRUD編】

第6回では、研修管理を実装します。

社員管理との大きな違いは次の2点です。

社員管理(第5回)
  → 1テーブル(employee)のCRUD
  → シンプルな登録・更新・削除

研修管理(第6回)
  → 複数テーブルのリレーション(training × employee × training_history)
  → 出席率・理解度・課題スコアの自動集計
  → JOIN を使った複合データの取得

複数テーブルを組み合わせたデータ操作は、実務の業務システムで最も頻繁に登場するパターンです。
本回でしっかり習得してください。

本連載は「作って終わり」ではなく、なぜそう設計するのかを重視しています。
設計の意図を理解することで、他のシステムにも応用できる力が身につきます。

本記事で学ぶこと

項目 内容
テーブルのリレーション 外部キーを使った複数テーブルの関連付け
JOIN 複数テーブルを結合してデータを取得する
出席率の自動計算 入力データから割合を自動算出する
集計クエリ AVG・COUNT・ROUND を使ったSQL集計
受講者登録 多対多の関係を中間テーブルで管理する

リレーションとは

リレーションとは「テーブル間の関係」のことです。
研修管理では以下の関係が存在します。

┌──────────┐       ┌─────────────────────┐       ┌──────────┐
│ employee │ 1   * │  training_history   │ *   1 │ training │
│──────────│───────│─────────────────────│───────│──────────│
│ id       │       │ employee_id (FK)    │       │ id       │
│ name     │       │ training_id (FK)    │       │ name     │
│ dept     │       │ attendance_rate     │       │ start_dt │
└──────────┘       │ understanding_level │       │ end_dt   │
                   │ report_score        │       └──────────┘
                   └─────────────────────┘

関係の読み方

関係 意味
1人の社員が複数の研修を受講できる employee 1 → * training_history
1つの研修に複数の社員が参加できる training 1 → * training_history
training_history が中間テーブル 多対多の関係を実現する

なぜ中間テーブルが必要か

NG:employee に training_name カラムを直接持つ
  → 1人が複数の研修を受けると行が増える
  → 社員情報が重複する
  → 正規化に反する

OK:training_history で関係を管理する
  → employee_id と training_id の組み合わせで「誰がどの研修を受けたか」を表現
  → 出席率・理解度も受講ごとに管理できる

実装:training_service.py

研修管理のビジネスロジックを実装します。

# services/training_service.py
from database import get_db


# ============================================================
# 研修マスタ CRUD
# ============================================================

def get_all_trainings() -> list[dict]:
    """研修一覧を取得する(新しい順)"""
    conn = get_db()
    rows = conn.execute(
        "SELECT * FROM training ORDER BY start_date DESC"
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]


def get_training_by_id(training_id: int) -> dict | None:
    """IDで研修を1件取得する"""
    conn = get_db()
    row = conn.execute(
        "SELECT * FROM training WHERE id = ?", (training_id,)
    ).fetchone()
    conn.close()
    return dict(row) if row else None


def create_training(data: dict) -> tuple[int, list[str]]:
    """
    研修を新規登録する
    Returns: (登録した研修のid, エラーメッセージのリスト)
    """
    errors = validate_training(data)
    if errors:
        return -1, errors

    conn = get_db()
    cur = conn.execute(
        """
        INSERT INTO training (training_name, start_date, end_date, description)
        VALUES (?, ?, ?, ?)
        """,
        (
            data.get("training_name", "").strip(),
            data.get("start_date", "").strip() or None,
            data.get("end_date", "").strip()   or None,
            data.get("description", "").strip() or None,
        )
    )
    conn.commit()
    new_id = cur.lastrowid
    conn.close()
    return new_id, []


def validate_training(data: dict) -> list[str]:
    """研修データのバリデーション"""
    errors = []
    training_name = data.get("training_name", "").strip()
    if not training_name:
        errors.append("研修名は必須です")
    return errors
# ============================================================
# 受講者管理(training_history)
# ============================================================

def get_training_histories(training_id: int) -> list[dict]:
    """
    研修に紐づく受講者一覧と成績を取得する
    employee テーブルと JOIN して社員名も含める
    """
    conn = get_db()
    rows = conn.execute(
        """
        SELECT
            th.id,
            th.employee_id,
            th.training_id,
            th.attendance_rate,
            th.understanding_level,
            th.report_score,
            e.employee_no,
            e.name AS employee_name,
            e.department
        FROM training_history th
        JOIN employee e ON th.employee_id = e.id
        WHERE th.training_id = ?
        ORDER BY e.employee_no
        """,
        (training_id,)
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]


def add_trainee(training_id: int, employee_id: int) -> tuple[bool, str]:
    """
    研修に受講者を追加する
    すでに登録済みの場合はエラーメッセージを返す

    Returns:
        (成功=True/失敗=False, メッセージ)
    """
    conn = get_db()
    # 重複チェック
    existing = conn.execute(
        """
        SELECT id FROM training_history
        WHERE training_id = ? AND employee_id = ?
        """,
        (training_id, employee_id)
    ).fetchone()
    if existing:
        conn.close()
        return False, "この社員はすでに受講登録されています"

    conn.execute(
        """
        INSERT INTO training_history (employee_id, training_id)
        VALUES (?, ?)
        """,
        (employee_id, training_id)
    )
    conn.commit()
    conn.close()
    return True, "受講者を追加しました"


def update_training_history(history_id: int, data: dict) -> list[str]:
    """
    受講履歴(出席率・理解度・課題スコア)を更新する

    バリデーション:
        - 出席率:0〜100 の数値
        - 理解度:0〜100 の整数
        - 課題スコア:0〜100 の整数
    """
    errors = validate_history(data)
    if errors:
        return errors

    conn = get_db()
    conn.execute(
        """
        UPDATE training_history
        SET attendance_rate     = ?,
            understanding_level = ?,
            report_score        = ?
        WHERE id = ?
        """,
        (
            float(data.get("attendance_rate", 0)),
            int(data.get("understanding_level", 0)),
            int(data.get("report_score", 0)),
            history_id,
        )
    )
    conn.commit()
    conn.close()
    return []


def validate_history(data: dict) -> list[str]:
    """受講履歴データのバリデーション"""
    errors = []

    # 出席率
    try:
        rate = float(data.get("attendance_rate", 0))
        if rate < 0 or rate > 100:
            errors.append("出席率は 0〜100 の範囲で入力してください")
    except (ValueError, TypeError):
        errors.append("出席率は数値で入力してください")

    # 理解度
    try:
        level = int(data.get("understanding_level", 0))
        if level < 0 or level > 100:
            errors.append("理解度は 0〜100 の範囲で入力してください")
    except (ValueError, TypeError):
        errors.append("理解度は整数で入力してください")

    # 課題スコア
    try:
        score = int(data.get("report_score", 0))
        if score < 0 or score > 100:
            errors.append("課題スコアは 0〜100 の範囲で入力してください")
    except (ValueError, TypeError):
        errors.append("課題スコアは整数で入力してください")

    return errors

出席率の自動計算

出席率は画面から直接入力する方式と、日別出席データから自動計算する方式があります。
本システムでは学習目的で直接入力方式を採用しますが、自動計算のロジックも紹介します。

# 自動計算方式の例(参考)
def calc_attendance_rate(attended_days: int, total_days: int) -> float:
    """
    出席率を計算する

    Args:
        attended_days: 出席日数
        total_days:    研修全日数

    Returns:
        出席率(0.0〜100.0)
        total_days が 0 の場合は 0.0 を返す
    """
    if total_days == 0:
        return 0.0
    return round(attended_days / total_days * 100, 1)


# 使用例
rate = calc_attendance_rate(attended_days=9, total_days=10)
# → 90.0

集計クエリ

研修ごとの平均出席率・平均理解度・平均課題スコアをSQLで集計します。

def get_training_summary(training_id: int) -> dict:
    """
    研修の集計結果を取得する
    - 受講者数
    - 平均出席率
    - 平均理解度
    - 平均課題スコア
    """
    conn = get_db()
    row = conn.execute(
        """
        SELECT
            COUNT(*)                      AS trainee_count,
            ROUND(AVG(attendance_rate), 1)    AS avg_attendance,
            ROUND(AVG(understanding_level), 1) AS avg_understanding,
            ROUND(AVG(report_score), 1)       AS avg_report_score
        FROM training_history
        WHERE training_id = ?
        """,
        (training_id,)
    ).fetchone()
    conn.close()
    return dict(row) if row else {}

SQLの集計関数

関数 意味
COUNT(*) 行数を数える 受講者数
AVG(col) 平均値を計算する 平均出席率
ROUND(val, n) 小数点n桁に丸める 90.333... → 90.3
SUM(col) 合計値を計算する (今回は未使用)

JOINの仕組み

training_history テーブルには employee_id(数値)しか入っていません。
社員名や部署名を一緒に取得するために JOIN を使います。

-- JOIN なし(employee_id しか取れない)
SELECT * FROM training_history WHERE training_id = 1;
-- → employee_id=1, attendance_rate=90.0, ...

-- JOIN あり(社員名・部署も取得できる)
SELECT
    th.attendance_rate,
    th.understanding_level,
    e.name AS employee_name,
    e.department
FROM training_history th
JOIN employee e ON th.employee_id = e.id
WHERE th.training_id = 1;
-- → employee_name="山田太郎", department="人事部", attendance_rate=90.0, ...

JOINの動きを図で理解する

training_history               employee
┌────────────┬────┐        ┌────┬──────────┐
│employee_id │ ...│        │ id │ name     │
├────────────┼────┤        ├────┼──────────┤
│     1      │ ...│──────▶│  1 │ 山田太郎  │
│     2      │ ...│──────▶│  2 │ 鈴木花子  │
│     3      │ ...│──────▶│  3 │ 田中一郎  │
└────────────┴────┘        └────┴──────────┘
                    ON th.employee_id = e.id

JOIN は「共通のキーを使って2つのテーブルを横に結合する」操作です。

実装:app.py(研修管理ルーティング)

# app.py(研修管理部分)
from services.training_service import (
    get_all_trainings, get_training_by_id, create_training,
    get_training_histories, add_trainee,
    update_training_history, get_training_summary
)
from services.employee_service import get_all_employees


@app.route("/trainings")
@login_required
@roles_required("admin", "staff")
def training_list():
    """研修一覧"""
    trainings = get_all_trainings()
    return render_template("training_list.html", trainings=trainings)


@app.route("/trainings/new", methods=["GET", "POST"])
@login_required
@roles_required("admin", "staff")
def training_new():
    """研修登録"""
    if request.method == "POST":
        new_id, errors = create_training(request.form)
        if errors:
            for err in errors:
                flash(err, "error")
            return render_template("training_form.html",
                                   training=request.form)
        flash("研修を登録しました", "success")
        return redirect(url_for("training_list"))
    return render_template("training_form.html", training=None)


@app.route("/trainings/<int:training_id>/history", methods=["GET", "POST"])
@login_required
@roles_required("admin", "staff")
def training_history(training_id):
    """研修履歴(受講者一覧・成績入力)"""
    training = get_training_by_id(training_id)
    if training is None:
        flash("研修が見つかりません", "error")
        return redirect(url_for("training_list"))

    # 受講者追加
    if request.method == "POST" and "add_employee_id" in request.form:
        employee_id = int(request.form["add_employee_id"])
        success, message = add_trainee(training_id, employee_id)
        flash(message, "success" if success else "error")
        return redirect(url_for("training_history",
                                training_id=training_id))

    # 成績更新
    if request.method == "POST" and "history_id" in request.form:
        history_id = int(request.form["history_id"])
        errors = update_training_history(history_id, request.form)
        if errors:
            for err in errors:
                flash(err, "error")
        else:
            flash("成績を更新しました", "success")
        return redirect(url_for("training_history",
                                training_id=training_id))

    # 一覧データ取得
    histories = get_training_histories(training_id)
    summary = get_training_summary(training_id)
    employees = get_all_employees()  # 受講者追加用の社員リスト

    return render_template(
        "training_history.html",
        training=training,
        histories=histories,
        summary=summary,
        employees=employees
    )

実装:テンプレート(training_history.html)

研修の受講者一覧・成績入力・集計結果を1画面で表示します。

{% extends "base.html" %}
{% block title %}研修履歴 - {{ training.training_name }}{% endblock %}

{% block content %}
<div class="page-header">
  <h1>📚 {{ training.training_name }}</h1>
  <a href="{{ url_for('training_list') }}" class="btn btn-secondary">← 研修一覧</a>
</div>

<!-- 研修情報 -->
<div class="info-card">
  <p>期間:{{ training.start_date or "未定" }} 〜 {{ training.end_date or "未定" }}</p>
  <p>{{ training.description or "" }}</p>
</div>

<!-- 集計結果 -->
{% if summary and summary.trainee_count > 0 %}
<div class="summary-card">
  <h3>📊 集計結果</h3>
  <table class="table table-compact">
    <tr>
      <th>受講者数</th>
      <th>平均出席率</th>
      <th>平均理解度</th>
      <th>平均課題スコア</th>
    </tr>
    <tr>
      <td>{{ summary.trainee_count }} 名</td>
      <td>{{ summary.avg_attendance }}%</td>
      <td>{{ summary.avg_understanding }}点</td>
      <td>{{ summary.avg_report_score }}点</td>
    </tr>
  </table>
</div>
{% endif %}

<!-- 受講者追加フォーム -->
<div class="form-card">
  <h3>受講者を追加</h3>
  <form method="POST" class="inline-form">
    <select name="add_employee_id" required>
      <option value="">-- 社員を選択 --</option>
      {% for emp in employees %}
        <option value="{{ emp.id }}">
          {{ emp.employee_no }} - {{ emp.name }}({{ emp.department or "―" }})
        </option>
      {% endfor %}
    </select>
    <button type="submit" class="btn btn-primary">追加</button>
  </form>
</div>

<!-- 受講者一覧・成績入力 -->
<h3>受講者一覧</h3>
<table class="table">
  <thead>
    <tr>
      <th>社員番号</th>
      <th>氏名</th>
      <th>部署</th>
      <th>出席率(%)</th>
      <th>理解度</th>
      <th>課題スコア</th>
      <th>操作</th>
    </tr>
  </thead>
  <tbody>
    {% if histories %}
      {% for h in histories %}
      <tr>
        <form method="POST">
          <input type="hidden" name="history_id" value="{{ h.id }}">
          <td>{{ h.employee_no }}</td>
          <td>{{ h.employee_name }}</td>
          <td>{{ h.department or "―" }}</td>
          <td>
            <input type="number" name="attendance_rate"
                   value="{{ h.attendance_rate }}"
                   min="0" max="100" step="0.1" style="width:80px">
          </td>
          <td>
            <input type="number" name="understanding_level"
                   value="{{ h.understanding_level }}"
                   min="0" max="100" style="width:80px">
          </td>
          <td>
            <input type="number" name="report_score"
                   value="{{ h.report_score }}"
                   min="0" max="100" style="width:80px">
          </td>
          <td>
            <button type="submit" class="btn btn-small btn-primary">保存</button>
          </td>
        </form>
      </tr>
      {% endfor %}
    {% else %}
      <tr>
        <td colspan="7" class="text-center text-muted">
          受講者が登録されていません。上のフォームから追加してください
        </td>
      </tr>
    {% endif %}
  </tbody>
</table>
{% endblock %}

実装:テンプレート(training_list.html)

{% extends "base.html" %}
{% block title %}研修一覧 - AI人事・研修システム{% endblock %}

{% block content %}
<div class="page-header">
  <h1>📚 研修管理</h1>
  <a href="{{ url_for('training_new') }}" class="btn btn-primary">+ 研修登録</a>
</div>

<table class="table">
  <thead>
    <tr>
      <th>研修名</th>
      <th>開始日</th>
      <th>終了日</th>
      <th>概要</th>
      <th>操作</th>
    </tr>
  </thead>
  <tbody>
    {% if trainings %}
      {% for tr in trainings %}
      <tr>
        <td>{{ tr.training_name }}</td>
        <td>{{ tr.start_date or "未定" }}</td>
        <td>{{ tr.end_date or "未定" }}</td>
        <td>{{ tr.description or "―" }}</td>
        <td>
          <a href="{{ url_for('training_history', training_id=tr.id) }}"
             class="btn btn-small btn-secondary">受講者・成績</a>
        </td>
      </tr>
      {% endfor %}
    {% else %}
      <tr>
        <td colspan="5" class="text-center text-muted">
          研修データがありません。「研修登録」から追加してください
        </td>
      </tr>
    {% endif %}
  </tbody>
</table>
{% endblock %}

実装:テンプレート(training_form.html)

{% extends "base.html" %}
{% block title %}研修登録 - AI人事・研修システム{% endblock %}

{% block content %}
<div class="page-header">
  <h1>📚 研修登録</h1>
</div>

<form method="POST" class="form-card">
  <div class="form-group">
    <label for="training_name">研修名 <span class="required">*</span></label>
    <input type="text" id="training_name" name="training_name"
           value="{{ training.training_name if training else '' }}"
           placeholder="例: Python基礎研修" required>
  </div>
  <div class="form-group">
    <label for="start_date">開始日</label>
    <input type="date" id="start_date" name="start_date"
           value="{{ training.start_date if training else '' }}">
  </div>
  <div class="form-group">
    <label for="end_date">終了日</label>
    <input type="date" id="end_date" name="end_date"
           value="{{ training.end_date if training else '' }}">
  </div>
  <div class="form-group">
    <label for="description">研修概要</label>
    <textarea id="description" name="description" rows="3"
              placeholder="研修の目的や内容を入力">{{ training.description if training else '' }}</textarea>
  </div>
  <div class="form-actions">
    <button type="submit" class="btn btn-primary">登録する</button>
    <a href="{{ url_for('training_list') }}" class="btn btn-secondary">キャンセル</a>
  </div>
</form>
{% endblock %}

社員管理CRUDとの比較

第5回と第6回の実装パターンを比較して、共通点と違いを整理します。

観点 社員管理(第5回) 研修管理(第6回)
テーブル数 1テーブル(employee) 3テーブル(training, employee, training_history)
主なSQL SELECT / INSERT / UPDATE SELECT + JOIN / INSERT / UPDATE
データ取得 単一テーブルから取得 JOIN で複数テーブルを結合して取得
バリデーション 必須チェック・重複チェック・形式チェック 数値範囲チェック(0〜100)を追加
表示 静的な一覧表示 集計結果(AVG / COUNT)を表示
フォーム 登録・編集が別アクション 一覧画面内にインライン編集フォーム

共通している設計パターン

1. 3層構造を維持する
   app.py → service → database

2. バリデーションはサービス層で行う
   フォームデータをそのまま INSERT しない

3. プレースホルダ(?)を徹底する
   SQLインジェクション対策

4. flash メッセージで操作結果を通知する
   成功 / エラーをユーザーにフィードバック

動作確認

手順

# Flaskアプリを起動
python app.py

# ブラウザで http://localhost:5000/trainings にアクセス

確認ポイント

操作 期待する動作
研修一覧を開く 登録済みの研修が一覧表示される
研修を新規登録する 研修名・期間を入力して登録。一覧に追加される
研修名を空にして送信 「研修名は必須です」エラーが表示される
「受講者・成績」を開く 研修の詳細画面が表示される
受講者をプルダウンから選択して追加 受講者一覧に追加される
同じ社員を2回追加する 「すでに受講登録されています」エラーが表示される
出席率に「105」を入力して保存 「0〜100 の範囲で」エラーが表示される
正しいスコアを入力して保存 「成績を更新しました」と表示。集計結果が更新される
集計結果を確認する 受講者数・平均出席率・平均理解度・平均課題スコアが表示される

今後の連載予定

タイトル 主な内容
第1回 業務システム全体設計編 システム概要・技術選定・アーキテクチャ
第2回 要求定義・要件定義編 業務分析・機能要件・非機能要件
第3回 ER図・画面設計編 テーブル設計・画面遷移図・ワイヤーフレーム
第4回 Flaskログイン機能編 セッション・パスワードハッシュ・認証ミドルウェア
第5回 社員管理CRUD編 一覧・登録・更新・削除・バリデーション
第6回 研修管理編(本記事) リレーション・集計・出席率自動計算
第7回 Excel業務自動化編 pandas取込・openpyxl出力・テンプレート活用
第8回 Dify API連携編 プロンプト設計・API呼び出し・エラーハンドリング
第9回 RAG構築編 Knowledgeへの登録・Embedding・検索精度改善
第10回 FAQチャットボット編 チャットUI・会話履歴・ストリーミングレスポンス
第11回 GitHubチーム開発編 ブランチ戦略・Pull Request・コードレビュー
第12回 テスト・振り返り編 pytest・テスト設計・デモ・振り返り

おわりに

第6回では、複数テーブルのリレーションと集計を使った研修管理を実装しました。

ポイントを振り返ります。

  • リレーションtraining_history を中間テーブルとして、社員と研修の多対多の関係を実現した
  • JOINemployee テーブルと結合して、社員名・部署を含む受講者一覧を取得した
  • 集計クエリAVG / COUNT / ROUND で平均出席率・平均理解度を計算した
  • バリデーション:数値範囲チェック(0〜100)を追加し、不正なスコア入力を防いだ
  • パターンの共通化:3層構造・flash通知・プレースホルダは社員管理と同じ設計パターンを踏襲した

次回は「Excel業務自動化編」として、pandas でのExcelインポートと openpyxl での帳票出力を実装します。
研修管理で登録したデータを Excel から一括取り込みする機能を追加します。

次回 : Python × Dify × RAGで学ぶ業務システム開発入門【第7回 Excel業務自動化編】

参考リンク

0
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
0
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?