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回では、研修管理を実装します。

本連載で使用するソースコードは、以下のGitHubリポジトリで公開しています。

aiota-jp/human-resource-ai

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

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

研修管理(第6回)
  → 複数テーブルのリレーション(training × employee × training_history)
  → 出席率・理解度・課題スコアの一括入力
  → LEFT JOIN とUPSERTを使った履歴の取得・保存

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

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

本記事で学ぶこと

項目 内容
テーブルのリレーション 外部キーを使った複数テーブルの関連付け
JOIN 複数テーブルを結合してデータを取得する
研修履歴の入力 出席率・理解度・課題スコアを社員ごとに入力する
LEFT JOIN 履歴が未登録の社員も含めて一覧表示する
UPSERT 未登録なら追加、登録済みなら更新する

リレーションとは

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

┌──────────┐       ┌─────────────────────┐       ┌──────────┐
│ 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_enrollmentテーブルもありますが、現在の研修履歴画面では使用していません。画面には有効な社員をすべて表示し、保存時には表示中の全社員についてtraining_historyを追加または更新します。未入力欄は0として保存されます。

実装:training_service.py

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

# services/training_service.py
from database import get_db

def get_all_trainings() -> list:
    conn = get_db()
    rows = conn.execute(
        "SELECT * FROM training ORDER BY start_date DESC, id DESC"
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]


def get_training_by_id(training_id: int) -> dict | None:
    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) -> int:
    conn = get_db()
    cur = conn.execute(
        """
        INSERT INTO training (training_name, start_date, end_date, description)
        VALUES (?, ?, ?, ?)
        """,
        (
            data.get("training_name"),
            data.get("start_date"),
            data.get("end_date"),
            data.get("description"),
        ),
    )
    conn.commit()
    training_id = cur.lastrowid
    conn.close()
    return training_id


def get_training_histories(training_id: int) -> list:
    conn = get_db()
    rows = conn.execute(
        """
        SELECT
            e.id AS employee_id,
            e.employee_no,
            e.name,
            e.department,
            th.id AS history_id,
            COALESCE(th.attendance_rate, 0) AS attendance_rate,
            COALESCE(th.understanding_level, 0) AS understanding_level,
            COALESCE(th.report_score, 0) AS report_score
        FROM employee e
        LEFT JOIN training_history th
               ON th.employee_id = e.id AND th.training_id = ?
        WHERE e.is_active = 1
        ORDER BY e.employee_no
        """,
        (training_id,),
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]


def upsert_training_history(employee_id: int, training_id: int, data: dict) -> bool:
    conn = get_db()
    conn.execute(
        """
        INSERT INTO training_history
            (employee_id, training_id, attendance_rate, understanding_level, report_score)
        VALUES (?, ?, ?, ?, ?)
        ON CONFLICT(employee_id, training_id) DO UPDATE SET
            attendance_rate = excluded.attendance_rate,
            understanding_level = excluded.understanding_level,
            report_score = excluded.report_score
        """,
        (
            employee_id,
            training_id,
            float(data.get("attendance_rate") or 0),
            int(data.get("understanding_level") or 0),
            int(data.get("report_score") or 0),
        ),
    )
    conn.commit()
    conn.close()
    return True


def calc_attendance_rate(employee_id: int, training_id: int) -> float:
    conn = get_db()
    row = conn.execute(
        "SELECT attendance_rate FROM training_history WHERE employee_id = ? AND training_id = ?",
        (employee_id, training_id),
    ).fetchone()
    conn.close()
    return float(row["attendance_rate"]) if row else 0.0

出席率の扱い

現在の画面では、出席率を0〜100の数値として直接入力します。calc_attendance_rate()という関数名ですが、日別の出席日数から割合を計算しているわけではなく、保存済みのattendance_rateを取得して返す処理です。

日別の出欠情報を保存するテーブルや、出席日数から出席率を自動計算する処理は、現在のソースにはありません。自動計算を実装する場合は、開催日・出欠記録を保存するテーブルを追加する必要があります。

UPSERTの仕組み

training_historyにはemployee_idとtraining_idの組み合わせにUNIQUE制約があります。ON CONFLICTを利用すると、同じ組み合わせが未登録ならINSERT、登録済みならUPDATEできます。

状態 処理
履歴が未登録 training_historyへ新規追加
履歴が登録済み 出席率・理解度・課題スコアを更新

LEFT JOINの仕組み

研修履歴画面では、履歴がまだない社員も入力対象として表示する必要があります。そのため、employeeを基準にtraining_historyをLEFT JOINします。

SELECT
    e.id AS employee_id,
    e.employee_no,
    e.name,
    e.department,
    COALESCE(th.attendance_rate, 0) AS attendance_rate
FROM employee e
LEFT JOIN training_history th
       ON th.employee_id = e.id AND th.training_id = 1
WHERE e.is_active = 1
ORDER BY e.employee_no;

JOINの動きを図で理解する

employee                       training_history
┌────┬──────────┐          ┌────────────┬─────────────┐
│ id │ name     │          │employee_id │training_id  │
├────┼──────────┤          ├────────────┼─────────────┤
│  1 │ 山田太郎 │─────────▶│     1      │      1      │
│  2 │ 鈴木花子 │─────────▶│  履歴なし   │             │
└────┴──────────┘          └────────────┴─────────────┘

通常のINNER JOINでは履歴がない社員は結果に含まれません。LEFT JOINを使うことで、有効な社員を全員表示し、未登録の値はCOALESCE(..., 0)で0として表示できます。

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

# app.py(研修管理部分)
from services.training_service import (
    get_all_trainings, get_training_by_id, create_training,
    get_training_histories, upsert_training_history
)


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


@app.route("/trainings/new", methods=["GET", "POST"])
@login_required
@roles_required("staff", "admin")
def training_new():
    if request.method == "POST":
        create_training(request.form.to_dict())
        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("staff", "admin")
def training_history(training_id):
    training = get_training_by_id(training_id)
    if not training:
        flash("研修が見つかりません", "warning")
        return redirect(url_for("training_list"))
    if request.method == "POST":
        for emp_id in request.form.getlist("employee_id"):
            upsert_training_history(int(emp_id), training_id, {
                "attendance_rate": request.form.get(f"attendance_rate_{emp_id}"),
                "understanding_level": request.form.get(f"understanding_level_{emp_id}"),
                "report_score": request.form.get(f"report_score_{emp_id}"),
            })
        flash("研修履歴を保存しました", "success")
        return redirect(url_for("training_history", training_id=training_id))
    return render_template(
        "training_history.html",
        training=training,
        histories=get_training_histories(training_id),
    )

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

有効な社員を一覧表示し、研修履歴を1画面でまとめて保存します。

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

{% block content %}
<div class="page-header">
  <div>
    <h1>📚 研修履歴</h1>
    <p class="muted">{{ training.training_name }} / {{ training.start_date }} 〜 {{ training.end_date }}</p>
  </div>
  <a href="{{ url_for('training_list') }}" class="btn btn-secondary">研修一覧へ</a>
</div>

<form method="POST" class="card">
  <table class="table">
    <thead><tr><th>社員番号</th><th>氏名</th><th>部署</th><th>出席率</th><th>理解度</th><th>課題</th></tr></thead>
    <tbody>
      {% for h in histories %}
      <tr>
        <td>{{ h.employee_no }}<input type="hidden" name="employee_id" value="{{ h.employee_id }}"></td>
        <td>{{ h.name }}</td><td>{{ h.department }}</td>
        <td><input type="number" name="attendance_rate_{{ h.employee_id }}" min="0" max="100" step="0.1" value="{{ h.attendance_rate }}"></td>
        <td><input type="number" name="understanding_level_{{ h.employee_id }}" min="0" max="100" value="{{ h.understanding_level }}"></td>
        <td><input type="number" name="report_score_{{ h.employee_id }}" min="0" max="100" value="{{ h.report_score }}"></td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
  <div class="form-actions"><button class="btn btn-primary" type="submit">保存する</button></div>
</form>
{% 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>
    </tr>
  </thead>
  <tbody>
    {% if trainings %}
      {% for tr in trainings %}
      <tr>
        <td>{{ tr.training_name }}</td>
        <td>{{ tr.start_date }}</td>
        <td>{{ tr.end_date }}</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="4" class="text-center">
          研修データがありません。
        </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基礎研修 2024" 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"
              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 + LEFT JOIN / UPSERT
データ取得 単一テーブルから取得 LEFT JOINで社員と履歴を結合して取得
入力制御 HTML必須属性・DB制約 数値入力のmin・max属性
表示 社員一覧 有効な全社員と履歴値を表示
フォーム 登録・編集画面 1つのフォームで全社員分を一括保存

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

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

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

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

現在のサービス層には、0〜100の範囲をサーバー側で検証する処理や、数値変換エラーを利用者向けメッセージへ変換する処理はありません。HTMLのmin・max属性はブラウザ側の入力補助なので、実務向けにはサーバー側の検証も追加してください。

動作確認

手順

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

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

確認ポイント

操作 期待する動作
研修一覧を開く 登録済みの研修が一覧表示される
研修を新規登録する 研修名・期間を入力して登録。一覧に追加される
研修名を空にして送信 ブラウザの必須入力チェックで送信されない
「受講者・履歴」を開く 有効な社員が全員表示される
未登録の社員の値を保存 training_historyへ新規登録される
登録済みの社員の値を変更して保存 同じ履歴レコードが更新される
出席率・理解度・課題を入力 0〜100の範囲をブラウザ側で入力できる
保存ボタンを押す 「研修履歴を保存しました」と表示される

研修管理
image.png

おわりに

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

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

  • リレーション:training_historyに社員と研修の組み合わせごとの実績を保存した
  • LEFT JOIN:履歴が未登録の社員を含む、有効な社員全員を取得した
  • UPSERT:未登録なら追加、登録済みなら同じレコードを更新した
  • 一括入力:出席率・理解度・課題スコアを社員ごとに入力し、まとめて保存した
  • パターンの共通化:3層構造・flash通知・プレースホルダは社員管理と同じ設計パターンを踏襲した

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

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

本連載について

本連載の各記事で実装していく内容は以下のとおりです。

回 タイトル 対応画面 主な内容
第1回 業務システム全体設計編 ― システム概要・技術選定・アーキテクチャ
第2回 要求定義・要件定義編 ― 業務分析・機能要件・非機能要件
第3回 ER図・画面設計編 ― テーブル設計・画面遷移図・ワイヤーフレーム
第4回 Flaskログイン機能編 🔐 ログイン セッション・ハッシュ・ロール認可・社員番号ログイン
第5回 社員管理CRUD編 👤 社員管理 一覧・検索・登録・更新・論理削除・入力制御
第6回 研修管理編 📚 研修管理 研修登録・LEFT JOIN・履歴一括入力・UPSERT
第7回 Excel業務自動化編 📥 取込 / 📤 出力 pandas取込・openpyxl出力
第8回 Dify API連携編 📊 評価管理 API・フォールバック・生成中UI・二重送信防止
第9回 RAG構築編 🔍 文書検索 Dify Chat API・質問送信・回答表示・エラー処理
第10回 FAQチャットボット編 💬 FAQチャット 会話ID・Fetch API・生成中表示・二重送信防止
公開編 Ubuntu VPS・Docker公開編 🌐 サービス公開 Ubuntu VPS・Docker・Nginxを使った本番環境構築とWebアプリケーション公開

参考リンク

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?