## Context

`EmployeeController::import` reads the first sheet of the uploaded file through `ImportWithHeading` (WithHeadingRow + SkipsEmptyRows) and creates each employee with `Account::createAccount($data, $profile, $employee)`. The position is passed as a name (`$data['position']`). `createAccount` resolves it again with `Position::findByName` (`->first()`), syncs `account_position`, and copies `department->leader_id` into `employee.leader_id`. The controller also calls `Position::findByName` to get the `company_id` for `Helper::workdayToMinute`.

Employees have no direct department or company column. The relationships are Account → (n-n) Position → Department → Company, so a wrong Position means a wrong department, company and leader.

Company access works as follows. `Account::canAccessCompany($mode)` accepts `all` only when `admin.company_access_mode = all`. A uuid is accepted only when it is among the admin's accessible companies. `CompanyAccessTrait::checkCompanyAccess` aborts with 403 otherwise. Most admin endpoints call it, but `import` and `updateMulti` do not.

`positions.name`, `departments.name` and `companies.name` have no unique constraint. String columns default to 191 characters (`Schema::defaultStringLength(191)`). `maatwebsite/excel` is pinned at 3.1.33.

## Goals / Non-Goals

**Goals:**
- Each import row resolves to exactly one position, or fails with an explicit reason.
- `company_mode` is enforced and scopes the resolution.
- The response lists every failed row with its Excel row number, employee code and reasons. Valid rows are still imported.
- Hidden failures (duplicates within the file, values too long, missing fields) become explicit reasons.

**Non-Goals:**
- Making position, department or company names unique in the database.
- Changing `update-multi` beyond adding the company access check. It does not touch positions.
- Assigning multiple positions per employee through the import.
- Building the frontend UI that displays the errors. Only the API contract is defined here.

## Decisions

### D1. Resolve the position in the controller, pass a `Position` model into `createAccount`
Load all positions once per request with `department.company`, grouped by trimmed name. For each row, take the group for "Vị trí" and filter it in PHP by the resolved company id (when a company scope applies) and by the trimmed department name (when "Phòng ban" is filled). The size of the result (0, 1, more than 1) decides between `position_not_found`, success and `position_ambiguous`. This replaces a per-row query (`filterByCompanies` + `whereHas('department')` + `limit(2)`), so large files cost one query instead of one per row.

`createAccount` accepts `$account['position']` as either a `Position` instance or a name string. A string keeps the current `findByName` behaviour, so other callers stay compatible. The import always passes the model.

*Alternative considered:* add a `position_id` column to the file. This was rejected because admins work with names, and IDs are not visible in the UI.

### D2. Company scope derived from `company_mode` first, then the "Công ty" column
```
checkCompanyAccess(company_mode)                       // 403 if not allowed
mode = X (uuid)  → scopeCompany = X
                   cong_ty filled && cong_ty != X.name → company_mismatch
mode = all       → cong_ty filled ? Company::where(name)->get():
                                       0 → company_not_found
                                      >1 → company_ambiguous
                                       1 → scopeCompany = it
                 : scopeCompany = none (all companies)
```
Companies are looked up once per request and cached in a name → [ids] map. This works because, after `checkCompanyAccess`, `all` mode implies the admin can access every company. No separate per-row access check is needed.

### D3. Validate the whole row first, then insert inside a per-row transaction
For each row, collect every failing reason into a list. When the list is not empty, record the error and skip the row. Otherwise, insert inside `DB::beginTransaction()`, then commit, or roll back on an exception and record `unknown_error`. Do not call `DB::commit()` after a rollback.

Duplicate tracking uses in-memory sets that start from the DB values: usernames (including soft-deleted accounts), employee codes and phones. A value is added to its set **as soon as a row that uses it passes validation and is inserted**. Later rows in the same file are then caught as duplicates. This also stops the `firstOrCreate` path in `createAccount` from silently reusing an account.

Length limits come from a small map in the controller: heading key → max length (191 for the varchar columns `ho`, `ten`, `email`, `sdt`, `ma_nv`, `vi_tri_hd`). `mb_strlen` is used so that Vietnamese characters count correctly.

### D4. Correct Excel row numbers
`SkipsEmptyRows` drops blank rows, so the collection index no longer maps to the Excel row. Add a dedicated `App\Imports\EmployeeImport` (WithHeadingRow + WithCalculatedFormulas, **without** SkipsEmptyRows) for this endpoint. Excel row = index + 2, and the loop skips rows whose cells are all blank. `ImportWithHeading` stays unchanged because `updateMulti` and others use it.

*Alternative considered:* `OnEachRow` with `Row::getIndex()`. That would require restructuring around the import class's callback. The simple index mapping is enough.

### D5. Response shape
```json
{
  "imported_successful_count": 2,
  "employee_code_error_count": 1,
  "username_error_count": 0,
  "phone_number_error_count": 0,
  "position_error_count": 0,
  "unknown_error_count": 0,
  "errors": [
    { "row": 3, "code": "NV001", "email": "a@x.com",
      "reasons": [ { "code": "code_duplicated", "column": "Mã NV", "message": "Mã NV đã tồn tại" } ] }
  ]
}
```
The existing counts are kept so that the current frontend does not break. A row with several reasons increments each matching count once. Messages are in Vietnamese, matching the rest of the import UI. Reason codes are stable so the frontend can localise them.

### D6. Template update
Edit `public/excels/hrm_employee_seeding.xlsx`:
- Insert "Phòng ban" and "Công ty" after "Vị trí".
- Delete the stale "Vị trí" sheet.
- Re-target the dropdown validations to the shifted "Giới tính", "Hình thức nhân viên" and "Loại thuế" columns.

Heading keys are slugged by Maatwebsite (`Phòng ban` → `phong_ban`, `Công ty` → `cong_ty`). Verify the exact slugs during implementation.

## Risks / Trade-offs

- [The frontend does not send `company_mode` to `import`/`update-multi` today] → Both endpoints would return 403 after the release. Confirm with the frontend and release together. The OpenAPI doc of `import` already lists the parameter.
- [Old templates in users' hands lack the new columns] → The columns are optional. Unique position names still import. Ambiguous ones fail with a message that tells the admin to add "Phòng ban"/"Công ty".
- [Removing the "Unknown" fallback means rows that used to be accepted now fail] → This is intended, and the failures are reported explicitly. The `employee-contract-position` spec is updated accordingly.
- [Name matching is exact after trim. Case or diacritic differences will not match] → Use exact (trimmed) matching, the same as the current "Vị trí" behaviour. Case-insensitive matching follows the DB collation anyway.
- [Large files: one position query per row] → Preload the positions with their department and company once per request into memory, and filter in PHP.

## Migration Plan

No DB migration. Deploy the backend together with the frontend change that sends `company_mode` and renders `errors`. Rollback means reverting the backend commit. The template file is versioned in the repo.

## Open Questions

- Does the frontend currently send `company_mode` on `import` and `update-multi`? If not, the frontend task must ship in the same release.
- Should the frontend also let users download the error list (for example as an annotated Excel file)? This is out of scope for this change.
