Files
em-spring/研修生用実装手順書.md
2026-10-14 07:57:34 +09:00

1424 lines
72 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 部署管理機能 実装手順書(研修生用)
この手順書は、`README.md` の課題「部署テーブルの CRUD 機能追加」を、**未経験でも一歩ずつ進められる**ようにした道しるべです。
- コードは **「写して終わり」にならないよう、考えてほしい部分を `/* ★考える */` で空けてあります**。空欄は、既存の社員機能(お手本)を読めば埋められます。
- 各ステップの最後に **「できたかの確認」** があります。確認できるまで次へ進まないでください。
- 詰まったら、先に「**詰まったとき**」(第10章) を読み、それでも解決しない場合は **30分** 悩んだ時点で相談してください。相談するときは「やりたいこと/期待した結果/実際の結果(エラー全文)/試したこと/自分の考え」をそろえてください。
---
## 目次
0. 全体像(何を作るのか・進め方)
1. 準備(環境・DB・起動確認)
2. 実装前に読むもの(読解ワーク)
3. 作業の基本ルール(ファイルの置き場所・名前)
4. 第1段階:検索
5. 第2段階:削除
6. 第3段階:更新(ここまでが提出可能ライン)
7. 第4段階:新規登録(チャレンジ)
8. 第5段階:アクセス制御アノテーション(チャレンジその2)
9. テストの進め方(テスト仕様書の使い方)
10. 詰まったとき(エラー別の調べ方)
11. 提出前チェックリスト
---
## 0. 全体像
### 0.1 作るもの
管理者が「部署」を **検索・削除・更新・新規登録** できる画面です。すでにある「社員」の機能と同じ作りで作ります。
| 段階 | 内容 | 到達目標 |
|---|---|---|
| 1 | 検索 | メニューから部署検索画面を開き、部署ID(完全一致)・部署名(部分一致)で検索できる |
| 2 | 削除 | 検索結果の各行から削除できる |
| 3 | 更新 | 検索結果の各行から部署名を更新できる(**提出可能ライン**) |
| 4 | 新規登録 | 入力 → 確認 → 登録 → 完了 の3画面で登録できる |
| 5 | アクセス制御 | 未ログイン・一般ユーザーは部署画面に入れない |
### 0.2 プログラムの構造(これだけは覚える)
画面でボタンを押してから、DBに届くまでの流れです。
```
[ブラウザ] ボタン押下
│ ① POST で送信(例:/selectDepartment)
▼
[Controller] presentation/controller/…Controller.java
│ 受け取る・どの画面に行くか決める・メッセージを用意する
│ ② Form ⇄ Model に詰め替え
▼
[Service] domain/service/…(インタフェース) と impl/…Impl.java(実装)
│ 業務の処理(検索する・削除する、など)
▼
[Repository] infra/repository/…Repository.java ←すでに用意済み
▼
[MySQL] department テーブル
▲
└ 結果は逆向きに戻り、Controller が [HTML(Thymeleaf)] に渡して画面になる
```
| 部品 | 役割 | 部署で使うもの |
|---|---|---|
| Form | 画面とControllerの間で値を運ぶ箱 | `DepartmentForm`(既存)、他は自作 |
| Model | 業務で使うデータ | `Department`、`SearchConditionDepartment`(既存) |
| Entity | DBの1行(infra層専用) | `DepartmentEntity`(既存) |
### 0.3 すでにあるもの・作るもの
| すでにある(触らない) | 自分で作る |
|---|---|
| `DepartmentEntity`、`DepartmentRepository` | Form 3つ |
| `Department`、`SearchConditionDepartment` | Service:削除・更新(IF+実装)、登録(**実装のみ**) |
| `DepartmentForm` | Controller 7つ |
| `SearchDepartment`(検索サービス) | HTML 5つ |
| `RegisterDepartment`(**インタフェースだけ**) | `menu.html` にリンク、定数の追記 |
| アノテーション `@SessionCheck` `@PermissionCheck` | |
### 0.4 進め方のコツ
1. **お手本(社員機能)を必ず動かして、コードを読んでから**書く。
2. 一度に全部書かない。**1ファイルできたらビルド・起動して確認**する。
3. エラーは**一番下の `Caused by` から**読む(第10章)。
4. 変更は**1つずつ**。直らなかったら元に戻して別の仮説を試す。
5. 第1〜4段階では `@PermissionCheck` / `@SessionCheck` は**付けない**(第5段階で付けます)。
---
## 1. 準備
### 1.1 必要なもの
| 項目 | 内容 | 確認方法 |
|---|---|---|
| JDK **17** | **17以外は不可**(JDK 25 では Lombok が動かずビルドエラーになります) | コマンドで `java -version` |
| MySQL 8 | 起動していること | 接続できる |
| Eclipse | README の手順でプロジェクトをインポート済み | 赤い×が出ていない |
### 1.2 DBを初期状態にする(README「課題に取りかかる前に」)
1. 未作成なら MySQL で DB とユーザーを作る。`設計書/start-db-ddl-dml.sql` の先頭に **コメントアウトされた** 次の3行があります。必要ならコメントを外して実行します。
```sql
CREATE DATABASE rezodb;
CREATE USER 'rezouser'@'localhost' IDENTIFIED BY 'Rezo_0000';
GRANT ALL ON rezodb.* TO 'rezouser'@'localhost';
```
2. A5:SQL Mk-2 等で `設計書/start-db-ddl-dml.sql` を**全部**実行する。
- このファイルの文字コードは **Shift-JIS** です。UTF-8のエディタで開くと文字化けします(A5M2 から実行してください)。
3. 確認:`department` に6件、`employee` に10件。
| id_department | nm_department |
|---|---|
| 10 | 役員 |
| 11 | 人事・総務部 |
| 12 | 経理部 |
| 13 | システム開発事業部 |
| 14 | 教育事業部 |
| 15 | 営業企画部 |
> テーブルが無いまま起動すると、ログインの時点で `Table 'rezodb.employee' doesn't exist` のエラーになります。
### 1.3 ビルドと起動
README のとおり、Eclipse で プロジェクトを右クリック → 実行 → **maven clean** → **maven install**。またはプロジェクト直下の `build.cmd`。起動は `run.cmd`(または Eclipse からアプリを実行)。
ブラウザで `http://localhost:8080/` を開き、ログインできることを確認します。
| 種類 | メールアドレス | パスワード | ログイン後 |
|---|---|---|---|
| 管理者 | `yamada@hoge.jp` | `aaa` | メニュー画面 |
| 一般 | `jtaka@hoge.jp` | `bbb` | 個人情報更新画面 |
### 1.4 できたかの確認
- [ ] 管理者でログインし、メニューが表示された
- [ ] 「社員検索」「社員登録」を一通り操作できた(**お手本の動きを体験**する)
---
## 2. 実装前に読むもの(読解ワーク)
**書き始める前に**必ず次を読み、下の「課題理解シート」を埋めてください(README にも「何を作るか・どこまで作るか理解してから作業を始める」とあります)。
1. `README.md`、`ヒント.md`
2. `設計書/【管理者】部署情報検索更新削除機能-詳細設計書.xlsx`
3. `設計書/【管理者】部署情報登録機能-詳細設計書.xlsx`
4. `設計書/【管理者】メニュー機能-詳細設計書.xlsx`
5. `テスト仕様書/単体テスト仕様書.xlsx` の「部署管理テスト仕様書」シート
6. 既存の社員機能のコード(第3章の表の「お手本」)
### 課題理解シート(ノート等に書く)
```
1. 作るもの(1行):
2. 画面の一覧(画面名/何をする画面か):
3. 画面ごとのボタンと、押したときの動き:
4. 画面に出すメッセージ(文言/どんなとき/どう出す):
5. 使うテーブルと項目(型・桁数):
6. 作るファイルの一覧と、それぞれのお手本にする既存ファイル:
7. 分からないこと:
```
### 設計書を読むときのポイント
- 「画面項目定義」=画面にある部品の一覧。「イベント処理」=ボタンを押したときの処理手順。
- **メッセージの文言は一字一句そのまま**使う(テスト仕様書で確認されます)。
- 設計書には、社員機能からのコピー由来と思われる**誤記**がいくつかあります(例:部署の設計書に「社員登録画面」「社員マスタに登録」など)。**おかしいと思った所はメモして質問**してください。
- **テーブル定義書と実際のDDLで、部署名の桁数が食い違っています**(定義書は50、DDLは `VARCHAR(10)`)。第7章で実際に確認します。気づいたらメモしておいてください。
### メッセージ一覧(設計書・テスト仕様書より)
| 場面 | 文言 | 出し方 |
|---|---|---|
| 検索結果0件 | `検索結果はありません。` | 画面内の文字 |
| 削除ボタン | `本当に削除しますか?` | 確認ダイアログ |
| 削除:対象IDが不明 | `削除対象のIDが不明です。` | エラー表示 |
| 削除:社員が所属している | `1名以上が所属している部署は削除出来ません。` | エラー表示 |
| 削除成功 | `削除しました。` | アラート |
| 更新ボタン | `本当によろしいですか?` | 確認ダイアログ |
| 更新:対象がない | `更新対象のデータが見つかりません。` | エラー表示 |
| 更新:同名あり/変更なし | `既に同名の部署が存在するか、値が変更されていません。` | エラー表示 |
| 更新:空欄 | `項目が空欄です。` | エラー表示 |
| 更新成功 | `更新が完了しました。` | アラート |
| 登録:部署名が不正 | `部署名が不正です。` | エラー表示 |
| 登録:同名あり | `既に同名の部署が存在します。` | エラー表示 |
| 登録成功 | `部署登録が完了しました` | 完了画面の見出し |
---
## 3. 作業の基本ルール
### 3.1 ファイルの置き場所
ベースは `src/main/java/com/example/rhizome/` です(以下 `…/` と略します)。
| 種類 | 置き場所 | 作るファイル |
|---|---|---|
| Form | `…/app/presentation/form/` | `SearchConditionDepartmentForm`、`UpdateDepartmentForm`、`InsertDepartmentForm` |
| Controller | `…/app/presentation/controller/` | `SelectDepartmentController`、`DeleteDepartmentController`、`UpdateDepartmentController`、`UpdateDepartmentResultController`、`InsertDepartmentInputController`、`InsertDepartmentConfirmController`、`InsertDepartmentController` |
| Service(IF) | `…/app/domain/service/` | `DeleteDepartment`、`UpdateDepartment` |
| Service(実装) | `…/app/domain/service/impl/` | `DeleteDepartmentImpl`、`UpdateDepartmentImpl`、`RegisterDepartmentImpl` |
| 定数 | `…/app/presentation/controller/pageproperty/` | 既存の `TransitionTargetPageNameKeyword`、`PageReturnAttributeKeyword` に**追記** |
| HTML | `src/main/resources/templates/` | `selectDepartment`、`updateDepartmentInput`、`insertDepartmentInput`、`insertDepartmentConfirm`、`insertDepartmentResult`(拡張子 `.html`)、既存 `menu.html` に追記 |
> **パッケージの注意**:すべて `com.example.rhizome` の配下に置きます。外に置くと Spring が見つけられず、起動時に `NoSuchBeanDefinitionException` になります。
### 3.2 お手本(社員機能)との対応
| 作るもの | お手本 |
|---|---|
| `SelectDepartmentController` | `SelectEmployeeController` |
| `SearchConditionDepartmentForm` | `SearchConditionEmployeeForm` |
| `selectDepartment.html` | `selectEmployee.html` |
| `DeleteDepartmentController`、`DeleteDepartment(Impl)` | `DeleteEmployeeController`、`DeleteEmployee(Impl)` |
| `UpdateDepartmentController` / `…ResultController` | `UpdateEmployeeController` / `UpdateEmployeeResultController` |
| `UpdateDepartmentForm`、`updateDepartmentInput.html` | `UpdateEmployeeInfoForm`、`updateEmployeeInput.html` |
| `UpdateDepartment(Impl)` | `UpdateEmployee(Impl)` |
| `InsertDepartment…Controller`、`InsertDepartmentForm`、`insertDepartment…html` | `InsertEmployee…Controller`、`InsertEmployeeForm`、`insertEmployee…html` |
| `RegisterDepartmentImpl` | `RegisterEmployeeImpl` |
**お手本は「構造を学ぶ」ために使います。** 名前を置換してコピーするだけでは、部署に不要な処理(部署のプルダウン、パスワード等)が残って動かなかったり、意味が分からないままになります。**各行が何をしているか説明できる状態**で写してください。
### 3.3 コードの書き方の約束(既存に合わせる)
- 変数・フィールド名:部署ID=`idDepartment`、部署名=`nmDepartment`
- クラスは `@Controller` / `@Service` / Lombok の `@Data` 等を、お手本と同じように使う
- 画面名・URL・画面に渡すキーは**文字列を直書きせず定数にする**(`TransitionTargetPageNameKeyword` / `PageReturnAttributeKeyword`)
- Controller の処理は、お手本と同様に `try { … } catch (Exception e) { … }` で囲む
---
## 4. 第1段階:検索
### 4.0 ゴールと完成の定義
メニューの「部署検索」から部署検索画面に行き、**部署ID=完全一致/部署名=部分一致** で検索できる。検索後も入力した条件が画面に残る。0件なら `検索結果はありません。`。
→ **単体テスト #1〜#8** が期待どおりになれば完成。
完成後の流れ:
```
menu.html「部署検索」 ──POST /selectDepartment──▶ SelectDepartmentController
└──▶ selectDepartment.html(検索条件+結果)
```
### ステップ 1-1:お手本を動かして、コードで追う
1. 社員検索を実行する。
2. `SelectEmployeeController` の `select` メソッドの最初の行に**ブレークポイント**を置いて、デバッグ実行し、ステップ実行で追う。
3. 次の質問に自分の言葉で答える(答えられなければコードを読み直す)。
- Form に値が入るのは、どの時点・誰の仕事?
- `select` メソッドが**2つ**あるのはなぜ?(片方は引数に検索条件の Form を持つ、片方は持たない。この違いは第2段階で効いてきます)
- 検索サービスは最終的にどこでSQLを作っている?(`EmployeeInfoRepository` の `Specification`)
**できたかの確認**:ホワイトボードやノートに、§0.2 のような流れ図を自分で描ける。
### ステップ 1-2:定数を追加する
`TransitionTargetPageNameKeyword.java` には、すでに `// Department` と書かれた空の枠があります。ここに追記します。
- **HTML名**:`templates` のファイル名(拡張子なし)。先頭に `/` は付けない。
- **URL**:Controller のURL。**先頭に `/` を付ける**。
```java
// Department
// HTML
public static final String SELECT_DEPARTMENT_HTML = "selectDepartment";
// Controller
public static final String SELECT_DEPARTMENT = "/selectDepartment";
```
> `menu.html` にある既存のJavaScript(`selectDepartmentSubmit()`)は、`selectDepartment` という名前に送信します。上の定数はそれに合わせています。
**できたかの確認**:ビルドが通る(まだ画面は動かなくてよい)。
### ステップ 1-3:検索条件の Form を作る
`…/presentation/form/SearchConditionDepartmentForm.java`
```java
package com.example.rhizome.app.presentation.form;
import com.example.rhizome.app.domain.model.SearchConditionDepartment;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 画面から渡ってきた部署の検索条件をまとめ、再度画面に表示する為のクラス
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SearchConditionDepartmentForm {
/** 部署ID */
private Integer idDepartment;
/** 部署名 */
private String nmDepartment;
/**
* Form から business 層の SearchConditionDepartment に変換する
*/
public static SearchConditionDepartment convertTo(SearchConditionDepartmentForm form) {
return new SearchConditionDepartment(/* ★考える:Form の2つの値を渡す */);
}
}
```
考えるポイント:
- 部署IDが `int` ではなく `Integer` なのはなぜ?(未入力=`null` を表したい)
- `@NoArgsConstructor` が無いと何が困る?(画面の値を自動で詰める時に、空のインスタンスが必要)
**できたかの確認**:ビルドが通る。「Form/Model/Entity の違い」を3行で説明できる。
### ステップ 1-4:検索サービスの使い方を調べる(コードは書かない)
`SearchDepartment`(インタフェース)には3つのメソッドがあります。
| メソッド | 動き |
|---|---|
| `selectAll()` | 全件取得 |
| `searchBy(条件)` | 部署ID=完全一致、部署名=**実質完全一致** |
| `searchByWithLike(条件)` | 部署ID=完全一致、部署名=**部分一致** |
> `searchBy` は内部で `like` を使っていますが、`%` を付けないので**完全一致と同じ**です。メソッド名に惑わされず、`SearchDepartmentImpl` と `DepartmentRepository` を読んで確認してください。
**課題**:今回の仕様「部署名は部分一致」を満たすのはどちらか。実際に両方試して(部署名に `営業` と入れて)確認し、理由をノートに書く。
### ステップ 1-5:検索 Controller を作る
`…/presentation/controller/SelectDepartmentController.java`(お手本:`SelectEmployeeController`)
```java
package com.example.rhizome.app.presentation.controller;
import com.example.rhizome.app.domain.service.SearchDepartment;
import com.example.rhizome.app.presentation.controller.pageproperty.PageReturnAttributeKeyword;
import com.example.rhizome.app.presentation.controller.pageproperty.TransitionTargetPageNameKeyword;
import com.example.rhizome.app.presentation.form.DepartmentForm;
import com.example.rhizome.app.presentation.form.SearchConditionDepartmentForm;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.http.HttpSession;
import java.util.logging.Level;
import java.util.logging.Logger;
@Controller
public class SelectDepartmentController {
private final static Logger LOGGER = Logger.getLogger(SelectDepartmentController.class.getName());
private final static String SEVERE_ERROR_MESSAGE = "エラーが発生しました!";
HttpSession httpSession;
SearchDepartment searchDepartment;
@Autowired
public SelectDepartmentController(HttpSession httpSession, SearchDepartment searchDepartment) {
this.httpSession = httpSession;
this.searchDepartment = searchDepartment;
}
/** 空の検索条件Formを、この Controller の画面用に用意する */
@ModelAttribute
public SearchConditionDepartmentForm searchConditionDepartmentForm() {
return new SearchConditionDepartmentForm();
}
@RequestMapping(value = TransitionTargetPageNameKeyword.SELECT_DEPARTMENT, method = RequestMethod.POST)
public String select(SearchConditionDepartmentForm searchConditionDepartmentForm, Model model) {
try {
// ★考える:①検索サービスを呼ぶ(Form→Model変換 → 部分一致のメソッド)
// ②結果(List<Department>)を DepartmentForm.convertFrom(...) で画面用に変換
// ③ model に PageReturnAttributeKeyword.DEPARTMENT_FORM_LIST という名前で載せる
return TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_HTML;
} catch (Exception e) {
httpSession.invalidate();
e.printStackTrace();
LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e);
return TransitionTargetPageNameKeyword.ERROR_HTML;
}
}
}
```
知っておくこと:
- `@ModelAttribute` のメソッドが返した Form は、`searchConditionDepartmentForm`(クラス名の先頭小文字)という名前で画面に渡ります。これは **この Controller クラスの中だけ**で有効です。
- `select(SearchConditionDepartmentForm form, …)` の引数 Form は、自動的に同じ名前で画面に渡されます。**だから検索後も入力値が画面に残ります**。
- メニューからの遷移は**パラメータ無しのPOST**です。条件が `null` や空だと全件が返ります(`Specification` で条件が外れる)。
**できたかの確認**:ビルドが通る。
### ステップ 1-6:検索画面 HTML を作る
`src/main/resources/templates/selectDepartment.html`(お手本:`selectEmployee.html`)
お手本の次の部分は**そのまま**使えます:`<head>`(CSS・JS読み込み)、見出しとログイン者名・ログアウトボタン、メッセージ表示、「メニューに戻る」リンク。
骨格(`★` の所を、お手本を見て完成させます):
```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
<script th:src="@{/js/common.js}"></script>
<link rel="stylesheet" th:href="@{/css/all.css}">
<link rel=stylesheet th:href="@{/css/style.css}" type="text/css">
<title>部署検索</title>
</head>
<body>
<h1>
部署検索条件入力   
<div class="rightSection">
<span class="loginInfo">ログイン者名:<span th:text="${session.loginUser.nmEmployee}"></span></span>
<button class="logoutButton" onclick="logoutSubmit()">ログアウト</button>
</div>
</h1>
<form action="selectDepartment" method="post" th:object="${searchConditionDepartmentForm}">
<div class="msgBox error" th:if="${messageError} != null">
<span class="error" th:text="${messageError.message}"></span>
</div>
<table>
<tr>
<th class="header">部署ID</th>
<td><input placeholder="部署ID" th:field="*{idDepartment}" type="number"/></td>
</tr>
<tr>
<th class="header">部署名</th>
<td><!-- ★部署名の入力欄(type="text")を作る --></td>
</tr>
<tr>
<td class="btnArea" colspan="2"><input type="submit" value="検索"/></td>
<td class="btnArea" colspan="2"><button onclick="clearInput()">クリア</button></td>
</tr>
</table>
</form>
<a href="showMenu">メニューに戻る</a>
<h1><span>検索結果</span></h1>
<!-- ★検索結果が1件以上:表(部署ID・部署名)を th:each で繰り返す。変数は departmentFormList -->
<!-- ★0件:「検索結果はありません。」を表示 -->
</body>
<script type="text/javascript">
function clearInput() {
document.getElementById('idDepartment').value = '';
// ★部署名の入力欄も空にする(id は th:field が自動で作る)
}
</script>
</html>
```
ヒント:
- `th:if="${departmentFormList != null and departmentFormList.size() > 0}"` で「1件以上」を判定できます(お手本参照)。
- `th:each="departmentForm : ${departmentFormList}"` と `th:text="${departmentForm.idDepartment}"`。
- `th:field="*{idDepartment}"` が何を作るかは、ブラウザで右クリック →「ページのソースを表示」で確認できます(`id` `name` `value` が作られる)。
**考えるポイント(クリアボタン)**:`<button>` は `type` を省略すると**送信ボタン**になります。`onclick` で値を空にした**あとに**フォームが送信されます。つまり「クリア=条件を消して全件検索」になります(テスト #35 は「クリア後に全件表示」を期待)。値を消すだけにしたい場合は `type="button"` を付けます。どちらがテスト仕様に合うか、自分で判断して理由をノートに書いてください。
### ステップ 1-7:メニューにリンクを追加
`menu.html` の `<table class="menuClass">` に、「社員登録」の行の次へ追加します。**JavaScript の関数(`selectDepartmentSubmit()`)は、すでにファイル下部にあります**。
```html
<tr>
<td><a class="menuClass" href="javascript:selectDepartmentSubmit()"><i class="fas fa-search"></i> 部署検索 </a></td>
<td class="description">登録済みの部署一覧表示と</br>更新、削除を行います。</td>
</tr>
```
なぜ `<a href>` で直接飛ばず、JavaScriptでフォームを作って送っているのか考えてください(Controller が POST 専用だから。リンクは GET で送られるので 405 になる)。
**できたかの確認**:再ビルド・再起動 → 管理者ログイン → メニュー →「部署検索」で全件が表示される。
### ステップ 1-8:動作確認(単体テスト #1〜#8)
| # | 操作 | 期待 |
|---|---|---|
| 1 | `yamada@hoge.jp` でログイン | メニューに部署検索が出る |
| 2 | 部署検索を押す | 検索条件と全件の結果 |
| 3 | 右上のログアウト | ログイン画面 |
| 4 | クリア → 部署ID `10` → 検索 | `役員` のみ。入力した `10` が残る |
| 5 | クリア → 部署ID `1000` → 検索 | `検索結果はありません。`+条件が残る |
| 6 | クリア → 部署名 `営業` → 検索 | `営業企画部` |
| 7 | クリア → 部署名 `開発` → 検索 | `システム開発事業部` |
| 8 | 「メニューに戻る」 | メニュー画面 |
※更新・削除ボタンは第2・3段階で追加するので、今は表示されなくて構いません(テスト #2 のボタン確認は後で実施)。
### 4.1 第1段階の振り返り(説明できればOK)
- [ ] 画面→Controller→Service→Repository の流れを説明できる
- [ ] 部分一致/完全一致をどのメソッドで実現し、なぜそれを選んだか説明できる
- [ ] `@ModelAttribute` が何のためにあるか説明できる
---
## 5. 第2段階:削除
### 5.0 ゴールと完成の定義
検索結果の各行の「削除」→ `本当に削除しますか?` → OK で削除。**その部署に所属する社員が1人でもいれば削除できず**エラー。削除後は一覧(全件)に戻る。
→ **単体テスト #32〜#35**。
### ステップ 2-1:処理の手順をフローチャートにする
設計書「イベント処理・部署検索・削除」の⑥を読み、次を埋めてください。
```
削除ボタン押下(部署IDを送信)
├ 部署IDが無い/その部署が存在しない → ???(メッセージ)→ 一覧へ
├ その部署に所属する社員が1人以上 → ???(メッセージ)→ 一覧へ
└ 削除する → ???(メッセージ)→ 一覧へ
```
**考えるポイント**:
- DBに外部キー制約が無い(DDLに `FOREIGN KEY` が無い)ので、所属社員がいても DB は削除を止めてくれません。**アプリ側で確認**する必要があります。
- 「存在しないID」はあり得ます(別の画面で削除済み、ブラウザの戻る→再送信 など)。
### ステップ 2-2:削除サービスを作る
**① インタフェース** `…/domain/service/DeleteDepartment.java`
```java
package com.example.rhizome.app.domain.service;
public interface DeleteDepartment {
void deleteDepartment(Integer idDepartment);
}
```
**② 実装** `…/domain/service/impl/DeleteDepartmentImpl.java`(お手本:`DeleteEmployeeImpl`)
```java
package com.example.rhizome.app.domain.service.impl;
import com.example.rhizome.app.domain.service.DeleteDepartment;
import com.example.rhizome.app.infra.repository.DepartmentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class DeleteDepartmentImpl implements DeleteDepartment {
DepartmentRepository departmentRepository;
@Autowired
public DeleteDepartmentImpl(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
public void deleteDepartment(Integer idDepartment) {
// ★考える:Repository のどのメソッドで「IDを指定して削除」できる?
// (お手本の DeleteEmployeeImpl を見る)
}
}
```
> **注意**:`@Service` を付け忘れると、Controller で使うとき起動時エラー(`No qualifying bean…`)になります。
### ステップ 2-3:削除 Controller を作る
`…/presentation/controller/DeleteDepartmentController.java`(お手本:`DeleteEmployeeController`)
まず、定数を追加します。
```java
// TransitionTargetPageNameKeyword の Department(Controller) の枠に追記
public static final String DELETE_DEPARTMENT = "/deleteDepartment";
```
骨格:
```java
@Controller
public class DeleteDepartmentController {
private final static Logger LOGGER = Logger.getLogger(DeleteDepartmentController.class.getName());
private final static String SEVERE_ERROR_MESSAGE = "エラーが発生しました!";
HttpSession httpSession;
DeleteDepartment deleteDepartment;
SearchDepartment searchDepartment;
SearchEmployee searchEmployee; // 所属社員の確認に使う(既存)
@Autowired
public DeleteDepartmentController(HttpSession httpSession, DeleteDepartment deleteDepartment,
SearchDepartment searchDepartment, SearchEmployee searchEmployee) {
this.httpSession = httpSession;
this.deleteDepartment = deleteDepartment;
this.searchDepartment = searchDepartment;
this.searchEmployee = searchEmployee;
}
@RequestMapping(value = TransitionTargetPageNameKeyword.DELETE_DEPARTMENT, method = RequestMethod.POST)
public String deleteDepartment(@RequestParam(value = "idDepartment", required = false) Integer idDepartment, Model model) {
final String UNKNOWN_ID = "削除対象のIDが不明です。";
final String HAS_EMPLOYEE = "1名以上が所属している部署は削除出来ません。";
final String DELETE_COMPLETE = "削除しました。";
try {
// ★① idDepartment が null、または searchDepartment.searchBy(new SearchConditionDepartment(idDepartment, null)) が空
// → MESSAGE_ERROR に UNKNOWN_ID を載せて一覧へ
// ★② searchEmployee.searchEmployeeByIdDepartment(idDepartment) が空でない
// → MESSAGE_ERROR に HAS_EMPLOYEE を載せて一覧へ
// ★③ deleteDepartment.deleteDepartment(idDepartment) → MESSAGE_INFO に DELETE_COMPLETE を載せて一覧へ
return "一覧へ戻る(ステップ 2-5 で決める)";
} catch (Exception e) {
httpSession.invalidate();
e.printStackTrace();
LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e);
return TransitionTargetPageNameKeyword.ERROR_HTML;
}
}
}
```
メッセージの載せ方(お手本のとおり):
```java
model.addAttribute(PageReturnAttributeKeyword.MESSAGE_ERROR, new MessageForm(UNKNOWN_ID));
```
**確認の順序は「存在 → 所属社員 → 削除」**です(削除してから確認しても意味がありません)。
### ステップ 2-4:「削除後にどの画面へ戻るか」を考える(重要)
**課題**:削除後は検索一覧(全件)を表示したい。でも、`return "selectDepartment";`(画面名で返す)だと画面が壊れます。なぜでしょう?
調べるヒント:
- 検索画面の `th:object="${searchConditionDepartmentForm}"` は、**どの Controller が用意している**?
- `@ModelAttribute` はどのクラスで有効だった?(ステップ 1-5)
→ そこで、**検索 Controller に処理を引き継ぐ `forward:`** を使います(お手本の `DeleteEmployeeController` を参照)。
```java
return "forward:" + TransitionTargetPageNameKeyword.【どの定数?】;
```
**ここに罠があります**:`forward:` は**元のリクエストのパラメータを引き継ぎます**。削除ボタンは `idDepartment=10` を送っているので、これを `/selectDepartment` に forward すると、「部署ID=10 で検索」が実行されてしまいます(実際に確認済みの動作です)。
解決方法は、お手本が `SelectEmployeeController` に**2つ目の `select`(引数に Form を持たない版)**を持っている理由と同じです。部署でも、**パラメータを一切使わず全件を返す、もう1つの入口**を作ります。
1. 定数を追加:
```java
public static final String SELECT_DEPARTMENT_FORM_EMPTY = "/selectDepartmentAll";
```
2. `SelectDepartmentController` に2つ目のメソッドを追加:
```java
@RequestMapping(value = TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_FORM_EMPTY, method = RequestMethod.POST)
public String select(Model model) {
try {
// ★全件取得 → DepartmentForm に変換 → DEPARTMENT_FORM_LIST で model へ
return TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_HTML;
} catch (Exception e) {
httpSession.invalidate();
e.printStackTrace();
LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e);
return TransitionTargetPageNameKeyword.ERROR_HTML;
}
}
```
3. 削除 Controller の戻り先を `forward:/selectDepartmentAll`(定数 `SELECT_DEPARTMENT_FORM_EMPTY` を使う)にする。
**なぜ `forward:` ならメッセージが画面に出るのか**:model に入れた値は request の属性として公開され、forward でも同じリクエストなので残るためです。
### ステップ 2-5:一覧にメッセージ表示と削除ボタンを足す
`selectDepartment.html` に追加します。
**① 情報メッセージ(アラート)**:お手本の `selectEmployee.html` の次の部分を、同じように入れる(`</h1>` の「検索結果」見出しの後あたり)。
```html
<th:block th:if="${messageInfo != null}">
<script th:inline="javascript">
alert([[${messageInfo.message}]]);
</script>
</th:block>
```
**② 結果テーブルに「操作」列と削除ボタン**:
```html
<th style="width: 150px;">操作</th> <!-- thead に追加 -->
<td> <!-- tbody の各行に追加 -->
<form action="deleteDepartment" method="post" onsubmit="return confirm('本当に削除しますか?');"
style="display: inline;">
<input name="idDepartment" th:value="${departmentForm.idDepartment}" type="hidden">
<input type="submit" value="削除">
</form>
</td>
```
考えるポイント:
- なぜ `<a>` ではなく `<form method="post">`?(削除のような「データを変える操作」を、URLを開くだけで実行される GET にしない)
- `hidden` の `name="idDepartment"` と、Controller の `@RequestParam("idDepartment")` は**一致している必要がある**。
### ステップ 2-6:動作確認
| # | 操作 | 期待 |
|---|---|---|
| 32 | 部署名`テスト`で検索 → 対象の削除を押す(※テスト用の部署が無ければ、第4段階の後にやるか、DBに直接 `INSERT INTO department (nm_department) VALUES ('テスト部署2');` を1行追加) | `本当に削除しますか?` |
| 33 | 〃 → キャンセル | 何も起きない |
| 34 | 〃 → OK | 削除され `削除しました。` |
| 35 | クリア(全件)→ `役員` の削除 → OK | `1名以上が所属している部署は削除出来ません。` |
追加で自分で確認(異常系):
- ブラウザの開発者ツール(F12)で hidden の値を `999` に書き換えて削除 →「削除対象のIDが不明です。」
- 削除直後にブラウザの「戻る」→ 再送信 → エラー画面にならず、メッセージが出る
### 5.1 第2段階の振り返り
- [ ] なぜ所属社員の確認をアプリで行う必要があるか説明できる
- [ ] なぜ戻り先を `forward:` の全件用エンドポイントにしたか(パラメータ引き継ぎ)説明できる
- [ ] 削除を POST にする理由を説明できる
---
## 6. 第3段階:更新(提出可能ライン)
### 6.0 ゴールと完成の定義
一覧の「更新」→ 更新画面(部署IDは変更不可)→ 部署名を変更 →「本当によろしいですか?」→ 成功時 `更新が完了しました。` で一覧へ。
→ **単体テスト #9〜#20**。
### ステップ 3-1:画面と処理を分解する
```
[一覧]「更新」押下 ─ POST /showUpdateDepartment(idDepartment)
├ 対象が存在しない → 「更新対象のデータが見つかりません。」→ 一覧
└ 存在する → [更新画面] に現在の値を表示(部署IDは変更不可)
[更新画面]「更新」押下 → 確認ダイアログ → POST /updateDepartmentResult
├ 部署名が空欄 → 「項目が空欄です。」→ 更新画面
├ 同じ名前の部署がある(自分と同じ名前=変更なし も含む)
│ → 「既に同名の部署が存在するか、値が変更されていません。」→ 更新画面
├ 更新対象が存在しない → 「更新対象のデータが見つかりません。」→ 一覧
└ 更新 → 「更新が完了しました。」(アラート) → 一覧
```
### ステップ 3-2:定数を追加
```java
// TransitionTargetPageNameKeyword
public static final String UPDATE_DEPARTMENT_INPUT_HTML = "updateDepartmentInput"; // HTML
public static final String SHOW_UPDATE_DEPARTMENT = "/showUpdateDepartment"; // Controller
public static final String UPDATE_DEPARTMENT_RESULT = "/updateDepartmentResult";
// PageReturnAttributeKeyword
public static final String UPDATE_DEPARTMENT_FORM = "updateDepartmentForm";
```
### ステップ 3-3:更新 Form
`…/presentation/form/UpdateDepartmentForm.java`
```java
package com.example.rhizome.app.presentation.form;
import com.example.rhizome.app.domain.model.Department;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UpdateDepartmentForm {
private Integer idDepartment;
private String nmDepartment;
/** Model(Department) → Form */
public static UpdateDepartmentForm convertFrom(Department department) {
return new UpdateDepartmentForm(department.getIdDepartment(), department.getNmDepartment());
}
/** Form → Model(Department) */
public static Department convertTo(UpdateDepartmentForm form) {
return new Department(form.getIdDepartment(), form.getNmDepartment());
}
/** 必須チェック。true:OK/false:部署ID か部署名が空 */
public boolean hasRequiredValue() {
// ★考える:idDepartment が null でなく、nmDepartment が null でも空白だけでもない
return false;
}
}
```
> `String` の空チェックには `isBlank()`(空白だけも空扱い)が便利です。
### ステップ 3-4:更新サービス
`…/domain/service/UpdateDepartment.java`
```java
package com.example.rhizome.app.domain.service;
import com.example.rhizome.app.domain.model.Department;
public interface UpdateDepartment {
void updateDepartment(Department department);
}
```
`…/domain/service/impl/UpdateDepartmentImpl.java`
```java
@Service
@Transactional
public class UpdateDepartmentImpl implements UpdateDepartment {
DepartmentRepository departmentRepository;
@Autowired
public UpdateDepartmentImpl(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
public void updateDepartment(Department department) {
// ★考える:Model を Entity に変換(DepartmentEntity.convertFrom)して、Repository の save に渡す
}
}
```
import 文は削除サービスを参考に自分で書いてください(IDE の「インポートの整理」が使えます)。
> **重要な落とし穴**:`save` は**IDがDBに無い(または null)だと更新ではなく新規追加(INSERT)**をします。後のステップで「存在確認してから呼ぶ」ようにします。
### ステップ 3-5:更新画面を表示する Controller
`…/presentation/controller/UpdateDepartmentController.java`(お手本:`UpdateEmployeeController`)
```java
@Controller
public class UpdateDepartmentController {
// LOGGER、SEVERE_ERROR_MESSAGE、httpSession、searchDepartment、コンストラクタ は他と同じ作り
@RequestMapping(value = TransitionTargetPageNameKeyword.SHOW_UPDATE_DEPARTMENT, method = RequestMethod.POST)
public String showUpdateDepartment(@RequestParam("idDepartment") Integer idDepartment, Model model) {
final String UPDATE_TARGET_ID_NOT_FOUND = "更新対象のデータが見つかりません。";
try {
// ★① searchDepartment.searchBy(new SearchConditionDepartment(idDepartment, null)) で対象を検索
// ★② 結果が空なら MESSAGE_ERROR を載せて "forward:" + SELECT_DEPARTMENT_FORM_EMPTY
// ★③ あれば UpdateDepartmentForm.convertFrom(結果の先頭) を
// UPDATE_DEPARTMENT_FORM という名前で model に載せ、UPDATE_DEPARTMENT_INPUT_HTML を返す
return "…";
} catch (Exception e) {
// 他と同じ
}
}
}
```
### ステップ 3-6:更新画面 HTML
`updateDepartmentInput.html`(お手本:`updateEmployeeInput.html`)
```html
<!-- head・見出し・ログイン者名・ログアウトは他の画面と同じ。title は「部署情報更新」 -->
<form action="updateDepartmentResult"
method="post"
onsubmit="return confirm('本当によろしいですか?');"
th:object="${updateDepartmentForm}">
<div class="msgBox error" th:if="${messageError != null}">
<span class="error" th:text="${messageError.message}"></span>
</div>
<table>
<tr>
<th class="header">部署ID</th>
<td>
<input class="readOnly" readonly="readonly" th:field="*{idDepartment}" type="text"/>
</td>
</tr>
<tr>
<th class="header">部署名</th>
<td>
<input maxlength="50" placeholder="部署名" required="required"
th:field="*{nmDepartment}" type="text"/>
</td>
</tr>
<tr>
<td class="btnArea" colspan="2"><input type="submit" value="更新"/></td>
</tr>
</table>
</form>
<a href="javascript:selectDepartmentSubmit()">一覧に戻る</a>
<script type="text/javascript">
function selectDepartmentSubmit() {
const form = document.createElement('form');
form.setAttribute('method', 'post');
form.setAttribute('action', 'selectDepartment');
document.body.appendChild(form);
form.submit();
}
</script>
```
考えるポイント:
- **`readonly` と `disabled` の違い**:`readonly` は編集不可だが**送信される**。`disabled` は**送信されない**。部署IDを `disabled` にすると ID が `null` で届き、更新が新規追加になってしまいます。開発者ツール(F12)の Network タブで、実際に送られる値を比べてみてください。
- 「一覧に戻る」は、検索の Controller が POST 専用なのでメニューと同じ方法(JavaScriptでフォームを作って送信)にしています。
- `action` は `updateDepartmentResult`(先頭に `/` を付けない相対パス)。
### ステップ 3-7:更新を実行する Controller(最大の山場)
`…/presentation/controller/UpdateDepartmentResultController.java`(お手本:`UpdateEmployeeResultController`)
```java
@Controller
public class UpdateDepartmentResultController {
// LOGGER、SEVERE_ERROR_MESSAGE、httpSession、
// searchDepartment(SearchDepartment)、updateDepartment(UpdateDepartment) とコンストラクタ
@RequestMapping(value = TransitionTargetPageNameKeyword.UPDATE_DEPARTMENT_RESULT, method = RequestMethod.POST)
public String departmentUpdateResult(@ModelAttribute UpdateDepartmentForm updateDepartmentForm, Model model) {
final String NOTHING = "項目が空欄です。";
final String DUPLICATION = "既に同名の部署が存在するか、値が変更されていません。";
final String NO_TARGET = "更新対象のデータが見つかりません。";
final String UPDATE_COMPLETE = "更新が完了しました。";
try {
// ★ここに「チェック→更新」を、下の【チェックの順序】どおりに書く
return "…";
} catch (Exception e) {
// 他と同じ
}
}
}
```
**【チェックの順序】**(順番が大切です。理由も考えてください)
| 順 | チェック | NGの場合 | 戻り先 |
|---|---|---|---|
| 1 | 必須(`hasRequiredValue()`) | `NOTHING` | 更新画面(`UPDATE_DEPARTMENT_INPUT_HTML`) |
| 2 | 更新後の部署名が既に存在する(`searchBy(new SearchConditionDepartment(null, 更新後の部署名))` が空でない) | `DUPLICATION` | 更新画面 |
| 3 | 更新対象IDが存在する(`searchBy(new SearchConditionDepartment(ID, null))` が空でない) | `NO_TARGET` | 一覧(forward) |
| 4 | 更新を実行 → `MESSAGE_INFO` に `UPDATE_COMPLETE` | | 一覧(forward) |
**なぜ必須チェックが先か**:部署名が空のまま「同名検索」を行うと、条件が無視されて**全件ヒット**してしまい、「同名の部署が存在する」と誤判定されるからです。(設計書は重複確認→必須確認の順に書かれていますが、この順で実装すると上記の問題が出ます。)
**「変更なし」も重複エラーになる理由**:変更しない(自分と同じ名前)で検索すると、自分自身がヒットします。そのため、同名あり・変更なしを1つの判定でまとめて捕まえられます(設計書のメッセージが1つにまとまっている理由)。
**エラー時に更新画面へ戻すとき**は、入力済みの値を残すために、次の2つを載せます:
```java
model.addAttribute(PageReturnAttributeKeyword.MESSAGE_ERROR, new MessageForm(DUPLICATION));
model.addAttribute(PageReturnAttributeKeyword.UPDATE_DEPARTMENT_FORM, updateDepartmentForm);
return TransitionTargetPageNameKeyword.UPDATE_DEPARTMENT_INPUT_HTML;
```
**ブラウザの `required` があるのに、サーバー側でも必須チェックする理由**:ブラウザの検証は、開発者ツールで簡単に回避できるためです。
### ステップ 3-8:一覧に「更新」ボタンを追加
`selectDepartment.html` の操作列(削除ボタンと同じ `<td>` の中)に追加:
```html
<form action="showUpdateDepartment" method="post" style="display: inline;">
<input name="idDepartment" th:value="${departmentForm.idDepartment}" type="hidden">
<input type="submit" value="更新">
</form>
```
### ステップ 3-9:動作確認(単体テスト #9〜#20)
| # | 操作 | 期待 |
|---|---|---|
| 9 | 部署ID `10` の「更新」 | 部署ID変更不可で `10`/`役員` |
| 10 | 何も変えずに更新 → OK | `既に同名の部署が存在するか、値が変更されていません。` |
| 12 | 部署名を `役員改` にして更新 → OK | `更新が完了しました。` → 一覧に反映 |
| 14 | 部署名を `人事・総務部` にして更新 → OK | 同名エラー |
| 15 | 「一覧に戻る」 | 変更されていない一覧 |
| 17 | 部署名を空欄で更新 | ブラウザの「このフィールドを入力してください。」(画面遷移なし) |
| 18 | 「一覧に戻る」 | 検索画面 |
| 19 | 更新画面で右上のログアウト | ログイン画面 |
| 20 | 部署名を `役員` に戻して更新 | `更新が完了しました。` |
追加で自分で確認(異常系):
- F12 で `required` を削除して空欄のまま送信 →「項目が空欄です。」
- F12 で ID を `999` に書き換えて送信 →「更新対象のデータが見つかりません。」(**DBに新しい行が増えていないこと**)
### 6.1 結合テスト(部署更新)
結合テスト仕様書の「ログインから部署検索、部署更新まで」:部署名を `役員` → `役員改` に変更し、社員検索画面で、もともと `役員` だった社員の所属部署が `役員改` と表示される。(社員テーブルは部署名ではなく部署IDだけを持ち、表示するときに部署テーブルと結合しているためです。)
※手順3の「社員ID `10`」は「部署ID `10`」の誤記と思われます。
### 6.2 ここまでが提出可能ライン — 振り返り
- [ ] 単体テスト #1〜#20、#32〜#35 が期待どおり
- [ ] 更新の検証順序の理由を説明できる
- [ ] `save` が新規追加になる条件を説明できる
- [ ] `readonly` と `disabled` の違いを説明できる
---
## 7. 第4段階:新規登録(チャレンジ)
### 7.0 ゴールと完成の定義
メニュー「部署登録」→ 入力 → 登録確認 → 登録 → 完了画面(`部署登録が完了しました`)。確認画面の「登録内容の編集」で、値を保ったまま入力画面に戻れる。
→ **単体テスト #21〜#31**、結合テストの登録シナリオ。
画面フロー:
```
[入力] 部署ID(自動で設定されます)/部署名 ──「登録確認」──▶ POST /insertDepartmentConfirm
[確認] すべて変更不可 ──「登録」──▶ POST /insertDepartment ──▶ [完了]
──「登録内容の編集」──▶ POST /insertDepartmentInput(値を保持)
[完了]「続けて登録する」──▶ POST /insertDepartmentInput(空)/「メニューに戻る」
```
### ステップ 4-1:定数を追加
```java
// TransitionTargetPageNameKeyword
public static final String INSERT_DEPARTMENT_INPUT_HTML = "insertDepartmentInput";
public static final String INSERT_DEPARTMENT_CONFIRM_HTML = "insertDepartmentConfirm";
public static final String INSERT_DEPARTMENT_RESULT_HTML = "insertDepartmentResult";
public static final String INSERT_DEPARTMENT_INPUT = "/insertDepartmentInput";
public static final String INSERT_DEPARTMENT_CONFIRM = "/insertDepartmentConfirm";
public static final String INSERT_DEPARTMENT = "/insertDepartment";
// PageReturnAttributeKeyword
public static final String INSERT_DEPARTMENT_FORM = "insertDepartmentForm";
```
### ステップ 4-2:登録 Form
`…/presentation/form/InsertDepartmentForm.java`
```java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class InsertDepartmentForm {
private String nmDepartment;
/** Form → Model。部署IDは自動採番なので null を渡す */
public static Department convertTo(InsertDepartmentForm form) {
return new Department(null, form.getNmDepartment());
}
/** 入力チェック。エラーのあった項目名のリストを返す(なければ空のリスト) */
public List<String> validateParameter() {
List<String> error = new ArrayList<>();
// ★部署名が null または空白だけなら error.add("部署名");
return error;
}
}
```
`InsertEmployeeForm.validateParameter()` が書き方のお手本です(`java.util.List` と `ArrayList` の import も忘れずに)。
### ステップ 4-3:登録サービス(起動エラーを体験・解決する)
`RegisterDepartment`(インタフェース)は**すでにありますが、実装クラスがありません**。この状態で Controller に使うと、**アプリの起動に失敗**し、次のようなエラーが出ます。
```
No qualifying bean of type '…RegisterDepartment' available
```
**考える**:このエラーは何を伝えている?(Spring が `RegisterDepartment` を実装した `@Service` を探したが無かった)
`…/domain/service/impl/RegisterDepartmentImpl.java`(お手本:`RegisterEmployeeImpl`)
```java
@Service
@Transactional
public class RegisterDepartmentImpl implements RegisterDepartment {
DepartmentRepository departmentRepository;
@Autowired
public RegisterDepartmentImpl(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
public void registerDepartment(Department department) {
// ★考える:Entity に変換して save。ID が null のとき save は何をする?(自動採番して追加)
}
}
```
### ステップ 4-4:3つの Controller
共通の約束:`@ModelAttribute` で空の `InsertDepartmentForm` を用意し(`insertDepartmentForm()` メソッド)、各メソッドの引数に `InsertDepartmentForm` を取ります。
**① `InsertDepartmentInputController`**(`/insertDepartmentInput`)
```java
@RequestMapping(value = TransitionTargetPageNameKeyword.INSERT_DEPARTMENT_INPUT, method = RequestMethod.POST)
public String insertInput(InsertDepartmentForm insertDepartmentForm, Model model) {
model.addAttribute(PageReturnAttributeKeyword.INSERT_DEPARTMENT_FORM, insertDepartmentForm);
return TransitionTargetPageNameKeyword.INSERT_DEPARTMENT_INPUT_HTML;
}
```
なぜ引数の Form を model に載せ直す?(確認画面の「登録内容の編集」から戻ったとき、入力済みの値を残すため。メニューから来たときは空の Form)
**② `InsertDepartmentConfirmController`**(`/insertDepartmentConfirm`)
| 順 | チェック | NGのとき |
|---|---|---|
| 1 | `insertDepartmentForm.validateParameter()` が空でない | メッセージ `String.join(", ", error) + "が不正です。"`(=`部署名が不正です。`)→ 入力画面 |
| 2 | 同名の部署がある(`searchBy(new SearchConditionDepartment(null, 部署名))` が空でない) | `既に同名の部署が存在します。` → 入力画面 |
| 3 | OK | 確認画面 `INSERT_DEPARTMENT_CONFIRM_HTML` |
エラー時も OK 時も、`INSERT_DEPARTMENT_FORM` で Form を model に載せます(値を画面に残すため)。コンストラクタは `HttpSession` と `SearchDepartment` を受け取ります。
**③ `InsertDepartmentController`**(`/insertDepartment`)
①と同じチェック(入力・重複)を**もう一度**行い、問題なければ `registerDepartment.registerDepartment(InsertDepartmentForm.convertTo(form))` を呼んで、`INSERT_DEPARTMENT_RESULT_HTML` を返します。コンストラクタは `HttpSession`、`RegisterDepartment`、`SearchDepartment`。
**考える**:確認画面でチェック済みなのに、なぜ登録時に再チェック?(確認画面を通らずに直接送られる/確認と登録の間に別の人が同名を登録することがある)。完了画面でブラウザを更新(F5)すると、もう一度送信されます。再チェックが無いと**二重登録**になってしまいます。
### ステップ 4-5:3つの HTML
お手本の `insertEmployeeInput.html` / `insertEmployeeConfirm.html` / `insertEmployeeResult.html` を見ながら作ります。**部署に不要な項目(社員名・パスワード・権限・所属部署など)はすべて取り除いてください。**
**`insertDepartmentInput.html`**(要点)
```html
<form action="insertDepartmentConfirm" method="post" th:object="${insertDepartmentForm}">
<!-- エラーメッセージ表示(他の画面と同じ) -->
<table>
<tr>
<th class="header">部署ID</th>
<td><input class="readOnly" readonly="readonly" type="text" value="自動で設定されます"/></td>
</tr>
<tr>
<th class="header">部署名</th>
<td><input maxlength="50" placeholder="部署名" required="required"
th:field="*{nmDepartment}" type="text"/></td>
</tr>
<tr><td class="btnArea" colspan="2"><input type="submit" value="登録確認"/></td></tr>
</table>
</form>
<a href="showMenu">メニューに戻る</a>
```
**`insertDepartmentConfirm.html`**(要点)
```html
<form action="insertDepartment" id="insertForm" method="post" th:object="${insertDepartmentForm}">
<table>
<tr>
<th class="header">部署ID</th>
<td><input class="readOnly" readonly="readonly" type="text" value="自動で設定されます"/></td>
</tr>
<tr>
<th class="header">部署名</th>
<td><input class="readOnly" name="nmDepartment" readonly="readonly"
th:value="*{nmDepartment}" type="text"/></td>
</tr>
<tr><td class="btnArea" colspan="2"><input type="submit" value="登録"/></td></tr>
</table>
<a href="javascript:onClickEdit()">登録内容の編集</a> | <a href="showMenu">メニューに戻る</a>
</form>
<script type="text/javascript">
function onClickEdit() {
const form = document.getElementById('insertForm');
form.setAttribute('method', 'post');
form.setAttribute('action', 'insertDepartmentInput');
form.submit();
}
</script>
```
`name="nmDepartment"` が付いているので、画面の値がそのまま次の POST で送られます。「登録内容の編集」は、同じフォームの送信先だけを `insertDepartmentInput` に切り替えているので、値が入力画面に戻ります。
**`insertDepartmentResult.html`**(要点)
```html
<h2>部署登録が完了しました</h2>
<a href="javascript:insertSubmit()">続けて登録する</a> | <a href="showMenu">メニューに戻る</a>
<script type="text/javascript">
function insertSubmit() {
const form = document.createElement('form');
form.setAttribute('method', 'post');
form.setAttribute('action', 'insertDepartmentInput');
document.body.appendChild(form);
form.submit();
}
</script>
```
(`<head>` は他の画面と同じ内容にしてください。)
### ステップ 4-6:メニューにリンク追加
```html
<tr>
<td><a class="menuClass" href="javascript:insertDepartmentSubmit()"><i class="fas fa-plus"></i> 部署登録 </a></td>
<td class="description">新規で部署追加を行います。</td>
</tr>
```
### ステップ 4-7:部署名の桁数を確認する(重要)
**実験**:部署名に **11文字以上**(例:`ABCDEFGHIJK`)を入れて登録してみてください。
- 期待されるのは、設計書の「1〜50文字」で登録できること。
- しかし、DDL は `nm_department VARCHAR(10)` のため、MySQL のエラー(`Data too long for column 'nm_department'`)になり、**エラー画面に移動してログアウト状態**になります(Controller の `catch` がセッションを破棄するため)。
**これは設計書と実際のDBの食い違い**です。あなたの実装のミスではありません。README の「問題が出そうな場合は、必ずルールにしたがって適切な相手に相談してください」に従い、**講師に報告して判断を仰いでください**。報告のしかた(例):
> 「部署名に11文字以上を入れると登録でDBエラーになります。テーブル定義書は `varchar(50)`、DDL は `VARCHAR(10)` です。再現手順は…、ログの該当行は…。どちらに合わせればよいでしょうか」
(講師から DDL の変更や `maxlength` の指示があれば、それに従ってください。)
### ステップ 4-8:動作確認(単体テスト #21〜#31)
| # | 操作 | 期待 |
|---|---|---|
| 21 | メニュー「部署登録」 | 部署IDは「自動で設定されます」、部署名は空欄 |
| 22, 23 | メニューに戻る/ログアウト | それぞれ遷移する |
| 24 | `テスト部署1` → 登録確認 | 確認画面に表示される |
| 25 | 「登録内容の編集」 | 入力画面に `テスト部署1` が残っている |
| 26 | `役員` → 登録確認 | `既に同名の部署が存在します。` |
| 27 | 確認画面で「メニューに戻る」 | メニュー画面 |
| 28 | `テスト部署1` → 登録確認 →「登録」 | `部署登録が完了しました` |
| 29 | 「続けて登録する」 | 入力画面(空) |
| 30 | `テスト部署1` → 登録確認 | 同名エラー |
| 31 | `テスト部署2` を登録 → 完了 → メニューに戻る | 登録できる |
### 7.1 結合テスト
結合テスト仕様書の部署関連シナリオ(部署の削除と登録、部署を使った社員登録、社員の異動後の部署削除)を実施します。
※結合テスト1件目の「`sugi@hoge.jp` でログイン」は、`sugi` が一般ユーザーのため社員登録ができません。管理者(`yamada@hoge.jp`)で実施してください。
### 7.2 第4段階の振り返り
- [ ] 確認画面から「登録内容の編集」で値が戻る仕組みを説明できる
- [ ] 登録時に再チェックする理由を説明できる
- [ ] 起動エラー(`No qualifying bean`)の意味を説明できる
---
## 8. 第5段階:アクセス制御アノテーション(チャレンジその2)
### 8.0 ゴール
第1〜4段階のすべての Controller メソッドに既存の `@SessionCheck` と `@PermissionCheck` を付け、**未ログインや一般ユーザーが部署画面に入れない**ようにします。
### ステップ 5-1:既存の仕組みを読み解く(README:機能を解析する)
次の順に読み、それぞれ**自分の言葉で**ノートに説明を書いてください。
1. `aop/aspect/SessionCheck.java` と `PermissionCheck.java`:アノテーションの定義(`@Target(METHOD)`、`@Retention(RUNTIME)` の意味)
2. `aop/aspect/SessionCheckAspect.java`:`@Pointcut`、`@Before`。ログイン情報がセッションに無いとどうなる?
3. `aop/aspect/PermissionCheckAspect.java`:リクエストのURLを何と比べている?(`TransitionTargetPageNameKeyword` の `getAdminPageList()` / `getUserPageList()`)
4. `aop/Handler/GlobalExceptionHandler.java`:投げられた例外を受けて、どの画面に何を表示している?
5. 既存の `MenuController`:アノテーションがどこに付いている?
> **README の注意「アノテーションをコピペしない」** の意味:アノテーションのクラス自体を別の場所にコピーして使うと、チェックが**一切動作しません(エラーも出ません)**。`SessionCheckAspect` の `@Pointcut` は「`com.example.rhizome.aop.aspect.SessionCheck` というクラスのアノテーション」を指定しているためです。**必ず既存のものを `import` して使ってください。**
### ステップ 5-2:付与する
全 Controller の `@RequestMapping` メソッドに、お手本(社員機能)と同じように2つを付けます(検索の2つの `select` メソッドも両方)。
```java
import com.example.rhizome.aop.aspect.PermissionCheck;
import com.example.rhizome.aop.aspect.SessionCheck;
@PermissionCheck
@SessionCheck
@RequestMapping(value = …, method = RequestMethod.POST)
public String …
```
### ステップ 5-3:URLを許可リストに登録する
付けた直後に管理者で操作してみてください。**ログイン画面に戻され「不正な遷移です。」と表示される**はずです。
**原因を自分で調べる**:コンソールのどの例外?(`InsufficientPermissionException`)それを投げているのは?(`PermissionCheckAspect`)何と何を比較している?
**対処**:`TransitionTargetPageNameKeyword.getAdminPageList()` の `Set.of(…)` に、**部署の Controller の URL をすべて**追加します。
```java
SELECT_DEPARTMENT,
SELECT_DEPARTMENT_FORM_EMPTY,
DELETE_DEPARTMENT,
SHOW_UPDATE_DEPARTMENT,
UPDATE_DEPARTMENT_RESULT,
INSERT_DEPARTMENT_INPUT,
INSERT_DEPARTMENT_CONFIRM,
INSERT_DEPARTMENT
```
> **特に重要**:`forward:` で到達する `SELECT_DEPARTMENT_FORM_EMPTY`(`/selectDepartmentAll`)も必要です。`forward:` で呼ばれた先でも権限チェックが動き、そのときのURLは **forward 先のURL** になるためです。登録を忘れると、**削除や更新はDBに反映されるのに、画面には「不正な遷移です。」が出ます**。
### ステップ 5-4:3パターンで確認する
| パターン | 操作 | 期待 |
|---|---|---|
| 未ログイン | ログインせずに `POST /selectDepartment` を送る | ログイン画面+`不正な遷移です。` |
| 一般ユーザー | `jtaka@hoge.jp`/`bbb` でログインし、`POST /selectDepartment` を送る | 同上 |
| 管理者 | `yamada@hoge.jp` で部署の全機能を操作 | すべて正常 |
POST を直接送る方法(Windows は `curl.exe`):
```bash
curl -s -c cj.txt -b cj.txt -X POST http://localhost:8080/doLogin -d "mailAddress=jtaka@hoge.jp&password=bbb"
curl -s -c cj.txt -b cj.txt -X POST http://localhost:8080/selectDepartment
```
**対照実験**:いったんアノテーションを外すと、一般ユーザーでも部署画面が見えてしまうことを確認し、付けるとブロックされることを確認してください。「メニューにリンクを出さなければ十分」ではない理由を説明できるようにします(URLを直接送れば画面に入れてしまうため)。
### 8.1 第5段階の振り返り
- [ ] AOP の仕組み(アノテーション → Aspect → 例外 → ControllerAdvice)を図で説明できる
- [ ] 許可リストに forward 先のURLも必要な理由を説明できる
- [ ] 3パターンの確認結果を記録した
---
## 9. テストの進め方(テスト仕様書の使い方)
1. `テスト仕様書/単体テスト仕様書.xlsx` の「部署管理テスト仕様書」を、**各段階の終わりに**、該当する項番を上から順に実施します。
- 検索:#1〜#8/削除:#32〜#35/更新:#9〜#20/登録:#21〜#31
2. 実施日と結果(OK/NG)、気づいたことを**備考欄に記入**します。
3. NG は原因を調べて修正し、**最初から**やり直します(一部だけ直して終わりにしない)。
4. テスト順の注意:#10・#12・#14・#17・#20 は、**前の項番の結果(部署名が `役員改` になっている等)を前提**にしています。上から順に実施してください。やり直すときはDBを初期状態に戻します(§1.2)。
5. `テスト仕様書` の「表示データ」シートは社員用のデータで、部署の期待値がそのまま書かれていません。期待される結果は、§1.2 の初期データとテスト項目の文言から判断します(分からなければ質問してください)。
> Maven のビルド(`mvn install`)では、プロジェクトの設定上、JUnit のテストは実行されません。テスト仕様書の確認は**画面を操作する手動テスト**で行います。
---
## 10. 詰まったとき
### 10.1 エラーの読み方(5ステップ)
1. **症状を言葉にする**:何をして、どうなった?
2. **Eclipse のコンソール**を開き、赤い文字のうち、**一番下の `Caused by:` の行**を探す。
3. 自分のクラス名(`com.example.rhizome…`)が出ている**最初の行**を見る(その行番号が原因の手がかり)。
4. ブラウザの **F12 → Network** タブで、送信先URLとパラメータを確認する。
5. Controller の先頭に**ブレークポイント**を置いてデバッグ実行し、値を確認する。
> Controller の `catch` はすべて、**セッションを破棄してエラー画面を出す**作りです。画面が「エラー」になったら、**コンソールのスタックトレースを必ず見て**ください。
### 10.2 症状別の早見表
| 症状 | 考えられる原因 | 確認すること |
|---|---|---|
| ビルドで「シンボルを見つけられません getXxx()」が大量に出る | JDK が17ではない/Lombok が効いていない | `java -version`、Eclipse の JRE 設定 |
| 起動時 `Table 'rezodb.employee' doesn't exist` | DDLを実行していない | DB のテーブルを確認 |
| 起動時 `Access denied` / `Unknown database` | DB・ユーザー未作成 | §1.2、`application.properties` |
| 起動時 `NoSuchBeanDefinitionException` / `UnsatisfiedDependencyException` | `@Service` の付け忘れ/実装クラスが無い/パッケージの置き場所 | 例外の最後の `Caused by` に書かれた型 |
| 画面が `エラー` になる | Controller 内で例外が発生(握りつぶされている) | コンソールの `Caused by` |
| 画面に「不正なリクエストです。」+ 405 | POSTのURLを GET で呼んだ(`<a href>` で直接遷移した等) | F12 Network のメソッド |
| 404 Not Found | URLの綴り違い/先頭の `/` の有無 | Network のURLと `@RequestMapping` の値を並べる |
| `Error resolving template [xxx]` | 返した画面名と `templates` のファイル名が不一致 | 戻り値の文字列とファイル名 |
| `Neither BindingResult nor plain target object for bean name 'xxxForm'` | `th:object` の Form が model に無い(`@ModelAttribute` が無い/別の Controller から画面名で返した) | その Form を誰が model に載せているか |
| `Exception evaluating SpringEL expression` | HTML 内の項目名の綴り違い | Form のフィールド名と `*{…}` |
| 検索で条件が効かない・全件になる | `name` 属性とFormのフィールド名の不一致 | HTML の `th:field` と Form |
| 削除・更新のあと、1件しか表示されない | `forward:` で元のパラメータが検索条件に使われた | 戻り先が全件用のURLか |
| 更新したのに行が**増えた** | IDが送られていない(`disabled`にした等)/ID `null` の `save` は追加 | F12 Network の送信値、DBの行 |
| 管理者なのに「不正な遷移です。」 | 許可リストにURL(特に forward 先)が無い | §8 ステップ 5-3 |
| `Data too long for column 'nm_department'` | DDLが `VARCHAR(10)` | §7 ステップ 4-7(講師に報告) |
| クリアを押したら検索が走る | `<button>` は `type` 省略で送信ボタン | §4 ステップ 1-6 |
| 日本語が文字化け | ファイルの文字コードが UTF-8 ではない | Eclipse のエンコーディング設定 |
| ポート8080が使用中 | 前回起動したプロセスが残っている | 停止してから再起動 |
### 10.3 相談の型(質問は次の4点をそろえて)
1. やりたいこと(設計書のどの項目か)
2. 期待した結果/実際の結果(画面の状態、**エラーの該当ログ**)
3. 試したこと(調べたキーワード、変更した内容)
4. 自分の考え(原因はこうだと思う、理由は…)
---
## 11. 提出前チェックリスト
**コード**
- [ ] ビルドが通り、起動できる
- [ ] 画面名・URL・modelのキーが定数になっている(文字列の直書きがない)
- [ ] 社員機能からの不要な処理(パスワード、部署プルダウン等)が残っていない
- [ ] 使っていない import・コメントアウトしたコード・デバッグ用の出力が残っていない
- [ ] メッセージの文言が設計書・テスト仕様書と一字一句同じ
- [ ] 既存のファイルを不要に書き換えていない(変更は定数・メニューへの追記のみ)
**テスト**
- [ ] 単体テスト仕様書(部署管理)の実施日・結果を記入(第3段階までは必須)
- [ ] 異常系を自分で追加確認した(存在しないID、`required` を外した送信 など)
- [ ] 結合テスト仕様書(部署関連)を実施できた範囲で記入
**理解**(口頭で説明できること)
- [ ] 画面からDBまでのリクエストの流れ
- [ ] `forward:` と全件用エンドポイントを使う理由
- [ ] 更新の検証順序の理由
- [ ] (第5段階まで行った人)AOPの仕組みと許可リストの役割
**気づいたことのメモ**(加点対象)
- [ ] 設計書の誤記や、設計書とDBの食い違い(桁数など)を報告した
**合格ライン**:第3段階まで(検索・削除・更新)とテストの実施記録。第4・5段階はチャレンジです。