# Import Dropdown Validation Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 上传 Excel 导入文件时，所有 `@DictDropdown` 字段对应列必须保留模板下拉框；缺失下拉框立即报错并阻止导入。

**Architecture:** 在 `EasyExcelUtil` 的公共读取入口统一增加模板结构校验，先用 POI 检查上传文件中对应 Sheet 的 `LIST` 数据验证覆盖情况，再交给 EasyExcel 执行现有数据读取。业务 Controller、异步导入、Redis 失败记录逻辑保持不变。

**Tech Stack:** Java 21, Spring Boot 3, EasyExcel, Apache POI 4.1.2, JUnit 5, Mockito.

---

## Project Notes

- 当前工作区已有未提交改动，包含 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`。实施时必须先阅读当前 diff，保留已有 `templateWriter(...).inMemory(Boolean.TRUE)` 改动，不要回滚用户或其他任务留下的内容。
- `.DS_Store` 忽略，不纳入任何暂存或提交。
- 本计划只涉及后端；不新增前端代码。
- 实现完成后必须新增实施记录：`docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`。

## File Map

- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`
  - 统一读取上传文件字节。
  - 解析 `@DictDropdown` 字段。
  - 使用 POI 校验 `LIST` 数据验证是否覆盖每个实际数据行。
  - 校验通过后继续调用 EasyExcel 读取数据。
- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java`
  - 覆盖缺失下拉框、非 LIST 验证、部分行覆盖、无字典字段绕过等工具层规则。
- Create: `docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`
  - 记录本次实现、影响范围、测试命令、用户文件验证和真实页面验证结果。
- Reference only: `docs/plans/backend/2026-04-30-import-dropdown-validation-backend-design.md`
  - 已审查通过的设计文档。

## Task 1: Add Failing Utility Tests

**Files:**
- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java`

- [ ] **Step 1: Create test class skeleton**

Add this file with package/imports and helper methods:

```java
package com.ruoyi.info.collection.utils;

import com.alibaba.excel.annotation.ExcelProperty;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DataValidationHelper;
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.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
```

Helper methods:

```java
private byte[] baseStaffWorkbook(boolean partyDropdown, boolean statusDropdown, boolean statusAsList, int statusLastRow) throws Exception {
    try (Workbook workbook = new XSSFWorkbook();
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
        Sheet sheet = workbook.createSheet("员工信息");
        Row header = sheet.createRow(0);
        String[] headers = {"姓名", "员工ID", "所属部门ID", "身份证号", "电话", "年收入（元/年）", "入职时间", "是否党员", "状态"};
        for (int i = 0; i < headers.length; i++) {
            header.createCell(i).setCellValue(headers[i]);
        }
        createBaseStaffRow(sheet, 1, "张三", 9020001L, "33010619850202101X", "0", "1");
        createBaseStaffRow(sheet, 2, "李四", 9020002L, "330106198603031022", "1", "1");

        if (partyDropdown) {
            addListValidation(sheet, 7, 1, 2, "0", "1");
        }
        if (statusDropdown) {
            if (statusAsList) {
                addListValidation(sheet, 8, 1, statusLastRow, "0", "1");
            } else {
                addIntegerValidation(sheet, 8, 1, 2);
            }
        }

        workbook.write(outputStream);
        return outputStream.toByteArray();
    }
}

private byte[] baseStaffDualSheetWorkbookWithMissingAssetStatusDropdown() throws Exception {
    try (Workbook workbook = new XSSFWorkbook();
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
        Sheet staffSheet = workbook.createSheet("员工信息");
        Row staffHeader = staffSheet.createRow(0);
        String[] staffHeaders = {"姓名", "员工ID", "所属部门ID", "身份证号", "电话", "年收入（元/年）", "入职时间", "是否党员", "状态"};
        for (int i = 0; i < staffHeaders.length; i++) {
            staffHeader.createCell(i).setCellValue(staffHeaders[i]);
        }
        createBaseStaffRow(staffSheet, 1, "张三", 9020001L, "33010619850202101X", "0", "1");
        addListValidation(staffSheet, 7, 1, 1, "0", "1");
        addListValidation(staffSheet, 8, 1, 1, "0", "1");

        Sheet assetSheet = workbook.createSheet("员工资产信息");
        Row assetHeader = assetSheet.createRow(0);
        String[] assetHeaders = {"员工身份证号*", "资产大类*", "资产小类*", "资产名称*", "产权占比", "购买/评估日期", "资产原值", "当前估值*", "估值截止日期", "资产状态*", "备注"};
        for (int i = 0; i < assetHeaders.length; i++) {
            assetHeader.createCell(i).setCellValue(assetHeaders[i]);
        }
        Row assetRow = assetSheet.createRow(1);
        assetRow.createCell(0).setCellValue("33010619850202101X");
        assetRow.createCell(1).setCellValue("房产");
        assetRow.createCell(2).setCellValue("住宅");
        assetRow.createCell(3).setCellValue("测试住宅");
        assetRow.createCell(7).setCellValue(1000000D);
        assetRow.createCell(9).setCellValue("正常");

        workbook.write(outputStream);
        return outputStream.toByteArray();
    }
}

private void createBaseStaffRow(Sheet sheet, int rowIndex, String name, long staffId, String idCard, String partyMember, String status) {
    Row row = sheet.createRow(rowIndex);
    row.createCell(0).setCellValue(name);
    row.createCell(1).setCellValue(staffId);
    row.createCell(2).setCellValue(103L);
    row.createCell(3, CellType.STRING).setCellValue(idCard);
    row.createCell(4, CellType.STRING).setCellValue("13370000001");
    row.createCell(5).setCellValue(new BigDecimal("180000").doubleValue());
    row.createCell(6).setCellValue("2026-04-30");
    row.createCell(7, CellType.STRING).setCellValue(partyMember);
    row.createCell(8, CellType.STRING).setCellValue(status);
}

private void addListValidation(Sheet sheet, int columnIndex, int firstRow, int lastRow, String... options) {
    DataValidationHelper helper = sheet.getDataValidationHelper();
    DataValidationConstraint constraint = helper.createExplicitListConstraint(options);
    DataValidation validation = helper.createValidation(
        constraint,
        new CellRangeAddressList(firstRow, lastRow, columnIndex, columnIndex)
    );
    sheet.addValidationData(validation);
}

private void addIntegerValidation(Sheet sheet, int columnIndex, int firstRow, int lastRow) {
    DataValidationHelper helper = sheet.getDataValidationHelper();
    DataValidationConstraint constraint = helper.createIntegerConstraint(
        DataValidationConstraint.OperatorType.BETWEEN,
        "0",
        "1"
    );
    DataValidation validation = helper.createValidation(
        constraint,
        new CellRangeAddressList(firstRow, lastRow, columnIndex, columnIndex)
    );
    sheet.addValidationData(validation);
}

private byte[] plainWorkbookWithoutDropdown() throws Exception {
    try (Workbook workbook = new XSSFWorkbook();
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
        Sheet sheet = workbook.createSheet("普通信息");
        Row header = sheet.createRow(0);
        header.createCell(0).setCellValue("名称");
        Row row = sheet.createRow(1);
        row.createCell(0).setCellValue("张三");
        workbook.write(outputStream);
        return outputStream.toByteArray();
    }
}

private static class PlainExcel {
    @ExcelProperty(value = "名称", index = 0)
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
```

- [ ] **Step 2: Add tests for accepted and rejected workbooks**

Add these tests:

```java
@Test
void importExcel_shouldPassWhenAllDictDropdownColumnsKeepListValidation() throws Exception {
    byte[] bytes = baseStaffWorkbook(true, true, true, 2);

    List<CcdiBaseStaffExcel> rows = EasyExcelUtil.importExcel(
        new ByteArrayInputStream(bytes),
        CcdiBaseStaffExcel.class,
        "员工信息"
    );

    assertEquals(2, rows.size());
}

@Test
void importExcel_shouldFailWhenPartyMemberDropdownIsMissing() throws Exception {
    byte[] bytes = baseStaffWorkbook(false, true, true, 2);

    ServiceException exception = assertThrows(ServiceException.class, () ->
        EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
    );

    assertTrue(exception.getMessage().contains("是否党员 列缺少下拉框"));
}

@Test
void importExcel_shouldFailWhenStatusDropdownIsMissing() throws Exception {
    byte[] bytes = baseStaffWorkbook(true, false, true, 2);

    ServiceException exception = assertThrows(ServiceException.class, () ->
        EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
    );

    assertEquals("员工信息 Sheet 的 状态 列缺少下拉框，请下载最新导入模板填写后重新导入", exception.getMessage());
}

@Test
void importExcel_shouldFailWhenValidationIsNotListType() throws Exception {
    byte[] bytes = baseStaffWorkbook(true, true, false, 2);

    ServiceException exception = assertThrows(ServiceException.class, () ->
        EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
    );

    assertTrue(exception.getMessage().contains("状态 列缺少下拉框"));
}

@Test
void importExcel_shouldFailWhenListValidationDoesNotCoverEveryActualDataRow() throws Exception {
    byte[] bytes = baseStaffWorkbook(true, true, true, 1);

    ServiceException exception = assertThrows(ServiceException.class, () ->
        EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
    );

    assertTrue(exception.getMessage().contains("状态 列缺少下拉框"));
}

@Test
void importExcel_shouldFailWhenSecondSheetDropdownIsMissing() throws Exception {
    byte[] bytes = baseStaffDualSheetWorkbookWithMissingAssetStatusDropdown();

    ServiceException exception = assertThrows(ServiceException.class, () ->
        EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffAssetInfoExcel.class, "员工资产信息")
    );

    assertEquals("员工资产信息 Sheet 的 资产状态 列缺少下拉框，请下载最新导入模板填写后重新导入", exception.getMessage());
}

@Test
void importExcel_shouldSkipDropdownStructureValidationWhenClassHasNoDictDropdownFields() throws Exception {
    byte[] bytes = plainWorkbookWithoutDropdown();

    List<PlainExcel> rows = EasyExcelUtil.importExcel(
        new ByteArrayInputStream(bytes),
        PlainExcel.class,
        "普通信息"
    );

    assertEquals(1, rows.size());
}
```

- [ ] **Step 3: Run the new tests and confirm they fail**

Run:

```bash
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test
```

Expected before implementation:

- Tests for missing dropdown, non-LIST validation, and partial coverage fail because no structure validation exists.
- The passing workbook test may pass already.

## Task 2: Implement Dropdown Structure Validation in EasyExcelUtil

**Files:**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`

- [ ] **Step 1: Add imports**

Add imports needed by the new helper methods:

```java
import com.alibaba.excel.annotation.ExcelProperty;
import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.exception.ServiceException;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
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;
import org.apache.poi.ss.util.CellRangeAddress;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Comparator;
```

Keep existing imports that are still used, including the current `ExcelWriterBuilder` import from the existing working tree.

- [ ] **Step 2: Update importExcel(String fileName, Class<T>)**

Change the file-name overload to use a stream so the same validation path is used:

```java
public static <T> List<T> importExcel(String fileName, Class<T> clazz) {
    try (InputStream inputStream = java.nio.file.Files.newInputStream(java.nio.file.Path.of(fileName))) {
        return importExcel(inputStream, clazz);
    } catch (ServiceException e) {
        throw e;
    } catch (Exception e) {
        throw new RuntimeException("导入Excel失败", e);
    }
}
```

- [ ] **Step 3: Update InputStream import overloads**

Make both stream overloads read bytes once, validate, then pass a fresh `ByteArrayInputStream` to EasyExcel:

```java
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz) {
    try {
        byte[] bytes = inputStream.readAllBytes();
        validateDictDropdownTemplate(bytes, clazz, null);
        return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet().doReadSync();
    } catch (ServiceException e) {
        throw e;
    } catch (Exception e) {
        throw new RuntimeException("导入Excel失败", e);
    }
}

public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz, String sheetName) {
    try {
        byte[] bytes = inputStream.readAllBytes();
        validateDictDropdownTemplate(bytes, clazz, sheetName);
        return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet(sheetName).doReadSync();
    } catch (ServiceException e) {
        throw e;
    } catch (Exception e) {
        throw new RuntimeException("导入Excel失败", e);
    }
}
```

- [ ] **Step 4: Add validation helpers**

Add these private helpers near the bottom of `EasyExcelUtil`, before `templateWriter(...)`:

```java
private static void validateDictDropdownTemplate(byte[] bytes, Class<?> clazz, String sheetName) {
    List<DropdownColumn> dropdownColumns = resolveDropdownColumns(clazz);
    if (dropdownColumns.isEmpty()) {
        return;
    }

    try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) {
        Sheet sheet = sheetName == null ? workbook.getSheetAt(0) : workbook.getSheet(sheetName);
        if (sheet == null) {
            return;
        }
        int lastDataRowIndex = findLastDataRowIndex(sheet);
        if (lastDataRowIndex < 1) {
            return;
        }

        for (DropdownColumn column : dropdownColumns) {
            if (!isListValidationCovered(sheet, column.index(), lastDataRowIndex)) {
                throw new ServiceException(sheet.getSheetName() + " Sheet 的 " + column.title()
                    + " 列缺少下拉框，请下载最新导入模板填写后重新导入");
            }
        }
    } catch (ServiceException e) {
        throw e;
    } catch (Exception e) {
        throw new RuntimeException("导入Excel失败", e);
    }
}

private static List<DropdownColumn> resolveDropdownColumns(Class<?> clazz) {
    List<DropdownColumn> columns = new ArrayList<>();
    Class<?> current = clazz;
    while (current != null && current != Object.class) {
        for (Field field : current.getDeclaredFields()) {
            if (field.getAnnotation(DictDropdown.class) == null) {
                continue;
            }
            ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
            if (excelProperty == null || excelProperty.index() < 0) {
                continue;
            }
            columns.add(new DropdownColumn(excelProperty.index(), resolveColumnTitle(field, excelProperty)));
        }
        current = current.getSuperclass();
    }
    columns.sort(Comparator.comparingInt(DropdownColumn::index));
    return columns;
}

private static String resolveColumnTitle(Field field, ExcelProperty excelProperty) {
    if (excelProperty.value().length > 0 && excelProperty.value()[0] != null && !excelProperty.value()[0].isBlank()) {
        return excelProperty.value()[0].replace("*", "");
    }
    return field.getName();
}

private static int findLastDataRowIndex(Sheet sheet) {
    int lastDataRowIndex = -1;
    for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
        Row row = sheet.getRow(rowIndex);
        if (hasData(row)) {
            lastDataRowIndex = rowIndex;
        }
    }
    return lastDataRowIndex;
}

private static boolean hasData(Row row) {
    if (row == null || row.getLastCellNum() < 0) {
        return false;
    }
    for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
        if (cellIndex < 0) {
            continue;
        }
        Cell cell = row.getCell(cellIndex);
        if (cell != null && cell.toString() != null && !cell.toString().isBlank()) {
            return true;
        }
    }
    return false;
}

private static boolean isListValidationCovered(Sheet sheet, int columnIndex, int lastDataRowIndex) {
    boolean[] coveredRows = new boolean[lastDataRowIndex + 1];
    for (DataValidation validation : sheet.getDataValidations()) {
        DataValidationConstraint constraint = validation.getValidationConstraint();
        if (constraint == null || constraint.getValidationType() != DataValidationConstraint.ValidationType.LIST) {
            continue;
        }
        for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) {
            if (address.getFirstColumn() > columnIndex || address.getLastColumn() < columnIndex) {
                continue;
            }
            int firstRow = Math.max(1, address.getFirstRow());
            int lastRow = Math.min(lastDataRowIndex, address.getLastRow());
            for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
                coveredRows[rowIndex] = true;
            }
        }
    }

    for (int rowIndex = 1; rowIndex <= lastDataRowIndex; rowIndex++) {
        if (hasData(sheet.getRow(rowIndex)) && !coveredRows[rowIndex]) {
            return false;
        }
    }
    return true;
}

private record DropdownColumn(int index, String title) {}
```

Implementation notes:

- Do not add dictionary-value checks; this task only checks template dropdown structure.
- Do not make Controller-specific changes.
- Do not change existing async import status behavior.
- If `sheet == null`, preserve current EasyExcel failure path rather than inventing a new fallback.

- [ ] **Step 5: Run focused tests**

Run:

```bash
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test
```

Expected: all tests in the new class pass.

## Task 3: Regression Tests for Existing Template Generation

**Files:**
- Modify only if needed: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java`

- [ ] **Step 1: Run existing template tests**

Run:

```bash
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest test
```

Expected: existing template-generation tests pass. This confirms the validation change did not break dropdown generation and preserves the existing `inMemory(Boolean.TRUE)` template writer fix.

- [ ] **Step 2: Run controller tests that mock EasyExcelUtil**

Run:

```bash
mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test
```

Expected: controller tests pass. This confirms method signatures and import entry points did not change.

## Task 4: Verify User-Provided Broken Workbook

**Files:**
- No committed file changes.

- [ ] **Step 1: Confirm workbook structure manually**

Run:

```bash
python3 - <<'PY'
from openpyxl import load_workbook
path = '/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx'
wb = load_workbook(path)
for ws in wb.worksheets:
    print(ws.title, len(ws.data_validations.dataValidation))
PY
```

Expected:

```text
员工信息 0
员工资产信息 0
```

- [ ] **Step 2: Validate through backend code path**

Use the real page upload in Task 6 as the authoritative backend-path validation. The expected page/API error is:

```text
员工信息 Sheet 的 状态 列缺少下拉框，请下载最新导入模板填写后重新导入
```

Do not commit this user-provided workbook or any generated upload files.

## Task 5: Add Implementation Report

**Files:**
- Create: `docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`

- [ ] **Step 1: Write implementation report**

Create the report with this structure:

```markdown
# 导入模板下拉框结构校验实施记录

## 修改内容

- 在 `EasyExcelUtil.importExcel(...)` 公共入口增加 `@DictDropdown` 列下拉框结构校验。
- 上传文件中对应 Sheet 的对应列必须由 `LIST` 类型数据验证覆盖每个实际数据行。
- 缺失下拉框时导入立即失败，不进入异步导入任务。

## 影响范围

- 影响所有使用 `EasyExcelUtil.importExcel(...)` 且导入对象含 `@DictDropdown` 字段的 Excel 导入。
- 不影响无 `@DictDropdown` 字段的导入。
- 不修改前端页面、业务字段校验、异步导入状态和失败记录逻辑。

## 验证结果

- `mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test`
- `mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest test`
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test`

## 用户文件验证

- 文件：`/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`
- 结果：上传后提示 `员工信息 Sheet 的 状态 列缺少下拉框，请下载最新导入模板填写后重新导入`

## 真实页面验证

- 页面：员工信息维护真实业务页面。
- 结果：记录下载真实模板、上传缺下拉文件、上传保留下拉测试文件的页面验证结论。
```

- [ ] **Step 2: Fill actual verification output after tests**

Replace the verification bullets with actual pass/fail results after running commands and browser validation.

## Task 6: Real Page Validation With browser-use

**Files:**
- No source file changes unless verification uncovers a bug.
- Generated test files must stay under ignored output paths such as `output/browser-use/` or `output/spreadsheet/` and must not be committed.

- [ ] **Step 1: Use browser-use skill**

Before browser work, open `/Users/wkc/.codex/plugins/cache/openai-bundled/browser-use/0.1.0-alpha1/skills/browser/SKILL.md` and follow it.

- [ ] **Step 2: Start backend using project script if needed**

If no backend is running or code changes require restart, run:

```bash
sh bin/restart_java_backend.sh
```

Expected: backend available at `http://localhost:62318`.

- [ ] **Step 3: Start frontend with nvm if needed**

If no frontend is running, run:

```bash
cd ruoyi-ui
nvm use
npm run dev
```

Expected: frontend dev server URL printed by Vite/Vue CLI. Keep the process id/session so it can be stopped after testing.

- [ ] **Step 4: Test broken workbook on real page**

In the real employee information maintenance page:

1. Log in through the real app or `/login/test` shortcut if already used by the project.
2. Open employee information maintenance.
3. Upload `/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`.
4. Confirm the page displays the backend error mentioning `状态 列缺少下拉框`.

Expected: no async import task is created for this upload.

- [ ] **Step 5: Test current real template still works**

1. Download the current import template from the real page.
2. Fill a small test workbook while preserving dropdown validations.
3. Upload it and confirm it enters the existing normal import chain.
4. Clean up any successfully imported test data.

Expected: dropdown validation does not block a valid template.

- [ ] **Step 6: Stop test processes**

Stop any backend/frontend process started during this task. Do not stop unrelated user-owned processes.

## Task 7: Final Verification and Commit Hygiene

**Files:**
- Modify: files from previous tasks only.

- [ ] **Step 1: Check worktree and staged state**

Run:

```bash
git status --short
git diff --cached --name-status
```

Expected: no staged unrelated files. `.DS_Store` remains ignored/uncommitted.

- [ ] **Step 2: Run final focused verification**

Run:

```bash
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest,EasyExcelUtilTemplateTest,CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test
```

Expected: all selected tests pass.

- [ ] **Step 3: Stage only this task's files**

Stage only these files:

```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java
git add ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java
git add docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md
```

If `EasyExcelUtil.java` still contains unrelated pre-existing edits that should not be committed, use partial staging or stop and ask the user before committing.

- [ ] **Step 4: Review staged diff**

Run:

```bash
git diff --cached --name-status
git diff --cached --stat
```

Expected: only the implementation files and implementation report are staged.

- [ ] **Step 5: Commit if requested**

If the user wants a commit, use a Chinese message:

```bash
git commit -m "新增导入模板下拉框校验"
```

Expected: commit succeeds without `.DS_Store` or unrelated docs/files.
