dodil/erp-method

modules/time-off/module.yaml

# Time off: the module source.
#
# PROVENANCE. Written 2026-10-25 in the wave-4 spec pass (erp-method-w4, spec/wave4-v1). Version 1 SIGNED
# by seemo 2026-10-25 (signoff/time-off/; wave 4). hr v2 lists absence as "out of scope for now, an extension point"; this is that module. Written
# from:
#   - the neighbours' specs at erp-method v0.1.35: hr v2 (the person, the engagement and assignment slices, the derived
#     manager HR-57, the location with its holiday_calendar, the work schedule, hr_holiday rows a localisation pack
#     loads, the Left event HR-54); payroll v1 and timesheets v1 (this wave), which read approved absences
#   - UK: the Working Time Regulations 1998 regs. 13 and 13A (5.6 weeks' paid leave, at most 28 days for a five-day
#     week), reg. 15A as amended by the Employment Rights (Amendment, Revocation and Transitional Provision)
#     Regulations 2023 (irregular-hours and part-year workers accrue 12.07% of hours worked, for leave years from
#     2024-04-01); the Banking and Financial Dealings Act 1971 (bank holidays per nation); the Social Security
#     Contributions and Benefits Act 1992 Part XI (statutory sick pay) and Part XII (statutory maternity pay), whose
#     money payroll computes; the Maternity and Parental Leave etc. Regulations 1999 (52 weeks, 10 keeping-in-touch days)
#   - Odoo 17 hr_holidays (hr.leave, hr.leave.allocation, hr.leave.type) as the migration source; SAP HCM Time
#     Management (infotypes 2001 absences, 2006 absence quotas) and SuccessFactors Time Off as parity references only
#
# The decisions an HR manager checks first:
#   - a person never approves their own time off, under any login (email AND sub); a manager's request goes one level up
#   - the balance is a ledger of dated entries plus what is derived on read (the entitlement from the employment slices),
#     never a number someone types over
#   - a day can be booked once: two requests for the same half day collide on one claim row, so a balance cannot be
#     spent twice by two tabs or two approvers
#   - public holidays come from hr's calendar of the person's location (England and Wales, Scotland, Northern Ireland
#     differ); this module adds only company closure days
#   - this module records absence; payroll decides what it costs (unpaid days, SSP, SMP). No money is computed here

# Version 2 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 1 stays the signed one until it is signed):
#   - seams only: GET /absences (TOF-20) listed for timesheets with its own with: row (it named timesheets only in
#     its status)
#   - TOF-01's arithmetic: available 10.00 (25.00 + 2.00 carried - 17.00 taken; the carried days are inside the 17),
#     not 8.00, which took the carried days off twice
#   - the policy unit is the policy's own: days for a full-time policy (UK-ANNUAL), hours for a part-time one (UK-
#     ANNUAL-PT); a person's quantities are all in their policy's unit; caps and annual_days are written in days and
#     converted at the contracted day (HR-62)
#   - permissions: tof:settings:write (the non-control settings) is in the catalog, held by hr_admin; the tests
#     named it, the catalog did not
#   - hr seam (decided answer 2): the approver email from HR-63; the year-end pass (TOF-17), the team calendar and
#     overlap count (TOF-10, TOF-04) and the migration's person match (TOF-23) from HR-64, in place of the
#     person_ids and employee_map doors
#   - PROCESS change (the migration run, seemo's decided answer 4, pattern §11): load writes staging rows only; sign
#     is the first write to the ledger, through the normal use cases; discard deletes staging rows only; a signed
#     run is reversed, never deleted. Effects added; no step renamed or removed
#   - TOF-23 follows it: a discard no longer deletes ledger rows (erp-brick-time-off 0.2.0 deleted an unsigned run's
#     entries)

id: time-off
name: Time off & absence
short: Time off
version: 2
maturity: tested                     # version 1 signed by seemo 2026-10-25; erp-brick-time-off 0.2.0 builds it (slices A and B, tested, not deployed); the latest version is a draft for re-sign
summary: >-
  Holiday, sickness and family leave in one place: everyone sees what they have left, asks for days off in two
  clicks, and their manager approves with the team calendar in front of them. Bank holidays follow where people
  work, part-timers get their fair share, sickness is recorded with its fit notes, and payroll and timesheets see
  exactly the days that were approved.

decisions:
  - proposed: "the approver is the derived manager from hr on the request's first day; a request whose approver would be the requester goes one level up; nobody approves their own time off (email AND sub)"
    status: "for sign-off"
  - proposed: "the yearly entitlement is derived on read from the policy and hr's employment slices (start, leave, FTE changes), prorated by calendar days and rounded up to the next half day; only carry-over, adjustments, accruals and approved bookings are stored, as ledger entries"
    status: "for sign-off"
  - proposed: "sickness is recorded, not requested: the employee or the manager records it, the manager confirms the return; a fit note is required from the 8th calendar day"
    status: "for sign-off"
  - proposed: "time-off owns no money: payroll reads approved absences with their pay treatment and computes deductions and statutory pay"
    status: "for sign-off"

defaults_to_confirm:                    # the business would choose these; chosen here so there is something to react to (seemo)
  - "leave year 1 January to 31 December (alternatives: 1 April, or each person's start anniversary)"
  - "annual leave 25 days plus the bank holidays of the person's nation (33 days for England and Wales in 2026), FTE-prorated; policies below 5.6 weeks are refused for employees"
  - "carry-over up to 5 days, to be used by 31 March, then expired by the year-end job"
  - "no negative balance (allow_negative_days 0)"
  - "half days allowed; minimum booking half a day; requests at most 365 days ahead"
  - "part-time and irregular-hours workers book in hours; irregular-hours workers accrue 12.07% of approved hours (timesheets)"
  - "fit note required from the 8th calendar day of sickness; self-certification before that"
  - "team overlap: warn when more than 40% of a team is off on a day (never a block by default)"
  - "a leaver's approved requests after the last day are cancelled automatically; the leaver's balance is handed to payroll in days to 2 decimals"
  - "a manager may record sickness for their team; HR may record any absence"

facets:
  domain: people
  industries: [any]
  customer_words:
    - "holiday booking"
    - "annual leave"
    - "how many days do I have left"
    - "sick days"
    - "absence management"
    - "the holiday spreadsheet"
    - "bank holidays"
    - "maternity leave"
    - "Bradford factor"
    - "Urlaubsantrag"
  replaces: [the holiday spreadsheet, a wall planner, BrightHR, Breathe, Timetastic, Odoo Time Off]
  pillars: [sql]
  first_module_fit: high
  depends_on:
    - module: hr
      why: the person, their employment and FTE on each date, their location's holiday calendar, their work pattern and their derived manager all come from hr
      required: true
    - module: payroll
      why: unpaid leave, sickness and maternity reach the payslip; without payroll the payroll provider reads the absences report
      required: recommended
    - module: timesheets
      why: approved absence fills the timesheet day; irregular-hours workers accrue holiday from approved hours
      required: optional
  feeds: [payroll, timesheets]
  owns_master_data: [leave_type]
  references_master_data: [person, holiday_calendar, work_schedule]

actors:
  - id: employee
    name: Anyone who works here
    does: sees their balances, asks for time off, withdraws a request, records their own sickness and return, uploads a fit note
    sees: only their own requests, balances and absences, and the team calendar's busy days without reasons
    permissions: [tof:request:read_own, tof:request:write_own, tof:sickness:record_own, tof:calendar:read_team]
  - id: manager
    name: Line manager (derived from hr)
    does: approves or rejects their team's requests with the team calendar in front of them, records a team member's sickness, confirms a return to work
    sees: the requests and absences of people they manage on the absence's dates (sickness reason only with tof:sickness:read_detail)
    permissions: [tof:request:read_own, tof:request:write_own, tof:sickness:record_own, tof:calendar:read_team, tof:request:read_team, tof:request:approve, tof:sickness:record_team]
    note: "a manager is an employee too: their own request goes to their manager, never to themselves"
  - id: hr_admin
    name: HR / people operations
    does: keeps leave types and policies, adjusts balances with a reason, records family leave and long-term sickness, reads every absence, runs the year end
    sees: every absence of their companies, sickness detail included
    permissions: [tof:request:read_all, tof:request:approve, tof:sickness:record_team, tof:sickness:read_detail, tof:absence:record, tof:balance:adjust, tof:policy:write, tof:settings:write, tof:leave_type:write, tof:closure:write, tof:report:read, tof:migration:read, tof:request:read_own, tof:request:write_own, tof:sickness:record_own, tof:calendar:read_team]
    note: "HR never approves or adjusts their own absence or balance: email AND sub"
  - id: owner
    name: The owner (or whoever the owner names)
    does: holds the controls (negative balances, carry-over caps, the overlap rule's mode, who may record sickness), signs the migration reconciliation
    sees: everything
    permissions: [tof:request:read_all, tof:settings:controls, tof:migration:read, tof:migration:sign]
    note: "anyone else holding tof:settings:controls is a configuration error the access test refuses"
  - id: system
    name: The system
    does: derives entitlements, accrues from hours, runs the year end (carry-over and expiry), reminds approvers, chases fit notes, takes hr's leaver event, answers payroll and timesheets, loads an Odoo migration
    sees: everything, through its own service account
    permissions: [tof:balance:accrue, tof:year_end:run, tof:reminder:tick, tof:leaver:intake, tof:absence:read_for_payroll, tof:balance:read_for_payroll, tof:migration:load, tof:migration:reconcile]
    note: "exact grants, never a wildcard: tof:leaver:intake to hr's service client alone; tof:absence:read_for_payroll to payroll's and timesheets' clients; tof:balance:read_for_payroll to payroll's client alone. The module's own client holds hr:employment:read, hr:manager:read, hr:work_pattern:read and ts:hours:read_approved"

stories:
  # ── balances and requests ───────────────────────────────────────────────────────────────
  - id: TOF-01
    kind: person
    actor: employee
    story: As an employee, I want to see how much holiday I have left this year and what it is made of, so that I can plan without asking HR.
    given: 'employee 4410 (UK01, London, Monday to Friday 7.5 hours, FTE 1.0000) under policy UK-ANNUAL: 25 days plus bank holidays, leave year 2026-01-01 to 2026-12-31; 2 days carried from 2025 (expire 2026-03-31, both used in February); 17 days taken; nothing booked'
    when: I open My time off on 2026-11-02
    then: >-
      entitlement 25.00 (derived from the policy and hr's employment slices, never stored), carried 2.00 (used 2.00,
      expired 0.00), taken 17.00 (the 2 carried days are inside it: carried days are used first), booked 0.00, available
      10.00 (25.00 + 2.00 - 17.00; v2: was 8.00, which took the carried days off twice), each figure opening to the entries behind it; the 8 bank
      holidays of England and Wales are listed apart and never deducted from the 25; the figures are as at the business
      date in business_timezone
    screen: My time off
    reference: not built
  - id: TOF-02
    kind: person
    actor: employee
    story: As an employee, I want to ask for days off and see at once how many days it costs, so that bank holidays and weekends are never counted against me.
    given: 'employee 4410 with 8.00 days available; hr''s calendar GB-EAW: 2026-12-25, 2026-12-28 and 2027-01-01 are bank holidays'
    when: 'I ask for 2026-12-21 to 2027-01-01, then a colleague in Edinburgh (calendar GB-SCT) asks for 2026-11-30 to 2026-12-04'
    then: >-
      mine costs 7.00 days (21, 22, 23, 24, 29, 30 and 31 December; 25 and 28 December and 1 January are bank holidays;
      26, 27 December and the weekend are not working days in hr''s work pattern); the request is requested, its days
      claimed one row per half day (tof_day_claim), my available shows 1.00 with 7.00 pending; the Edinburgh request
      costs 4.00 days because 2026-11-30 (St Andrew''s Day) is a bank holiday in GB-SCT only; a request that would take
      the balance below allow_negative_days (0) is refused 422 {gate: balance, reason: "7.00 asked, 6.00 available"}
    screen: My time off
    reference: not built
  - id: TOF-03
    kind: person
    actor: employee
    story: As an employee, I want a second request for a day I already asked for to be refused, so that the same day is never taken twice.
    given: 'employee 4410 has requested 2026-12-21 to 2026-12-24 (pending); a second browser tab still shows the old balance'
    when: 'I ask for 2026-12-24 morning from the second tab, and two approvals of two overlapping requests arrive at the same moment'
    then: >-
      the second request is refused 409 {gate: day_free, reason: "2026-12-24 am is already in request UK01-TOF-2026-00381"};
      the day claim (person_id, date, part) is a plain INSERT, so of two racing writes exactly one wins and the other
      reads the winner; a withdrawn or rejected request releases its claims in the same transaction
    screen: My time off
    reference: not built
  - id: TOF-04
    kind: person
    actor: manager
    story: As a manager, I want to approve or reject my team's requests with the team calendar and the balance in front of me, so that I never leave the team uncovered by accident.
    given: 'request UK01-TOF-2026-00381 of 4410 for 7.00 days; manager of 4410 on 2026-12-21 is 2280 (hr, derived); team of 5, two already off on 2026-12-23; overlap rule warn above 40%'
    when: I approve it
    then: >-
      the approval is recorded (approved_by, approved_sub, at, revision 1); a warning shows 3 of 5 (60%) off on
      2026-12-23 and I approve with the warning acknowledged; a ledger entry take of -7.00 is written keyed
      "req:UK01-TOF-2026-00381:r1"; after commit payroll and timesheets can read the absence; a rejection needs a reason
      and releases the day claims; I cannot approve a request of my own, or one routed to me as a delegate when I am the
      requester (email AND sub): my own goes to my manager (level 2 of hr's ManagerOf)
    screen: Approvals
    path: { process: time-off.leave_request, choices: { Gateway_leave_request_overlap: warn } }
    reference: not built
  - id: TOF-05
    kind: person
    actor: employee
    story: As an employee, I want to cancel approved leave I no longer need, so that the days come back to me.
    given: 'request UK01-TOF-2026-00381 approved for 2026-12-21 to 2026-12-31'
    when: 'on 2026-12-01 I ask to cancel it; my manager confirms; on 2026-12-22 I try to cancel 2026-12-21 (already past)'
    then: >-
      the ask is recorded on the approved request and it moves to cancelled when the manager confirms; a reversal entry +7.00
      keyed "req:UK01-TOF-2026-00381:r1:cancel" is written and the day claims are released, so payroll and timesheets
      read no absence for those days; a day in the past cannot be cancelled by the employee ("ask HR to correct a day
      already taken"): HR corrects it with an adjustment (TOF-12); nothing moves back to requested
    screen: My time off
    reference: not built
  - id: TOF-06
    kind: person
    actor: employee
    story: As a part-time employee, I want my holiday in hours that match my working days, so that a bank holiday on my day off neither costs nor cheats me.
    given: 'employee 5120 works Monday, Tuesday and Wednesday, 7.5 hours (FTE 0.6000, hr work pattern); policy UK-ANNUAL in hours: (25 days + 8 bank holidays) x 7.5 hours x 0.6 = 148.50 hours for 2026'
    when: 'I view my balance; 2026-12-25 (Friday) and 2026-12-28 (Monday) are bank holidays; I ask for Tuesday 2026-12-29 and Wednesday 2026-12-30'
    then: >-
      the bank holiday on Monday 2026-12-28 (a working day for me) deducts 7.50 hours from my 148.50 automatically
      (entry bank_holiday, keyed "bh:5120:2026-12-28"); the Friday one costs nothing; my request costs 15.00 hours;
      hours are NUMERIC(19,4); a request for a day I do not work costs 0 and is refused as empty
    screen: My time off
    reference: not built
    customer_varies: "bank holidays inside the allowance (prorated) or on top for everyone who works that day"
  - id: TOF-07
    kind: person
    actor: employee
    story: As an employee, I want to record that I am off sick today and when I am back, so that my manager knows and my pay is right.
    given: 'employee 4410 wakes up ill on Tuesday 2026-11-10'
    when: 'I record sickness from 2026-11-10, expected back 2026-11-13; on 2026-11-13 my manager confirms my return with last day off 2026-11-12'
    then: >-
      a sickness absence UK01-TOF-2026-00412 open from 2026-11-10, then closed with 3 working days (10, 11, 12 November);
      self-certified (fewer than 8 calendar days, so no fit note is asked); pay treatment sick, which payroll reads with
      its qualifying days (TOF-20); the reason is visible only to me and to holders of tof:sickness:read_detail, my
      manager sees "sick"; the absence is approved by its recording, never requested
    screen: My time off
    reference: not built
  - id: TOF-08
    kind: person
    actor: manager
    story: As a manager, I want a fit note asked for when sickness goes past seven days, so that the evidence is there before payroll needs it.
    given: 'sickness of employee 4425 open since Monday 2026-11-02; fit_note_after_days 7'
    when: 'the daily pass runs on 2026-11-09 (the 8th calendar day), and the employee uploads a fit note on 2026-11-10 covering 2026-11-02 to 2026-11-20'
    then: >-
      on 2026-11-09 the absence shows gate fit_note missing and one reminder goes to the employee and one to me, keyed
      (kind fit_note, absence_id, business_date) so a second pass the same day sends nothing; the upload is stored as an
      object raw/time-off/UK01-TOF-2026-00399/<sha256>.pdf with its dates; payroll reads evidence_ok per absence and
      decides itself what a missing fit note does to sick pay
    screen: Approvals
    reference: not built
  - id: TOF-09
    kind: person
    actor: hr_admin
    story: As HR, I want maternity leave recorded with its expected week of childbirth and the evidence, so that the leave, the return date and the statutory pay start from the same facts.
    given: 'employee 4431 gives notice on 2026-06-01: expected week of childbirth starting Sunday 2026-11-15, MATB1 dated 2026-05-28, leave from Monday 2026-11-02, 52 weeks'
    when: I record the maternity leave
    then: >-
      an absence of leave type MATERNITY from 2026-11-02 to 2027-10-31 (52 weeks), pay treatment statutory_maternity,
      with ewc 2026-11-15 and evidence_received_on 2026-06-01; payroll reads these to assess SMP (it computes the money);
      up to 10 keeping-in-touch days are recorded on the absence and never end it; annual leave keeps accruing over the
      leave (the ledger shows it); a start before the 11th week before the EWC (before 2026-08-30) is refused with the
      date named
    screen: Absences
    reference: not built
    customer_varies: "enhanced maternity pay is payroll's (a scheme row), never a different leave type"
  - id: TOF-10
    kind: person
    actor: manager
    story: As a manager, I want one calendar of who is off in my team, so that I can see a clash before I approve.
    given: 'team of 2280 on December 2026: 4410 (approved 21-31 Dec), 4425 (requested 23-24 Dec), 4431 (maternity)'
    when: I open the team calendar for December
    then: >-
      each person and day shows approved, requested or bank holiday; reasons are shown only as leave type names, sickness
      as "sick" without detail; the busy-days view an employee sees shows names and days only; the calendar reads one
      query for the team and the month and contains only the team's rows
    screen: Team calendar
    reference: not built
  - id: TOF-11
    kind: person
    actor: hr_admin
    story: As HR, I want leave types and policies kept as dated rows per company, so that a new rule for next year is entered, not programmed.
    given: 'policy UK-ANNUAL 25 days from 2026-01-01; leave types ANNUAL (paid), SICK (pay treatment sick), UNPAID (unpaid), MATERNITY, PATERNITY, ADOPTION, SHARED_PARENTAL (statutory), COMPASSIONATE (paid, 5 days), TOIL (paid, from approved overtime)'
    when: 'I add a policy UK-ANNUAL-SENIOR of 28 days from 2027-01-01 for employee subgroup SENIOR, and try a policy of 18 days for employees'
    then: >-
      the first is a dated row valid from 2027-01-01 (after today), used by every request whose days fall in 2027; the
      second is refused 422 {gate: statutory_minimum, reason: "18 days plus 8 bank holidays is below 5.6 weeks (28 days for a five-day week, WTR reg. 13 and 13A)"};
      a leave type''s pay treatment is one of paid | unpaid | sick | statutory_maternity | statutory_paternity |
      statutory_adoption | statutory_shared_parental, and only payroll turns it into money
    screen: Settings
    reference: not built
  - id: TOF-12
    kind: person
    actor: hr_admin
    story: As HR, I want to adjust a balance with a reason, so that a correction is visible and never a silent overwrite.
    given: 'employee 4410 took 2026-10-05 as annual leave, then brought a fit note showing they were sick that day'
    when: 'I record sickness for 2026-10-05 and give the day back with reason "sick during annual leave, fit note 2026-10-07"; I try to adjust my own balance'
    then: >-
      an adjustment entry +1.00 keyed "adj:4410:ANNUAL:2026:<n>" with my name, sub and reason, and a sickness absence for
      that day which payroll reads as a change to a closed period (it corrects in the next run); my own balance is refused
      ("another HR user adjusts your balance": email AND sub); entries are never edited or deleted
    screen: Absences
    reference: not built
  - id: TOF-13
    kind: person
    actor: hr_admin
    story: As HR, I want company closure days (the Christmas shutdown) booked for everyone at once, so that nobody has to ask for them.
    given: 'closure days 2026-12-29, 2026-12-30, 2026-12-31 for company UK01, taken from the annual allowance'
    when: 'I publish the closure on 2026-10-01; employee 4410 already has those days approved; a new starter joins on 2026-11-02'
    then: >-
      each employed person gets a closure booking keyed "closure:UK01:2026-12-29:<person_id>" for each closure day
      they work, deducted like an approved request; 4410''s days are already claimed, so no second deduction is made and
      the closure is shown as covered by their request; the starter gets the bookings when their employment starts
      (derived from hr on read); a person whose balance would go negative is listed for HR, never silently negative
    screen: Settings
    reference: not built
  - id: TOF-14
    kind: person
    actor: owner
    story: As the owner, I want the controls on balances held by me, so that a manager cannot let their team go into the red.
    given: 'allow_negative_days 0; carry_over_max_days 5'
    when: 'HR tries to set allow_negative_days to 5; I set carry_over_max_days to 3 from 2027-01-01'
    then: 'the first is refused 403 (tof:settings:controls); mine is a dated row after today; the 2026 year end still carries up to 5'
    screen: Settings
    reference: not built
  # ── system ──────────────────────────────────────────────────────────────────────────────
  - id: TOF-15
    kind: system
    actor: system
    trigger: on read (derived), whenever a balance or a request's cost is computed
    story: The system derives each person's entitlement from the policy and hr's employment slices, so that starters, leavers and FTE changes are right without anyone recalculating.
    given: 'employee 4450 starts on 2026-09-01 (FTE 1.0000) under UK-ANNUAL 25 days; employee 4460 changes from FTE 1.0000 to 0.8000 on 2026-07-01'
    when: their 2026 balances are read
    then: >-
      4450: 25 x 122 / 365 = 8.36, rounded up to the next half day: 8.50 days; 4460: 25 x (181/365 x 1.0 + 184/365 x 0.8)
      = 22.48, rounded up 22.50; the calculation shows each slice; the result is never stored, so a backdated change in
      hr changes it on the next read; a booked day that the new entitlement no longer covers is listed for HR, never
      cancelled silently
    reference: not built
  - id: TOF-16
    kind: system
    actor: system
    trigger: when timesheets approves a sheet of an irregular-hours worker, and on read
    story: The system accrues holiday for irregular-hours workers from the hours they worked, so that the 12.07% rule is applied without a spreadsheet.
    given: 'worker 4470 on policy UK-IRREGULAR (accrual 12.07% of hours worked, leave year from 2026-01-01); timesheets approved 32.00 hours for week 2026-W46'
    when: time-off reads the approved hours (timesheets GET /approved-hours) after the sheet's approval, and again after a timeout
    then: >-
      one accrual entry of 32.00 x 12.07% = 3.8624 hours keyed "acc:4470:ts:4470:2026-W46:r1"; the repeat writes
      nothing; a corrected sheet (revision 2, 30.00 hours) writes a reversal of the r1 entry and an r2 entry of 3.6210, so
      the balance is a pure sum; hours are NUMERIC(19,4)
    reference: not built
  - id: TOF-17
    kind: system
    actor: system
    trigger: on 2027-01-01 for the 2026 leave year (the composition's daily worker; no server-side scheduler)
    story: The system carries over unused days and expires what may not be carried, so that the year end needs nobody.
    given: 'employee 4410 ends 2026 with 1.00 day available; employee 4425 with 7.50; carry_over_max_days 5, carried days expire 2027-03-31'
    when: the year-end pass runs for 2026, and runs again
    then: >-
      4410: carry +1.00 into 2027 keyed "carry:4410:ANNUAL:2026"; 4425: carry +5.00 and 2.50 lapse (entry lapse, keyed
      "lapse:4425:ANNUAL:2026") shown to HR, who may override with a reason before 2027-01-31 (sickness or family leave
      prevented the leave: such days carry by law); on 2027-04-01 unused carried days expire (keyed "expire:<person>:ANNUAL:2026");
      the second run writes nothing
    reference: not built
    customer_varies: "whether unused days are carried, paid out or lost, and until when"
  - id: TOF-18
    kind: system
    actor: system
    trigger: daily, in the composition's worker
    story: The system reminds approvers of waiting requests and chases fit notes, so that nothing waits silently.
    given: 'request UK01-TOF-2026-00390 waiting 3 business days (approval_reminder_days 2); the fit note of TOF-08 missing'
    when: 'the daily pass runs on 2026-11-09, and again on the same business date'
    then: 'one reminder to the approver and one fit-note reminder, keyed (kind, ref, business_date); the second pass sends nothing; the business date is in business_timezone'
    reference: not built
  # ── integration ─────────────────────────────────────────────────────────────────────────
  - id: TOF-19
    kind: integration
    actor: system
    trigger: on every request (cost), approval (approver) and read of a balance
    story: The person's employment, work pattern, holiday calendar and manager come from hr.
    given: 'employee 4410 employed in UK01 at location LON (calendar GB-EAW), Monday to Friday 7.5 hours; manager 2280 on 2026-12-21'
    when: time-off computes a request's cost and its approver
    then: >-
      it reads hr's GET /persons/4410/work-pattern?from=2026-12-21&to=2027-01-01 (contracted minutes and public holidays
      per day), GET /persons/4410/employment?on= and GET /persons/4410/manager?on=2026-12-21; it keeps no copy of the
      calendar, the pattern or the manager; the signed-in user's person_id comes from GET /persons?pool_user_sub=
    owner: hr
    reference: not built
  - id: TOF-20
    kind: integration
    actor: system
    trigger: when payroll calculates a pay run, and when timesheets builds a week
    story: Payroll and timesheets read the approved absences of a period, so that pay and hours follow exactly what was approved.
    given: 'November 2026 in UK01: 4410 sick 10-12 November (3 working days, self-certified), 4431 maternity from 2026-11-02, 4480 unpaid leave 2026-11-16 (1 day)'
    when: payroll calls GET /absences?company_code=UK01&from=2026-11-01&to=2026-11-30, twice, and a sickness is extended after the first call
    then: >-
      one row per absence and revision with its days (date, part, minutes, pay_treatment, evidence_ok, statutory details
      such as ewc), approved or recorded only (requested and rejected never appear); the second call shows the extended
      sickness as revision 2 with changed_since, so payroll can tell what changed since its calculation; time-off never
      computes money and never writes prl_ or ts_ tables
    owner: time-off
    reference: not built
  - id: TOF-21
    kind: integration
    actor: system
    trigger: when hr's Left event commits (hr HR-54)
    story: hr tells time-off that a person leaves, so that leave after the last day is cancelled and the final balance is ready for payroll.
    given: 'employee 4490 leaves; last day 2026-10-16; approved leave 2026-10-26 to 2026-10-30; 2026 entitlement 25 days, 15.00 taken'
    when: hr calls POST /employees/4490/leaver {last_day 2026-10-16, source_ref "hr:left:4490:1"}, twice
    then: >-
      the leave after the last day is cancelled (reason left, its claims released), the entitlement is derived to the
      last day: 25 x 289 / 365 = 19.79 (to 2 decimals, never rounded up for a leaver), so 4.79 days are owed; payroll
      reads them through GET /persons/4490/balance?on=2026-10-16; the repeat answers the same; a negative balance is
      answered negative and payroll decides whether the contract lets it deduct
    owner: time-off
    reference: not built
  - id: TOF-22
    kind: integration
    actor: system
    trigger: when payroll prepares a leaver's final pay
    story: Payroll reads the leaver's untaken holiday, so that it is paid in lieu once and correctly.
    given: TOF-21's leaver
    when: payroll calls GET /persons/4490/balance?leave_type_code=ANNUAL&on=2026-10-16
    then: '{person_id 4490, on 2026-10-16, leave_type_code ANNUAL, unit days, entitled "19.79", carried "0.00", taken "15.00", booked "0.00", adjustments "0.00", available "4.79", day_minutes 450}; time-off never pays it'
    owner: time-off
    reference: not built
  - id: TOF-23
    kind: person
    actor: owner
    story: As the owner moving off Odoo, I want this year's allocations and taken leave carried and reconciled per person, so that I can sign that nobody gained or lost a day.
    given: 'Odoo 17 hr_holidays: allocations and validated leaves for 2026, three requests waiting for approval'
    when: the migration loads, reconciles and I sign
    then: >-
      validated leaves of 2026 become approved requests (request_id odoo:<db>:hr.leave:<id>); allocations become an
      opening entry per person and type equal to Odoo''s allocated days less what this module derives, so the available
      days per person equal Odoo''s to the hundredth; waiting requests are not carried (approved in Odoo first, or
      re-entered); earlier years carry as read-only history. v2 (seemo's decided answer 4, SAP's migration cockpit):
      load and reconcile write staging rows only; the requests and opening entries reach the ledger when I sign; a
      discard deletes the staging rows and nothing else, and payroll, which pulls, never sees an unsigned run; a
      signed run is put right by reversing entries, never deleted
    screen: Migration
    reference: not built

processes:
  - key: leave_request
    pcf_id: null                       # APQC 7.5 Reward and retain employees (manage leave and absence, 7.5.x): id to confirm against PCF v8.0
    pcf_name: Manage employee leave and absence
    reference_names: ["Absence management", "Leave to approval"]
    brick: time-off
  - key: sickness
    pcf_id: null                       # APQC 7.5.x: id to confirm
    pcf_name: Manage employee sickness absence
    reference_names: ["Sickness and return to work"]
    brick: time-off

workflows:
  - entity: leave_request
    states: [requested, approved, rejected, withdrawn, cancelled]
    terminal: [rejected, withdrawn, cancelled]
    transitions:
      - { key: request_leave, name: Ask for time off, from: none, to: requested, by: employee, gate: "employed on every day (hr); every half day free (one claim row each); the cost within the balance and allow_negative_days; at most max_days_ahead ahead", effect: "request_id claimed from the company's yearly series; the day claims written; the approver derived from hr on the first day and fixed" }
      - { key: approve_leave, name: Approve, from: requested, to: approved, by: [manager, hr_admin], approval: true, gate: "the approver is the request's approver or a delegate and not the requester (email AND sub); the overlap rule answered (warn needs an acknowledgement, block refuses)", effect: "ledger entry take keyed req:<request_id>:r<rev>; visible to payroll and timesheets after commit" }
      - { key: reject_leave, name: Reject, from: requested, to: rejected, by: [manager, hr_admin], requires: reason, effect: "day claims released" }
      - { key: withdraw_leave, name: Withdraw before a decision, from: requested, to: withdrawn, by: employee, effect: "day claims released" }
      - { key: ask_leave_cancellation, name: Ask to cancel approved leave, from: approved, to: approved, by: employee, gate: "no day of it in the past (a past day is corrected by HR, TOF-12); no open cancellation ask", effect: "a cancellation ask recorded on the request (asked_at, by); the leave stands until it is decided" }
      - { key: cancel_leave, name: Cancel approved leave, from: approved, to: cancelled, by: [manager, hr_admin, system], requires: reason, gate: "the canceller is not the requester (email AND sub), except the system for a leaver (TOF-21)", effect: "reversal entry req:<request_id>:r<rev>:cancel; day claims released" }
      - { key: refuse_leave_cancellation, name: Refuse the cancellation ask, from: approved, to: approved, by: [manager, hr_admin], requires: reason, gate: "an open cancellation ask; the decider is not the requester", effect: "the ask closed with its reason; no ledger entry" }
    readiness:
      - { transition: request_leave, gate: employed, reason: "not employed on {date} (hr)", fix: "HR checks the start or last day" }
      - { transition: request_leave, gate: day_free, reason: "{date} {part} is already in request {request_id}", fix: "change the dates, or withdraw the other request" }
      - { transition: request_leave, gate: balance, reason: "{cost} asked, {available} available", fix: "ask for fewer days, or for unpaid leave" }
      - { transition: approve_leave, gate: second_person, reason: "you asked for this time off", fix: "it goes to your manager" }
      - { transition: approve_leave, gate: overlap, reason: "{n} of {team} off on {date}", fix: "acknowledge the warning, or reject" }
    note: >-
      A cancellation ask is a recorded fact on an approved request, not a state, so no edge ever re-enters an earlier
      state. "Taken" is derived from the dates (an approved day in the past), never a state.
  - entity: sickness
    states: [open, closed, voided]
    terminal: [voided]
    transitions:
      - { key: record_sickness, name: Record sickness, from: none, to: open, by: [employee, manager, hr_admin], gate: "employed on the first day; no other absence on the same days (sickness during approved leave is recorded by HR, TOF-12)", effect: "absence_id claimed; day claims of the days so far; pay treatment sick" }
      - { key: extend_sickness, name: Extend the expected return, from: open, to: open, by: [employee, manager, hr_admin], effect: "revision +1; payroll reads changed_since" }
      - { key: add_fit_note, name: Add a fit note, from: open, to: open, by: [employee, hr_admin], effect: "evidence object; evidence_ok when it covers every day past fit_note_after_days" }
      - { key: add_late_fit_note, name: Add a fit note after the return, from: closed, to: closed, by: [employee, hr_admin], effect: "evidence only: the days never change; payroll reads changed_since" }
      - { key: confirm_return, name: Confirm the return to work, from: open, to: closed, by: [manager, hr_admin], requires: last_day_off, gate: "the confirmer is not the sick person", effect: "the days fixed; claims after last_day_off released" }
      - { key: void_sickness, name: Void a mistaken record, from: open, to: voided, by: hr_admin, requires: reason, gate: "no day of it already in a calculated pay run, else HR closes it and corrects with an adjustment" }
  - entity: tof_migration_run
    states: [loaded, reconciled, signed, discarded]
    terminal: [signed, discarded]
    transitions:
      - { key: load_tof_from_odoo, name: "Load Odoo's time off", from: none, to: loaded, by: system, gate: "hr's migration signed", effect: "v2 (pattern §11, seemo's decided answer 4): staging rows only (tof_migration_row, keyed by the source id); nothing written to the ledger, balances or journals" }
      - { key: reconcile_tof_migration, name: Reconcile against Odoo, from: loaded, to: reconciled, by: system }
      - { key: sign_tof_migration, name: Sign the reconciliation, from: reconciled, to: signed, by: owner, approval: true, gate: "every check equal or explained", effect: "v2 (decided answer 4): the staged rows written through this module's normal use cases, idempotent on the source key; the first write to the ledger; a signed run is later reversed by reversing entries, never deleted" }
      - { key: discard_tof_migration, name: Discard the run, from: [loaded, reconciled], to: discarded, by: owner, requires: reason, effect: "v2 (decided answer 4): the run's staging rows deleted, nothing else (no ledger row was written)" }

entities:
  - id: leave_type
    meaning: a kind of time off and how payroll is to treat it
    natural_key: [company_code, leave_type_code, valid_from]
    key_attributes: [name, pay_treatment (paid | unpaid | sick | statutory_maternity | statutory_paternity | statutory_adoption | statutory_shared_parental), recorded_or_requested (requested | recorded), counts_against (a policy code | none), evidence_rule, max_days_per_year]
    master_data: "SHARED, sole writer (payroll and timesheets read leave_type_code and pay_treatment on each absence)"
    reference_table: tof_leave_type
  - id: policy
    meaning: how much of a leave type a group of people gets and how it accrues
    natural_key: [company_code, policy_code, valid_from]
    key_attributes: [leave_type_code, applies_to (worker types, employee subgroups, locations), unit (days | hours: v2, the POLICY's own unit, days for a full-time policy such as UK-ANNUAL and hours for a part-time one such as UK-ANNUAL-PT; every quantity of a person (entries, adjustments, carry, lapse, opening) is in their policy's unit; caps and annual_days are written in days and converted at the person's contracted day, HR-62), annual_days, bank_holidays (included | on_top), accrual (front_loaded | monthly | hours_worked), accrual_pct (12.07 for hours_worked), leave_year_start (MM-DD | start_anniversary), rounding (half_day_up | none), carry_over_max, carry_over_expires (MM-DD)]
    master_data: module-owned
    reference_table: tof_policy
  - id: leave_request
    meaning: one ask for time off by one person, or a recorded absence such as maternity leave
    natural_key: 'request_id = "<company_code>-TOF-<yyyy>-<nnnnn>" (claimed from the company''s yearly series; migrated odoo:<db>:hr.leave:<id>)'
    key_attributes: [person_id, leave_type_code, from_date, from_part (am | pm | full), to_date, to_part, cost (NUMERIC 19,4), unit, status, revision, approver_person_id, approved_by, approved_sub, decided_at, reason, overlap_ack, statutory_json (ewc, evidence_received_on, kit_days)]
    relationships: ["-> person (hr)", "1..n day_claim", "0..n balance_entry"]
    master_data: module-owned
    reference_table: tof_request
  - id: sickness
    meaning: one spell of sickness of one person, recorded rather than requested
    natural_key: 'absence_id = "<company_code>-TOF-<yyyy>-<nnnnn>" (the same yearly series as requests, so one key names any absence)'
    key_attributes: [person_id, first_day, expected_return, last_day_off, status, revision, reason_detail (sensitive), self_certified, evidence_ok, recorded_by, recorded_sub, confirmed_by]
    master_data: module-owned
    reference_table: tof_sickness
  - id: day_claim
    meaning: the row a half day of one person races for, so a day is never booked twice
    natural_key: [person_id, absence_date, part]
    key_attributes: [absence_ref (a request_id or absence_id), minutes, claimed_at]
    master_data: "module-owned (a plain INSERT; a collision is 23505, read as day_free refused; released by delete in the same transaction as a withdrawal, rejection or cancellation)"
    reference_table: tof_day_claim
  - id: balance_entry
    meaning: one dated movement of a person's balance of a leave type
    natural_key: 'entry_ref (derived: "req:<request_id>:r<rev>", "req:<request_id>:r<rev>:cancel", "carry:<person_id>:<type>:<year>", "lapse:…", "expire:…", "adj:<person_id>:<type>:<year>:<n>", "acc:<person_id>:<sheet ref>", "bh:<person_id>:<date>", "closure:<company>:<date>:<person_id>", "open:<person_id>:<type>:<year>" from a migration)'
    key_attributes: [person_id, leave_type_code, leave_year, kind (take | reverse | carry | lapse | expire | adjust | accrue | bank_holiday | closure | opening), quantity (NUMERIC 19,4, signed), unit, on_date, by, by_sub, reason]
    master_data: "module-owned (append-only; the entitlement itself is derived, never an entry)"
    reference_table: tof_balance_entry
  - id: closure_day
    meaning: a day the company closes and everyone's allowance pays for
    natural_key: [company_code, closure_date]
    key_attributes: [name, leave_type_code, published_by, published_at]
    master_data: module-owned
    reference_table: tof_closure_day
  - id: evidence
    meaning: a fit note, a MATB1 or another document backing an absence
    natural_key: object key raw/time-off/<absence_ref>/<sha256>.<ext>
    key_attributes: [kind (fit_note | matb1 | other), covers_from, covers_to, uploaded_by, uploaded_at, retain_until]
    master_data: module-owned
    reference_table: tof_evidence
  - id: outbox
    meaning: a side effect waiting after the local commit (a reminder, a notice to payroll that a closed period changed)
    natural_key: [target, ref]
    key_attributes: [payload, status, attempts, last_error]
    master_data: module-owned
    reference_table: tof_outbox
  - id: settings
    meaning: the controls and defaults, dated
    natural_key: [key, valid_from]
    key_attributes: [value, set_by]
    master_data: module-owned
    reference_table: tof_settings

master_data:
  owns: [tof_leave_type]
  references:
    - entity: person
      owner: hr
      how: "person_id; employment, FTE and company on each date from hr (GET /persons/{person_id}/employment)"
      if_absent: "no time off: this module keeps no copy of the employee"
    - entity: holiday_calendar
      owner: hr
      how: "the location's holiday_calendar and hr_holiday rows, answered per day by GET /persons/{person_id}/work-pattern"
      if_absent: "bank holidays would be typed twice and disagree"
    - entity: work_schedule
      owner: hr
      how: "contracted minutes per day, from the same route"
      if_absent: "a part-timer's request cannot be costed"
  warning: >-
    A holiday tool with its own staff list and its own bank-holiday table is how a leaver keeps booking holiday and an
    Edinburgh office loses St Andrew's Day. The person, their pattern and their calendar stay in hr.

jobs:
  - story: TOF-15
    trigger: on read
    runs: the entitlement from policy and hr slices
    where_options: [derived on read]
    idempotent: "nothing stored"
  - story: TOF-16
    trigger: after timesheets approves a sheet (timesheets calls nothing; time-off reads on its daily pass and on read)
    runs: accrual entries from approved hours
    where_options: [the composition's daily worker, on read of an irregular-hours balance]
    idempotent: 'entry_ref "acc:<person_id>:<sheet_id>:r<rev>"; a later revision reverses the earlier entry'
  - story: TOF-17
    trigger: on the first day of each leave year, and on each carried-days expiry date
    runs: carry-over, lapse and expiry entries
    where_options: [always-on worker (--reserved 1 --max-replicas 1), external scheduler]
    idempotent: "entry refs carry:/lapse:/expire:<person>:<type>:<year>"
  - story: TOF-18
    trigger: daily
    runs: approval and fit-note reminders
    where_options: [always-on worker (--reserved 1 --max-replicas 1), external scheduler]
    idempotent: "reminders keyed (kind, ref, business_date)"

seams:
  - story: TOF-19
    with: hr
    owner: hr
    direction: outbound
    route: "GET /persons/{person_id}/work-pattern?from=&to="
    permission: hr:work_pattern:read
    request: "path person_id; query from, to (YYYY-MM-DD, at most 400 days apart)"
    request_schema:
      - { field: person_id, type: person_id, required: true, in: path }
      - { field: from, type: date, required: true, in: query }
      - { field: to, type: date, required: true, in: query }
    response: '200 {person_id, from, to, days: [{date, employed, contracted_minutes, public_holiday (name | null), location_code, holiday_calendar, fte (NUMERIC 5,4)}]}'
    rule: "the cost of a request and a bank-holiday deduction are computed from this answer; time-off stores the cost on the request at submission, never the pattern"
    status: "satisfied by hr v3 (HR-62, the work pattern on a range)"
  - story: TOF-19
    with: hr
    owner: hr
    direction: outbound
    route: "GET /persons/{person_id}/employment?on="
    permission: hr:employment:read
    request: "path person_id; query on"
    rule: "employed on every requested day; company and worker type pick the policy"
    status: "exists in hr v2 (HR-56); hr v3 lists time-off as a caller"
  - story: TOF-19
    with: hr
    owner: hr
    direction: outbound
    route: "GET /persons/{person_id}/manager?on=&level="
    permission: hr:manager:read
    request: "path person_id; query on (the request's first day), level 1, or 2 when level 1 is the requester"
    rule: "the approver, fixed on the request at submission"
    status: "exists in hr v2 (HR-57); hr v3 lists time-off as a caller"
  - story: TOF-19
    with: hr
    owner: hr
    direction: outbound
    route: "GET /persons?pool_user_sub="
    permission: hr:employment:read
    request: "query pool_user_sub (the caller's own sub)"
    rule: "the signed-in user's person_id"
    status: "exists in hr v2 (HR-58); hr v3 lists time-off as a caller"
  - story: TOF-20
    with: payroll
    owner: time-off
    direction: inbound
    route: "GET /absences?company_code=&from=&to=&person_id=&changed_since="
    permission: tof:absence:read_for_payroll
    request: "query company_code (required), from, to (at most 93 days apart), person_id (optional), changed_since (optional, an RFC 3339 instant)"
    request_schema:
      - { field: company_code, type: company_code, required: true, in: query }
      - { field: from, type: date, required: true, in: query }
      - { field: to, type: date, required: true, in: query }
      - { field: person_id, type: person_id, required: false, in: query }
      - { field: changed_since, type: string, required: false, in: query }
    response: '200 {company_code, from, to, as_of, absences: [{absence_ref, revision, person_id, leave_type_code, pay_treatment, status (approved | recorded | closed | cancelled), evidence_ok, statutory {kind, ewc, evidence_received_on, kit_days} | null, changed_at, days: [{date, part, minutes}]}]}; cancelled rows appear only with changed_since, so a reader learns what to undo'
    rule: "approved requests and recorded absences only; ordered by person_id then absence_ref; the absence of another company never appears; payroll and timesheets never read tof_ tables"
    status: "offered by time-off v1; payroll v1 PRL-21 and timesheets v1 TS-19 call it"
  - story: TOF-20
    with: timesheets
    owner: time-off
    direction: inbound
    route: "GET /absences?company_code=&from=&to=&person_id=&changed_since="
    permission: tof:absence:read_for_payroll
    request: "query company_code (required), from, to (at most 93 days apart), person_id (optional), changed_since (optional, an RFC 3339 instant)"
    request_schema:
      - { field: company_code, type: company_code, required: true, in: query }
      - { field: from, type: date, required: true, in: query }
      - { field: to, type: date, required: true, in: query }
      - { field: person_id, type: person_id, required: false, in: query }
      - { field: changed_since, type: string, required: false, in: query }
    response: '200 {company_code, from, to, as_of, absences: [{absence_ref, revision, person_id, leave_type_code, pay_treatment, status (approved | recorded | closed | cancelled), evidence_ok, statutory {kind, ewc, evidence_received_on, kit_days} | null, changed_at, days: [{date, part, minutes}]}]}; cancelled rows appear only with changed_since, so a reader learns what to undo'
    rule: "approved requests and recorded absences only; ordered by person_id then absence_ref; the absence of another company never appears; payroll and timesheets never read tof_ tables"
    status: "time-off v2 2026-10-26 (spec gaps wave 4): listed for timesheets TS-19 (absence days on the week)"
  - story: TOF-22
    with: payroll
    owner: time-off
    direction: inbound
    route: "GET /persons/{person_id}/balance?leave_type_code=&on="
    permission: tof:balance:read_for_payroll
    request: "path person_id; query leave_type_code (default ANNUAL), on (the last day)"
    request_schema:
      - { field: person_id, type: person_id, required: true, in: path }
      - { field: leave_type_code, type: string, required: false, in: query }
      - { field: "on", type: date, required: true, in: query }
    response: '200 {person_id, on, leave_type_code, unit, entitled, carried, taken, booked, adjustments, available (strings, NUMERIC 19,4 shown to 2 places for days), day_minutes}'
    rule: "derived as on the person's own screen; payroll pays or deducts it; time-off never computes money"
    status: "offered by time-off v1; payroll v1 PRL-08 calls it"
  - story: TOF-21
    with: hr
    owner: time-off
    direction: inbound
    route: "POST /employees/{person_id}/leaver"
    permission: tof:leaver:intake
    request: '{last_day (YYYY-MM-DD), source_ref: "hr:left:<person_id>:<n>"}'
    request_schema:
      - { field: person_id, type: person_id, required: true, in: path }
      - { field: last_day, type: date, required: true }
      - { field: source_ref, type: string, required: true }
    response: "200 {person_id, last_day, cancelled_requests, balance {leave_type_code, available}}; 200 the same for a repeated source_ref; 422 {gate: bounds, reason, fix}"
    rule: "called by hr after the Left event commits, from its pending rows; idempotent on source_ref; a moved last day (a second Left revision, source_ref n+1) re-derives the balance and cancels or keeps leave accordingly; time-off never deletes a request"
    status: "offered by time-off v1; hr v3 calls it on hr.person.left (HR-54)"
  - story: TOF-16
    with: timesheets
    owner: timesheets
    direction: outbound
    route: "GET /approved-hours?company_code=&from=&to=&person_id="
    permission: ts:hours:read_approved
    request: "query company_code, from, to, person_id (an irregular-hours worker)"
    rule: "approved sheet revisions only; time-off writes one accrual entry per sheet revision"
    status: "satisfied by timesheets v1 (TS-20)"
  # ── v2 (2026-10-26, seemo's decided answer 2): hr's organisational assignment and roster by person id, in place of typed-in doors ──
  - story: TOF-19
    with: hr
    owner: hr
    route: "GET /persons/{person_id}/assignment?on="
    permission: hr:assignment:read
    rule: "the approver email and the company of a person by person_id; nothing typed in"
    status: "satisfied by hr v4 (HR-63)"
  - story: TOF-17
    with: hr
    owner: hr
    route: "GET /roster?company_code=&on=&org_unit_code=&manager_person_id=&limit=&offset="
    permission: hr:roster:read
    rule: "the year-end pass covers everyone employed in the company on the last day of the leave year (was a person_ids list from the worker, or only people this module held a row for)"
    status: "satisfied by hr v4 (HR-64)"
  - story: TOF-10
    with: hr
    owner: hr
    route: "GET /roster?company_code=&on=&org_unit_code=&manager_person_id=&limit=&offset="
    permission: hr:roster:read
    rule: "the team calendar and TOF-04 overlap count read the manager's team (manager_person_id, level 1) on each day, so a team member with no request is counted"
    status: "satisfied by hr v4 (HR-64)"
  - story: TOF-23
    with: hr
    owner: hr
    route: "GET /roster?company_code=&on=&org_unit_code=&manager_person_id=&limit=&offset="
    permission: hr:roster:read
    rule: "the migration matches Odoo employees by work email (was an employee_map door)"
    status: "satisfied by hr v4 (HR-64)"

screens:
  - { id: my_time_off, name: My time off, stories: [TOF-01, TOF-02, TOF-03, TOF-05, TOF-06, TOF-07], actors: [employee, manager, hr_admin], note: "balances per type with the entries behind them, a calendar to pick days with the cost shown before sending, my requests and their state, record sickness, upload a fit note" }
  - { id: approvals, name: Approvals, stories: [TOF-04, TOF-08], actors: [manager, hr_admin], note: "requests waiting for me, the team calendar for their days, the overlap warning, open sickness with missing fit notes" }
  - { id: team_calendar, name: Team calendar, stories: [TOF-10], actors: [manager, employee, hr_admin], note: "month view of the team; reasons hidden from employees" }
  - { id: absences, name: Absences, stories: [TOF-09, TOF-12], actors: [hr_admin], note: "every absence, family leave with its statutory facts, adjustments with reasons, the leavers' balances" }
  - { id: settings, name: Settings, stories: [TOF-11, TOF-13, TOF-14], actors: [hr_admin, owner], note: "leave types, policies, closure days, reminders; the controls to the owner only" }
  - { id: migration, name: Migration, stories: [TOF-23], actors: [owner, hr_admin] }

permissions:
  namespace: tof
  catalog:
    - tof:request:read_own
    - tof:request:write_own
    - tof:request:read_team
    - tof:request:read_all
    - tof:request:approve
    - tof:sickness:record_own
    - tof:sickness:record_team
    - tof:sickness:read_detail     # the reason for a sickness: hr_admin only by default
    - tof:absence:record           # family leave and absences HR records directly
    - tof:calendar:read_team
    - tof:balance:adjust
    - tof:policy:write
    - tof:settings:write           # v2: the settings that are not controls (max_days_ahead, min_booking, reminders...); the tests named it, the catalog did not
    - tof:leave_type:write
    - tof:closure:write
    - tof:report:read
    - tof:settings:controls        # negative balances, carry-over caps, overlap mode; never held by whoever they control
    - tof:migration:read
    - tof:migration:sign
    - tof:balance:accrue           # machine
    - tof:year_end:run             # machine
    - tof:reminder:tick            # machine
    - tof:leaver:intake            # machine: hr's service client only
    - tof:absence:read_for_payroll # machine: payroll's and timesheets' service clients
    - tof:balance:read_for_payroll # machine: payroll's service client only
    - tof:migration:load           # machine
    - tof:migration:reconcile      # machine
  machine:
    - tof:balance:accrue
    - tof:year_end:run
    - tof:reminder:tick
    - tof:leaver:intake
    - tof:absence:read_for_payroll
    - tof:balance:read_for_payroll
    - tof:migration:load
    - tof:migration:reconcile
  row_visibility:
    rule: "an employee sees only their own requests and balances, and the team's busy days without reasons; a manager sees the absences of people they manage on the absence's dates, sickness as 'sick' only; HR and the owner see every absence of their companies; sickness detail needs tof:sickness:read_detail"
    enforced: "in every use case: the caller's person_id from hr (by sub), the approver fixed on the request, hr's derived manager on the date"
    not_found_not_forbidden: true

config_rows:
  - table: tof_settings
    changes: "how time off is booked, approved and chased; every row dated (valid_from after today; the first row of a key may start today)"
    settings:
      - { key: allow_negative_days,    type: decimal, default: "0",    changes: "how far below zero a balance may go; tof:settings:controls" }
      - { key: carry_over_max_days,    type: decimal, default: "5",    changes: "the most days carried into the next leave year; tof:settings:controls" }
      - { key: carry_over_expires,     type: str,     default: "03-31", changes: "the day carried days expire in the new leave year" }
      - { key: max_days_ahead,         type: int,     default: 365,    changes: "how far ahead a request may start" }
      - { key: min_booking,            type: str,     default: "half_day", changes: "half_day | full_day | hour" }
      - { key: overlap_threshold_pct,  type: decimal, default: "40",   changes: "the share of a team off on one day above which approval warns" }
      - { key: overlap_mode,           type: str,     default: "warn", changes: "warn | block; tof:settings:controls" }
      - { key: fit_note_after_days,    type: int,     default: 7,      changes: "calendar days of sickness after which a fit note is asked" }
      - { key: approval_reminder_days, type: int,     default: 2,      changes: "business days before an approver is reminded" }
      - { key: manager_records_sickness, type: bool,  default: true,   changes: "managers may record sickness for their team; tof:settings:controls" }
      - { key: leaver_cancel_future,   type: bool,    default: true,   changes: "a leaver's approved leave after the last day is cancelled automatically" }
  - { table: tof_leave_type, changes: "the kinds of time off, their pay treatment and evidence rules" }
  - { table: tof_policy, changes: "entitlements per group, unit, accrual, leave year, carry-over" }
  - { table: tof_closure_day, changes: "company closure days" }
in_code_not_rows:
  - "that nobody approves, cancels or adjusts their own time off, under any login (email AND sub)"
  - "that a half day of a person is claimed once (tof_day_claim)"
  - "that a balance is the derived entitlement plus the sum of entries; entries are never edited or deleted"
  - "that an employee policy is never below 5.6 weeks (WTR regs. 13 and 13A)"
  - "that public holidays come from hr's calendar, never from a table here"
  - "that time-off computes no money"
  - "that neighbours read only approved or recorded absences; requested ones are never visible to payroll"
  - "that 'today' is the business date in business_timezone"

ai_steps:
  - key: suggest_dates
    benefit: "an employee asking for a week sees the cheapest week around it (bank holidays included) and the weeks the team is lightest"
    mode: "off"
    modes_allowed: ["off", suggest]
    safe_for_auto: false
    inputs: [the person's balance, the team calendar, the holiday calendar]
    output: "up to three alternatives with their cost and team cover"
    fallback: "the calendar alone"
    audit: "tof_outbox (the suggestion shown, the choice made)"
    eval: "replayed 2025 requests: share of suggestions with lower cost and lower overlap"
    note: "a task in the signed process (Task_leave_request_suggest_dates); off by default because a plain calendar does the job"

extension_points:
  - { name: toil, what: "time off in lieu earned from approved overtime in timesheets (TOIL accrual entries)" }
  - { name: bradford_factor, what: "a sickness pattern score (S x S x D) on the Absences screen, off by default (it is contested)" }
  - { name: shared_parental_booking, what: "SPL notices and discontinuous blocks with the partner's declaration" }
  - { name: other_countries, what: "a country pack: Germany's Bundesurlaubsgesetz (24 working days on a six-day week), France's congés payés (2.5 days a month)" }

tests:
  acceptance: "one per story id above, named for it"
  access:
    - "an employee cannot read another person's request (404), approve anything, or see a sickness reason"
    - "a manager sees only the people they manage on the absence's dates; never approves their own request"
    - "HR cannot adjust or approve their own balance or request"
    - "tof:settings:write cannot change a control; anyone but the owner holding tof:settings:controls is refused at configuration"
    - "tof:leaver:intake is refused to every client but hr's; tof:balance:read_for_payroll to every client but payroll's"
    - "the machine permissions are refused to every person role and to a wildcard grant"
  invariants:
    - "claim keys: a half day (person_id, date, part), a ledger entry (entry_ref), a request number (the yearly series) and a leaver intake (source_ref) are each claimed once; two requests for one half day, two approvals of one request, two year-end passes and two leaver calls each leave exactly one row"
    - "side effects (reminders, the notice that an absence in a calculated period changed) are sent only from pending outbox rows written in the same transaction as the state change; a rolled-back approval sends nothing"
    - "no backward states: requested never returns after a decision; a rejected, withdrawn or cancelled request is terminal; a cancellation ask is a fact on the approved request, never a state; a closed sickness only takes evidence (tested by attempting every reverse edge)"
    - "self-approval: no request is approved, cancelled or adjusted by its requester, matched on email AND sub, including as a delegate and including HR"
    - "the business date is in business_timezone: a request sent at 23:30 Europe/London on 2026-12-20 counts 2026-12-20 for max_days_ahead and 'past day' rules; the UTC date is never used; absence dates are calendar dates"
    - "bounds: a quantity that is negative where it cannot be, more than 4 decimals, a range longer than 400 days, or an absence end before its start answers 422 {gate: bounds}, never a 500"
    - "results marked stale: a request's stored cost is recomputed and the request flagged when hr's pattern, calendar or employment for its days changes before approval; approval is refused on a flagged request until it is recomputed"
    - "flat statements: a person's balance, a team calendar and GET /absences contain only their own person's, team's or company's rows; seeding another company's absences and another person's entries changes none of them"
    - "the balance is order-independent: the same entries written in any order give the same available"

  scenarios:
    - id: SC-TOF-01
      name: Christmas across two calendars
      stories: [TOF-02, TOF-04, TOF-10]
      given: London 4410 and Edinburgh 4500 under the same policy
      when: both ask for the last two weeks of December 2026 and are approved
      then: 4410 pays 7.00 days, 4500 pays a different count from GB-SCT; payroll reads both
    - id: SC-TOF-02
      name: a leaver with leave booked after the last day
      stories: [TOF-21, TOF-22]
      given: 4490 with leave approved after the last day
      when: hr's leaver call arrives twice
      then: the leave is cancelled once; payroll reads 4.79 days owed
    - id: SC-TOF-03
      name: a manager's own request and a second login
      stories: [TOF-04]
      given: manager 2280 asks for leave
      when: 2280 tries to approve it under a second email with the same sub
      then: refused; it waits for 1904

scale:
  reads: "the tables reader serves 16 concurrent reads; a balance is one query over entries plus one hr call; the team calendar is one query for the team and month; GET /absences is one query per company and range"
  hot_paths: ["request (costing and day claims)", "the team calendar", "payroll's period read"]
  workers: "the daily reminder pass and the year-end pass in the composition's worker; nothing pinned warm is required"
  volumes: "a company of 500 people makes about 6,000 requests a year; the ledger grows by about 10,000 entries a year"

platform_constraints:
  - constraint: "No read-your-writes inside an open transaction."
    today: "a request writes its claims and the request in one transaction; the balance shown after it is read in a second"
  - constraint: "A plain INSERT of a committed key is rejected (23505)."
    today: "day claims and ledger entries are plain INSERTs on purpose: the rejection is the collision guard"

customer_varies:
  - question: When does your holiday year start, and how many days does everyone get?
    changes: tof_policy (leave_year_start, annual_days)
    standard: "Working Time Regulations 1998 regs. 13 and 13A: 5.6 weeks, at most 28 days for a five-day week"
    typical:
      uk_office: "1 January, 25 days plus bank holidays"
      public_sector_style: "1 April, 27 to 30 days plus bank holidays"
  - question: Are bank holidays part of the allowance, or on top?
    changes: tof_policy.bank_holidays
    typical:
      uk_office: on top, for everyone who works that day
      warning: "part-timers must not lose out: on top for full-timers is prorated for part-timers"
  - question: Can unused days be carried over, how many, and until when?
    changes: carry_over_max_days, carry_over_expires
    typical:
      uk_office: "5 days, used by 31 March"
  - question: Who may record sickness, and when do you need a fit note?
    changes: manager_records_sickness, fit_note_after_days
    standard: "Social Security (Medical Evidence) Regulations 1976 (a fit note after 7 calendar days)"
  - question: Do you close between Christmas and New Year, and does it come out of holiday?
    changes: tof_closure_day
  - question: Do you have workers on irregular hours or zero-hours contracts?
    changes: a UK-IRREGULAR policy with accrual hours_worked 12.07%
    standard: "Working Time Regulations 1998 reg. 15A (as amended 2023)"

variation_points:
  - node: Task_leave_request_approve_leave
    op: narrow
    default: "the derived manager; level 2 when the manager is the requester"
    depth: 1
    requirement: "refuse - a route in which the requester approves their own time off"
  - node: Gateway_leave_request_overlap
    op: row_value
    default: "warn above 40%"
    depth: 1
    guideline: "warn - block stops genuine emergencies; a warning keeps the manager deciding"
  - node: Task_leave_request_suggest_dates
    op: on_off
    default: "off"
    depth: 1
    guideline: "warn - suggest only; a person always picks the days"

migration:
  odoo:
    runs_after: [hr]
    reads:
      - model: hr.leave
        fields: [id, employee_id, holiday_status_id, request_date_from, request_date_to, request_unit_half, request_unit_hours, number_of_days, number_of_hours, state (draft | confirm | validate1 | validate | refuse; to verify per version at the door)]
        filter: "state validate within the door's years"
        why: "taken and booked leave"
      - model: hr.leave.allocation
        fields: [id, employee_id, holiday_status_id, number_of_days, date_from, date_to, state]
        filter: "state validate"
        why: "the opening balances"
      - model: hr.leave.type
        fields: [id, name, requires_allocation, leave_validation_type, time_type, request_unit]
        filter: "all active"
        why: "the leave types, mapped at the door"
    maps:
      - { from: hr.leave, to: leave_request, rule: 'request_id = "odoo:<db>:hr.leave:<id>"; the person matched through hr''s roster (HR-64) by hr.employee.work_email, then HR-63 by person_id; an unmatched employee is listed for HR at the door, never guessed, never a typed map (v2, 2026-10-26); status approved' }
      - { from: hr.leave.allocation, to: balance_entry, rule: 'kind opening, entry_ref "open:<person_id>:<type>:<year>" = Odoo allocated less this module''s derived entitlement' }
      - { from: hr.leave.type, to: leave_type, rule: "mapped by hand at the door to a leave_type_code and pay treatment" }
    door:
      - "which Odoo leave types are which leave types here, and their pay treatment"
      - "requests waiting for approval: approved or refused in Odoo before cut-over (recommended)"
      - "how many past years to carry as history (recommended: the current and the prior leave year)"
    reconcile:
      - { check: "available days per person and type", odoo: "allocated - taken (validated) per employee and type", ours: "available on the cut-over date", must: equal }
      - { check: "approved leave days per month", odoo: "SUM(number_of_days) of validated hr.leave by month", ours: "SUM(cost) of migrated requests by month", must: equal }
    not_carried:
      - "requests in draft or waiting for approval"
      - "Odoo's public holidays (hr's calendar is the source)"
    idempotent_on: 'request_id = "odoo:<db>:hr.leave:<id>"; entry_ref open:<person>:<type>:<year>; tof_migration_map on [odoo_db, odoo_model, odoo_id]'

tool_names:                            # shared.yaml conventions.tool_names: the migration family names this module's noun
  - { tool: load_tof_from_odoo, route: "the tof_migration_run workflow's transition (route chosen at build)" }
  - { tool: reconcile_tof_migration, route: "the tof_migration_run workflow's transition (route chosen at build)" }
  - { tool: sign_tof_migration, route: "the tof_migration_run workflow's transition (route chosen at build)" }
  - { tool: discard_tof_migration, route: "the tof_migration_run workflow's transition (route chosen at build)" }
  - { tool: get_tof_migration_map, route: "GET /migration-map/{db}/{model}" }

needs: []                              # every route this spec calls exists in its owner (hr v3 HR-62 and the caller lists; timesheets v1 TS-20)

reference:
  # `name` is the brick's DISTRIBUTION name (pyproject [project].name): erp-compose init pins
  # `<name> @ git+<https>@<tag>`, and pip refuses a pin whose name differs from the package's.
  repos:
    - name: brick-time-off
      role: brick
      https: https://git.dodil.io/dodil/erp-brick-time-off.git
      ssh: git@git.dodil.io:dodil/erp-brick-time-off.git
      tag: v0.2.0
      read_first: [README.md, AGENTS.md]
    - name: erp-platform
      role: platform
      https: https://git.dodil.io/dodil/erp-platform.git
      ssh: git@git.dodil.io:dodil/erp-platform.git
      tag: v0.9.13
      read_first: [README.md, AGENTS.md]
  packages: []
  contracts: []
  build_guide: null
  known_gaps:
    - "nothing is built; this is version 1 of the spec, unsigned"
    - "UK first: other countries are packs (extension other_countries)"
    - "shared parental leave is recorded as an absence; its notices and partner declarations are an extension"
    - "APQC ids are null until verified against PCF v8.0"

sap_parity:
  - { sap: "Absences (infotype 2001) and absence quotas (infotype 2006)", ours: covered, where: "leave_request, sickness, balance_entry" }
  - { sap: "Quota generation (time evaluation RPTQTA00)", ours: different, where: "TOF-15, TOF-16", why: "derived on read from policy and hr slices; accruals only for hours-based workers" }
  - { sap: "Holiday calendars and work schedules", ours: covered, where: "hr (HR-62), read here" }
  - { sap: "ESS/MSS leave request with approval", ours: covered, where: "TOF-02, TOF-04" }
  - { sap: "Quota carry-over and deduction rules", ours: covered, where: "TOF-17, tof_policy" }
  - { sap: "Time evaluation and attendances", ours: different, where: "timesheets", why: "attendance is timesheets'; time-off holds absence only" }