# CalculateTimeBlocks

## Permission Scope

`calculatedBlock`

## Overview

CalculateTimeBlocks derives or refreshes CalculatedTimeBlocks for an Assignment and workday by applying the work-rules effective on that date to the current ReportedTimeBlocks, decomposing them into categorized, rule-applied minutes.

## Business Rules

- CalculatedTimeBlocks are never a source of truth: they are always re-derivable from `sourceReportedBlockIds` plus the work-rules effective on `workDate`, and may be discarded and regenerated without data loss (ADR-014)
- Only current (non-superseded) ReportedTimeBlocks are consumed as source input; a superseded reported block is never the source of a live calculated block
- `minutes` is a non-negative integer count of minutes (no float hours)
- Every span the classification strategy emits is validated before it is persisted: `minutes` a non-negative integer, `startAt` on or before `endAt`, and every `sourceReportedBlockId` referencing a current ReportedTimeBlock for the Assignment and workday. A strategy (including a custom one) that violates this is rejected with `STRATEGY_OUTPUT_INVALID` rather than persisting a corrupt or untraceable calculated block
- `timeEntryCodeKey` and `payCodeKey` reference work-rules TimeEntryCode by stable key, never by display name; relabeling a code never alters a calculation (ADR-015, issue #14)
- A span's category binds to a TimeEntryCode via the resolved WorkRule's `premiumRatePercent` entry when it pins a `key` for that category; an unpinned category falls back to the category-wide lookup, which must resolve to exactly one code — zero, duplicates, or a dangling pinned key are unresolvable configuration (`WORK_RULE_NOT_FOUND`), never a row-order-dependent pick (ADR-024 #5)
- `calculationTagKeys` records every rule/tag that fired, sufficient to reconstruct the block's derivation for audit (issue #7)
- Distinct categories (regular, overtime, late-night, holiday) are emitted as separate blocks with their own timeEntryCodeKey and tags
- If prior calculated blocks exist for the workday, they are discarded and replaced by the newly derived set (not accumulated)
- The WorkRule is resolved through the workforce context of the Assignment (WorkerEmployment / Position), picking the WorkRuleAssignment in force on `workDate` by most-specific target: `WORKER` > `POSITION` > `JOB_PROFILE` > `EMPLOYMENT_TYPE` > `WORK_REGIME`, then the WorkRule generation effective on `workDate` within the assigned rule's `versionOf` series
- After regeneration, if an OPEN Timecard covers the workday for the Assignment, its per-category totals (`categoryTotals`, keyed by the strategy-defined category key — not fixed JP columns) are refreshed from the covered CalculatedTimeBlocks

## Process Flow

```mermaid
flowchart TD
    A[Trigger: ReportedTimeBlock change for an Assignment + workday] --> B{Assignment effective on workDate?}
    B -- No --> B2[Reject: ASSIGNMENT_NOT_FOUND]
    B -- Yes --> B3[Resolve workforce context: WorkerEmployment / Position]
    B3 --> C[Resolve WorkRuleAssignment in force by most-specific target, then WorkRule generation effective on workDate]
    C --> D{Rules resolvable?}
    D -- No --> E[Reject: WORK_RULE_NOT_FOUND]
    D -- Yes --> D2[Gather current ReportedTimeBlocks for Assignment + workDate]
    D2 --> D3{Any current reported blocks?}
    D3 -- No --> D4[Reject: NO_REPORTED_BLOCKS]
    D3 -- Yes --> F[Decompose intervals into categorized spans: regular/overtime/late-night/holiday]
    F --> F2{Every span valid: minutes non-negative integer, startAt <= endAt, sourceReportedBlockIds exist?}
    F2 -- No --> F3[Reject: STRATEGY_OUTPUT_INVALID]
    F2 -- Yes --> G[Emit CalculatedTimeBlocks with minutes, timeEntryCodeKey, payCodeKey, calculationTagKeys]
    G --> H{Prior calculated blocks exist for this workday?}
    H -- Yes --> I[Discard prior blocks]
    H -- No --> J[Persist new blocks as current]
    I --> J
    J --> J2[Refresh covering OPEN Timecard category totals]
    J2 --> K[Downstream: payroll / costing / Article-36 agreement read CalculatedTimeBlock]
```

## External Dependencies

- [time-tracking::ReportedTimeBlock](../model/ReportedTimeBlock.md) - the current declared blocks consumed as calculation input
- [time-tracking::WorkRuleAssignment](../model/WorkRuleAssignment.md) - the effective-dated binding resolved (most-specific target first) to pick the WorkRule for the worker on `workDate`
- [time-tracking::WorkRule](../model/WorkRule.md) - the rule generation effective on `workDate`, supplying rounding, break deduction, overtime threshold, night window (ADR-015)
- [time-tracking::TimeEntryCode](../model/TimeEntryCode.md) - resolved by category/key to bind `timeEntryCodeKey`/`payCodeKey`
- [time-tracking::CompanyHoliday](../model/CompanyHoliday.md) - consulted to classify the workday as a statutory/prescribed holiday
- workforce Assignment / WorkerEmployment / Position (cross-module, injected queries) - establish whose time this is and the worker's employmentType/workType/position targets for rule resolution

## Error Scenarios

- **WORK_RULE_NOT_FOUND**: no WorkRule exists for the given id, or no WorkRule is effective for the relevant date
- **ASSIGNMENT_NOT_FOUND**: no workforce Assignment is effective for the relevant worker/date
- **NO_REPORTED_BLOCKS**: There are no current ReportedTimeBlocks for the Assignment and workday to calculate from
- **STRATEGY_OUTPUT_INVALID**: the classification strategy produced a span with non-integer/negative minutes, an inverted interval, or a sourceReportedBlockId not among the workday's current reported blocks

## Test Cases

- rejects when no workforce Assignment is effective for the workDate
- rejects when no WorkRule resolves for the Assignment on the workDate
- rejects when there are no current ReportedTimeBlocks for the Assignment and workday
- resolves the WorkRule by most-specific target: WORKER over POSITION over JOB_PROFILE over EMPLOYMENT_TYPE over WORK_REGIME
- applies the WorkRule generation effective on workDate, not the latest generation
- emits a single REGULAR block when worked minutes fit within the daily overtime threshold
- splits minutes beyond dailyOvertimeThresholdMinutes into a separate OVERTIME block
- emits NIGHT blocks for spans intersecting the night window
- emits HOLIDAY blocks for the whole day when workDate is a CompanyHoliday
- applies clock-in/clock-out rounding from the WorkRule before categorization
- deducts BREAK block minutes from worked time before the overtime split
- stamps calculationTagKeys with every rule that fired
- excludes superseded ReportedTimeBlocks from the source query (only current blocks are consumed)
- discards prior CalculatedTimeBlocks for the workday before inserting the regenerated set
- resolves timeEntryCodeKey/payCodeKey from TimeEntryCode by key, never by display name
- binds a span to the WorkRule's pinned TimeEntryCode key even when other codes share the category
- rejects when the WorkRule pins a TimeEntryCode key that does not exist
- rejects when an unpinned span category has more than one TimeEntryCode (never picks by row order)
- sets sourceReportedBlockIds to the ids of the current reported blocks each span derived from
- rejects when the strategy emits a span with negative or non-integer minutes
- rejects when the strategy emits a span whose startAt is after its endAt
- rejects when the strategy emits a span referencing a sourceReportedBlockId not among the workday's reported blocks
- refreshes the covering OPEN Timecard's per-category totals after regeneration
