0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

チェック処理の設計

0
Last updated at Posted at 2026-07-06

はじめに

ファイルをインポートする機能で、入力チェックをどう設計するかをまとめる。
ポイントは次の3つ。

  1. エラーを 「中断(単一メッセージ)」「継続(リストメッセージ)」 に分ける
  2. チェックを 正しい順序で並べる(ゲート → 行ごと)
  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つ+addIfNotNull 1行」。
  • delete / update の 相互排他は各メソッド内で担保(呼び出し側に if/else を書かない)。

補足:テスト容易性

各チェックを 直接ユニットテスト したい場合、private だと呼べない。
テスト容易性を優先するなら package-private にする手もある(挙動は同じ)。
TDD で進めるなら、各 checkXxx を package-private にして「該当→メッセージ/非該当→null」を1つずつ検証するのが早い。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?