はじめに
ファイルをインポートする機能で、入力チェックをどう設計するかをまとめる。
ポイントは次の3つ。
- エラーを 「中断(単一メッセージ)」 と 「継続(リストメッセージ)」 に分ける
- チェックを 正しい順序で並べる(ゲート → 行ごと)
- 空白行を「末尾のパディング(除去)」と「途中の行抜け(エラー)」で 区別する
1. 設計の考え方
1.1 チェック順の原則
中断系(ファイル全体のゲート) → 継続系(行ごと)
- 中断系を先に:中断するなら行ごとのエラーを集める意味がない。
-
中断系の中は依存関係で決まる:
ファイル名→末尾空白トリム→全空判定→1000超過
(全空・1000超過は「末尾トリム後のデータ」で判定するので、トリムが先)。 -
継続系の中は「空白行 → キー → 他項目」:
空白行なら以降のチェックは無意味なのでcontinueでスキップ。安い・前提となるチェックを先に。
1.2 空白行は「位置」で区別する(removeIf の落とし穴)
空白行の意味は、「その行より後ろにデータがあるか」 で変わる。
| 空白行の位置 | 意味 | 扱い |
|---|---|---|
| 後ろにデータが ない(末尾) | 単なるパディング | 除去 |
| 後ろにデータが ある(途中) | 行抜け | エラー(継続) |
removeIf は 位置を見ず条件一致を全部消す ため、この2つを区別できない。
そこで「末尾トリム → 残りに空白があればエラー」の2段で処理する。
- 末尾トリムは位置限定なので
subList(0, last)が適切。 - 途中の空白検出は index 付きループ。
1.3 空白行判定 isAllNull は boolean を返す
-
HatchuDataは変更不可なので、呼び出し側(Logic)に判定を置く。 - 判定対象は 値が入りうる参照型の項目だけ。
int/booleanなどプリミティブは 絶対 null にならない ので含めない
(含めると「永遠に空にならない」バグになる)。
2. 実装
各チェックを private メソッドに分割 し、「該当 → メッセージ / 非該当 → null」を返す形にする。
呼び出し側は溜めるだけになり、順番の入れ替え・追加が楽になる。
2.1 結果の入れ物
/** チェック結果。中断(単一)と継続(リスト)を分けて持つ。 */
private static class CheckResult {
String abortMessage; // 中断(単一)。null なら中断なし
final List<String> messages = new ArrayList<>(); // 継続(複数)
boolean isAborted() { return abortMessage != null; }
}
2.2 補助メソッド
/** 全項目 null(空白行)か。プリミティブは対象外・参照型のみ。 */
private boolean isAllNull(HatchuData d) {
if (d == null) return true;
return d.getDeleteKubun() == null // 1列目 削除制御
&& d.getIraiNo() == null // 2列目 依頼番号(キー)
&& d.getIraiMeisaiNo() == null // 3列目 依頼明細番号(キー)
&& d.getShizaiCode() == null // 4列目 資材コード
// … 5〜18列目 …
&& d.getBikoShizai() == null; // 19列目 備考(資材)
// ※ getter名は実際の HatchuData に合わせる
}
private boolean isBlank(String s) { return s == null || s.isEmpty(); }
private void addIfNotNull(List<String> list, String msg) {
if (msg != null) list.add(msg);
}
/** 末尾の空白行(後ろにデータが無い=パディング)を除いた実データを返す。 */
private List<HatchuData> trimTrailingBlank(List<HatchuData> rows) {
int last = rows.size();
while (last > 0 && isAllNull(rows.get(last - 1))) last--;
return new ArrayList<>(rows.subList(0, last));
}
2.3 中断系(ゲート)
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
/** ① ファイル名の番号 ≠ 画面(更新時のみ) */
private static final int NO_LENGTH = 14;
private static final DateTimeFormatter TS_FORMAT =
DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT);
/** ① ファイル名の番号 ≠ 画面 + 形式チェック(更新時のみ) */
private String checkFileName(String fileName, String screenNo, boolean isUpdate) {
if (!isUpdate || isBlank(screenNo)) return null;
// 画面の番号自体が14桁数字でなければ、比較の前提が崩れるため形式エラー扱い
if (!isValidNo(screenNo)) {
return "画面の番号が正しくありません。";
}
if (fileName == null) {
return "ファイル名の番号が画面の番号と一致しません。";
}
// 拡張子を除去(拡張子が付かない想定なら削除可)
String name = stripExtension(fileName);
// 期待フォーマット hatchu_<番号14桁>_YYYYMMddHHmmss
// startsWith により「番号がアンダースコアで正しく挟まれた位置」にあることも同時に検証
String expectedPrefix = "hatchu_" + screenNo + "_";
if (!name.startsWith(expectedPrefix)) {
return "ファイル名の番号が画面の番号と一致しません。";
}
// 番号の直後は14桁の実在タイムスタンプのみ
String timestamp = name.substring(expectedPrefix.length());
if (!isValidTimestamp(timestamp)) {
return "ファイル名の形式が正しくありません。";
}
return null;
}
/** 14桁の数字か。 */
private boolean isValidNo(String no) {
return no != null && no.matches("\\d{" + NO_LENGTH + "}");
}
/** 14桁の文字列が実在する日時(YYYYMMddHHmmss)か。 */
private boolean isValidTimestamp(String timestamp) {
if (timestamp == null || !timestamp.matches("\\d{14}")) {
return false;
}
try {
LocalDateTime.parse(timestamp, TS_FORMAT);
return true;
} catch (DateTimeParseException e) {
return false;
}
}
/** 拡張子を除去する。拡張子が無ければそのまま返す。 */
private String stripExtension(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot > 0 ? fileName.substring(0, dot) : fileName;
}
/** ③ 全て空行(実データ0件)※data は末尾トリム済み */
private String checkAllBlank(List<HatchuData> data) {
return data.isEmpty() ? "ファイル内が全て空行です。" : null;
}
/** ④ 1000行を超過した行に値が設定されている ※data は末尾トリム済み */
private String checkOver1000(List<HatchuData> data) {
return data.size() > 1000
? "取込可能な最大1000行を超えて値が設定されています。"
: null;
}
// 超過検知だけの専用ガード
private boolean hasDataOver1000(File file) {
XSSFWorkbook wb = null;
try {
wb = new XSSFWorkbook(new FileInputStream(file)); // .xlsm もこれで開ける
Sheet sheet = wb.getSheetAt(0);
int last = sheet.getLastRowNum(); // 0-based
for (int i = 1001; i <= last; i++) { // index1001=データ1001行目以降
Row row = sheet.getRow(i);
if (!isBlankRow(row)) return true; // 1件でもデータがあれば超過
}
return false;
} catch (IOException e) {
throw new RuntimeException("超過判定のためのファイル読取に失敗", e);
} finally {
try { if (wb != null) wb.close(); } catch (IOException ignore) {}
}
}
import java.io.File;
import java.io.IOException;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
private static final int MAX_ROWS = 1000;
/** 走査対象の列範囲(0始まり)。A列=0 〜 AA列=26。 */
private static final int FIRST_COL_INDEX = 0; // A列
private static final int LAST_COL_INDEX = 26; // AA列
/** 行数超過チェック(A〜AA列のいずれかに値がある行を有効行とみなす)。 */
private String checkRowCount(String tempFilePath) {
try (Workbook wb = WorkbookFactory.create(new File(tempFilePath))) {
Sheet sheet = wb.getSheetAt(0);
int headerRows = 1;
int lastRow = findLastDataRow(sheet, headerRows);
int dataCount = (lastRow < 0) ? 0 : (lastRow - headerRows + 1);
if (dataCount > MAX_ROWS) {
return "取り込み可能な行数(" + MAX_ROWS + "行)を超えています。";
}
} catch (IOException e) {
return "ファイルの読み取りに失敗しました。";
}
return null;
}
/**
* A〜AA列の範囲で、いずれかのセルに値がある最終行のインデックス(0始まり)。
* 有効行が無ければ -1。途中の空行は飛ばして最後まで走査する。
*/
private int findLastDataRow(Sheet sheet, int headerRows) {
int lastData = -1;
for (int r = headerRows; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r);
if (!isEmptyRowInRange(row)) {
lastData = r;
}
}
return lastData;
}
/** A〜AA列の範囲がすべて空なら true。 */
private boolean isEmptyRowInRange(Row row) {
if (row == null) return true;
for (int c = FIRST_COL_INDEX; c <= LAST_COL_INDEX; c++) {
if (!isBlankCell(row.getCell(c))) {
return false;
}
}
return true;
}
/** セルが空か。null・BLANK・空白のみ文字列を空とみなす。 */
private boolean isBlankCell(Cell cell) {
if (cell == null) return true;
if (cell.getCellType() == CellType.BLANK) return true;
if (cell.getCellType() == CellType.STRING) {
return cell.getStringCellValue().trim().isEmpty();
}
return false;
}
2.4 継続系(行ごと)
/** 途中の空白行(何行目か明示) */
private String checkBlankRow(HatchuData d, int rowNo) {
return isAllNull(d) ? rowNo + "行目に空白行が存在します。" : null;
}
/** 削除(1列目=削除)なのにキー(2・3列目)が無い */
private String checkDeleteKey(HatchuData d, int rowNo) {
if (!"削除".equals(d.getDeleteKubun())) return null; // 削除行のみ対象
if (isBlank(d.getIraiNo()) || isBlank(d.getIraiMeisaiNo())) {
return rowNo + "行目:削除には番号・明細番号(キー)が必要です。";
}
return null;
}
/** 更新モードでキー(2・3列目)が無い(削除行は対象外=相互排他) */
private String checkUpdateKey(HatchuData d, int rowNo, boolean isUpdate) {
if (!isUpdate) return null; // 更新モードのみ
if ("削除".equals(d.getDeleteKubun())) return null; // 削除行は checkDeleteKey が担当
if (isBlank(d.getIraiNo()) || isBlank(d.getIraiMeisaiNo())) {
return rowNo + "行目:更新には番号・明細番号(キー)が必要です。";
}
return null;
}
2.5 オーケストレーション(呼び出し順=チェック順)
private CheckResult validateImport(List<HatchuData> rows, String fileName,
String screenOrderNo, boolean isUpdate) {
CheckResult r = new CheckResult();
String abort;
// ── 中断系(ゲート): 1つでも該当したら単一メッセージで即終了 ──
if ((abort = checkFileName(fileName, screenOrderNo, isUpdate)) != null) {
r.abortMessage = abort; return r; // ①
}
List<HatchuData> data = trimTrailingBlank(rows); // ② 末尾空白除去
if ((abort = checkAllBlank(data)) != null) {
r.abortMessage = abort; return r; // ③
}
if ((abort = checkOver1000(data)) != null) {
r.abortMessage = abort; return r; // ④
}
// ── 継続系(行ごと): メッセージはリストに溜めて処理は続ける ──
for (int i = 0; i < data.size(); i++) {
int rowNo = i + 1; // データ行番号(1始まり)
HatchuData d = data.get(i);
String blank = checkBlankRow(d, rowNo);
if (blank != null) { r.messages.add(blank); continue; } // 空白行は以降スキップ
addIfNotNull(r.messages, checkDeleteKey(d, rowNo));
addIfNotNull(r.messages, checkUpdateKey(d, rowNo, isUpdate));
// … 他項目チェックも addIfNotNull(r.messages, checkXxx(d, rowNo)) …
}
return r;
}
2.6 呼び出し側(中断か継続かで出し分け)
CheckResult r = validateImport(rows, fileName, screenOrderNo, isUpdate);
if (r.isAborted()) {
showError(r.abortMessage); // 中断:単一メッセージ・取込中止
return;
}
if (!r.messages.isEmpty()) {
showErrorList(r.messages); // 継続:リストでまとめて表示
return; // 登録はしない(オールオアナッシング)
}
// エラーなし → 登録処理へ
3. 処理の流れ(具体例)
入力 rows(ヘッダ除く):
1: [削除, PO-1, 1, …値あり] → 正常
2: [null,null,…] → 途中の空白 → 継続「2行目に空白行」
3: [未選択, null, null, 資材X…] → 更新でキー無し → 継続「3行目 更新キー無し」
4: [削除, PO-1, null, …] → 削除でキー(3列目)無し → 継続「4行目 削除キー無し」
5〜1000: 値あり → 各行チェック
1001: [値あり] → 1000超過に値 → ★中断★(単一メッセージ・即終了)
1002〜: [null…] → 末尾空白 → ②で除去(無視)
判定:
① ファイル名OK
② 末尾空白(1002〜)除去
③ 全空でない
④ 1001行目に値 → 中断「1000行を超えて値が…」→ 終了(継続系へは進まない)
※ 1001行目に値が 無ければ 中断④は起きず、継続系で「2行目空白/3行目キー/4行目キー」を
すべてリストに溜めて 返す(=処理継続)。
## チェック内容
チェックリスト
# 発注データ インポート 製造時チェックリスト
**用途:** 製造(コーディング)フェーズで、各製造単位(Task)の**完了を判定**するためのチェックリスト。
「作る指示」ではなく「作った結果を検証する項目」として運用する。
- `[ ]` 未達 / `[~]` 実装済み・テスト未Green / `[x]` DoD達成(テストGreen・ビルド通過)
- 各項目の `… test:` は Red→Green→Refactor で固定する対応テスト(TDD前提)。
- 実装計画・設計方針は別ファイル [`import-checklist.md`](./import-checklist.md) を参照。移植は §末尾リンク参照。
> 判定ルール一つの Task は「全 DoD が `[x]` +対応テスト Green +ビルド通過」で完了とする。前提条件ゲート(§0)が未解決の項目に依存する Task は着手しない。
---
## 0. 製造前ゲート(着手前に解決済みであること)
製造単位の DoD ではなく、**製造着手の前提条件**。未解決なら該当 Task は着手不可。
- [ ] G-1. criteria クラスへフィールド追加可否が確定(既存フィールド非改変で追加できるか)→ Task1.1, 8.1
- [ ] G-2. 明細の空判定で半角スペースのみの扱いが確定(`isBlank`/`isEmpty`・業務判断)→ Task3.1, 5.x
- [ ] G-3. 統合モデルの基本情報・算定タブの取得元と実型が確定 → Task1.1, 6.1
- [ ] G-4. 汎用マスタ照会の並び順・絞り込みが画面と一致(取得ロジック共有可否)→ Task5.4
- [ ] G-5. パターンC(enum/リスト)の変換元・変換先定義が確定 → Task5.4
- [ ] G-6. メッセージID の採番方針が確定(ApplicationResources キー)→ Task1.3
- [ ] G-7. 実プロジェクトの型・DAO・ValidationResult シグネチャが判明(雛形の `★要置換`)→ 移植前提
---
## Task1 基盤の型・モデル・外部化リソース
### Task1.1 型・統合モデル(Req1.1, 9, 14.8)
- [ ] 明細1行の型が定義され、削除区分・依頼番号・依頼明細番号・その他項目を保持する
- [ ] 削除退避キー型が定義されている
- [ ] ファイル情報型(開始行=2・種別=excel・SJIS・ファイル名・画面番号・モード)が定義されている
- [ ] 統合レスポンスモデルが request と同型・別インスタンスで生成できる … test: `responseModel_isDistinctInstanceFromRequest`
- [ ] ビルド通過・コンパイルエラーなし
### Task1.2 検証結果の型分離(Req4.5, 5.1, 11.1)
- [ ] 中断系・継続系リスト・削除退避キー・補完後モデルを1つの結果型で返せる
- [ ] 「中断系設定時は継続系・退避キーが空」の不変条件がテストで固定 … test: `abortSet_thenContinuationAndKeysEmpty`
### Task1.3 外部化リソース(Req7.3, 7.4, 7.5)
- [ ] 最大行数(1000)・接頭辞(`hatchu_`)がコード外(定数/設定)から取得される … test: `threshold/prefix_loadedFromConfig`
- [ ] エラーメッセージがリソースから取得され、表示行番号等がパラメータ埋込される … test: `message_resolvedWithParams`
---
## Task2 POI 全行読み取り(共有リーダ非依存)
### Task2.1 一時ファイル経由の全行読取(Req10.1, 10.2, 10.5, 10.6)
- [ ] 共有 `SimpleImporter` を呼ばずに全データ行を読み取る
- [ ] 1000超の行も保持し件数を取得できる … test: `read_1001Rows_allRetained`
- [ ] 正常時に一時ファイルが finally で削除される … test: `tempFile_deleted_onSuccess`
- [ ] 例外時にも一時ファイルが残らない(先にRedで固定)… test: `tempFile_deleted_onException`
- [ ] ファイルハンドルが解放される(ロック残りなし)
### Task2.2 ファイル情報の解釈・列マッピング(Req9.2〜9.5, 10.4)
- [ ] 開始行=2(物理1=ヘッダ/物理2=先頭データ)で解釈 … test: `dataStartsAtPhysicalRow2`
- [ ] 種別excel・拡張子xlsm を対象とし、excel系でSJISを使わない … test: `excel_ignoresCharset`
- [ ] ファイル名 `hatchu_<番号>_yyyymmddhhmmss.xlsm` を解釈し拡張子除去後を検証へ渡す
- [ ] B〜AA 列の各行→明細マッピングが成立 … test: `mapRow_columnsBtoAA`
### Task2.3 フック構造の踏襲(Req10.3)
- [ ] 共有読取クラスを改修していない(差分なし)
- [ ] 戻り形状(画面反映リスト+検証結果リスト)が既存フックと整合
---
## Task3 空白行処理・表示行番号
### Task3.1 末尾トリム2段階(Req3.1, 3.2, 3.4)
- [ ] 全項目空を空白行判定(参照型のみ・プリミティブ除外)… test: `isBlankRow_referenceOnly`
- [ ] 末尾連続空白のみ除去し、一括除去(removeIf等)を使っていない … test: `trimTrailing_keepsMiddleBlank`
### Task3.2 途中空白行検出・表示行番号(Req3.3, 6.1〜6.4)
- [ ] 末尾トリム後の残存空白行を継続系へ計上 … test: `middleBlank_reportedAsContinuation`
- [ ] 表示行番号=ヘッダ除外連番かつ末尾トリム前基準(トリムでズレない)… test: `displayRowNo_stableAfterTrim`
- [ ] 全エラーメッセージに一貫した表示行番号を含む
---
## Task4 中断系(ファイル全体ゲート)
### Task4.1 事前ガード・全空(Req2.1〜2.3, 4.3)
- [ ] rows=null で例外を出さず実データ0件扱い … test: `nullRows_noException_abort`
- [ ] 参照前に行・項目値の null 判定を行う
- [ ] 末尾トリム後0件で単一中断系メッセージ・即終了 … test: `allBlank_singleAbort`
### Task4.2 更新モードのファイル名検証(Req4.1, 4.2)
- [ ] 形式不一致/番号不一致で単一メッセージ即終了 … test: `updateMode_fileNameMismatch_abort`
- [ ] 番号14桁固定チェック … test: `no_is14digits`
- [ ] タイムスタンプ実在チェック(STRICT/`uuuu`/Java8)… test: `timestamp_strictExists`
- [ ] 新規モードは番号一致チェックを実施しない … test: `newMode_skipsNoCheck`
### Task4.3 1000行超過判定(Req4.4, 7.1, 7.2)
- [ ] 末尾トリム後(途中空白含む)行数>1000で即終了 … test: `rows1001_abort` / `rows1000_ok`
- [ ] 判定がライブラリ切り捨てより前・元ファイル基準であることを確認 … test: `count_beforeTruncation`
### Task4.4 順序制御(Req1.5, 4.5)
- [ ] 中断系を全評価後にのみ継続系を評価 … test: `abort_blocksContinuation`
- [ ] 中断系検出時は継続系リストを生成しない
---
## Task5 継続系(行ごと)
### Task5.1 全行評価ループ(Req5.1, 5.6)
- [ ] 途中終了せず全行を評価しエラーを蓄積 … test: `collectsAllRowErrors`
- [ ] 空白行は継続系計上+以降の項目チェックをスキップ
- [ ] 継続系1件以上で明細を1件も登録しない
### Task5.2 キー相互排他・削除区分(Req5.3, 5.4, 5.5, 8.1, 8.2)
- [ ] 削除行にキー無しで継続系計上 … test: `deleteRow_missingKey_error`
- [ ] 更新モード非削除行にキー無しで継続系計上 … test: `updateMode_nonDeleteMissingKey_error`
- [ ] 同一行に両チェックを重複適用しない … test: `keyChecks_mutuallyExclusive`
- [ ] 新規モードの削除行混入で継続系計上・更新用キーチェック非適用 … test: `newMode_deleteRow_error`
### Task5.3 削除対象キー退避(Req11.1)
- [ ] 削除区分=削除かつキー有効な行のキーのみ退避(DB非アクセス)… test: `stageDeleteKeys_noDbAccess`
- [ ] 新規モードでは退避しない
### Task5.4 (P) 汎用マスタ存在チェック(Req15.1, 15.2, 15.4)
- [ ] 対象値が汎用マスタ未登録なら表示行番号付き継続系(照会のみ・DB非変更)… test: `masterMissing_error_readOnly`
- [ ] 種類別抽出(`コード:ラベル`→コード / ラベルのみ / enum変換)… test: `extract_byPatternABC`
- [ ] 再インポート時のコロン分割がエクスポート区切りと一致 … test: `reimport_splitColon`
- [ ] 行検証(validating)から呼び出せる形になっている
---
## Task6 取込時の行後処理(画面onChange相当)
### Task6.1 モデル格納・3タブ統合(Req14.1, 14.3, 14.4, 14.8)
- [ ] ファイル値を index 単位でレスポンスモデルへ格納 … test: `storeFileValues_byIndex`
- [ ] 基本情報・算定は request から複製統合し request を書き換えない … test: `request_notMutated`
### Task6.2 専用計算・値補完(Req14.2, 14.5, 14.7)
- [ ] 画面用メソッドを流用・改変せず別実装(純計算式のみ共有)… test: `importCalc_separateFromScreen`
- [ ] 対象項目を定義順に全項目反復して適用 … test: `applyAllFields_inOrder`
- [ ] 計算不能/不正値で表示行番号付き継続系 … test: `calcError_continuation`
### Task6.3 実行位置・2パス集計(Req14.6, 14.9, 14.10)
- [ ] 空白行スキップ後かつ行項目チェック前に行後処理を実行 … test: `postProcess_positionInLoop`
- [ ] 行後処理でDB非アクセス
- [ ] 集計依存項目を全行完了後に再計算(2パス)… test: `aggregation_recalcAfterAllRows`
---
## Task7 削除の遅延実行(永続化)
### Task7.1 (P) 単一Tx削除(Req11.5)
- [ ] 退避キーで削除、途中の永続化エラーで全件ロールバック … test: `delete_rollbackOnError`
- [ ] 退避キー空なら削除を発行しない … test: `emptyKeys_noDelete`
### Task7.2 全検証通過後のみ削除(Req1.2〜1.4, 11.2〜11.4, 15.3)
- [ ] 中断系・継続系・マスタが全て0件のときのみ削除実行 … test: `deleteOnlyWhenNoErrors`
- [ ] エラー1件以上でDB非アクセス・削除しない … test: `anyError_noDbAccess`
- [ ] 途中で先行削除しない
---
## Task8 オーケストレーション・画面反映
### Task8.1 取込エントリ統合(Req1.5, 1.6, 5.5)
- [ ] 押下ボタンで振り分け、読取→中断系→行ループ→判定→削除の順で統括 … test: `entry_ordersPhases`
- [ ] DB変更は削除のみ・非削除行は永続化しない
### Task8.2 画面反映(Req12.1〜12.4)
- [ ] 全通過時に新規登録行・更新行をDB非アクセスで画面反映 … test: `reflectRows_noDbAccess`
- [ ] エラー1件以上で画面反映せずエラー返却 … test: `anyError_noReflect`
- [ ] 新規/更新行の登録・更新・照会を行わない
### Task8.3 帳票出力分岐との整合
- [ ] インポート/帳票の分岐が既存フローと整合(Controller無改修)
---
## 製造完了の総合判定
- [ ] 全 Task の DoD が `[x]`
- [ ] 全対応テストが Green(Red→Green→Refactor 済み)
- [ ] ビルド通過・警告/静的解析の未対応なし
- [ ] request(画面値)非改変がテストで固定されている
- [ ] DBを変更する経路が「削除のみ」であることを横断確認
> 移植(雛形→実プロジェクト)の `★要置換` 確認は本チェックリストの対象外。移植チェックは [`import-checklist.md`](./import-checklist.md) §15 を参照し、別途実施する。
共通
import com.example.export.logic.CommonLogic;
/**
* {@link CommonLogic} の実装。リストボックス(入力規則)1種類の出力を担う。
* 選択肢の取得元は呼び出し側が用意し、ここは受け取った選択肢の出力に専念する。
*
* ■ writeListBox の処理の流れ(1種類ぶん)
* 1. writeListColumn … 選択肢を、印刷範囲外の外れた列に縦に書き出す
* 2. applyValidation … その範囲を参照する入力規則(ドロップダウン)を対象列に設定
* 3. hideColumn … 選択肢を書いた列を非表示にする
*
* ・入力規則は参照範囲方式(createFormulaListConstraint)。255文字制限を避けるため。
* ・インポートが1シート目固定のため、選択肢は2シート目でなく1シート目の外れた列に置く。
*/
public class CommonLogicImpl implements CommonLogic {
/** 選択肢の出力開始行(0始まり。ヘッダー回避で1行目から) */
private static final int LIST_START_ROW = 1;
@Override
public void writeListBox(XSSFSheet sheet, List<String> items,
int targetColIndex, int listColIndex, int firstRow, int lastRow) {
// 1. 選択肢を外れた列に縦に書き出す
writeListColumn(sheet, listColIndex, items);
// 2. その範囲を参照する入力規則(ドロップダウン)を対象列に設定
applyValidation(sheet, listColIndex, items.size(), targetColIndex, firstRow, lastRow);
// 3. 選択肢を書いた列を非表示にする
hideColumn(sheet, listColIndex);
}
/**
* 選択肢を指定列へ縦に出力する。
*
* @param sheet 対象シート
* @param listColIndex 選択肢出力先の列(0始まり)
* @param items 選択肢文字列のリスト
*/
private void writeListColumn(XSSFSheet sheet, int listColIndex, List<String> items) {
for (int i = 0; i < items.size(); i++) {
int rowIndex = LIST_START_ROW + i;
Row row = sheet.getRow(rowIndex);
if (row == null) {
row = sheet.createRow(rowIndex);
}
row.createCell(listColIndex).setCellValue(items.get(i));
}
}
/**
* 選択肢の範囲を参照する入力規則を、対象データ列に設定する。
* 選択肢が0件の場合は設定しない。
*
* @param sheet 対象シート
* @param listColIndex 選択肢を出力した列(0始まり)
* @param itemSize 選択肢件数
* @param targetColIndex ドロップダウン設定先のデータ列(0始まり)
* @param firstRow データ範囲の開始行(0始まり)
* @param lastRow データ範囲の終了行(0始まり)
*/
private void applyValidation(XSSFSheet sheet, int listColIndex, int itemSize,
int targetColIndex, int firstRow, int lastRow) {
if (itemSize <= 0) {
return;
}
// 選択肢範囲を絶対参照で組み立てる(例 $BB$2:$BB$50)
String colLetter = CellReference.convertNumToColString(listColIndex);
int fromRow = LIST_START_ROW + 1; // 1始まりの行番号
int toRow = LIST_START_ROW + itemSize;
String formula = "$" + colLetter + "$" + fromRow + ":$" + colLetter + "$" + toRow;
XSSFDataValidationHelper helper = new XSSFDataValidationHelper(sheet);
DataValidationConstraint constraint = helper.createFormulaListConstraint(formula);
CellRangeAddressList addressList =
new CellRangeAddressList(firstRow, lastRow, targetColIndex, targetColIndex);
DataValidation validation = helper.createValidation(constraint, addressList);
// xlsx(XSSF) では矢印表示に true が必要(HSSFとは逆)
validation.setSuppressDropDownArrow(true);
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
}
/**
* 選択肢列を非表示にする。
*
* @param sheet 対象シート
* @param listColIndex 非表示にする列(0始まり)
*/
private void hideColumn(XSSFSheet sheet, int listColIndex) {
sheet.setColumnHidden(listColIndex, true);
}
}
チェック処理
package com.example.export.common.impl;
// モード検証、明細空判定、出力可否、DB行数、UPDATE照合
/**
* {@link ValidateCheck} の実装。エクスポート/インポートのチェックを集約する。
*
* ■ 提供するチェック(公開メソッド)
* - isValidMode … 画面モードが有効か(不正なら false)
* - isMeisaiEmpty … 明細が実質空か(全項目空も空とみなす)
* - validateExportable … 未保存明細ありなら出力不可メッセージを積む
* - validateNoUnsavedChange… UPDATE時、DBと画面を突合し未保存の変更を検知
*
* ■ このクラスに集約している「基準」
* - 空判定の基準 … isAllBlank(全機能で同一基準を使う)
* - 照合の型変換 … eqStr / eqNum / eqDate
* DB側 BigDecimal/Date と 画面側 文字列 を、項目ごとに型を合わせて比較する
*/
public class ValidateCheckImpl implements ValidateCheck {
/** 画面モード(★要置換 定数クラスの値を参照する) */
private static final String GAMEN_MODE_NEW = "NEW";
private static final String GAMEN_MODE_COPY = "COPY";
private static final String GAMEN_MODE_UPDATE = "UPDATE";
/** 画面の日付文字列フォーマット(★要置換 画面の実フォーマットに合わせる) */
private static final String SCREEN_DATE_FORMAT = "yyyy/MM/dd";
// ==================================================================
// 【公開】各チェック … Logic層から呼ばれるチェックの入口
// ==================================================================
@Override
public boolean isValidMode(String mode) {
if (StringUtils.isBlank(mode)) {
return false;
}
return GAMEN_MODE_NEW.equals(mode)
|| GAMEN_MODE_COPY.equals(mode)
|| GAMEN_MODE_UPDATE.equals(mode);
}
@Override
public boolean isMeisaiEmpty(List<RecordDto> meisai) {
if (CollectionUtils.isEmpty(meisai)) {
return true;
}
for (RecordDto row : meisai) {
if (!isAllBlank(row)) {
return false;
}
}
return true;
}
@Override
public void validateExportable(boolean hasUnsavedMeisai, ValidationResult validation) {
if (hasUnsavedMeisai) {
// ★要置換 メッセージID(未保存の明細が存在するため出力不可)
validation.add("MSG_EXPORT_UNSAVED_MEISAI").build();
}
}
@Override
public void validateNoUnsavedChange(List<RecordModel> dbList, List<RecordDto> screenList,
ValidationResult validation) {
int dbSize = (dbList == null) ? 0 : dbList.size();
int screenSize = (screenList == null) ? 0 : screenList.size();
// 件数が違えば未保存の変更あり
if (dbSize != screenSize) {
// ★要置換 メッセージID(未保存の変更があるため出力不可)
validation.add("MSG_EXPORT_UNSAVED_CHANGE").build();
return;
}
// 各行の全項目を、型を合わせて比較。1つでも異なれば未保存
for (int i = 0; i < dbSize; i++) {
if (!isSameRow(dbList.get(i), screenList.get(i))) {
validation.add("MSG_EXPORT_UNSAVED_CHANGE").build();
return;
}
}
}
// ==================================================================
// 【内部】突合の判定 … 1行分の全項目を、型を合わせて比較する
// ==================================================================
/**
* DB明細1行(BigDecimal/Date含む)と画面明細1行(全文字列)が等しいかを、
* 項目ごとに型を合わせて判定する。
*
* @param dbMeisai DB側の明細(BigDecimal/Date)
* @param screenMeisai 画面側の明細(文字列)
* @return 全項目が等しければ true
*/
private boolean isSameRow(RecordModel dbMeisai, RecordDto screenMeisai) {
if (dbMeisai == null || screenMeisai == null) {
return dbMeisai == null && screenMeisai == null;
}
// 文字列項目はそのまま比較(null と空文字は同一視)
// 数値項目は BigDecimal に揃えて比較("1.0" と "1.00" を同値扱い)
// 日付項目は Date に揃えて比較(形式差を吸収)
return isSameText(dbMeisai.getShohinCode(), screenMeisai.getShohinCode())
&& isSameText(dbMeisai.getShohinName(), screenMeisai.getShohinName())
&& isSameNumber(dbMeisai.getSuryo(), screenMeisai.getSuryo())
&& isSameNumber(dbMeisai.getTanka(), screenMeisai.getTanka())
&& isSameNumber(dbMeisai.getKingaku(), screenMeisai.getKingaku())
&& isSameText(dbMeisai.getTorihikiCode(), screenMeisai.getTorihikiCode())
&& isSameText(dbMeisai.getTorihikiName(), screenMeisai.getTorihikiName())
&& isSameText(dbMeisai.getTantosha(), screenMeisai.getTantosha())
&& isSameDate(dbMeisai.getTorokubi(), screenMeisai.getTorokubi())
&& isSameDate(dbMeisai.getKoshinbi(), screenMeisai.getKoshinbi())
&& isSameText(dbMeisai.getBiko(), screenMeisai.getBiko())
&& isSameText(dbMeisai.getKanriNo(), screenMeisai.getKanriNo());
}
/**
* 文字列が同じ値かを判定する。null と空文字(空白のみ含む)は「値なし」として同一視する。
*
* @param dbValue DB側の値
* @param screenValue 画面側の値
* @return 同じ値なら true
*/
private boolean isSameText(String dbValue, String screenValue) {
// 両方が空(null / 空文字 / 空白のみ)なら、値なし同士として同じとみなす
if (StringUtils.isBlank(dbValue) && StringUtils.isBlank(screenValue)) {
return true;
}
// それ以外は通常の文字列比較(StringUtils.equals は null 安全)
return StringUtils.equals(dbValue, screenValue);
}
/**
* 数値が同じ値かを判定する。DB側 BigDecimal と画面側文字列を BigDecimal に揃えて比較する。
* 値としての等価を見るため compareTo を使う("1.0" と "1.00" を同値扱い)。
*
* @param dbValue DB側の数値
* @param screenValue 画面側の数値文字列
* @return 数値として等しければ true
*/
private boolean isSameNumber(BigDecimal dbValue, String screenValue) {
boolean dbEmpty = (dbValue == null);
boolean screenEmpty = StringUtils.isBlank(screenValue);
if (dbEmpty || screenEmpty) {
return dbEmpty && screenEmpty; // 両方空なら等しい
}
try {
BigDecimal screenNumber = new BigDecimal(screenValue.trim());
return dbValue.compareTo(screenNumber) == 0;
} catch (NumberFormatException e) {
// 画面値が数値に変換できない場合は不一致扱い
return false;
}
}
/**
* 日付が同じ値かを判定する。DB側 Date と画面側文字列を Date に揃えて比較する。
*
* @param dbValue DB側の日付
* @param screenValue 画面側の日付文字列
* @return 日付として等しければ true
*/
private boolean isSameDate(Date dbValue, String screenValue) {
boolean dbEmpty = (dbValue == null);
boolean screenEmpty = StringUtils.isBlank(screenValue);
if (dbEmpty || screenEmpty) {
return dbEmpty && screenEmpty;
}
try {
// SimpleDateFormat はスレッドセーフでないため都度生成
SimpleDateFormat sdf = new SimpleDateFormat(SCREEN_DATE_FORMAT);
sdf.setLenient(false);
Date screenDate = sdf.parse(screenValue.trim());
// 画面はyyyy/MM/dd想定のため、DB側も同フォーマットで正規化して比較
String dbFormatted = sdf.format(dbValue);
String screenFormatted = sdf.format(screenDate);
return dbFormatted.equals(screenFormatted);
} catch (ParseException e) {
return false;
}
}
// ==================================================================
// 【内部】空判定の基準 … 明細1行が全項目空かどうか(全機能で共通)
// ==================================================================
/**
* 明細1行の全対象項目が空かどうかを判定する(画面明細=全文字列)。
* 空判定の基準をこのメソッドに集約し、他のチェックと基準を統一する。
*
* @param meisai 判定対象の明細1行
* @return 全項目が空なら true、1項目でも値があれば false
*/
private boolean isAllBlank(RecordDto meisai) {
if (meisai == null) {
return true;
}
// ★要置換 実際の明細項目に合わせて全項目を判定する
return StringUtils.isBlank(meisai.getShohinCode())
&& StringUtils.isBlank(meisai.getShohinName())
&& StringUtils.isBlank(meisai.getSuryo())
&& StringUtils.isBlank(meisai.getTanka())
&& StringUtils.isBlank(meisai.getKingaku())
&& StringUtils.isBlank(meisai.getTorihikiCode())
&& StringUtils.isBlank(meisai.getTorihikiName())
&& StringUtils.isBlank(meisai.getTantosha())
&& StringUtils.isBlank(meisai.getBiko())
&& StringUtils.isBlank(meisai.getKanriNo());
}
}
チェック処理 具体例
package com.example.export.logic.impl;
/**
*
* ■ 処理の流れ(excelFileExport の上から順に)
* 1. モード検証 … 画面モードが有効か。不正なら null を返し中断(ValidateCheck へ委譲)
* 2. DB明細取得 … NEW 以外のとき、画面ロード時の明細をDBから取得(loadDbModels)
* 3. 出力種別の決定 … モード×明細有無から ExportKind を決める(decideExportKind)
* 4. 出力可否の検証 … 未保存明細の有無、UPDATE時の突合チェック(ValidateCheck へ委譲)
* 5. ファイル生成 … 出力種別ごとに、そのまま出力/データ入り出力を振り分け(exportByKind)
* - 明細なし出力(リストボックスのみ設定、writeToTemplate に空リスト)
* - データ転記+リストボックス出力(writeToTemplate、リストボックスは本クラス内)
* 5-6. ファイル生成 … 出力ファイル名を先に決め、最初から正式名で生成(リネーム不要)
*
* ■ テンプレートの前提
* - 1 行目にヘッダーが記載済み
* - データ行は全セルが文字列の表示書式(スタイルはコードで触らない)
* - 対象外の列は背景色設定済み
*/
public class MeisaiLogicImpl extends AbstractExporter implements MeisaiLogic {
// ==================================================================
// 定数・フィールド(テンプレート位置、ファイル名規則、委譲先の各ロジック)
// ===================================================================
/** 画面モード(★要置換 既存クラスの定数を参照する) */
private static final String GAMEN_MODE_NEW = "NEW";
private static final String GAMEN_MODE_COPY = "COPY";
private static final String GAMEN_MODE_UPDATE = "UPDATE";
private static final String GAMEN_MODE_REFERENCE = "REFERENCE";
/** テンプレートのクラスパス位置(★要置換 実際の配置に合わせる) */
private static final String TEMPLATE_PATH = "com/example/export/template.xlsm";
/** ヘッダー行(0 始まり) */
private static final int HEADER_ROW_INDEX = 0;
/** データ開始行(0 始まり。ヘッダーの次) */
private static final int DATA_START_ROW_INDEX = 1;
/**
* 入力規則(ドロップダウン)を設定する行数。
* データ件数に関わらず常にこの行数分(2行目〜1001行目)に設定する。
*/
private static final int LIST_BOX_ROW_COUNT = 1000;
/** ダミー発注依頼番号(★要置換 実際のダミー番号ルールに合わせる) */
private static final String DUMMY_ORDER_NO = "H0000000000000";
/** ファイル名の日時フォーマット */
private static final DateTimeFormatter FILE_TS_FORMAT =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** ファイル名のプレフィックス・拡張子 */
private static final String FILE_PREFIX = "hatchu_";
private static final String FILE_EXT = ".xlsm";
/** DB取得DAO(★要置換 実プロジェクトのDI/生成方法に合わせる) */
private RecordDao recordDao = new RecordDaoImpl();
/** チェック処理(★要置換 実プロジェクトのDI/生成方法に合わせる) */
private ValidateCheck validateCheck = new ValidateCheckImpl();
/** 汎用マスタDAO(引数の区分コードを変えて取得。★要置換) */
private GeneralMasterDao generalMasterDao = new GeneralMasterDaoImpl();
/** 別マスタDAO(専用の取得メソッド。★要置換) */
private OtherMasterDao otherMasterDao = new OtherMasterDaoImpl();
/** リストボックスの選択肢を出力する開始行(0始まり。ヘッダー回避で1行目から) */
private static final int LIST_START_ROW = 1;
/**
* リストボックスの識別子。データ転記時の値変換(コード→表示名)で、
* どのマスタを使うかを、この識別子で ColumnDef と紐付ける。
*/
private enum ListBoxKind {
NONE, // リストボックス対象外(変換しない)
GENERAL_1, // 汎用マスタ 区分1
GENERAL_2, // 汎用マスタ 区分2
OTHER_1, // 別マスタ1
OTHER_2 // 別マスタ2
}
// ==================================================================
// リストボックス設定(どのマスタの値を、どの列のドロップダウンにするか)
// ★要置換 実際の区分コード・対象列・出力列に合わせる
// リストボックスは4つ。全てマスタ由来・結合不要。
// 1,2 … 汎用マスタから区分コードを変えて取得
// 3,4 … 別マスタから別DAOで取得
// ===================================================================
// 汎用マスタの区分コード(1,2つ目。引数だけ変えて同じ方法で取得)
private static final String KUBUN_1 = "KUBUN_1";
private static final String KUBUN_2 = "KUBUN_2";
// ドロップダウンを設定するデータ列(0始まり)。仮で X列以降(X=23, Y=24, Z=25, AA=26)
private static final int LB_1_TARGET_COL = 23; // X列
private static final int LB_2_TARGET_COL = 24; // Y列
private static final int LB_3_TARGET_COL = 25; // Z列
private static final int LB_4_TARGET_COL = 26; // AA列
// 選択肢を出力する列(0始まり。★後日共有される値に差し替え。仮で BB以降)
private static final int LB_1_LIST_COL = 53; // 仮BB
private static final int LB_2_LIST_COL = 54; // 仮BC
private static final int LB_3_LIST_COL = 55; // 仮BD
private static final int LB_4_LIST_COL = 56; // 仮BE
// ==================================================================
// 出力種別の定義(モードと明細有無から、この4種類のどれかに分類する)
// ===================================================================
/**
* エクスポートの出力種別。このクラス内でのみ使用する内部enum。
*/
private enum ExportKind {
/** テンプレートそのまま出力・ダミー番号(NEW / COPY 明細なし) */
TEMPLATE_DUMMY,
/** テンプレートそのまま出力・正規番号(UPDATE でDB明細なし) */
TEMPLATE_WITH_ORDER_NO,
/** DB明細を反映して出力(UPDATE でDB明細あり) */
UPDATE_WITH_DATA,
/** 出力不可。未保存の明細が存在するため中断(NEW / COPY 明細あり) */
NOT_EXPORTABLE
}
// ==================================================================
// 【入口】excelFileExport … 検証→取得→種別決定→検証→出力の順に進める
// ===================================================================
@Override
public ResponseModel excelFileExport(SearchCriteria criteria, FileDto fileDto) {
ResponseModel responseModel;
// === STEP 1. モード検証 ===
// 画面モードが定数のいずれとも一致しない場合の扱い。
// モード不一致は、UIから正常操作していれば起こり得ないプログラム的な異常であり、
// かつ専用のエラーコードが用意されていない(明示的なエラー表示ができない)。
// そのため、ここでは意図的に null を返し、後段でのハンドリング(フレームワークの
// 内部エラー)に処理を委ねる。※これはバグではなく意図的な挙動。安易に修正しないこと。
if (!validateCheck.isValidMode(criteria.getGamenMode())) {
responseModel = null;
return responseModel;
}
ValidationResult validation = new ValidationResult();
// === STEP 2. DB明細取得 ===
// 画面ロード時の明細をDBから取得する。
// NEW は取得処理を行わない(既存メソッドが NEW 未定義で null を返し得るため)。
// UPDATE / REFERENCE / COPY のときだけ取得する。
List<RecordModel> dbModels = loadDbModels(criteria);
// === STEP 3. 出力種別の決定 ===
// モード × DB明細有無 × 画面明細有無 から、どう出力するかを決める。
ExportKind kind = decideExportKind(criteria, dbModels);
// === STEP 4. 出力可否の検証 ===
// 4-1. 未保存明細ありは出力不可(NEW/COPYで画面に明細がある場合)
validateCheck.validateExportable(kind == ExportKind.NOT_EXPORTABLE, validation);
// 4-2. UPDATEでDB明細ありは、DBと画面を突合して未保存の変更を検知
// DB側は BigDecimal/Date、画面側は文字列のため、型を合わせて比較する
if (kind == ExportKind.UPDATE_WITH_DATA
&& !validateCheck.isNoUnsavedChange(dbModels, criteria.getScreenMeisai())) {
// 変更なし=trueなので、falseなら未保存の変更ありとしてメッセージを積む
// ★要置換 メッセージID(未保存の変更があるため出力不可)
validation.add("MSG_EXPORT_UNSAVED_CHANGE").build();
}
// 検証でエラーがあれば、メッセージを積んで中断
if (validation.hasError()) {
responseModel = toErrorModel(validation);
return responseModel;
}
// === STEP 5. ファイル生成 ===
// 出力ファイル名を先に決め、出力種別ごとに、そのまま出力/データ入り出力を振り分ける。
// ファイルは最初から正式名で生成するため、リネームは行わない。
responseModel = exportByKind(criteria, dbModels, fileDto, kind);
return responseModel;
}
// ==================================================================
// STEP 2・3 の実体:DB明細の取得と、出力種別の決定
// ===================================================================
/**
* 画面ロード時の明細をDBから取得する。
* NEW のときは取得処理を行わず空リストを返す
* (既存の取得メソッドは NEW が未定義で、初期値 null を返す可能性があるため呼ばない)。
* UPDATE / REFERENCE / COPY のときだけ既存メソッドで取得する。
*
* @param criteria 画面モード・発注依頼番号を保持する検索条件
* @return DB明細のリスト(NEW または未取得時は空リスト。null は返さない)
*/
private List<RecordModel> loadDbModels(SearchCriteria criteria) {
String mode = criteria.getGamenMode();
if (GAMEN_MODE_NEW.equals(mode)) {
// NEW は取得しない
return new ArrayList<RecordModel>();
}
// UPDATE / REFERENCE / COPY は既存メソッドで取得
List<RecordModel> models = recordDao.findByOrderNo(criteria.getOrderNo());
// 既存メソッドが null を返す可能性に備え、null は空リストに正規化する
return (models == null) ? new ArrayList<RecordModel>() : models;
}
/**
* 画面モードとDB明細・画面明細の有無から、出力種別を決定する。
* モードは事前に検証済みの前提。
*
* @param criteria 画面モード・画面明細を保持する検索条件
* @param dbModels DBから取得済みの明細(null/空は「DB明細なし」)
* @return 決定した出力種別
*/
private ExportKind decideExportKind(SearchCriteria criteria, List<RecordModel> dbModels) {
String mode = criteria.getGamenMode();
boolean screenEmpty = validateCheck.isMeisaiEmpty(criteria.getScreenMeisai());
boolean dbEmpty = (dbModels == null || dbModels.isEmpty());
// NEW / COPY は同一扱い(明細ありは未保存とみなし出力不可)
if (GAMEN_MODE_NEW.equals(mode) || GAMEN_MODE_COPY.equals(mode)) {
return screenEmpty ? ExportKind.TEMPLATE_DUMMY : ExportKind.NOT_EXPORTABLE;
}
// それ以外(UPDATE)は DB明細の有無で分岐
return dbEmpty ? ExportKind.TEMPLATE_WITH_ORDER_NO : ExportKind.UPDATE_WITH_DATA;
}
// ==================================================================
// STEP 5:出力種別ごとに、生成方法を振り分ける
// ===================================================================
/**
* 出力種別ごとにファイルを生成し、Modelに載せて返す。
*
* @param criteria 検索条件
* @param dbModels DBから取得済みの明細(UPDATE_WITH_DATAで使用)
* @param fileDto ファイル情報
* @param kind 決定済みの出力種別
* @return 生成したファイルを載せたModel
*/
private ResponseModel exportByKind(SearchCriteria criteria, List<RecordModel> dbModels,
FileDto fileDto, ExportKind kind) {
switch (kind) {
case TEMPLATE_DUMMY:
// 明細なし。データ転記はしないが、リストボックスは設定して出力(ダミー番号)
return toFileModel(writeToTemplate(new ArrayList<RecordDto>(), buildDummyFileName()));
case TEMPLATE_WITH_ORDER_NO:
// 明細なし。データ転記はしないが、リストボックスは設定して出力(発注依頼番号)
return toFileModel(writeToTemplate(new ArrayList<RecordDto>(), buildRealFileName(criteria)));
case UPDATE_WITH_DATA:
// DB明細を転記+リストボックス設定して、発注依頼番号のファイル名で出力
List<RecordDto> dtoList = toDtoList(dbModels);
return toFileModel(writeToTemplate(dtoList, buildRealFileName(criteria)));
default:
return new ResponseModel();
}
}
// ==================================================================
// ファイル名の組み立て(hatchu_番号_日時.xlsm。番号はダミー or 発注依頼番号)
// ===================================================================
/**
* ダミー番号を使ったファイル名を生成する。
*
* @return hatchu_(ダミー番号)_(yyyyMMddHHmmss).xlsm
*/
private String buildDummyFileName() {
return buildFileName(DUMMY_ORDER_NO);
}
/**
* 正規の発注依頼番号を使ったファイル名を生成する。
*
* @param criteria 発注依頼番号を保持する検索条件
* @return hatchu_(発注依頼番号)_(yyyyMMddHHmmss).xlsm
*/
private String buildRealFileName(SearchCriteria criteria) {
return buildFileName(criteria.getOrderNo());
}
/**
* 発注依頼番号と現在時刻からファイル名を組み立てる。
*
* @param orderNo 発注依頼番号(ダミーまたは正規)
* @return 形式 hatchu_(番号)_(yyyyMMddHHmmss).xlsm
*/
private String buildFileName(String orderNo) {
String timestamp = LocalDateTime.now().format(FILE_TS_FORMAT);
return FILE_PREFIX + orderNo + "_" + timestamp + FILE_EXT;
}
// ==================================================================
// 出力方法B:テンプレートにデータを転記+リストボックス設定して出力
// ===================================================================
/**
* テンプレートを開き、ヘッダー名で列を突き合わせて値を転記し、指定ファイル名で出力する。
* スタイル・表示書式・背景色はテンプレート側に設定済みのため、コードでは触らない。
* 出力先を最初から正式名で作るため、後でのリネームは不要。
*
* @param dtoList 転記する出力用DTOのリスト
* @param fileName 出力ファイル名(hatchu_番号_日時.xlsm)
* @return 生成した出力ファイル
*/
private File writeToTemplate(List<RecordDto> dtoList, String fileName) {
File outputFile = newOutputFile(fileName);
// InputStream と FileOutputStream は AutoCloseable のため try-with-resources で自動クローズ。
// XSSFWorkbook は POI 3.9 では AutoCloseable でない(close も無い)ため、括弧外で扱う。
try (InputStream is = openTemplate();
FileOutputStream fos = new FileOutputStream(outputFile)) {
XSSFWorkbook workbook = new XSSFWorkbook(is);
XSSFSheet sheet = workbook.getSheetAt(0);
Row headerRow = sheet.getRow(HEADER_ROW_INDEX);
Map<String, Integer> headerIndex = buildHeaderIndex(headerRow);
validateColumns(headerIndex);
// 4つのマスタを取得(型は取得元ごとに異なる)
// 1,2 汎用マスタ(GeneralMaster)/3 専用dto(OtherListItem)/4 文字列
List<GeneralMaster> master1 = generalMasterDao.findByKubun(KUBUN_1);
List<GeneralMaster> master2 = generalMasterDao.findByKubun(KUBUN_2);
List<OtherListItem> master3 = otherMasterDao.findListItems1();
List<String> master4 = otherMasterDao.findListItems2();
// リストボックス種別ごとの「コード→表示名」変換Mapを用意
// 変換ありは 1,2,3。4(文字列)は変換しないためMapを持たない
Map<ListBoxKind, Map<String, String>> labelMaps =
buildLabelMaps(master1, master2, master3);
// データ転記(対象列はコード→表示名に変換して書く)
int rowIndex = DATA_START_ROW_INDEX;
for (RecordDto dto : dtoList) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
row = sheet.createRow(rowIndex);
}
rowIndex++;
for (ColumnDef col : COLUMNS) {
int colIndex = headerIndex.get(col.header).intValue();
Cell cell = row.getCell(colIndex);
if (cell == null) {
cell = row.createCell(colIndex);
}
String value = toStr(getValue(dto, col.fieldName));
// リストボックス対象列のうち、変換Mapを持つ種別(1,2,3)だけコード→表示名に変換。
// 4つ目(OTHER_2=文字列)は変換Mapを持たないため、値をそのまま書く。
Map<String, String> labelMap = labelMaps.get(col.listBoxKind);
if (labelMap != null) {
value = convertCodeToLabel(labelMap, value);
}
cell.setCellValue(value);
}
}
// リストボックス(入力規則)を設定。データ件数に関わらず常に1000行固定。
// 選択肢は取得済みのマスタから作る(表示名。変換後の値と一致させる)
writeAllListBoxes(sheet, master1, master2, master3, master4);
workbook.write(fos);
// workbook.close() は POI 3.9 に存在しないため呼ばない
} catch (IOException e) {
throw new RuntimeException("テンプレート出力に失敗しました", e);
}
return outputFile;
}
/**
* リストボックス種別ごとに「コード→表示名」の変換Mapを作る。
*
* @param master1 汎用マスタ区分1
* @param master2 汎用マスタ区分2
* @param master3 別マスタ1
* @param master4 別マスタ2
* @return 種別をキー、コード→表示名Mapを値とするMap
*/
private Map<ListBoxKind, Map<String, String>> buildLabelMaps(
List<GeneralMaster> master1, List<GeneralMaster> master2,
List<OtherListItem> master3) {
Map<ListBoxKind, Map<String, String>> maps =
new HashMap<ListBoxKind, Map<String, String>>();
// 1,2 汎用マスタ(getCode/getLabel)
maps.put(ListBoxKind.GENERAL_1, toCodeLabelMap(master1));
maps.put(ListBoxKind.GENERAL_2, toCodeLabelMap(master2));
// 3 専用dto(public フィールド code/label)
maps.put(ListBoxKind.OTHER_1, toCodeLabelMapFromOther(master3));
// 4(String)は変換しないため Map を持たない
return maps;
}
/**
* 汎用マスタのリストを「コード→表示名」のMapにする(getCode/getLabel)。
*
* @param masters 汎用マスタのリスト
* @return コードをキー、表示名を値とするMap
*/
private Map<String, String> toCodeLabelMap(List<GeneralMaster> masters) {
Map<String, String> map = new HashMap<String, String>();
if (masters == null) {
return map;
}
for (GeneralMaster m : masters) {
map.put(m.getCode(), m.getLabel());
}
return map;
}
/**
* 専用dtoのリストを「コード→表示名」のMapにする。
* コード・表示名は public フィールド(code/label)で取得する。
*
* @param items 専用dtoのリスト
* @return コードをキー、表示名を値とするMap
*/
private Map<String, String> toCodeLabelMapFromOther(List<OtherListItem> items) {
Map<String, String> map = new HashMap<String, String>();
if (items == null) {
return map;
}
for (OtherListItem item : items) {
map.put(item.code, item.label);
}
return map;
}
/**
* DB値(コード)を、変換Mapで表示名に変換する。
* 空値、またはMapに無いコードは、空文字にする(選択肢に無い値は書かない)。
*
* @param labelMap コード→表示名のMap
* @param code DB値(コード)
* @return 対応する表示名。無ければ空文字
*/
private String convertCodeToLabel(Map<String, String> labelMap, String code) {
if (labelMap == null || code == null || code.isEmpty()) {
return "";
}
String label = labelMap.get(code);
return (label == null) ? "" : label;
}
/** 値を文字列にする(null は空文字)。 */
private String toStr(Object value) {
return (value == null) ? "" : value.toString();
}
/**
* 出力先ファイルを、指定ファイル名で一時ディレクトリに用意する。
*
* @param fileName 出力ファイル名
* @return 出力先の File(未作成の状態)
*/
private File newOutputFile(String fileName) {
// システムの一時ディレクトリ配下に、正式名で出力先を決める
String tempDir = System.getProperty("java.io.tmpdir");
return new File(tempDir, fileName);
}
/**
* 4種類のリストボックスを出力する。選択肢は取得済みのマスタの表示名を使う。
* 入力規則はデータ件数に関わらず常に1000行固定(2行目〜1001行目)で設定する。
* リストボックス出力はエクスポート独自の処理のため、このクラス内に持つ。
*
* @param sheet 対象シート
* @param master1 汎用マスタ区分1(リストボックス1つ目、GeneralMaster)
* @param master2 汎用マスタ区分2(リストボックス2つ目、GeneralMaster)
* @param master3 専用マスタ(リストボックス3つ目、OtherListItem)
* @param master4 専用マスタ(リストボックス4つ目、文字列。変換なし)
*/
private void writeAllListBoxes(XSSFSheet sheet,
List<GeneralMaster> master1, List<GeneralMaster> master2,
List<OtherListItem> master3, List<String> master4) {
// 入力規則を設定する行範囲(0始まり)。2行目〜1001行目に固定
int firstRow = DATA_START_ROW_INDEX;
int lastRow = DATA_START_ROW_INDEX + LIST_BOX_ROW_COUNT - 1;
// 選択肢の作り方は型ごとに異なる(1,2は汎用/3は専用dto/4は文字列そのまま)
writeListBox(sheet, toItems(master1), LB_1_TARGET_COL, LB_1_LIST_COL, firstRow, lastRow);
writeListBox(sheet, toItems(master2), LB_2_TARGET_COL, LB_2_LIST_COL, firstRow, lastRow);
writeListBox(sheet, toItemsFromOther(master3), LB_3_TARGET_COL, LB_3_LIST_COL, firstRow, lastRow);
// 4つ目は文字列リストをそのまま選択肢にする(変換なし)
writeListBox(sheet, (master4 == null) ? new ArrayList<String>() : master4,
LB_4_TARGET_COL, LB_4_LIST_COL, firstRow, lastRow);
}
/**
* 専用dtoのリストから、選択肢文字列(表示名)のリストを作る。
* 表示名は public フィールド label で取得する。
*
* @param items 専用dtoのリスト
* @return 選択肢文字列のリスト
*/
private List<String> toItemsFromOther(List<OtherListItem> items) {
List<String> result = new ArrayList<String>();
if (items == null) {
return result;
}
for (OtherListItem item : items) {
result.add(item.label);
}
return result;
}
/**
* リストボックス(入力規則)を1種類、シートに出力する。
* 選択肢を外れた列に縦出力 → 参照範囲方式の入力規則を対象列に設定 → 選択肢列を非表示。
* インポートが1シート目固定のため、選択肢は2シート目でなく1シート目の外れた列に置く。
*
* @param sheet 対象シート
* @param items 選択肢文字列のリスト
* @param dropdownColIndex ドロップダウン(入力規則)を設定する列(0始まり。ユーザーが選択する列)
* @param choiceListColIndex 選択肢リストを書き込む列(0始まり。印刷範囲外の外れた列。非表示にする)
* @param firstRow データ範囲の開始行(0始まり)
* @param lastRow データ範囲の終了行(0始まり)
*/
private void writeListBox(XSSFSheet sheet, List<String> items,
int dropdownColIndex, int choiceListColIndex, int firstRow, int lastRow) {
// 1. 選択肢を外れた列に縦に書き出す
writeListColumn(sheet, choiceListColIndex, items);
// 2. その範囲を参照する入力規則(ドロップダウン)を対象列に設定
applyListValidation(sheet, choiceListColIndex, items.size(), dropdownColIndex, firstRow, lastRow);
// 3. 選択肢を書いた列を非表示にする
sheet.setColumnHidden(choiceListColIndex, true);
}
/**
* 選択肢を指定列へ縦に出力する。
*
* @param sheet 対象シート
* @param choiceListColIndex 選択肢リストの書き込み先の列(0始まり)
* @param items 選択肢文字列のリスト
*/
private void writeListColumn(XSSFSheet sheet, int choiceListColIndex, List<String> items) {
for (int i = 0; i < items.size(); i++) {
int rowIndex = LIST_START_ROW + i;
Row row = sheet.getRow(rowIndex);
if (row == null) {
row = sheet.createRow(rowIndex);
}
row.createCell(choiceListColIndex).setCellValue(items.get(i));
}
}
/**
* 選択肢の範囲を参照する入力規則を、対象データ列に設定する。
* 選択肢が0件の場合は設定しない。参照範囲方式のため255文字制限を受けない。
*
* @param sheet 対象シート
* @param choiceListColIndex 選択肢リストを書き込んだ列(0始まり)
* @param itemSize 選択肢件数
* @param dropdownColIndex ドロップダウン設定先の列(0始まり。ユーザーが選択する列)
* @param firstRow データ範囲の開始行(0始まり)
* @param lastRow データ範囲の終了行(0始まり)
*/
private void applyListValidation(XSSFSheet sheet, int choiceListColIndex, int itemSize,
int dropdownColIndex, int firstRow, int lastRow) {
if (itemSize <= 0) {
return;
}
// 選択肢範囲を絶対参照で組み立てる(例 $BB$2:$BB$50)
String colLetter = CellReference.convertNumToColString(choiceListColIndex);
int fromRow = LIST_START_ROW + 1; // 1始まりの行番号
int toRow = LIST_START_ROW + itemSize;
String formula = "$" + colLetter + "$" + fromRow + ":$" + colLetter + "$" + toRow;
XSSFDataValidationHelper helper = new XSSFDataValidationHelper(sheet);
DataValidationConstraint constraint = helper.createFormulaListConstraint(formula);
CellRangeAddressList addressList =
new CellRangeAddressList(firstRow, lastRow, dropdownColIndex, dropdownColIndex);
DataValidation validation = helper.createValidation(constraint, addressList);
// xlsx(XSSF) では矢印表示に true が必要(HSSFとは逆)
validation.setSuppressDropDownArrow(true);
validation.setShowErrorBox(true);
sheet.addValidationData(validation);
}
/**
* 汎用マスタのリストを選択肢文字列リストにする(結合なし・表示名をそのまま)。
* ★要置換 選択肢に使う項目(コードか表示名か)を実際に合わせる。
*
* @param masters 汎用マスタのリスト
* @return 選択肢文字列のリスト
*/
private List<String> toItems(List<GeneralMaster> masters) {
List<String> items = new ArrayList<String>();
for (GeneralMaster m : masters) {
// 結合不要。取得した値をそのまま選択肢にする
items.add(m.getLabel());
}
return items;
}
/** クラスパス上のテンプレートを InputStream で取得する。 */
private InputStream openTemplate() {
InputStream is = getClass().getClassLoader().getResourceAsStream(TEMPLATE_PATH);
if (is == null) {
throw new IllegalStateException("テンプレートが見つかりません: " + TEMPLATE_PATH);
}
return is;
}
/** テンプレートのヘッダー行から「ヘッダー名 -> 列インデックス」を作る。 */
private Map<String, Integer> buildHeaderIndex(Row headerRow) {
Map<String, Integer> map = new HashMap<String, Integer>();
for (int j = 0; j < headerRow.getLastCellNum(); j++) {
Cell cell = headerRow.getCell(j);
if (cell != null && cell.getCellType() == Cell.CELL_TYPE_STRING) {
String header = cell.getStringCellValue().trim();
map.put(header, Integer.valueOf(j));
}
}
return map;
}
/**
* 列定義のヘッダーがすべてテンプレートに存在するか確認する。
*
* @param headerIndex テンプレートのヘッダー対応表
* @throws IllegalStateException 定義したヘッダーがテンプレートに無い場合
*/
private void validateColumns(Map<String, Integer> headerIndex) {
List<String> missing = new ArrayList<String>();
for (ColumnDef col : COLUMNS) {
if (!headerIndex.containsKey(col.header)) {
missing.add(col.header);
}
}
if (!missing.isEmpty()) {
throw new IllegalStateException(
"テンプレートに存在しないヘッダーがあります: " + missing);
}
}
// ==================================================================
// DB明細(RecordModel)→出力用(RecordDto)へ変換(数値・日付を文字列化)
// ===================================================================
/**
* DBから取得した model を、ファイル出力用の dto へ詰め替える。
* 数値(BigDecimal)・日付(Date)は文字列へ変換する。
*
* @param models DB明細モデルのリスト
* @return 出力用DTOのリスト
*/
private List<RecordDto> toDtoList(List<RecordModel> models) {
List<RecordDto> dtoList = new ArrayList<RecordDto>();
for (RecordModel m : models) {
RecordDto dto = new RecordDto();
dto.setShohinCode(m.getShohinCode());
dto.setShohinName(m.getShohinName());
dto.setSuryo(numToStr(m.getSuryo()));
dto.setTanka(numToStr(m.getTanka()));
dto.setKingaku(numToStr(m.getKingaku()));
dto.setTorihikiCode(m.getTorihikiCode());
dto.setTorihikiName(m.getTorihikiName());
dto.setTantosha(m.getTantosha());
dto.setTorokubi(dateToStr(m.getTorokubi()));
dto.setKoshinbi(dateToStr(m.getKoshinbi()));
dto.setBiko(m.getBiko());
dto.setKanriNo(m.getKanriNo());
dtoList.add(dto);
}
return dtoList;
}
/** BigDecimal を文字列にする(null は空文字)。 */
private String numToStr(java.math.BigDecimal v) {
return (v == null) ? "" : v.toPlainString();
}
/** Date を文字列にする(null は空文字。★フォーマットは画面に合わせる)。 */
private String dateToStr(java.util.Date v) {
if (v == null) {
return "";
}
return new SimpleDateFormat("yyyy/MM/dd").format(v);
}
// ==================================================================
// 列定義(出力するフィールドと、テンプレートのヘッダー名の対応表)
// ===================================================================
/**
* 1 列分の定義(dto フィールド名・ヘッダー名・リストボックス種別)。
* listBoxKind が NONE 以外の列は、DB値(コード)を対応マスタの表示名に変換して転記する。
*/
/** 値の種別(文字列変換・整形の判断に使う)。 */
private enum ValueType { TEXT, NUMBER, DATE }
/**
* 1 列分の定義(dto フィールド名・ヘッダー名・リストボックス種別・値の種別)。
* listBoxKind が NONE 以外の列は、DB値(コード)を対応マスタの表示名に変換して転記する。
* valueType は値の整形(日付・数値の書式)判断に使う。
*/
private static class ColumnDef {
final String fieldName;
final String header;
final ListBoxKind listBoxKind;
final ValueType valueType;
ColumnDef(String fieldName, String header, ListBoxKind listBoxKind, ValueType valueType) {
this.fieldName = fieldName;
this.header = header;
this.listBoxKind = listBoxKind;
this.valueType = valueType;
}
}
/** 列定義一覧(列位置はテンプレートのヘッダーから決まる)。 */
private static final List<ColumnDef> COLUMNS = buildColumns();
/**
* 列定義。listBoxKind が NONE 以外の列は、リストボックス対象列であり、
* かつ DB値(コード)を表示名に変換して転記する対象。
* valueType は値の種別(TEXT/NUMBER/DATE)。整形の判断に使う。
* ★要置換 どの列がどのリストボックス・どの種別かは実際の項目に合わせる。
*/
private static List<ColumnDef> buildColumns() {
List<ColumnDef> list = new ArrayList<ColumnDef>();
list.add(new ColumnDef("shohinCode", "商品コード", ListBoxKind.NONE, ValueType.TEXT));
list.add(new ColumnDef("shohinName", "商品名", ListBoxKind.NONE, ValueType.TEXT));
list.add(new ColumnDef("suryo", "数量", ListBoxKind.NONE, ValueType.NUMBER));
list.add(new ColumnDef("tanka", "単価", ListBoxKind.NONE, ValueType.NUMBER));
list.add(new ColumnDef("kingaku", "金額", ListBoxKind.NONE, ValueType.NUMBER));
// ★要置換 以下は例。リストボックス対象列に、対応する変換マスタを紐付ける
list.add(new ColumnDef("torihikiCode", "取引先コード", ListBoxKind.GENERAL_1, ValueType.TEXT));
list.add(new ColumnDef("torihikiName", "取引先名", ListBoxKind.GENERAL_2, ValueType.TEXT));
list.add(new ColumnDef("tantosha", "担当者", ListBoxKind.OTHER_1, ValueType.TEXT));
list.add(new ColumnDef("torokubi", "登録日", ListBoxKind.NONE, ValueType.DATE));
list.add(new ColumnDef("koshinbi", "更新日", ListBoxKind.NONE, ValueType.DATE));
list.add(new ColumnDef("biko", "備考", ListBoxKind.OTHER_2, ValueType.TEXT));
list.add(new ColumnDef("kanriNo", "内部管理番号", ListBoxKind.NONE, ValueType.TEXT));
return list;
}
/**
* フィールド名に対応する dto の値を取り出す。列を追加したらここにも1行足す。
*
* @param dto 値を取り出す対象
* @param fieldName dto のフィールド名
* @return 対応する値
*/
private Object getValue(RecordDto dto, String fieldName) {
if ("shohinCode".equals(fieldName)) return dto.getShohinCode();
if ("shohinName".equals(fieldName)) return dto.getShohinName();
if ("suryo".equals(fieldName)) return dto.getSuryo();
if ("tanka".equals(fieldName)) return dto.getTanka();
if ("kingaku".equals(fieldName)) return dto.getKingaku();
if ("torihikiCode".equals(fieldName)) return dto.getTorihikiCode();
if ("torihikiName".equals(fieldName)) return dto.getTorihikiName();
if ("tantosha".equals(fieldName)) return dto.getTantosha();
if ("torokubi".equals(fieldName)) return dto.getTorokubi();
if ("koshinbi".equals(fieldName)) return dto.getKoshinbi();
if ("biko".equals(fieldName)) return dto.getBiko();
if ("kanriNo".equals(fieldName)) return dto.getKanriNo();
throw new IllegalArgumentException("未定義のフィールド名: " + fieldName);
}
// ==================================================================
// 戻り値の組み立て(ファイル格納/エラー格納)とリソース後始末
// ===================================================================
/**
* 出力ファイルを成功Modelに載せて返す。
* ファイルは既に正式名で生成済みのため、リネームは行わない。
*
* @param outputFile 正式名で生成済みの出力ファイル
* @return ファイルを載せたModel
*/
private ResponseModel toFileModel(File outputFile) {
ResponseModel model = new ResponseModel();
model.setOutputFile(outputFile);
return model;
}
/**
* Validationのメッセージを載せたエラーModelを生成する。
*
* @param validation エラーメッセージを保持するValidation
* @return returnMessage にメッセージを格納したModel
*/
private ResponseModel toErrorModel(ValidationResult validation) {
ResponseModel model = new ResponseModel();
model.setReturnMessage(validation.getMessages());
return model;
}
}
4. 要件対応表
| 種別 | 要件 | 実装 |
|---|---|---|
| 中断(単一) | ファイル名の番号≠画面 | checkFileName |
| 中断(単一) | 全て空行 | checkAllBlank |
| 中断(単一) | 1000行超過に値 | checkOver1000 |
| 継続(リスト) | 行間の空白行(何行目か明示) | checkBlankRow |
| 継続(リスト) | 削除なのにキー無し | checkDeleteKey |
| 継続(リスト) | 更新なのにキー無し | checkUpdateKey |
5. 設計のポイント(まとめ)
-
中断 vs 継続を型で分離:
abortMessage(単一・即 return)とmessages(リスト・溜めて続行)。 - チェック順はゲート → 行ごと。中断系の中は依存関係、継続系の中は「空白 → キー → 他項目」。
-
空白行は位置で区別:末尾はトリム、途中はエラー。
removeIf単体では不可。 -
isAllNullは boolean・参照型のみ(プリミティブ注意)。 -
各チェックは private メソッドで単一責務。追加は「メソッド1つ+
addIfNotNull1行」。 - delete / update の 相互排他は各メソッド内で担保(呼び出し側に if/else を書かない)。
補足:テスト容易性
各チェックを 直接ユニットテスト したい場合、private だと呼べない。
テスト容易性を優先するなら package-private にする手もある(挙動は同じ)。
TDD で進めるなら、各 checkXxx を package-private にして「該当→メッセージ/非該当→null」を1つずつ検証するのが早い。