# Timesheets: 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/timesheets/; wave 4). projects v1 lists "no time tracking or timesheets" as a known gap; this is that module.
# Written from:
# - the neighbours' specs at erp-method v0.1.35: projects v1 (projects from what was sold, items, the lead),
# sales-order v8 (POST /sales-orders/{id}/deliveries on a service line in HUR, SO-23), ar v7 (POST
# /invoices/raise, AR-20), product v6 (GET /products/{product_id}/price), gl v7 (POST /journal-entry/journals,
# cost centres GL-61, periods GL-32), hr v3 (employment, manager, work pattern HR-56..62), time-off v1 (approved
# absences, TOF-20)
# - UK: the Working Time Regulations 1998 reg. 9 (records of working time, kept for two years) and the National
# Minimum Wage Regulations 2015 reg. 59 (records of hours for time workers), which is why approved sheets are
# never edited, only superseded
# - Odoo 17 hr_timesheet (account.analytic.line) and sale_timesheet (service_policy delivered_timesheet) as the
# migration source and the reference for "delivered hours on the order line"; SAP CATS (Cross-Application Time
# Sheet) with its transfers to CO (activity allocation), PS and SD (resource-related billing) as parity references
#
# The decisions a finance manager checks first:
# - nobody approves their own time, under any login (email AND sub); a project lead's own hours go to their manager
# - an approved sheet is never edited: a correction is a new revision that reverses the old one, so the cost, the
# billing and the payroll hours all move by the difference, once
# - billable time is billed through what was sold: hours on a project from a sales order are delivered on its
# service line (the order prices and invoices them); hours on a project from a contract or by hand are raised in
# invoicing at the price list's rate, monthly. This module never writes an invoice
# - cost is a standard cost rate per rate group, never a person's salary: the project's cost centre is charged and
# the person's home cost centre relieved; the salary itself is payroll's journal
# 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 /approved-hours (TS-20) listed for time-off (TOF-16)
# - hr seam (decided answer 2): person and approver identity from HR-63 by person id (a migrated week now has its
# person); the reminder pass (TS-15) covers everyone employed through HR-64; the migration matches employees by
# work email (was the employee_map door)
# - 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
# - PROCESS change (billing_run): hours bill by work date (decided answer 5): a week across a month end splits
# between the two months' runs, each line priced on its work date (TS-13); create_time_billing_run's effect says
# so
# - within_tolerance: the sales-order delivery gate's one name (was within_ordered here)
id: timesheets
name: Timesheets
short: Timesheets
version: 2
maturity: tested # version 1 signed by seemo 2026-10-25; erp-brick-timesheets 0.2.0 builds it (slices A and B, tested, not deployed); the latest version is a draft for re-sign
summary: >-
Everyone fills in one week at a time against the projects and tasks they worked on, with their holidays already
in it. Managers approve with one click, billable hours reach the customer's invoice through what was sold, the
projects carry their real labour cost, and hourly-paid staff are paid for the hours that were approved.
decisions:
- proposed: "one sheet per person per ISO week (Monday to Sunday), approved as a whole by the person's derived manager; a project lead may approve the project lines first (approval_route)"
status: "for sign-off"
- proposed: "a returned or reopened sheet is a new revision; an approved revision is never edited and every downstream effect (cost, delivery, billing, payroll hours, accrual) moves by the difference between revisions"
status: "for sign-off"
- proposed: "billing follows the project's source: a sales-order project reports delivered hours on the order's service line; a contract or manual project is raised in ar monthly at the product price list's rate on the work date"
status: "for sign-off"
- proposed: "labour cost is hours x a standard cost rate per rate group (finance's rows), posted on approval: project cost centre debited, the person's home cost centre credited"
status: "for sign-off"
defaults_to_confirm: # the business would choose these; chosen here so there is something to react to (seemo)
- "the week is Monday to Sunday (ISO); sheets are due by Monday 10:00 for the week before"
- "approval_route manager (the derived manager approves the whole sheet); project_lead_then_manager is the alternative"
- "a working day's contracted hours must be covered by time or absence to submit (missing_hours_mode block); more than 12 hours on a day warns"
- "hours in quarter hours (time_increment_minutes 15); billing rounds nothing further"
- "billable by default only on a project whose terms are time_and_materials; the person may make a line non-billable, never billable on a non-billable project"
- "contract and manual T&M projects are billed monthly on the last day of the month, one draft invoice per project"
- "cost rates per rate group: JUNIOR 28.0000, SENIOR 52.0000, PRINCIPAL 75.0000 GBP an hour from 2026-01-01 (placeholders the customer replaces)"
- "overtime for payroll = approved hours above the contracted hours of the week (weekly_above_contracted)"
- "a correction after billing is billed as the difference in the next run; a negative month waits for the biller"
facets:
domain: operations
industries: [professional-services, any]
customer_words:
- "timesheets"
- "time tracking"
- "billable hours"
- "who worked on which project"
- "bill the client for the hours"
- "utilisation"
- "project cost"
- "hours for payroll"
- "Stundenzettel"
replaces: [the timesheet spreadsheet, Harvest, Toggl, Clockify, Odoo Timesheets, Productive]
pillars: [sql]
first_module_fit: medium
depends_on:
- module: hr
why: the person, their employment, contracted hours and holidays per day, and their derived manager come from hr
required: true
- module: projects
why: what time can be booked to, whether it is billable, and how it is billed come from the project's time terms
required: recommended
- module: gl
why: labour cost is posted to the project's cost centre; cost centres and open periods are gl's
required: recommended
- module: time-off
why: approved absence fills the week, so a holiday is never typed twice
required: recommended
- module: sales-order
why: hours on a sales-order project are delivered on its service line and invoiced by the order
required: optional
- module: ar
why: hours on a contract or manual project are raised as a draft invoice
required: optional
- module: product
why: the billing rate of a contract or manual project is the price of its time product
required: optional
feeds: [sales-order, ar, gl, payroll, time-off]
owns_master_data: [activity]
references_master_data: [person, project, product, cost_centre, business_partner]
actors:
- id: employee
name: Anyone who records time
does: fills in their week against projects, items, activities and internal cost centres, submits it, answers questions
sees: only their own sheets, and the projects they may book to
permissions: [ts:sheet:read_own, ts:sheet:write_own, ts:sheet:submit]
- id: manager
name: Line manager (derived from hr)
does: approves or returns their team's sheets; reads the team's hours
sees: the sheets of people they manage in the sheet's week
permissions: [ts:sheet:read_own, ts:sheet:write_own, ts:sheet:submit, ts:sheet:read_team, ts:sheet:approve]
note: "a manager records time too: their own sheet goes to their manager"
- id: project_lead
name: Project lead (from projects)
does: approves the lines on their projects when approval_route asks for it; reads hours, billable hours, billed hours and cost per project
sees: the lines booked to projects they lead, never the rest of a person's week
permissions: [ts:sheet:read_own, ts:sheet:write_own, ts:sheet:submit, ts:line:approve_project, ts:project_hours:read]
- id: finance
name: Finance / the biller
does: keeps cost rates and rate groups, reopens an approved sheet for a correction, releases or holds a billing run, reads every sheet
sees: every sheet of their companies
permissions: [ts:sheet:read_all, ts:sheet:reopen, ts:cost_rate:write, ts:billing_run:release, ts:project_hours:read, ts:activity:write, ts:migration:read]
note: "finance never reopens or approves a sheet of their own: email AND sub"
- id: owner
name: The owner (or whoever the owner names)
does: holds the controls (approval route, missing-hours mode, who may reopen), signs the migration reconciliation
sees: everything
permissions: [ts:sheet:read_all, ts:settings:controls, ts:migration:read, ts:migration:sign]
note: "anyone else holding ts:settings:controls is a configuration error the access test refuses"
- id: system
name: The system
does: opens weeks with absences filled in, posts labour cost, reports delivered hours, runs the monthly billing, reminds, answers payroll and time-off, loads an Odoo migration
sees: everything, through its own service account
permissions: [ts:sheet:open_auto, ts:cost:post_auto, ts:delivery:report, ts:billing_run:create, ts:reminder:tick, ts:hours:read_approved, ts:migration:load, ts:migration:reconcile]
note: "exact grants, never a wildcard: ts:hours:read_approved is granted to payroll's and time-off's service clients alone. This module's own client holds hr:employment:read, hr:manager:read, hr:work_pattern:read, tof:absence:read_for_payroll, pj:time_terms:read, so:order:deliver, ar:invoice:raise, product:price_list:read, gl:journal:post, gl:cost_centre:read and gl:period:read"
stories:
# ── recording time ──────────────────────────────────────────────────────────────────────
- id: TS-01
kind: person
actor: employee
story: As an employee, I want my week laid out with my contracted hours and my approved holiday already in it, so that I only add what I worked on.
given: 'employee 4410 (UK01, home cost centre CONS-LON, Monday to Friday 7.5 hours, hr work pattern); time-off approved annual leave on Friday 2026-11-20'
when: 'I open week 2026-W47 (Monday 2026-11-16 to Sunday 2026-11-22)'
then: >-
sheet 4410:2026-W47 revision 1 is open with expected 37.50 hours (from hr''s work pattern), a read-only absence
line of 7.50 on Friday (ANNUAL, absence_ref UK01-TOF-2026-00405), and Monday to Thursday empty; the sheet is
opened once (a second open answers the same sheet); a bank holiday shows as such with 0 expected; a week before
my start or after my last day does not open
screen: My timesheet
reference: not built
- id: TS-02
kind: person
actor: employee
story: As an employee, I want to book hours against a project item or an internal cost centre with an activity, so that the time lands where it belongs and billable is set for me.
given: >-
projects bookable for 4410 on 2026-11-16: P1 "pj:sales_order:quote:Q-2026-0042@v2" (time_and_materials, item
"...|2|1" Implementation, product CONSULT-SR on sales order line 2 in HUR) and P2 "pj:contract:CT-2026-0009"
(time_and_materials, product CONSULT-SR at the price list); activities DEV (billable), MEETING (billable), ADMIN
(not billable)
when: 'I book Monday 7.50 and Tuesday 7.50 on P1 DEV, Wednesday 4.00 on P1 DEV and 3.50 ADMIN on cost centre CONS-LON, Thursday 7.50 on P2 MEETING'
then: >-
four lines; P1 and P2 lines billable (the project''s terms and the activity), the ADMIN line not; totals P1 19.00,
P2 7.50, internal 3.50, absence 7.50, week 37.50 = expected; hours are NUMERIC(19,4) in steps of 0.25 (15
minutes), a negative or a day above 24.00 is refused 422 {gate: bounds}; a project that is not bookable on the day
(closed, cancelled, or the item done) is refused with the project named; I may untick billable, never tick it on a
non-billable project
screen: My timesheet
reference: not built
- id: TS-03
kind: person
actor: employee
story: As an employee, I want to submit my week and be told what is missing, so that I fix it before my manager has to send it back.
given: 'sheet 4410:2026-W47 with 37.50 booked of 37.50 expected; a colleague''s sheet 4425:2026-W47 with Wednesday empty'
when: 'I submit on Friday 2026-11-20; my colleague submits theirs'
then: >-
gates: every working day''s contracted hours covered by time or absence (missing_hours_mode block), every project
line bookable on its day, every internal cost centre open in gl on its day, no stale line; mine moves to submitted
and its lines are frozen; my colleague''s is refused {gate: hours_covered, reason: "Wednesday 2026-11-18: 0.00 of
7.50"}; a day above max_day_hours (12.00) warns and needs a note; the approver is the derived manager on the
week''s Monday, fixed on the sheet
screen: My timesheet
reference: not built
# ── approving ───────────────────────────────────────────────────────────────────────────
- id: TS-04
kind: person
actor: manager
story: As a manager, I want to approve my team's weeks with the hours per project in front of me, so that cost and billing start from time I have seen.
given: 'sheet 4410:2026-W47 submitted; manager of 4410 on 2026-11-16 is 2280 (sub m2280); approval_route manager'
when: 'I approve it; earlier, my own sheet 2280:2026-W47 was routed: its approver is my manager 1904, not me'
then: >-
the sheet is approved (approved_by, approved_sub, at, revision 1); after commit the labour cost is posted (TS-11),
P1''s 19.00 hours are reported to the sales order (TS-12), P2''s 7.50 wait for the monthly run (TS-13), and payroll
and time-off can read the approved hours (TS-20); an approver who is the sheet''s person (same sub, or the same
email under a second login) is never routed, and approving one''s own sheet is refused {gate: second_person}
screen: Approvals
path: { process: timesheets.timesheet, choices: { Gateway_timesheet_route: manager } }
reference: not built
- id: TS-05
kind: person
actor: project_lead
story: As a project lead, I want to approve the hours booked to my project before the line manager, so that the client is billed only for time I recognise.
given: 'approval_route project_lead_then_manager; P1 led by 3020; sheet 4410:2026-W47 submitted with 19.00 hours on P1 and 7.50 on P2 (led by 3020 too); 3020''s own sheet has 6.00 hours on P1'
when: 'I approve 4410''s P1 and P2 lines, then try to approve the P1 lines of my own sheet'
then: >-
each project line records its approval (ts_line_approval: line, approver, sub, at); the sheet goes to 2280 when
every project line is approved; my own lines go to my line manager instead (email AND sub); I see only lines on
projects I lead, never the rest of the week; returning a line returns the whole sheet (TS-06)
screen: Approvals
reference: not built
- id: TS-06
kind: person
actor: manager
story: As a manager, I want to send a week back with a question, so that the person corrects it without starting again.
given: 'sheet 4425:2026-W47 revision 1 submitted with 10.00 hours on P1 on Monday'
when: 'I return it with "Monday looks like two days of work, please split"; the employee changes Monday to 7.50 on P1 and 2.50 on P2 and submits'
then: >-
revision 1 is returned (terminal for that revision, with my question kept); revision 2 opens with the lines
copied and is submitted and approved on its own; approvals of revision 1 never count for revision 2; nothing
moves back to an earlier state
screen: Approvals
reference: not built
- id: TS-07
kind: person
actor: finance
story: As finance, I want to correct an approved week as a new revision, so that cost, billing and payroll hours all move by the difference once.
given: 'sheet 4410:2026-W47 revision 1 approved: P1 19.00 delivered to the sales order, cost posted 988.00 on P1; the employee reports that 2.00 of Wednesday were on P2, not P1'
when: 'I reopen it with the reason "2h Wednesday on P2"; revision 2 is edited, submitted and approved by 2280'
then: >-
revision 1 becomes superseded (never edited); on revision 2''s approval the labour journal of revision 1 is reversed
(event reverse) and revision 2''s posted: P1 17.00 x 52.0000 = 884.00, P2 9.50 x 52.0000 = 494.00; the sales order
receives a correction of -2.0000 HUR on line 2 (corrects_ref ts:4410:2026-W47:r1:1); P2''s November run bills 9.50;
payroll and time-off read revision 2 with changed_since; I cannot reopen my own sheet or one I approved (email
AND sub); a week whose payroll period is closed is corrected the same way and payroll settles the difference in
its next run
screen: Approvals
reference: not built
- id: TS-08
kind: person
actor: finance
story: As finance, I want cost rates kept per rate group as dated rows, so that a pay review changes project cost from a date without anyone editing the past.
given: 'rate groups JUNIOR 28.0000, SENIOR 52.0000, PRINCIPAL 75.0000 GBP an hour from 2026-01-01; 4410 in SENIOR from 2025-04-01'
when: 'I set SENIOR to 55.0000 from 2027-01-01 and move 4425 to PRINCIPAL from 2026-12-01'
then: >-
both are dated rows with valid_from after today; a line costs at the rate in force on its work date and the person''s
rate group on that date; approved and posted lines never change; a rate is NUMERIC(19,4), a line cost is rounded
once to NUMERIC(18,2); the cost rate is never read from a salary
screen: Settings
reference: not built
- id: TS-09
kind: person
actor: project_lead
story: As a project lead, I want hours, billable hours, billed hours and cost per project and item, so that I see whether the project is making money while there is time to act.
given: 'P2 in November: 64.00 hours approved (58.00 billable), 46.00 billed, cost 3,328.00; 6.00 hours submitted not approved'
when: I open Project hours for P2
then: >-
approved 64.00, billable 58.00, billed 46.00 (the runs released), unbilled 12.00, submitted not approved 6.00,
cost 3,328.00, per item and per person; only projects I lead (finance sees all); the figures come from this
module''s rows in one query per project, never from ar''s tables
screen: Project hours
reference: not built
- id: TS-10
kind: person
actor: owner
story: As the owner, I want the controls on time approval held by me, so that a manager cannot turn off the missing-hours check for their team.
given: 'missing_hours_mode block; approval_route manager'
when: 'finance tries to set missing_hours_mode to warn; I set approval_route to project_lead_then_manager from 2026-12-01'
then: 'the first is refused 403 (ts:settings:controls); mine is a dated row after today; sheets of weeks starting before 2026-12-01 keep the manager route'
screen: Settings
reference: not built
# ── system ──────────────────────────────────────────────────────────────────────────────
- id: TS-11
kind: system
actor: system
trigger: when a sheet revision is approved (and when its correction supersedes it)
story: The system posts the labour cost of approved project time, so that each project carries what it cost on the day it was approved.
given: 'sheet 4410:2026-W47 r1 approved on 2026-11-23 (business date); P1 cost centre DELIV-1, P2 cost centre DELIV-2; 4410 SENIOR 52.0000; home cost centre CONS-LON'
when: the post runs, and runs again after a timeout
then: >-
gl POST /journal-entry/journals with journal_id derived_id("ts:4410:2026-W47:r1", "approve"), source_module
timesheets, company UK01, journal_date 2026-11-23: debit project_labour 988.00 (DELIV-1, 19.00 x 52.0000) and
390.00 (DELIV-2, 7.50 x 52.0000), credit labour_recovery 1,378.00 (CONS-LON); internal time on the home cost
centre and absence lines post nothing; the retry is gl''s replay; a closed period leaves the cost pending with gl''s
reason, shown to finance, and the sheet stays approved
reference: not built
- id: TS-12
kind: integration
actor: system
trigger: after a sheet revision with billable hours on a sales-order project is approved
story: Billable hours on a project sold by a sales order are delivered on the order's service line, so that the order invoices them at the price it was sold at.
given: 'P1 from sales order quote:Q-2026-0042@v2, line 2 CONSULT-SR, 120 HUR ordered at 95.0000, 40.00 delivered so far'
when: 'sheet 4410:2026-W47 r1 is approved, and the call is retried'
then: >-
sales-order POST /sales-orders/quote:Q-2026-0042@v2/deliveries {line_no 2, quantity "19.0000", source timesheets,
delivery_ref "ts:4410:2026-W47:r1:1", delivered_on 2026-11-23}; the order shows 59.00 delivered and invoices by its
own policy; the retry records once (delivery_ref); hours beyond the ordered 120 are refused by the order
(within_tolerance) and the line is shown to the project lead as over-delivered, never silently dropped; this module
never writes so_ tables
owner: sales-order
reference: not built
- id: TS-13
kind: system
actor: system
trigger: on the last day of each month, for projects whose terms bill time through ar (contract or manual)
story: The system raises one draft invoice per project for the month's approved billable hours, so that time on a contract is billed without anyone adding it up.
given: 'P2 (pj:contract:CT-2026-0009, customer bp_id 7311, GBP) with 7.50 approved billable hours of 4410 on 2026-11-19 and 38.50 of others in November; product CONSULT-SR priced 110.0000 per HUR for bp 7311 on those dates'
when: 'the November run is created on 2026-11-30, finance releases it, and the release is retried'
then: >-
one billing run "tsbill:pj:contract:CT-2026-0009:2026-11" listing each billable line once (a ts_billing_claim row per
sheet revision line, so a line is never in two runs); on release ar POST /invoices/raise {source_kind timesheet,
source_id "ts:pj:contract:CT-2026-0009:2026-11", bp_id 7311, currency GBP, lines per product and rate: 46.00 HUR x
110.0000 = 5,060.00}; the retry is ar''s replay on source_id; a superseded line is billed as its difference in the
next run; a run whose total would be negative waits for finance (a credit note in ar by hand). v2 (seemo's
decided answer 5, SAP's resource-related billing by posting date): hours bill by their WORK DATE, so a week that
straddles a month end splits: week 2026-W49 (2026-11-30 to 2026-12-06) bills its Monday 30 November hours in the
November run and the rest in December's, each line priced on its own work date; a line approved late for an
earlier month falls into the next run, still priced on its work date
reference: not built
- id: TS-14
kind: integration
actor: system
trigger: when a billing run is created
story: The billing rate of a contract or manual project comes from the product price list, so that time is billed at the customer's agreed rate.
given: 'P2''s time terms name product CONSULT-SR; price list LIST-GBP with a customer row for bp 7311 at 110.0000 per HUR'
when: 'the run prices 4410''s 7.50 hours worked on 2026-11-19'
then: 'product GET /products/CONSULT-SR/price?quantity=7.5&unit_code=HUR&bp_id=7311¤cy=GBP&priced_on=2026-11-19 answers 110.0000 per 1 HUR; the run stores the price and priced_from on the line; no rate is typed here'
owner: product
reference: not built
- id: TS-15
kind: system
actor: system
trigger: every Monday and daily, in the composition's worker
story: The system reminds people of weeks not submitted and approvers of weeks waiting, so that month end is not a chase.
given: 'on Monday 2026-11-23 10:00: sheet 4425:2026-W47 open; sheet 4433:2026-W46 submitted 4 business days ago (approval_reminder_days 2)'
when: the pass runs, and again on the same business date
then: 'one reminder to 4425 and one to 4433''s approver, keyed (kind, ref, business_date); the second pass sends nothing; the business date and 10:00 are in business_timezone'
reference: not built
- id: TS-16
kind: system
actor: system
trigger: when an absence, a work pattern or a project's terms change for a day of an open or submitted sheet
story: The system marks lines stale when what they were built from changes, so that nobody approves a week built on yesterday's facts.
given: 'sheet 4425:2026-W47 submitted with 7.50 on P1 on Thursday; time-off then approves sick leave for Thursday; P1''s item is then marked done'
when: the sheet is next read or approved
then: >-
the Thursday line is flagged stale (absence overlaps work, item done); approval is refused {gate: not_stale} until
the person corrects it (a return); approved revisions are never flagged, only corrected by a new revision
reference: not built
# ── integration ─────────────────────────────────────────────────────────────────────────
- id: TS-17
kind: integration
actor: system
trigger: when a sheet is filled, submitted and approved, and when a billing run is created
story: What time can be booked to, whether it is billable, how it is billed and who leads the project come from projects.
given: 'P1 and P2 active on 2026-11-16'
when: 'timesheets calls projects GET /projects/bookable?person_id=4410&on=2026-11-16 and GET /projects/pj:contract:CT-2026-0009/time-terms?on=2026-11-19'
then: >-
the bookable list names each active project with its open items; the time terms answer billing
(time_and_materials | fixed_price | non_billable), bill_via (sales_order with sales_order_id and so_line_no per
product | ar), the time product per rate group, the customer bp_id, currency, the project cost centre and the
lead''s person_id; timesheets copies them onto the line at submission and never reads pj_ tables
owner: projects
reference: not built
- id: TS-18
kind: integration
actor: system
trigger: when a week is opened, submitted and routed
story: The person, their expected hours, holidays and manager come from hr.
given: 'employee 4410 employed in UK01, Monday to Friday 450 minutes, manager 2280'
when: timesheets opens and routes 4410:2026-W47
then: 'it reads hr''s GET /persons/4410/work-pattern?from=2026-11-16&to=2026-11-22, GET /persons/4410/employment?on=2026-11-16 (company, home cost centre) and GET /persons/4410/manager?on=2026-11-16; the signed-in user is GET /persons?pool_user_sub=; nothing is copied but the expected minutes and the approver fixed on the sheet'
owner: hr
reference: not built
- id: TS-19
kind: integration
actor: system
trigger: when a week is opened, and when it is read before approval
story: Approved absence comes from time-off, so that a holiday is never typed into a timesheet.
given: 'annual leave of 4410 on 2026-11-20 approved in time-off'
when: 'timesheets calls time-off GET /absences?company_code=UK01&from=2026-11-16&to=2026-11-22&person_id=4410'
then: 'the absence becomes a read-only line of 7.50 with its absence_ref; a later cancellation in time-off (seen with changed_since) removes it from an open sheet and flags a submitted one stale; timesheets never writes tof_ tables'
owner: time-off
reference: not built
- id: TS-20
kind: integration
actor: system
trigger: when payroll calculates a weekly or monthly run, and when time-off accrues for irregular-hours workers
story: Payroll and time-off read approved hours, so that hourly pay, overtime and holiday accrual follow exactly what was approved.
given: 'hourly-paid worker 4470 (contracted 0 hours, irregular) approved 32.00 hours in 2026-W46; salaried 4410 approved 40.00 in 2026-W48 against 37.50 contracted'
when: 'payroll calls GET /approved-hours?company_code=UK01&from=2026-11-09&to=2026-11-15, and later with changed_since'
then: >-
one row per person, day and approved sheet revision: 4470 32.00 normal (no contract, so nothing is overtime by the
weekly rule unless a policy says so); for week 48, 4410 37.50 normal and 2.50 overtime (weekly_above_contracted);
superseded revisions appear with changed_since as superseded, so the reader settles the difference; only approved
revisions, only the company asked; timesheets computes no pay
owner: timesheets
reference: not built
- id: TS-21
kind: integration
actor: system
trigger: on submission (internal cost centres) and before posting (the period)
story: Cost centres and open periods come from gl.
given: 'cost centre CONS-LON open; period 2026-11 open in UK01'
when: 'timesheets calls GET /cost-centres/CONS-LON and GET /periods/open?date=2026-11-23&company_code=UK01'
then: 'an internal line on a closed cost centre is refused at submission with gl''s reason; a closed period leaves the cost pending (TS-11); timesheets never writes gl_ tables'
owner: gl
reference: not built
- id: TS-22
kind: person
actor: owner
story: As the owner moving off Odoo, I want this year's approved timesheets carried and the unbilled hours reconciled per project, so that I can sign that nothing billable was lost.
given: 'Odoo 17 account.analytic.line timesheets for 2026; 120 approved hours on sales-order lines not yet invoiced'
when: the migration loads, reconciles and I sign
then: >-
each Odoo employee-week becomes an approved sheet revision 1 (sheet_id "odoo:<db>:<employee>:<iso week>"), cost
not re-posted (gl's migration carries the books); hours already invoiced in Odoo are marked billed; the 120
unbilled hours equal Odoo''s per sales order line (the order''s delivered quantity is the order''s own migration);
weeks not validated in Odoo are not carried
screen: Migration
reference: not built
processes:
- key: timesheet
pcf_id: null # APQC 7.5.x / 5.3.2 (record time worked, execute service delivery): id to confirm against PCF v8.0
pcf_name: Record and approve time worked
reference_names: ["Time to bill", "Cross-application timesheet"]
brick: timesheets
- key: time_billing
pcf_id: null # APQC 9.2? Invoice customer (time and materials): id to confirm
pcf_name: Bill time and materials
reference_names: ["Resource-related billing", "Time and materials invoicing"]
brick: timesheets
workflows:
- entity: timesheet
states: [open, submitted, approved, returned, superseded]
terminal: [returned, superseded]
transitions:
- { key: open_timesheet, name: Open the week, from: none, to: open, by: [employee, system], gate: "employed on a day of the week (hr)", effect: "sheet_id <person_id>:<iso week>, revision 1; expected minutes from hr; approved absences as read-only lines" }
- { key: submit_timesheet, name: Submit the week, from: open, to: submitted, by: employee, gate: "hours_covered; lines bookable on their days; cost centres open; not_stale", effect: "lines frozen; the route fixed (approval_route in force on the week's Monday); the approver derived from hr on the Monday" }
- { key: approve_project_lines, name: Approve the project lines, from: submitted, to: submitted, by: project_lead, approval: true, gate: "approval_route project_lead_then_manager; the lead leads the line's project and is not the sheet's person (email AND sub)", effect: "the line approvals recorded" }
- { key: approve_timesheet, name: Approve the week, from: submitted, to: approved, by: [manager, finance], approval: true, gate: "the approver is the sheet's approver or a delegate and not its person (email AND sub); every project line approved when the route asks; not_stale", effect: "after commit, from pending rows: the labour journal (TS-11), deliveries (TS-12), billing claims (TS-13); payroll and time-off can read the hours" }
- { key: return_timesheet, name: Return with a question, from: submitted, to: returned, by: [manager, project_lead, finance], requires: reason, effect: "revision n+1 opens with the lines copied; rev n approvals void" }
- { key: reopen_timesheet, name: Reopen an approved week for a correction, from: approved, to: superseded, by: finance, requires: reason, gate: "the reopener is neither the sheet's person nor its approver (email AND sub)", effect: "revision n+1 opens with the lines copied; on its approval revision n's journal is reversed and every downstream effect moves by the difference" }
readiness:
- { transition: submit_timesheet, gate: hours_covered, reason: "{date}: {booked} of {expected} hours", fix: "book the missing hours, or ask for the absence in time off" }
- { transition: submit_timesheet, gate: bookable, reason: "{project} is not bookable on {date}: {why}", fix: "book to another project or item" }
- { transition: submit_timesheet, gate: cost_centre_open, reason: "cost centre {cost_centre} is closed (gl)", fix: "book to an open cost centre" }
- { transition: approve_timesheet, gate: second_person, reason: "this is your own week", fix: "it goes to your manager" }
- { transition: approve_timesheet, gate: project_lines_approved, reason: "lines on {project} wait for {lead}", fix: "the lead approves them, or returns the week" }
- { transition: approve_timesheet, gate: not_stale, reason: "lines changed under the week: named", fix: "return the week for a correction" }
note: "returned and superseded are terminal for their revision; the week continues as revision n+1, so no state is ever re-entered"
- entity: billing_run
states: [draft, released, held, cancelled]
terminal: [released, cancelled]
transitions:
- { key: create_time_billing_run, name: Gather the month's billable hours, from: none, to: draft, by: system, gate: "the project's terms bill via ar; approved billable lines not claimed by another run", effect: "run_id tsbill:<project_id>:<period_id>; a ts_billing_claim row per line and work date (plain INSERT); v2: a line is in the run of the month of its work date, so a week across a month end is split (decided answer 5)" }
- { key: release_time_billing_run, name: Release to invoicing, from: [draft, held], to: released, by: finance, gate: "the total not negative; the releaser is not a person whose hours are in it (email AND sub)", effect: "ar POST /invoices/raise from a pending row after commit" }
- { key: hold_time_billing_run, name: Hold the run, from: draft, to: held, by: finance, requires: reason }
- { key: cancel_time_billing_run, name: Cancel the run, from: [draft, held], to: cancelled, by: finance, requires: reason, effect: "the claims released, so the lines fall into the next run" }
- entity: ts_migration_run
states: [loaded, reconciled, signed, discarded]
terminal: [signed, discarded]
transitions:
- { key: load_ts_from_odoo, name: "Load Odoo's timesheets", from: none, to: loaded, by: system, gate: "hr's and projects' migrations signed", effect: "v2 (pattern §11, seemo's decided answer 4): staging rows only (ts_migration_row, keyed by the source id); nothing written to the ledger, balances or journals" }
- { key: reconcile_ts_migration, name: Reconcile against Odoo, from: loaded, to: reconciled, by: system }
- { key: sign_ts_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_ts_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: timesheet
meaning: one person's week, approved as a whole
natural_key: 'sheet_id = "<person_id>:<iso week>" (4410:2026-W47) with revision; migrated "odoo:<db>:<employee>:<iso week>"'
key_attributes: [person_id, company_code, week_start, revision, status, expected_minutes, approval_route, approver_person_id, submitted_at, submitted_by, submitted_sub, approved_by, approved_sub, approved_at, journal_id, reason, home_cost_centre]
master_data: module-owned
reference_table: ts_sheet
- id: line
meaning: "one row of a week (a project item and activity, an internal cost centre, or an absence)"
natural_key: [sheet_id, revision, line_no]
key_attributes: [kind (project | internal | absence), project_id, item_id, activity_code, cost_centre, billable, bill_via (sales_order | ar | none), sales_order_id, so_line_no, product_id, rate_group, unit_cost (NUMERIC 19,4, the cost rate copied at submission), absence_ref, description, stale, stale_reason]
master_data: module-owned
reference_table: ts_line
- id: entry
meaning: the hours of one line on one day
natural_key: [sheet_id, revision, line_no, work_date]
key_attributes: ["hours (NUMERIC 19,4, steps of time_increment_minutes)", note]
master_data: module-owned
reference_table: ts_entry
- id: line_approval
meaning: a project lead's approval of one line, when the route asks for it
natural_key: [sheet_id, revision, line_no]
key_attributes: [approver_person_id, decided_by, decided_sub, decided_at]
master_data: "module-owned (append-only)"
reference_table: ts_line_approval
- id: activity
meaning: what kind of work an hour was (development, meeting, travel, admin) and whether it is billable by default
natural_key: [company_code, activity_code, valid_from]
key_attributes: [name, billable_default, internal_only]
master_data: SHARED, sole writer (projects and payroll read activity_code on a line)
reference_table: ts_activity
- id: cost_rate
meaning: the standard cost of an hour of a rate group, from a date
natural_key: [company_code, rate_group, valid_from]
key_attributes: ["rate_amount (NUMERIC 19,4)", currency]
master_data: module-owned
reference_table: ts_cost_rate
- id: person_rate_group
meaning: which rate group a person is costed at, from a date
natural_key: [person_id, valid_from]
key_attributes: [rate_group, set_by]
master_data: module-owned
reference_table: ts_person_rate_group
- id: billing_run
meaning: one month's billable hours of one project billed through invoicing
natural_key: 'run_id = "tsbill:<project_id>:<period_id>"; a second run of a month "...:<period_id>:<n>"'
key_attributes: [project_id, bp_id, currency, status, total_amount, invoice_id, released_by, released_sub, released_at]
master_data: module-owned
reference_table: ts_billing_run
- id: billing_claim
meaning: the row a billable line (or its correction) races for, so it is billed once
natural_key: [sheet_id, revision, line_no, purpose (delivery | ar_run)]
key_attributes: [run_id, delivery_ref, quantity (NUMERIC 19,4, signed for a correction), unit_price (NUMERIC 19,4), priced_from, claimed_at]
master_data: "module-owned (append-only; a plain INSERT; the delivery and the ar run of one line never share a purpose)"
reference_table: ts_billing_claim
- id: outbox
meaning: a side effect waiting after the local commit (journal, delivery, invoice raise, reminder)
natural_key: [target, ref]
key_attributes: [payload, status, attempts, last_error]
master_data: module-owned
reference_table: ts_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: ts_settings
master_data:
owns: [ts_activity]
references:
- entity: person
owner: hr
how: "person_id; employment, home cost centre, contracted minutes, holidays and manager per date from hr"
if_absent: "no timesheets: this module keeps no staff list"
- entity: project
owner: projects
how: "project_id and item_id on a line; terms from GET /projects/{project_id}/time-terms"
if_absent: "time is booked to cost centres only, nothing is billed"
- entity: product
owner: product
how: "the time product's price on the work date (contract and manual projects)"
if_absent: "only sales-order projects can be billed"
- entity: cost_centre
owner: gl
how: "GET /cost-centres/{cost_centre}"
if_absent: "no labour cost posting"
warning: >-
A time tool with its own project list and its own client rates bills a project that was cancelled last week at a
rate nobody agreed. Projects say what is bookable and how it bills, product says the price, the order says what was
sold; this module says only how many hours were approved.
jobs:
- story: TS-11
trigger: when a sheet revision is approved
runs: post the labour journal (and reverse the superseded revision's)
where_options: [in the approval request after commit, the composition's worker]
idempotent: 'journal_id = derived_id("ts:<sheet_id>:r<revision>", "approve" | "reverse")'
- story: TS-13
trigger: the last day of each month
runs: create billing runs for projects billed via ar
where_options: [always-on worker (--reserved 1 --max-replicas 1), external scheduler]
idempotent: "run_id tsbill:<project_id>:<period_id>; ts_billing_claim per line"
- story: TS-15
trigger: every Monday 10:00 and daily
runs: submission and approval reminders
where_options: [always-on worker (--reserved 1 --max-replicas 1), external scheduler]
idempotent: "reminders keyed (kind, ref, business_date)"
- story: TS-16
trigger: on read and before approval
runs: the stale check against time-off, hr and projects
where_options: [derived on read]
idempotent: "stale is a flag recomputed from the inputs; nothing else stored"
seams:
- story: TS-17
with: projects
owner: projects
direction: outbound
route: "GET /projects/bookable?person_id=&on="
permission: pj:time_terms:read
request: "query person_id, on (the work date)"
request_schema:
- { field: person_id, type: person_id, required: true, in: query }
- { field: "on", type: date, required: true, in: query }
response: '200 {person_id, on, projects: [{project_id, name, bp_id, billing, items: [{item_id, title, state}]}]}'
rule: "active projects with open items; every project is bookable by everyone unless the project restricts it to its team (projects' setting)"
status: "satisfied by projects v2 (PJ-25)"
- story: TS-17
with: projects
owner: projects
direction: outbound
route: "GET /projects/{project_id}/time-terms?on="
permission: pj:time_terms:read
request: "path project_id; query on"
request_schema:
- { field: project_id, type: project_id, required: true, in: path }
- { field: "on", type: date, required: true, in: query }
response: '200 {project_id, on, state, bp_id, currency, billing (time_and_materials | fixed_price | non_billable), bill_via (sales_order | ar | none), sales_order_id, products: [{rate_group, product_id, so_line_no}], cost_centre, lead_person_id}; 404 for an unknown project'
rule: "copied onto each line at submission; a change after submission marks open lines stale (TS-16)"
status: "satisfied by projects v2 (PJ-25)"
- story: TS-12
with: sales-order
owner: sales-order
direction: outbound
route: "POST /sales-orders/{sales_order_id}/deliveries"
permission: so:order:deliver
request: '{line_no (the so_line_no), quantity (string, NUMERIC 19,4, in the line''s unit, HUR), source: timesheets, delivery_ref: "ts:<sheet_id>:r<rev>:<line_no>", delivered_on (the approval business date), corrects_ref (a correction: the earlier revision''s delivery_ref, with a negative quantity)}'
request_schema:
- { field: sales_order_id, type: sales_order_id, required: true, in: path }
- { field: line_no, type: so_line_no, required: true }
- { field: quantity, type: decimal, required: true }
- { field: source, type: string, required: true, values: [timesheets] }
- { field: delivery_ref, type: string, required: true }
- { field: delivered_on, type: date, required: true }
- { field: corrects_ref, type: string, required: false }
response: "the order view; 409 {gate: within_tolerance | service_line | not_below_invoiced, reason, fix}"
rule: "one delivery per approved line revision; a refusal leaves the claim with the order's reason for the project lead; timesheets never writes so_ tables"
status: "satisfied by sales-order v9 (SO-51)"
- story: TS-13
with: ar
owner: ar
direction: outbound
route: "POST /invoices/raise"
permission: ar:invoice:raise
request: '{source_kind: timesheet, source_id: "ts:<project_id>:<period_id>[:<n>]", bp_id, currency, kind: regular, lines: [{description (the product and the month), quantity (HUR, string), unit_code: HUR, unit_price (NUMERIC 19,4, from product), product_id, tax_category (from the product)}]}'
request_schema:
- { field: source_kind, type: string, required: true, values: [timesheet] }
- { field: source_id, type: string, required: true }
- { field: bp_id, type: bp_id, required: true }
- { field: currency, type: currency, required: true }
- { field: kind, type: string, required: false, values: [regular] }
- { field: lines, type: array, required: true }
response: "201 the draft invoice; 200 the same for the same (source_kind, source_id)"
rule: "one draft per released run; ar owns the invoice, its tax and its issue; timesheets never writes ar_ tables"
status: "satisfied by ar v8 (AR-20 row for timesheets, source_kind timesheet)"
- story: TS-14
with: product
owner: product
direction: outbound
route: "GET /products/{product_id}/price?quantity=&unit_code=&bp_id=¤cy=&priced_on="
permission: product:price_list:read
request: "path product_id; query quantity, unit_code HUR, bp_id, currency, priced_on (the work date)"
rule: "the price per pricing_quantity of the pricing unit is copied onto the billing claim with priced_from"
status: "exists in product v6 (PRD-31)"
- story: TS-11
with: gl
owner: gl
direction: outbound
route: "POST /journal-entry/journals"
permission: gl:journal:post
request: '{journal_id: derived_id("ts:<sheet_id>:r<revision>", event), source_module: timesheets, source_document: "ts:<sheet_id>:r<revision>", event: approve | reverse, company_code, journal_date (the approval business date), currency, memo, lines: [{purpose: project_labour | labour_recovery, cost_centre, debit | credit}]}'
rule: "one journal per approved revision; a correction reverses the superseded revision's journal; timesheets never writes gl_ tables"
status: "satisfied by gl v8 (GL-30 row for source_module timesheets)"
- story: TS-21
with: gl
owner: gl
direction: outbound
route: "GET /cost-centres/{cost_centre}"
permission: gl:cost_centre:read
request: "path cost_centre"
rule: "an internal line's cost centre must be open on its work date"
status: "exists in gl v7 (GL-61)"
- story: TS-21
with: gl
owner: gl
direction: outbound
route: "GET /periods/open"
permission: gl:period:read
request: "query date (the approval business date), company_code"
rule: "a closed period leaves the cost pending"
status: "exists in gl v7 (GL-32)"
- story: TS-18
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 (the week)"
rule: "expected minutes per day; a bank holiday expects 0"
status: "satisfied by hr v3 (HR-62)"
- story: TS-18
with: hr
owner: hr
direction: outbound
route: "GET /persons/{person_id}/employment?on="
permission: hr:employment:read
request: "path person_id; query on (the week's Monday)"
rule: "employed in the week; the company and home cost centre"
status: "exists in hr v2 (HR-56); hr v3 lists timesheets as a caller"
- story: TS-18
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 week's Monday)"
rule: "the approver, fixed on the sheet at submission"
status: "exists in hr v2 (HR-57); hr v3 lists timesheets as a caller"
- story: TS-18
with: hr
owner: hr
direction: outbound
route: "GET /persons?pool_user_sub="
permission: hr:employment:read
request: "query pool_user_sub"
rule: "the signed-in user's person_id"
status: "exists in hr v2 (HR-58); hr v3 lists timesheets as a caller"
- story: TS-19
with: time-off
owner: time-off
direction: outbound
route: "GET /absences?company_code=&from=&to=&person_id=&changed_since="
permission: tof:absence:read_for_payroll
request: "query company_code, from, to (the week), person_id"
rule: "approved and recorded absences become read-only lines"
status: "satisfied by time-off v1 (TOF-20)"
- story: TS-20
with: payroll
owner: timesheets
direction: inbound
route: "GET /approved-hours?company_code=&from=&to=&person_id=&changed_since="
permission: ts:hours:read_approved
request: "query company_code (required), from, to (at most 93 days apart), person_id (optional), changed_since (optional, RFC 3339)"
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, rows: [{person_id, work_date, sheet_id, revision, status (approved | superseded), normal_hours, overtime_hours, absence_hours, changed_at}]}'
rule: "approved revisions, and superseded ones when changed_since is given; overtime by the weekly rule in force; no rates, no cost; payroll and time-off never read ts_ tables"
status: "offered by timesheets v1; payroll v1 PRL-22 and time-off v1 TOF-16 call it"
- story: TS-20
with: time-off
owner: timesheets
direction: inbound
route: "GET /approved-hours?company_code=&from=&to=&person_id=&changed_since="
permission: ts:hours:read_approved
request: "query company_code (required), from, to (at most 93 days apart), person_id (optional), changed_since (optional, RFC 3339)"
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, rows: [{person_id, work_date, sheet_id, revision, status (approved | superseded), normal_hours, overtime_hours, absence_hours, changed_at}]}'
rule: "approved revisions, and superseded ones when changed_since is given; overtime by the weekly rule in force; no rates, no cost; payroll and time-off never read ts_ tables"
status: "timesheets v2 2026-10-26 (spec gaps wave 4): listed for time-off TOF-16"
# ── 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: TS-18
with: hr
owner: hr
route: "GET /persons/{person_id}/assignment?on="
permission: hr:assignment:read
rule: "the person's and the approver's email and company by person_id, so a migrated week has its person's identity (was blank: hr could not be asked by person id)"
status: "satisfied by hr v4 (HR-63)"
- story: TS-15
with: hr
owner: hr
route: "GET /roster?company_code=&on=&org_unit_code=&manager_person_id=&limit=&offset="
permission: hr:roster:read
rule: "the reminder pass chases every employed person whose week was never opened, not only weeks that exist"
status: "satisfied by hr v4 (HR-64)"
- story: TS-22
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)"
tool_names: # shared.yaml conventions.tool_names: the migration family and the billing run name this module's noun
- { tool: load_ts_from_odoo, route: "the ts_migration_run workflow's transition (route chosen at build)" }
- { tool: reconcile_ts_migration, route: "the ts_migration_run workflow's transition (route chosen at build)" }
- { tool: sign_ts_migration, route: "the ts_migration_run workflow's transition (route chosen at build)" }
- { tool: discard_ts_migration, route: "the ts_migration_run workflow's transition (route chosen at build)" }
- { tool: get_ts_migration_map, route: "GET /migration-map/{db}/{model}" }
screens:
- { id: my_timesheet, name: My timesheet, stories: [TS-01, TS-02, TS-03], actors: [employee, manager, project_lead], note: "a week grid (days across, lines down), absences pre-filled and locked, expected vs booked per day, a project and item picker from projects, billable shown per line; copy last week's lines" }
- { id: approvals, name: Approvals, stories: [TS-04, TS-05, TS-06, TS-07], actors: [manager, project_lead, finance], note: "weeks waiting for me, hours per project per day, stale and over-12h warnings, the project lines waiting for their lead" }
- { id: project_hours, name: Project hours, stories: [TS-09], actors: [project_lead, finance], note: "approved, billable, billed, unbilled and cost per project, item and person; the billing runs" }
- { id: settings, name: Settings, stories: [TS-08, TS-10], actors: [finance, owner], note: "activities, cost rates, rate groups, reminders; the controls to the owner only" }
- { id: migration, name: Migration, stories: [TS-22], actors: [owner, finance] }
permissions:
namespace: ts
catalog:
- ts:sheet:read_own
- ts:sheet:write_own
- ts:sheet:submit
- ts:sheet:read_team
- ts:sheet:read_all
- ts:sheet:approve
- ts:sheet:reopen
- ts:line:approve_project
- ts:project_hours:read
- ts:cost_rate:write
- ts:activity:write
- ts:billing_run:release
- ts:settings:controls # approval route, missing-hours mode, who may reopen; never held by whoever they control
- ts:migration:read
- ts:migration:sign
- ts:sheet:open_auto # machine
- ts:cost:post_auto # machine
- ts:delivery:report # machine
- ts:billing_run:create # machine
- ts:reminder:tick # machine
- ts:hours:read_approved # machine: payroll's and time-off's service clients only
- ts:migration:load # machine
- ts:migration:reconcile # machine
machine:
- ts:sheet:open_auto
- ts:cost:post_auto
- ts:delivery:report
- ts:billing_run:create
- ts:reminder:tick
- ts:hours:read_approved
- ts:migration:load
- ts:migration:reconcile
row_visibility:
rule: "an employee sees only their own sheets; a manager sees the sheets of people they manage in the sheet's week; a project lead sees only the lines on projects they lead; finance and the owner see every sheet of their companies; cost rates are visible to finance only"
enforced: "in every use case: the caller's person_id from hr (by sub), the approver fixed on the sheet, the lead from projects' terms copied on the line"
not_found_not_forbidden: true
config_rows:
- table: ts_settings
changes: "how weeks are filled, approved and billed; every row dated (valid_from after today; the first row of a key may start today)"
settings:
- { key: approval_route, type: str, default: "manager", changes: "manager | project_lead_then_manager; ts:settings:controls" }
- { key: missing_hours_mode, type: str, default: "block", changes: "block | warn when a working day is not covered; ts:settings:controls" }
- { key: max_day_hours, type: decimal, default: "12.00", changes: "hours on a day above which a note is asked" }
- { key: time_increment_minutes, type: int, default: 15, changes: "the smallest step of time" }
- { key: submit_due, type: str, default: "MON 10:00", changes: "when last week's sheet is due" }
- { key: approval_reminder_days, type: int, default: 2, changes: "business days before an approver is reminded" }
- { key: billing_day, type: str, default: "month_end", changes: "when contract and manual T&M projects are billed" }
- { key: overtime_rule, type: str, default: "weekly_above_contracted", changes: "what payroll reads as overtime: weekly_above_contracted | daily_above_contracted | none" }
- { table: ts_activity, changes: "the activities people book to and whether each is billable by default" }
- { table: ts_cost_rate, changes: "the cost of an hour per rate group, dated" }
- { table: ts_person_rate_group, changes: "which rate group each person is costed at, dated" }
in_code_not_rows:
- "that nobody approves, reopens or releases billing for their own hours, under any login (email AND sub)"
- "that an approved revision is never edited; a correction is a new revision and every effect moves by the difference"
- "that a billable line is delivered or billed once (ts_billing_claim)"
- "that a line is billable only when its project's terms are billable"
- "that cost comes from a cost rate, never a salary"
- "that neighbours are called only from pending rows after the local commit"
- "that 'today' and the Monday deadline are in business_timezone"
ai_steps:
- key: draft_week
benefit: "a week pre-filled from last week's lines and the calendar, so a consultant confirms rather than types"
mode: "off"
modes_allowed: ["off", suggest]
safe_for_auto: false # hours bill a customer and pay a person; a person confirms every line
inputs: [the person's last four approved weeks, the bookable projects]
output: "suggested lines marked until confirmed"
fallback: "copy last week's lines (no model)"
audit: "ts_outbox (suggested and confirmed lines)"
eval: "replayed weeks: share of suggested hours kept unchanged"
note: "a task in the signed process (Task_timesheet_draft_week); off by default"
extension_points:
- { name: timer, what: "a start/stop timer that writes entries in steps of time_increment_minutes" }
- { name: expenses_on_projects, what: "expense lines charged to a project (expenses' projects extension) beside the hours" }
- { name: budgets, what: "hours and cost budgets per project item with warnings at 80% and 100%" }
- { name: toil, what: "approved overtime as time off in lieu (time-off's toil extension)" }
- { name: fixed_price_revenue, what: "percentage-of-completion revenue on fixed-price projects from hours against budget (IFRS 15 input method)" }
tests:
acceptance: "one per story id above, named for it"
access:
- "an employee cannot read another person's sheet (404) or approve anything"
- "a manager sees only the sheets of people they managed in the week; never approves their own"
- "a project lead sees only lines on projects they lead; never approves their own lines"
- "finance cannot reopen a sheet of their own or one they approved; cannot release a run with their own hours in it"
- "ts:settings:write cannot change a control; anyone but the owner holding ts:settings:controls is refused at configuration"
- "ts:hours:read_approved is refused to every client but payroll's and time-off's, and to every person role"
invariants:
- "claim keys: a sheet (person_id, iso week, revision), a journal (derived_id(ts:<sheet>:r<rev>, approve | reverse)), a delivery (delivery_ref ts:<sheet>:r<rev>:<line>) and a billing claim (sheet, rev, line, purpose) are each claimed once; two approvals, two runs or two retries leave one of each"
- "side effects (journal, delivery, invoice raise, reminders) 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: submitted never returns to open (a return makes revision n+1), approved never returns to submitted (a reopen supersedes it), a released run is terminal (tested by attempting each reverse)"
- "self-approval: no sheet or project line is approved by its own person, matched on email AND sub, including as a delegate and including finance"
- "the business date is in business_timezone: an approval at 23:30 Europe/London on 2026-11-23 posts on 2026-11-23; 00:30 BST on 2026-07-14 posts on 2026-07-14; the UTC date is never used; work dates are the sheet's own"
- "bounds: hours negative, above 24.00 a day, not a multiple of the increment, or with more than 4 decimals answer 422 {gate: bounds}; a cost or billed amount beyond NUMERIC(18,2) is refused, never a 500"
- "results marked stale: an absence, pattern or terms change under a submitted line flags it; approval is refused on a stale line; approved revisions never change"
- "flat statements: My timesheet, Project hours, a billing run and GET /approved-hours contain only their own person's, project's or company's rows; seeding another company's sheets and another project's lines changes none of them"
- "differences, not totals: after any sequence of revisions the hours delivered to an order line, billed through ar, costed in gl and read by payroll for a line equal the latest approved revision's hours"
scenarios:
- id: SC-TS-01
name: a consultant's week from grid to invoice
stories: [TS-01, TS-02, TS-03, TS-04, TS-11, TS-12, TS-13]
given: 4410's week 2026-W47 with a holiday, a sales-order project and a contract project
when: filled, submitted, approved and the month closed
then: 19.00 HUR delivered to the order; 7.50 in P2's November invoice at 110.0000; labour journal 1,378.00 each side
- id: SC-TS-02
name: a correction after delivery
stories: [TS-07]
given: SC-TS-01 approved
when: finance reopens for 2.00 hours moved from P1 to P2
then: order -2.0000; P2 +2.00 billed; journal reversed and reposted; payroll reads revision 2
- id: SC-TS-03
name: the lead's own hours
stories: [TS-05]
given: approval_route project_lead_then_manager; lead 3020 books 6.00 on their own project
when: 3020 tries to approve them under a second email with the same sub
then: refused; they go to 3020's manager
scale:
reads: "the tables reader serves 16 concurrent reads; a week is one query for its lines and entries; Approvals one query per approver; GET /approved-hours one query per company and range"
hot_paths: ["submit (gates)", "approve (journal, deliveries, claims)", "the Monday rush of submissions"]
workers: "the reminder pass and the month-end billing run in the composition's worker; nothing pinned warm is required"
volumes: "a firm of 200 consultants files about 10,000 sheets and 50,000 lines a year; a month-end run of 300 projects in one pass"
platform_constraints:
- constraint: "No read-your-writes inside an open transaction."
today: "approval commits the sheet and its pending rows; the journal and deliveries are sent in a second step"
- constraint: "A plain INSERT of a committed key is rejected (23505)."
today: "billing claims and line approvals are plain INSERTs; the loser re-reads"
customer_varies:
- question: Who approves time, the line manager, the project lead, or both?
changes: approval_route
typical:
consultancy: "project lead, then line manager"
agency: "line manager only"
- question: Must every contracted hour be accounted for before a week can be submitted?
changes: missing_hours_mode
typical:
consultancy: "yes (block)"
product_company: "warn; time is recorded only on projects"
- question: How do you bill time, through the order you sold or monthly on a contract, and at whose rates?
changes: projects' time terms; product price lists
- question: What does an hour cost you per seniority?
changes: ts_cost_rate, ts_person_rate_group
warning: "a cost rate below the true cost shows every project as profitable"
- question: What counts as overtime for pay?
changes: overtime_rule
standard: "Working Time Regulations 1998 reg. 4 (48-hour average weekly limit, opt-out recorded by hr)"
- question: How long do you keep timesheets?
changes: retention of ts_ rows
standard: "Working Time Regulations 1998 reg. 9 (two years); National Minimum Wage Regulations 2015 reg. 59 (six years for pay records)"
variation_points:
- node: Task_timesheet_approve_timesheet
op: narrow
default: "the derived manager"
depth: 1
requirement: "refuse - a route in which a person approves their own hours"
- node: Gateway_timesheet_route
op: row_value
default: "manager"
depth: 1
guideline: "warn - project_lead_then_manager adds a step; worth it where clients dispute hours"
- node: Task_timesheet_draft_week
op: on_off
default: "off"
depth: 1
guideline: "warn - suggest only; a person confirms every line"
migration:
odoo:
runs_after: [hr, projects, sales-order]
reads:
- model: account.analytic.line
fields: [id, date, employee_id, project_id, task_id, unit_amount (hours), so_line, timesheet_invoice_id, validated (hr_timesheet validation), name]
filter: "project_id set, the door's years"
why: "approved hours per employee and week; so_line and timesheet_invoice_id give the billing state"
maps:
- { from: account.analytic.line, to: "timesheet, line, entry", rule: 'grouped per employee and ISO week into sheet_id "odoo:<db>:<employee>:<iso week>" revision 1 approved; 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); project through projects'' migration map; billed when timesheet_invoice_id is set' }
door:
- "which Odoo projects map to which projects here (projects' own migration)"
- "how many years of history (recommended: the current and the prior year)"
- "weeks not validated in Odoo: validated there before cut-over, or re-entered"
reconcile:
- { check: "unbilled hours per sales order line", odoo: "SUM(unit_amount) of lines with so_line and no timesheet_invoice_id", ours: "approved billable hours not billed per so line", must: equal }
- { check: "hours per employee per month", odoo: "SUM(unit_amount) by employee and month", ours: "SUM(hours) of migrated sheets", must: equal }
not_carried:
- "Odoo's analytic cost lines (gl's migration carries the books)"
- "unvalidated timesheets"
idempotent_on: 'sheet_id "odoo:<db>:<employee>:<iso week>"; ts_migration_map on [odoo_db, odoo_model, odoo_id]'
needs: [] # every route this spec calls exists in its owner (projects v2 PJ-25, sales-order v9 SO-51, ar v8 AR-20, gl v8 GL-30, hr v3, time-off v1)
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: erp-brick-timesheets
role: brick
https: https://git.dodil.io/dodil/erp-brick-timesheets.git
ssh: git@git.dodil.io:dodil/erp-brick-timesheets.git
tag: v0.2.0
read_first: [README.md, AGENTS.md]
- name: brick-business-partner
role: brick
https: https://git.dodil.io/dodil/erp-brick-business-partner.git
ssh: git@git.dodil.io:dodil/erp-brick-business-partner.git
tag: v0.4.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"
- "no budgets, timer or percentage-of-completion revenue (extension points)"
- "hours only: day rates are billed as hours unless the time product's unit is DAY and the project's terms say so"
- "APQC ids are null until verified against PCF v8.0"
sap_parity:
- { sap: "CATS time sheet with approval", ours: covered, where: "TS-01..TS-06, timesheet" }
- { sap: "Transfer to CO (activity allocation at a planned activity price)", ours: covered, where: "TS-11 (cost rate per rate group to the project cost centre)" }
- { sap: "Transfer to PS (actual hours on WBS elements)", ours: different, where: "TS-09", why: "hours stay here and projects reads them; no WBS" }
- { sap: "Resource-related billing (DP90) from SD orders", ours: covered, where: "TS-12 (delivered hours on the order line), TS-13 (ar runs)" }
- { sap: "Transfer to HR payroll (attendances, overtime)", ours: covered, where: "TS-20 (payroll reads approved hours)" }
- { sap: "Time evaluation (RPTIME00) with time types", ours: deferred, milestone: "M6", where: "overtime_rule only", why: "UK mid-market pays overtime from approved weekly hours" }