## Context

`EmployeeController::import` reads the first sheet with `App\Imports\EmployeeImport` (heading row 1, empty rows not skipped). It reports failures in `errors[]` as `{row, code, email, reasons[{code, column, message}]}`. Here `row` is the Excel row number of the uploaded sheet and `column` is a display title such as "Mã NV". The importer maps cells by slugged heading keys, ignores unknown columns, and has no template check.

Existing downloads (`/employees/export`, `/payrolls/export`, and others) are authenticated GET endpoints that return a blob through `Excel::download`. The frontend already downloads these with the bearer token.

Stack: Laravel 6.20, maatwebsite/excel 3.1.33, PhpSpreadsheet 1.25.2, file cache, and a Laravel scheduler in `app/Console/Kernel.php`.

## Goals / Non-Goals

**Goals:**
- Return an annotated copy of the uploaded file that contains only the failed rows, can be fixed in Excel and imported again.
- Restrict downloads to authenticated admins with the right access, for 24 hours.

**Non-Goals:**
- The same feature for `update-multi`.
- Keeping error files longer than 24 hours, or listing past imports.
- Recovering leading zeros the user's own file had already lost (a numeric cell `123` in the upload stays `123`).

## Decisions

### D1. Authenticated download endpoint instead of a signed URL
`error_file_url` points to `GET /admin/employees/import-errors/{id}` behind the usual `auth:api` admin group. A signed URL would work without login, which breaks the acceptance criterion "cannot be downloaded when not logged in". It also cannot express "the importer or an admin of the same company". The endpoint returns a blob, the same as the existing exports, so the frontend reuses its download helper.

### D2. Storage: private disk file plus a JSON metadata file
`storage/app/employee-import-errors/{uuid}.xlsx` and `{uuid}.json`. The JSON file holds `account_id`, `company_mode`, `created_at` and `download_name`. The `{uuid}` is a random UUID v4, so ids cannot be guessed. Expiry is checked from `created_at` (more than 24 hours → 404), so it does not depend on the cache.

*Alternatives considered:*
- A DB table. This was rejected as unnecessary for temporary data, and it would need a migration.
- The cache store. This was rejected because clearing the cache would break links that are still valid.

### D3. Build the file by editing the uploaded workbook with PhpSpreadsheet
1. `IOFactory::createReaderForFile($path)`. For a CSV, set the delimiter and encoding the same way the importer does, and use `StringValueBinder`, so values are kept as typed and `00123` stays `00123`.
2. Take sheet 0. From the highest row down to row 2, `removeRow($r)` every row that is not in `errors[].row`. Deleting from the bottom keeps the remaining row numbers valid while iterating. A map of original row → new row is kept for annotation.
3. Header map: for each cell of row 1, slug the header the same way Maatwebsite does (`HeadingRowFormatter` with the default `slug` formatter), giving key → column letter. The last non-empty header column + 1 becomes "Dòng gốc", and + 2 becomes "Lý do lỗi". If the uploaded file already has these columns, because it is a previous error file, they are reused instead of being added again.
4. "Mã NV" (`ma_nv`) and "SĐT" (`sdt`) columns: set the number format to `@` and rewrite each existing value with `setValueExplicit(..., TYPE_STRING)`.
5. For each error: write "Dòng gốc" and "Lý do lỗi" (`implode("\n", messages)`, wrap text), fill "Lý do lỗi" with `FDE2E1`, and for each reason fill the matching data cell and add a comment.
6. Save with the Xlsx writer.

Reasons need a heading key to match columns robustly. The controller already has `$columnTitles` (key → title), so the service receives a reverse map (title → key). The response stays unchanged.

*Alternative considered:* rebuilding a new workbook from `errors[]` and the row values. This was rejected because it loses cell formatting, column widths and text types, which the frontend team explicitly asked to keep.

### D4. Build after the import loop, never fail the import
The file is built after all rows are processed, but only when `errors` is not empty. The build runs inside `try/catch`. On failure it logs the error and sets `error_file_url = null`, because rows that were already created must still be reported.

### D5. Access check on download
```
meta missing or expired          → 404
user.id == meta.account_id       → allow
checkAuthorize('employees_info') + user.canAccessCompany(meta.company_mode) → allow
otherwise                        → 403
```

### D6. Re-import compatibility
The importer already ignores unknown heading keys. The only change is that the blank-row check skips the keys `dong_goc` and `ly_do_loi`. The new error file reuses the existing annotation columns (see D3, step 3), so repeated cycles do not keep adding columns.

### D7. Cleanup
A daily `$schedule->call(...)` deletes `{uuid}.json` and `{uuid}.xlsx` pairs whose `created_at` is more than 24 hours old, plus orphan files whose modification time is more than 24 hours old.

## Risks / Trade-offs

- [`removeRow` is O(rows × cells), so large files are slow] → Imports are already `set_time_limit(0)` and usually have fewer than a few thousand rows. If needed, consecutive rows can be removed with one `removeRow($r, $count)` call.
- [Formulas that refer to removed rows break in the error file] → This is acceptable. The template has no formulas, and the importer reads calculated values.
- [Data validations and merged cells in the user's file may not shift exactly on row removal] → This is best effort. Data is unaffected.
- [Headers renamed by the user do not match, so the cell is not filled] → The reason is still listed in "Lý do lỗi".
- [Disk usage] → Files live for 24 hours and are cleaned up daily.
- [Multiple app servers with local storage] → The file is written and read on the same `local` disk. If the deployment has more than one app node, use a shared disk. Confirm with ops.

## Migration Plan

No migration. Deploy the backend, then the frontend that reads `error_file_url` and downloads it with its auth helper. Older frontends ignore the new field.

## Open Questions

- Is production a single app node, or do the nodes share `storage/app`? (See the risks above.)
