# CloseTimecardPeriod

## Permission Scope

`timecard`

## Overview

CloseTimecardPeriod is the period-level close: it locks every APPROVED Timecard for an exact `[periodStart, periodEnd]` in one operation, the payroll-finalization event a labor/HR operator runs once a period's approvals are in. This is the normal close path; the per-card `LockTimecard` remains only for the single-card exception (re-locking one card after a post-lock historical correction). The close is a **partial, idempotent** operation: APPROVED cards are locked, cards not yet APPROVED are returned as blockers (never abort the whole close), and cards already LOCKED are no-op passes — so an operator can chase stragglers and re-run the close safely.

## Business Rules

- Scope is an exact match on `periodStart` AND `periodEnd`; an optional `assignmentIds` subset narrows the set (the app layer resolves an organizational scope — department / site / company — to `assignmentIds` via workforce queries and passes them here). Omitting `assignmentIds` targets every Timecard in the period. The module itself is organization-agnostic.
- Only APPROVED Timecards are transitioned to LOCKED (APPROVED → LOCKED); each locked card records `lockedAt` and `lockedBy` (the closing actor)
- In-scope Timecards that are not APPROVED (OPEN or SUBMITTED) are left untouched and reported as blockers in `skipped`; they never abort the close
- In-scope Timecards already LOCKED are idempotent no-ops, reported in `alreadyLocked`, never an error — this is what makes re-running the close after chasing stragglers safe
- `fullyClosed` is true exactly when `skipped` is empty (every in-scope card is now LOCKED)
- `dryRun` resolves the identical scope and reports the same locked / skipped / alreadyLocked partition **without** performing any UPDATE, so a preview can never disagree with the real run
- An empty in-scope set is rejected with `NO_TIMECARDS_IN_PERIOD` (a mistyped period must not silently succeed as a no-op close)
- Locking closes the period for payroll hand-off (ADR-014); a LOCKED card's covered ReportedTimeBlocks are no longer correctable by supersede — any post-lock change goes through ReopenTimecard (reason required): reopen the card to OPEN, correct via supersede (which re-derives the CalculatedTimeBlocks payroll reads), then re-lock
- The whole close runs in one transaction; in-scope rows are selected `FOR UPDATE` and the LOCKED write carries `WHERE status = 'APPROVED'` as a concurrency belt-and-braces (a card approved after the snapshot simply lands in `skipped` and is picked up by a re-run)

## Process Flow

```mermaid
flowchart TD
    A[Labor/HR operator closes a period] --> B[Select in-scope Timecards: periodStart = ? AND periodEnd = ? and optional assignmentIds, FOR UPDATE]
    B --> C{Any Timecard in scope?}
    C -- No --> D[Reject: NO_TIMECARDS_IN_PERIOD]
    C -- Yes --> E[Partition by status]
    E --> F[APPROVED -> set LOCKED, lockedAt, lockedBy; collect in locked]
    E --> G[OPEN/SUBMITTED -> leave untouched; collect in skipped as blockers]
    E --> H[Already LOCKED -> no-op; collect in alreadyLocked]
    F --> I{dryRun?}
    G --> I
    H --> I
    I -- Yes --> J[Report partition without writing]
    I -- No --> K[Commit LOCKED transitions]
    K --> L[Return locked / skipped / alreadyLocked / fullyClosed]
    J --> L
```

## External Dependencies

- [time-tracking::Timecard](../model/Timecard.md) - the period sign-off unit whose APPROVED members are transitioned to LOCKED
- [time-tracking::TimeCorrectionLog](../model/TimeCorrectionLog.md) - the journal any post-close change against a locked Timecard must be recorded in

## Error Scenarios

- **NO_TIMECARDS_IN_PERIOD**: no Timecard matches the given period (and optional assignmentIds), so there is nothing to close

## Test Cases

- locks every APPROVED card and stamps lockedAt/lockedBy
- leaves OPEN/SUBMITTED cards in skipped while still locking APPROVED (partial close)
- treats an already-LOCKED card as a no-op (alreadyLocked, no UPDATE)
- does not lock anything on dryRun but still returns the partition
- rejects with NO_TIMECARDS_IN_PERIOD when nothing matches
- applies the assignmentIds subset filter to the period select
