# Attendance Module — Business & API Guide

This guide documents every confirmed feature of the **Attendance** module in the ERPFLOW backend. Every statement below is traceable to the reviewed source files listed in Appendix A. Where a detail could not be verified, the guide says exactly: **"Not specified in the reviewed source files."**

> বাংলা ভার্সন: [`ATTENDANCE_MODULE_GUIDE_BN.md`](./ATTENDANCE_MODULE_GUIDE_BN.md) · Bilingual PDF: [`ATTENDANCE_MODULE_GUIDE.pdf`](./ATTENDANCE_MODULE_GUIDE.pdf)

---

## 1. What is this module?

The Attendance module is the company's **digital time-and-leave engine**. Verified capabilities:

- Record check-in/check-out **punches** (multi-punch per day, multiple sources)
- Maintain master data: **Shifts**, **Attendance Types**, **Leave/Holiday Policies**
- Attach shifts and policies to scopes (**Assignments**) — company-wide down to a single employee
- Resolve what an employee effectively holds on a date (**Assignment Resolution**)
- Calculate **daily attendance records** with a frozen policy snapshot
- Run a scheduled or manual **Close Day** calculation job
- Manage **Leave Balances**, accrual, carry-forward and manual adjustments with a full ledger
- Handle the full **Leave Request** lifecycle (preview → apply → approve/reject/cancel)
- Handle **Attendance Correction Requests** (missing/incorrect punch, wrong status)
- Close the month via **Monthly Attendance Approval**, lock records and flag them `ready_for_payroll`

**Important:** Everything is tied to your **current company**. Every query is scoped through the platform tenancy context; the company arrives on the request as the `X-Company-Id` header.

---

## 2. Who uses it?

Access is granted by permission keys enforced by `permission:` middleware on each route group. The registry below is from the module spec (Section 4) cross-checked against route usage:

| Permission key | Grants |
|---|---|
| `attendance.menu-view` | See the Attendance section; read policies, attendance types, shifts, assignments; list punches |
| `attendance.config-manage` | CRUD attendance types, shifts, policies |
| `attendance.policy-group-view` | View policy groups and eligibility attributes |
| `attendance.policy-group-manage` | Create/edit/status/delete policy groups; eligibility preview; apply/backfill to employees |
| `attendance.assignment-manage` | Create/update/end assignments; bulk assign; trigger record recalculation endpoint |
| `attendance.assignment-preview` | Assignment resolution diagnostic tool |
| `attendance.punch-create` | Punch in/out |
| `attendance.punch-create-others` | Punch/list punches on behalf of another employee (enforced inside PunchService) |
| `attendance.record-view-own` / `-team` / `-all` | Daily record visibility tiers (route accepts any of the three; deeper checks inside controller) |
| `attendance.record-export` | Export daily summaries |
| `attendance.record-recalculate` | "Recalculate with current policy" override (enforced inside the recalculation service) and close-day job endpoints |
| `attendance.correction-create` | Submit/cancel own correction requests; view correction queue |
| `attendance.correction-approve` | Approve/reject corrections (direct fallback path) |
| `attendance.correction-override-lock` | Approve a correction against a locked month (enforced in AttendanceCorrectionExecutor) |
| `attendance.leave-apply` | Apply/preview/cancel own leave |
| `attendance.leave-approve` | Approve/reject leave; view any employee's leave |
| `attendance.leave-balance-view` | List all balances; view another employee's ledger history |
| `attendance.leave-balance-adjust` | Manual balance adjustment; trigger accrual / carry-forward jobs |
| `attendance.monthly-view` | Monthly approval grid, detail, breakdown, batch status |
| `attendance.monthly-approve` | Build/approve/bulk-approve a month |
| `attendance.monthly-unlock` | Unlock an approved month |

Per the spec, the self-service keys (`attendance.record-view-own`, `attendance.punch-create`, `attendance.correction-create`, `attendance.leave-apply`) belong to a seeded `employee` role that every user receives by default.

---

## 3. Platform conventions that apply to every endpoint

| Convention | Fact (verified) |
|---|---|
| Base URL | `/api/v1/attendance` (`RouteServiceProvider`) |
| Route-name prefix | `api.attendance.` |
| Global middleware | `auth:api`, `onboarding.access`, `onboarding.document` wrap **every** route in the module |
| Per-route authorization | `permission:<key>` middleware groups (keys in §2) |
| Tenancy | Company id comes from `TenantContext`; controllers throw `PermissionDeniedException('company.context')` when it is missing. Header: `X-Company-Id` (spec §0.3) |
| Employee identity | `employee_id` references `employee_personal_infos.id`; several validations use `exists:employee_personal_infos,id` |
| Approval engine | Leave, correction and monthly approval decisions go through the platform **ApprovalGateway**; when approval is disabled for the action, `onApproved` runs synchronously (documented bypass, not an error) |
| Pagination | List endpoints return paginated resources; default page size is 15 unless stated otherwise |

---

## 4. Feature catalog

### 4.1 Module health check

- **Business purpose:** Verify the module is operational and that its assignment-resolution cache can actually be invalidated in this deployment.
- **What it does:** Returns the cache store name, whether it supports tags, an overall `status` of `ok`/`degraded`, and a human-readable `detail`. Logs activity event `attendance.health_viewed`.
- **Endpoint:** `GET /health` → `AttendanceController@health` (no extra permission beyond the global auth group).
- **Response behavior:** JSON `{module: "attendance", status: "ok"|"degraded", cache:{store, supports_tags, supported, detail}}`. A store without tag support yields `degraded` because resolved assignments could serve stale data for up to 24 hours.
- **Data entities:** None (reads `cache.default` config).

### 4.2 Attendance Types (master data)

- **Business purpose:** Define the vocabulary of day statuses (Present, Absent, Late, Half Day, Leave, Holiday, Weekend, WFH, Business Trip, Missing Check-In, Missing Check-Out) plus company-specific types.
- **What it does:** CRUD + status toggle. System-seeded rows carry an immutable `system_code`; HR-created rows have `system_code = NULL`. The calculation engine resolves statuses by `system_code`, never by name/id.
- **Endpoints** (read group under `attendance.menu-view`; write group under `attendance.config-manage`):

  | Method | Path | Controller@action |
  |---|---|---|
  | GET | `/attendance-types` | `AttendanceTypeController@index` |
  | GET | `/attendance-types/{id}` | `AttendanceTypeController@show` |
  | POST | `/attendance-types` | `AttendanceTypeController@store` (201) |
  | PUT | `/attendance-types/{id}` | `AttendanceTypeController@update` |
  | PATCH | `/attendance-types/{id}/status` | `AttendanceTypeController@updateStatus` |
  | DELETE | `/attendance-types/{id}` | `AttendanceTypeController@destroy` |

- **Validation (verified):** `name` required ≤100 unique per company · `code` required ≤50, regex `^[A-Z0-9_]+$`, unique per company · `system_code` **prohibited** on input · `category` required ≤50 · `is_paid`, `counts_as_working_day`, `eligible_for_payroll` required booleans · `color` ≤30, `icon` ≤50 required · status change accepts only `active|inactive`. Update re-validates uniqueness ignoring the current row.
- **Delete guard (verified):** A system type cannot be deleted (409, "Cannot delete a system-seeded attendance type…"); a type referenced by existing records cannot be deleted (409).
- **Data entity:** table `attendance_types` (fields per model fillable + spec §3.1).

### 4.3 Shifts

- **Business purpose:** Define working-time templates: start/end time, timezone, break, grace period, minimum hours for present/half-day, working days, overnight flag.
- **Endpoints:**

  | Method | Path | Permission group | Controller@action |
  |---|---|---|---|
  | GET | `/shifts` | menu-view | `Shift\ShiftManagementController@index` |
  | GET | `/shifts/{shiftId}` | menu-view | `…@show` |
  | POST | `/shifts` | config-manage | `…@store` (returns 200 "Shift Create Successfully Done") |
  | PUT | `/shifts/{shiftId}` | config-manage | `…@update` |
  | PATCH | `/shifts/{shiftId}/status` | config-manage | `…@updateStatus` |
  | DELETE | `/shifts/{shiftId}` | config-manage | `…@destroy` (message: "Shift archived successfully.") |

- **Validation (verified):** `name` ≤150 (required on create, nullable on update) · `start_time`/`end_time` required `H:i` · `timezone` nullable valid timezone · `break_minutes` int ≥0 · `working_hours` numeric >0 ≤24 · `grace_minutes` int ≥0 · `min_hours_present` numeric >0 ≤24 · `min_hours_half_day` numeric ≥0 ≤24 · `working_days` required array ≥1 of distinct integers 1–7 (ISO-8601: 1 = Monday) · `is_overnight` required boolean · `status` nullable in `active|inactive`.
- **Note (code fact):** the list endpoint defaults `per_page` to **1** when not supplied.
- **Data entity:** table `shifts`.


### 4.4 Attendance Policies (Leave & Holiday)

- **Business purpose:** Author the rules behind leave entitlements and holiday calendars, effective-dated, with a typed JSON `config`.
- **What it does:** Two policy types — `leave` (entitlement, accrual method, carry-forward, encashment, half-day, LWP, advance notice, backdate limit, document threshold) and `holiday` (list of holiday entries with optional `recurring`). Config shape is validated by dedicated rule objects (`HolidayPolicyConfigRule` / `LeavePolicyConfigRule`) selected by policy type; update validates against the stored policy's type.
- **Endpoints:** read under `menu-view` (`GET /policies`, `GET /policies/{id}`); write under `config-manage`: `POST /policies` (201), `PUT /policies/{id}`, `PATCH /policies/{id}/status`, `DELETE /policies/{id}`.
- **Validation (verified):** create requires `policy_type` ∈ {`leave`,`holiday`} (model constants), `name` ≤150, `code` ≤50, `effective_date` date, `config` array, optional `status` ∈ {`Active`,`Inactive`,`Archived`}. Status change uses the same status set.
- **Data entity:** table `attendance_policies` (`policy_type`, `config` json-cast).

### 4.5 Policy Groups (eligibility bundles + apply/backfill)

- **Business purpose:** Bundle policies with an **eligibility rule document** so HR can say "who receives what", then push the result onto employees as ordinary assignments.
- **What it does (verified model docblock & services):** Groups are an authoring layer only — nothing at runtime resolves through a group; applying a group creates employee-scoped `assignments` rows. Editing a group never reaches anyone by itself; application is a separate, deliberate act with preview first.
- **Endpoints:**

  | Method | Path | Permission | Purpose |
  |---|---|---|---|
  | GET | `/policy-groups` | policy-group-view | Paginated list |
  | GET | `/policy-groups/attributes` | policy-group-view | Rule-builder vocabulary: attributes + allowed operators |
  | GET | `/policy-groups/{id}` | policy-group-view | Detail (includes policies when loaded) |
  | POST | `/policy-groups` | policy-group-manage | Create (201) |
  | POST | `/policy-groups/eligibility/preview` | policy-group-manage | Preview how many active employees unsaved rules reach (`matched_count`, `total_active`, `sample`) |
  | PUT | `/policy-groups/{id}` | policy-group-manage | Update |
  | PATCH | `/policy-groups/{id}/status` | policy-group-manage | Active/Inactive |
  | DELETE | `/policy-groups/{id}` | policy-group-manage | Delete |
  | POST | `/policy-groups/{id}/apply/preview` | policy-group-manage | Backfill preview (eligible / already_assigned / new / conflicts / skipped + sample) |
  | POST | `/policy-groups/{id}/apply` | policy-group-manage | Queue chunked backfill (202) |
  | GET | `/policy-groups/{id}/apply/{batchId}` | policy-group-manage | Batch status |

- **Eligibility attributes (verified registry):** `employment_type_id`, `grade_id`, `branch_id`, `division_id`, `department_id`, `section_id`, `team_id` (reference ids from company-scoped tables) and `gender`, `probation_status` (normalized strings). Operators: `in`, `not_in`, `equals`, `not_equals`. Empty rules mean the whole company ("Company General"). `match` may be `all` (default) or `any`.
- **Validation (verified):** `name` ≤150, `code` ≤50 (both unique per company — service-level), `description` ≤1000 nullable, `eligibility` required array validated by `PolicyGroupEligibilityRuleValidator` (also checks referenced ids exist in the company), `effective_date_strategy` ∈ {`joining_date`,`probation_end_date`} (default `joining_date`; probation strategy falls back to joining date when no probation end recorded), `status` ∈ {`Active`,`Inactive`}, `policy_ids` array of ints that must be **active** policies of this company. Backfill requests accept optional `date` and `sample_size` (0–100 apply-preview; 0–50 eligibility-preview).
- **Data entities:** `attendance_policy_groups`, pivot `attendance_policy_group_policy`, produced rows in `assignments` (with `policy_group_id` stamped).
- **Auto-assignment:** a `PolicyGroupAutoAssignmentService` keeps one employee's policy assignments aligned with matching groups (additive only); an `EligibilitySweepService` announces employees whose organization assignment recently came into force (scheduled command exists).

### 4.6 Assignments (linking shifts/policies to scopes)

- **Business purpose:** The single source that says which shift, which leave policies and which holiday calendar apply at each organizational level and effective date.
- **Scope model (verified `Assignment` model):** `scope_type` ∈ hierarchy `company → branch → division → department → section → team → employee`; resolution walks **most-specific first**. `assignable_type` ∈ {`shift`,`policy`}; for policies `assignable_subtype` ∈ {`leave`,`holiday`} and must match the policy's type. Cardinality: shift and holiday are exclusive per scope; leave is multi. Sources: `manual`, `auto`, `bulk`. Statuses: `Active`, `Inactive`.


  | Method | Path | Permission | Notes |
  |---|---|---|---|
  | GET | `/assignments` | menu-view | Filters: scope_type, scope_id, assignable_type, assignable_subtype, source, history(bool), per_page ≤100 |
  | GET | `/assignments/{id}` | menu-view | Detail |
  | POST | `/assignments` | assignment-manage | Idempotent create (`assignOrGet`) — same assignable+scope+effective date returns existing row without a second write/event. Only **Active** shifts/policies can be assigned. Overlap conflict → `AssignmentOverlapException` error response |
  | PATCH | `/assignments/{id}` | assignment-manage | Partial: `end_date`, `status` |
  | POST | `/assignments/{id}/end` | assignment-manage | Requires `end_date` |
  | POST | `/assignments/bulk` | assignment-manage | Target by `filter{scope_type,scope_id}` (company…team) or explicit `employee_ids`; sync response or **202 queued** `{batch_id,status,total,processed,succeeded,failed}` |
  | GET | `/assignments/bulk/{batchId}` | assignment-manage | Batch progress |

- **Create validation (verified `StoreAssignmentRequest`):** as per scope model above; `assignable_subtype` must be null when type is shift; `scope_id` prohibited for `company`, required otherwise; `effective_date` required; `end_date` ≥ effective_date. Bulk variant additionally requires either `filter` or `employee_ids`.
- **Queued mode threshold:** switching between synchronous and queued is threshold-based; the numeric value is **not specified in the reviewed source files**.
- **Data entity:** table `assignments`; scope existence is checked against the matching company-scoped Configuration/Employee tables (verified `assertScopeExists`).
- **Event:** class `AssignmentChanged` exists and is imported by `AssignmentService`.

### 4.7 Assignment Resolution (diagnostic + engine core)

- **Business purpose:** Answer "what does this employee hold on this date?" — the same answer the punch, calculation and leave engines rely on.
- **Endpoint:** `GET /resolve/assignment?employee_id=&date=` (`attendance.assignment-preview`). Validation: `employee_id` required integer; `date` required `Y-m-d`.
- **Response (verified DTO):** `{shift, leave_policies, holiday_calendar, timezone, resolved_from}` where `resolved_from` names the winning scope per slot. Timezone falls back shift → company. No active shift ⇒ validation error bag `unassigned` ("No active shift found for the given date.").
- **Caching:** results cached 24h keyed per company/employee/date with tag-based invalidation when supported.

### 4.8 Punch In/Out (append-only multi-punch)

- **Business purpose:** Capture raw attendance events from web, mobile, biometric, API or manual entry — never mutated afterwards, only superseded.
- **Endpoints:** `POST /punch` (`attendance.punch-create`) · `GET /punches?employee_id=&date=` (`attendance.menu-view`).
- **Validation (verified `StorePunchRequest`):** `punch_type` ∈ {`in`,`out`} · `source` ∈ {`web`,`mobile`,`biometric`,`api`,`manual`} · `punch_time` nullable `Y-m-d H:i:s` · `employee_id` nullable integer · `ip_address` ≤45 · `device_info` ≤255 · `remarks` ≤255.
- **Behavior (verified `PunchService`):**
  - Punches default to the caller's linked employee profile; supplying another `employee_id` requires `attendance.punch-create-others` (403 otherwise). Listing follows the same rule.
  - "Today" is computed in the company timezone (fallback `Asia/Dhaka` when unset).
  - Assignment resolution runs first; no active shift ⇒ **422** with `code: unassigned`, message "No shift is assigned for this date."
  - Overnight shifts: a local time before `start_time` is attributed to the previous `attendance_date`.
  - Two consecutive punches of the same type are rejected (422) with the last punch time in the message.
  - Rows are append-only (`UPDATED_AT = null`), sequenced per day (`sequence_no`), and can later be superseded via `superseded_by_id` (set by approved corrections — originals are never edited/deleted).
  - Event `PunchCreated` fires on creation.
- **Response:** 201 with `PunchResource` (id, company_id, employee_id, attendance_date, punch_type, punch_time ISO-8601, source, ip_address, device_info, remarks, sequence_no, superseded_by_id, correction_request_id, created_at).
- **Data entity:** table `attendance_punches`.


### 4.9 Daily Attendance Records (view & export)

- **Business purpose:** One calculated row per employee per day — first check-in, last check-out, worked/overtime hours, late/early minutes, punch count, applied-policy snapshot, lock flag.
- **Endpoints:**

  | Method | Path | Permission group | Notes |
  |---|---|---|---|
  | GET | `/attendance-records/today` | record-view-own \| -team \| -all | Caller needs a linked employee profile (404 otherwise); recalculates today then returns the record |
  | GET | `/attendance-records` | same | Filters `employee_id,start_date,end_date,status`; range span >366 days ⇒ 422; visibility scoped by the logged-in user inside the repository |
  | GET | `/attendance-records/{id}` | same | 404 if absent; without `record-view-all` the record must appear in the caller's filtered scope else `PermissionDeniedException('attendance.record-view')` |
  | GET | `/attendance-records/{id}/punches` | same | All punches of that day (including superseded) |
  | GET | `/attendance-records/export` | record-export | `format` query defaults `xlsx`; ≤2000 rows (config `attendance.exports.export_queue_threshold`, default 2000) streams `attendance_records_<timestamp>.<format>` via Excel; larger dispatches `QueueAttendanceExportJob` and returns **202** `{token}` (40-char random) for later download |

- **Calculation core (verified `AttendanceRecordService::calculateDaily`):** locked records are returned untouched; otherwise non-superseded punches are paired in time order within the resolved shift/timezone; weekend / holiday (incl. recurring month-day matches) / working-day classification; grace vs late; half-day thresholds from the shift; system attendance types are auto-created on demand keyed by `system_code`; the applied settings are frozen into `policy_snapshot` (grace minutes, working hours, min-hours present/half-day, working days, timezone, resolved_at). Locked days are never recalculated.
- **Data entities:** `attendance_records` (+ relations employee/shift/attendanceType/punches).

### 4.10 Record Recalculation (snapshot replay vs live override)

- **Business purpose:** Rebuild a day's numbers after data fixes — normally replaying the **stored** snapshot so history stays faithful; optionally re-resolving **live** policy as an audited override.
- **Endpoint:** `POST /records/{recordId}/recalculate`. **Route middleware is `attendance.assignment-manage`.** Body (`RecalculateAttendanceRecordRequest`): `use_current_policy` **required boolean**; other documented fields (`user_id,date,policy_snapshot,mock_resolved_policy,shift,is_locked,has_permission`) nullable.
- **Behavior (verified):** 404 when record missing or belongs to another company. Locked record ⇒ `AttendanceRecordLockedException`. With `use_current_policy=true`, the service additionally demands engine permission **`attendance.record-recalculate`** else `RecalculationUnauthorizedException`; it flushes the resolution cache, resolves live policy and writes a before/after audit log. Without it, the stored `policy_snapshot` is replayed (older records without a snapshot fall back to live resolve with a warning log).

### 4.11 Close Day job (manual trigger + status)

- **Business purpose:** Ensure every active employee has a calculated record for a given past day — the same work the nightly scheduler performs (hourly command batches companies whose local time hits hour 02:00, per build sequence card 9.1-BE).
- **Endpoints:** `POST /jobs/close-day` and `GET /jobs/close-day/{batchId}` — route permission **`attendance.record-recalculate`**, prefix `jobs`.
- **Validation (verified `CloseDayRequest`):** `date` required `Y-m-d`, `before_or_equal:today`, `after_or_equal: today−90 days`; `employee_id` nullable `exists:employee_personal_infos,id`.
- **Behavior (verified):** Skips entirely when the Payroll module's `PayrollPeriodGuard` reports the period closed → **200** "No employees to process or period is locked." Otherwise chunks active employees **without** a record for that date (chunk size 200) into `ProcessCloseDayChunkJob`s and returns **202** `{batchId}`. Status endpoint returns Laravel batch fields (total/pending/failed/processed/progress, status finished|cancelled|pending) or 404.
- **Cross-module dependency:** `Modules\Payroll\Support\PayrollPeriodGuard` (explicit import).

### 4.12 Leave Balances (view, adjust, ledger)

- **Business purpose:** Per employee, policy and year: entitled, used, carried-forward and encashed days, with an append-only ledger proving every movement.
- **Available-days formula (verified model accessor):** `entitled_days + carried_forward_days − used_days − encashed_days`.
- **Endpoints:**

  | Method | Path | Authorization | Notes |
  |---|---|---|---|
  | GET | `/leave-balances/me` | none beyond auth group | Current-year balances for own profile; 403 when login lacks an employee link |
  | GET | `/leave-balances/{employeeId}/history` | internal check | Own id always; anyone else needs `attendance.leave-balance-view` else denied. `year` input defaults to current year; returns paginated ledger rows |
  | GET | `/leave-balances` | leave-balance-view | Filters `employee_id` (∃ employee_personal_infos), `year` 2000–2100, `policy_id` (∃ attendance_policies) |
  | PATCH | `/leave-balances/{id}/adjust` | leave-balance-adjust | See below |

- **Adjustment (verified):** body `days` required numeric ≠0, `reason` required ≤255, `allow_negative` nullable boolean. Row is locked `FOR UPDATE`; adjustment applies to `entitled_days`; a negative resulting balance is rejected (`DomainException`) unless `allow_negative`; a `manual_adjustment` **ledger row** (signed days, reason, actor) is written in the same transaction.
- **Ledger entry types (verified enum):** `accrual`, `carry_forward`, `consumption`, `reversal`, `encashment`, `manual_adjustment`.
- **Data entities:** `leave_balances`, `leave_balance_ledger`.


### 4.13 Leave Accrual & Carry-Forward jobs

- **Business purpose:** Grow balances monthly per policy rules and roll unused days across year-end — runnable on schedule (dedicated console commands exist) or manually for backfill.
- **Endpoints (permission `attendance.leave-balance-adjust`, prefix `jobs`):**
  - `POST /jobs/accrue-leave` — body: `month` 1–12, `year` current±5, optional `policy_id` (∃ attendance_policies). Queues chunked accrual; **202** `{batchId}` or **200** `{applied:0}` "No active leave assignments found to accrue or accrual already completed."
  - `POST /jobs/carry-forward` — body: `from_year` current±5, optional `policy_id`. **202** `{batchId}` or **200** "No active leave assignments found for carry-forward."
  - `GET /jobs/{batchId}` — Laravel batch progress or 404.
- **Carry-forward rules (verified service):** governed by policy config `carry_forward_allowed` and capped by `max_carry_forward` (`min(available, max)`); idempotent — skips when a `carry_forward` ledger row already exists for the target balance; scopes are resolved **as of the historical date**, so a past-year run uses the organization the employee sat in then.

### 4.14 Leave Requests (self-service lifecycle)

- **Business purpose:** Employees apply for leave against assigned policies; approvers decide; approval stamps leave days and deducts balance atomically.
- **Endpoints (group permission `attendance.leave-apply | attendance.leave-approve`, prefix `/leave-requests`):**

  | Method | Path | Action |
  |---|---|---|
  | GET | `/applicable-policies?date=` | Policies the caller may apply against on a date (assignment-driven, **not** balance-driven so first-time applicants see options). Each row: id, name, code, half_day_allowed, lwp_allowed, available_days, entitled_days, year, balance_provisioned. Requires own employee link (403 with explanatory message otherwise); `date` validated `Y-m-d`, defaults today |
  | POST | `/preview` | Dry-run cost of a candidate range — returns `total_days`, `excluded_dates[]`, `balance{available_days, after_request_days}`, `requires_document`, `document_threshold_days`, `blockers[]` (reported, not thrown), `warnings[]` |
  | POST | `/` | Submit (201). Adds `reason` required, `attachment` file pdf/jpg/jpeg/png ≤5120KB (stored `leave_attachments` public disk), optional `attachment_path`. Blockers become errors here; overlapping **approved** leave answers 409. Submits via ApprovalGateway (`leave-approve`, correlation `leave_request:{id}`) |
  | GET | `/` | Approver without `employee_id` sees all (`indexForHr`); everyone else is forced to their own — passing a colleague's id without `attendance.leave-approve` raises that denial. Filters: status, employee_id, policy_id, from, to |
  | GET | `/{id}` | Approver sees any; others only their own |
  | PATCH | `/{id}/cancel` | Own only; withdraws the linked approval request (stated in response message) |
  | POST | `/{id}/approve` | Engine check `attendance.leave-approve` inside the action; optional `comment`/`reason` |
  | POST | `/{id}/reject` | Same permission; non-empty `reason` required else 422 |

- **Approval effects (verified `LeaveRequestExecutor`):** locks the request row; pending-only (409 "Request already finalized"); rejects overlap with another **approved** request (409); computes per-working-day values excluding weekends/holidays using the resolved shift+holiday calendar; stamps those days as `attendance_records` of the leave type with `policy_snapshot {leave_request_id}` (spec §5.3: executor also deducts balance + writes consumption ledger row); verifies Σday-values equals `total_days` (else 422); sets approved/decided_by/at; fires `LeaveDecided`.
- **Validation inputs (verified):** preview/apply share `leave_policy_id` int, `start_date`/`end_date` `Y-m-d` (end ≥ start), `duration_type` ∈ {`full_day`,`half_day_first`,`half_day_second`}.
- **Data entities:** `leave_requests`, `leave_request_days` (per counted working day, `day_value` 1.00/0.50, void support).


### 4.15 Attendance Correction Requests

- **Business purpose:** Let employees dispute a day — missing check-in/out, incorrect time, wrong status, other — with evidence; HR decides; approval rewrites the day **without destroying history**.
- **Endpoints:**

  | Method | Path | Permission | Notes |
  |---|---|---|---|
  | POST | `/correction-requests` | correction-create | 201; duplicate pending for same date ⇒ **409** |
  | POST | `/correction-requests/{id}/cancel` | correction-create | Policy-authorised cancel |
  | GET | `/correction-requests` | correction-create \| correction-approve | Query `status` validated against allowed set (invalid ⇒ 422), `employee_id` defaults `me`, `from`,`to` |
  | GET | `/correction-requests/{id}` | correction-create \| correction-approve | Includes `approval_request_uuid/status` and a `current_day` snapshot (record metrics + every punch incl. superseded) so approvers need no separate record permission |
  | POST | `/correction-requests/{id}/approve` | correction-approve | Direct-decision fallback when no platform approval request backs the row (e.g., approval disabled at submission); policy `approve` check |
  | POST | `/correction-requests/{id}/reject` | correction-approve | Inline validation: `reason` required string ≤500 |

- **Submission validation (verified `StoreCorrectionRequestRequest`):** `attendance_date` required date; `request_type` ∈ {`missing_in`,`missing_out`,`incorrect_time`,`wrong_status`,`other`}; `requested_check_in` required for missing_in/incorrect_time (`Y-m-d H:i:s`); `requested_check_out` required for missing_out/incorrect_time; `attendance_type_override_id` required for wrong_status (∃ attendance_types); `reason` ≤5000 required; `attachment` optional file, mimes from config `employee.documents.allowed_mimes` (default pdf,jpg,jpeg,png,doc,docx), size cap `employee.documents.max_file_size_kb` (default 5120).
- **Service guards (verified `CorrectionRequestService`):** own-record only (mismatched `employee_id` ⇒ permission denial); correction window (spec: `attendance.correction_window_days`, default 30); no second pending request per date; requested times must fit the resolved shift window; locked-month state recorded on the row (`month_locked`) and overriding it at decision time requires **`attendance.correction-override-lock`** with prominent audit logging (executor constant + spec).
- **Approval effects (verified `AttendanceCorrectionExecutor`):** inserts replacement punches with `source=manual` and `correction_request_id`, stamps replaced originals `superseded_by_id` (originals never mutated/deleted), validates the resulting sequence (two consecutive identical types ⇒ 422), recalculates the day preserving its snapshot; `wrong_status` overrides the day's `attendance_type_id` with an audit log; rejection changes nothing but status/reason. Fires `CorrectionDecided` (imported).
- **Data entity:** `correction_requests` (incl. `attendance_type_override_id` added by migration 2026_08_20).

### 4.16 Monthly Attendance Approval (close the month → payroll-ready)

- **Business purpose:** Aggregate a person-month into audited totals, surface unresolved issues, approve (optionally overriding), lock the underlying days, and mark the month ready for payroll; unlock reverses this while the month is not yet frozen by Finance.
- **Endpoints:**

  | Method | Path | Permission | Notes |
  |---|---|---|---|
  | POST | `/monthly-attendance/build` | monthly-approve | With `employee_id`: synchronous single summary (**201** resource). Without: bulk build queued (**202** batch payload; empty selection ⇒ validation error "No employees found in the specified criteria."). Rebuilding an approved month ⇒ 409 |
  | GET | `/monthly-attendance` | monthly-view | Filters: month, year, status, ready_for_payroll, department_id, search, per_page |
  | GET | `/monthly-attendance/{id}` | monthly-view | Detail |
  | GET | `/monthly-attendance/{id}/breakdown` | monthly-view | Day-level records of that month (filters status, per_page; user-scoped) |
  | GET | `/monthly-attendance/batch/{batchId}` | monthly-view | Cache-backed batch status (TTL 24h; 404 when absent) |
  | POST | `/monthly-attendance/{id}/approve` | monthly-approve | Body `override` nullable bool. Already-approved ⇒ 409. Unresolved without override ⇒ 409 listing issues. Override is activity-logged (`monthly_attendance.approved_with_override`) |
  | POST | `/monthly-attendance/bulk-approve` | monthly-approve | `ids` required ∃ monthly_attendance_approvals; queues chunks of 50; **202** `{mode:"queued",batch_id,…}` |
  | POST | `/monthly-attendance/{id}/unlock` | monthly-unlock | `reason` required ≤255. Frozen month ⇒ 409 "Cannot unlock a frozen month." Resets is_locked/ready_for_payroll, stores reason, sets status pending, unlocks the days |

- **Build totals (verified):** sums present/absent/leave/unpaid-leave/half days, late count, overtime & working hours from the month's records; future months count elapsed days only. `unresolved_flag` becomes true when any of: missing check-out day found, fewer records than elapsed days (unassigned/missing data), **pending correction requests**, **pending leave requests** — the response-visible issue strings come from `getUnresolvedIssuesList`.
- **Approve effects (verified gateway + executor):** submission through ApprovalGateway (`monthly-approve`, entity `monthly_attendance_approvals`, correlation `monthly_attendance:{id}`); executor sets `status=approved`, `approved_by/at`, `is_locked=true`, `ready_for_payroll=true` and locks every `attendance_records` row of that employee-month; event **`MonthApproved`** fires. Spec §5.3 confirms the executor is the only place these transitions happen.
- **Data entity:** `monthly_attendance_approvals` (unique per company+employee+month+year; carries `frozen_at/frozen_by/unfreeze_reason`).

---

## 5. Background jobs & scheduled commands (verified inventory)

**Queue jobs:** `BuildBulkMonthlyAttendanceJob`, `ProcessBulkMonthlyApproveJob`, `ProcessBulkAssignmentJob`, `ProcessCloseDayChunkJob`, `ProcessLeaveAccrualChunkJob`, `ProcessCarryForwardChunkJob`, `ProcessPolicyGroupBackfillChunkJob`, `QueueAttendanceExportJob`.

**Console schedulers:** `ScheduleCloseDayCommand` (hourly; fires a company's batch when local hour == 2 — build sequence 9.1-BE), `ScheduleLeaveAccrualCommand`, `ScheduleLeaveCarryForwardCommand` (pair with the manual triggers above — 9.2-BE), `ScheduleEligibilitySweepCommand` (announces recent organization-assignment changes to policy-group auto-assignment).

---

## 6. Domain events raised (classes verified)

`PunchCreated` (dispatched in PunchService) · `AssignmentChanged` (imported by AssignmentService) · `AttendanceCalculated` · `LeaveDecided` (dispatched in LeaveRequestExecutor) · `LeaveDayVoided` · `CorrectionDecided` (imported by AttendanceCorrectionExecutor) · `MonthApproved` (dispatched in MonthlyAttendanceApprovalService).

---

## 7. Database entities owned by the module

Migrations present (verified filenames): `shifts`, `attendance_types`, `attendance_policies`, `assignments` (+ scope-normalization & identity-constraint migration, `policy_group_id` addition), `attendance_punches`, `leave_balances`, `leave_balance_ledger`, `attendance_records`, `correction_requests` (+ `attendance_type_override_id` migration), `leave_requests`, `leave_request_days`, `monthly_attendance_approvals`, `attendance_policy_groups`, `attendance_policy_group_policy`.

Seeders: `AttendanceDatabaseSeeder`, `AttendanceTypeSeeder`, `ShiftSeeder`.

---

## 8. Integrations with other modules (explicitly confirmed)

| Module / Platform service | Evidence |
|---|---|
| **Employee** | `EmployeePersonalInfo` relations on punches/records/corrections/leave/monthly; `exists:employee_personal_infos,id` validations; repositories for close-day & bulk-build employee selection; `user->employeeProfile` self-service links |
| **Configuration** | Scope models Branch/Division/Department/Section/Team imported by `AssignmentService::scopeModelMap()`; `departments` existence check in monthly build validation |
| **Payroll** | `Modules\Payroll\Support\PayrollPeriodGuard` gates close-day; `ready_for_payroll` + `frozen_at` hand-off consumed by Payroll snapshots (§9) |
| **Platform Tenancy** | `TenantContext` company scoping everywhere; `X-Company-Id` header convention |
| **Platform Permissions** | `permission:` middleware + `PermissionEngineContract` point-checks (punch-others, record-view-all, leave-approve, leave-balance-view, record-recalculate, override-lock) |
| **Platform Approval Gateway/Engine** | leave-approve, correction-approve, monthly-approve submissions with `correlationId`s; executors registered per entity type (`leave_request`, `attendance_correction`, `monthly_attendance`) |
| **Activity Log** | health view, unresolved-month override approval, correction lock-override & wrong-status override |
| **Excel (Maatwebsite)** | attendance export streaming + queued export |

---

## 9. Payroll hand-off facts (from spec + build sequence, code-anchored where possible)

- An approved month sets `ready_for_payroll = true`; Finance-side **freeze/unfreeze lives in the Payroll module** (`payroll.month-freeze`, segregation-of-duties with `attendance.monthly-approve`). Freeze requires approved status; unfreeze requires a reason and, for paid runs, `payroll.month-unfreeze-paid`.
- **`attendance_snapshots`** (card 8.0-BE, done): immutable, timestamp-less, `unique(payroll_run_id, employee_id)`; copied verbatim from frozen monthly approvals — no recalculation; rebuild idempotent. Four endpoints exist **inside the Payroll module** (build draft-only under `payroll.run-create`, list, show, diagnostic divergence). They are therefore not part of this module's route file.
- Payslips must consume attendance exclusively via `AttendanceSnapshotServiceInterface::getPayrollAttendanceForRun()`; a guard test proves neither `attendance_records` nor `monthly_attendance_approvals` is queried on that path. Payslip generation itself (`8.2a-BE`) is **not yet in the codebase** (pending card).

---

## 10. Error-behavior cheat-sheet (verified codes/messages)

| Situation | Result |
|---|---|
| Missing company context | `PermissionDeniedException('company.context')` |
| Punch on unassigned date | 422, `code: unassigned` |
| Duplicate consecutive punch type | 422 with last-punch message |
| Date-range >366 days on records list | 422 "Date range cannot exceed 366 days." |
| Invalid correction/leave status filter value | 422 |
| Second pending correction same date | 409 `DuplicateCorrectionRequestException` |
| Approve already-approved month / rebuild approved month / unlock frozen month | 409 |
| Unresolved month approve w/o override | 409 with comma-joined issue list |
| Negative leave-balance adjust w/o allow_negative | DomainException "Adjustment rejected: …" |
| Overlap conflicts on assignment create/update | `AssignmentOverlapException` error response |
| Recalculate locked record / unauthorized live override | Dedicated exceptions (`AttendanceRecordLockedException`, `RecalculationUnauthorizedException`) |

Anything not enumerated above (exact transport envelopes, rate limits, additional HTTP codes) — **Not specified in the reviewed source files.**

---

## 11. What this guide intentionally does not cover

- Front-end screens/UI flows (only backend routes, services, jobs, events reviewed; FE cards referenced in the build sequence were not inspected).
- Role seeding mechanics beyond the spec sentence quoted in §2.
- Exact values for: bulk-assignment sync/queue threshold, pagination envelope internals of the base controller, queue connection names.

For all of the above: **Not specified in the reviewed source files.**

---

## Appendix A — Reviewed sources

1. `backend/Modules/Attendance/routes/api.php` (291 lines, complete)
2. Controllers (17): AttendanceController, PunchController, AttendanceRecordController, AttendancePolicyController, AttendanceTypeController, Shift/ShiftManagementController, AssignmentController, BulkAssignmentController, AssignmentResolutionController, AttendanceRecalculationController, PolicyGroupController, LeaveBalanceController, LeaveBalanceJobController, CloseDayJobController, LeaveRequest/LeaveRequestManagementController, CorrectionRequestController, MonthlyAttendanceApprovalController
3. Form Requests (45 files — all rules quoted verbatim)
4. Models: Assignment, AttendancePolicy, AttendancePolicyGroup, AttendancePunch, AttendanceRecord, AttendanceType, CorrectionRequest, LeaveBalance, LeaveBalanceLedger, LeaveRequest, LeaveRequestDay, MonthlyAttendanceApproval, Shift
5. Service contracts (22 interfaces) and implementations: PunchService, AttendanceRecordService, AttendanceRecalculationService, AssignmentService, AssignmentResolutionService, CorrectionRequestService, LeaveRequestService, LeaveBalanceService, MonthlyAttendanceApprovalService, CloseDayService, AttendanceTypeService, AttendancePolicyGroupService, PolicyGroupEligibilityEvaluator, BulkAssignmentService contract
6. Approval executors: MonthlyAttendanceExecutor, AttendanceCorrectionExecutor, LeaveRequestExecutor
7. Support: EligibilityAttributeRegistry, GenderNormalizer, BatchProgressStore; DTOs (ResolvedContext, EmployeeEligibilityContext, ResolvedPolicyAssignment, LeaveEvaluation, AutoAssignmentOutcome, PolicySnapshotData)
8. Jobs (8), Events (7), Resources (14 incl. Applicable/Eligibility attribute resources), Exports, Console schedulers (4), Providers (RouteServiceProvider), Migrations (17), Seeders (3)
9. Reference documents: `../attendance-payroll/ATTENDANCE_PAYROLL_MODULE_SPEC.md` (§0 ground rules, §3 data model, §4 permissions, §5 approvals, §6 contracts, Parts D–F stories), `../attendance-payroll/ATTENDANCE_PAYROLL_BUILD_SEQUENCE.md` (wave progress incl. 8.0/8.1/8.2 cards, 9.1/9.2 jobs, 6.1/6.2 monthly close/freeze)
10. Style reference: `../employee/EMPLOYEE_MODULE_GUIDE.md` / `../employee/EMPLOYEE_MODULE_GUIDE_BN.md`
