はじめに
前回は、社員管理のCRUD(一覧・検索・登録・更新・論理削除)を実装しました。
前回の記事:Python × Dify × RAGで学ぶ業務システム開発入門【第5回 社員管理CRUD編】
第6回では、研修管理を実装します。
本連載で使用するソースコードは、以下のGitHubリポジトリで公開しています。
社員管理との大きな違いは次の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の範囲をブラウザ側で入力できる |
| 保存ボタンを押す | 「研修履歴を保存しました」と表示される |
おわりに
第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アプリケーション公開 |
