dodil/erp-brick-timesheets

DODIL ERP brick: timesheets

AGENTS.md

Instructions for agents working on this brick

Built from the timesheets module spec (erp-method modules/timesheets/module.yaml, version 1, signed 2026-10-25; BPMN in modules/timesheets/signoff/timesheets/). Read erp-platform's BRICKS.md before changing anything - the rules and why each one exists. The shape is erp-brick-expenses' and erp-brick-time-off's. Slices A and B are built (the timesheet workflow, the cost posted to gl, the billing run, project hours, reminders, the Odoo migration); see "What is left".

Where a change goes

You are changing...It goes in
a business rule (the state machine, the approver, the gates of a submit, the cost and delivery arithmetic)domain/sheet.py - and its test in tests/test_invariants.py / test_stories.py / test_rules.py
who may do something, or which rowsapplication/use_cases.py - the permission on the declaration (or _need), the row filters; never a role's name
a new action for people and agentsa new use case in application/use_cases.py - a route AND a tool, named <verb>_<module noun>_<object> unless it is a signed transition key
how a neighbour is reached (hr, projects, time-off, gl, sales-order, product, ar, the mail capability)infrastructure/seams.py only
how Odoo is readinfrastructure/odoo.py (the reader port); the mapping is domain/odoo.py; the use cases application/migration.py
the billing run's arithmetic and the project-hours figuresdomain/billing.py, domain/hours.py - the use cases application/billing.py, application/reports.py
a setting the customer editsdomain/values.py (SETTING_DEFS) + SettingsRepository; controls need ts:settings:controls
a column or tableinfrastructure/tables.py AND schema.sql AND a new migrations/NNNNN_<name>/ (append; never drop or rename; no SERIAL, no UPDATE ... FROM); tests/test_schema.py keeps them equal
the processnever here first: erp-compose change propose, a new signature, then application/process.yaml; tests/test_process.py pins the signed BPMN byte for byte (all three processes)

Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Don't copy hr's calendar, work pattern or staff list. Business time is work.now() counted in business_timezone (_today(repo)); audit stamps are real time.

The rules that must not be undone (each has a test in tests/test_invariants.py / test_stories.py)

  • One claim key per operation on a week revision (ts_claim: open, submit, approval, close, reopen via ClaimRepository.once): the loser of a race is a 409, never a second effect. Approve and return share the decision on a revision.
  • Side effects only after the commit, from pending rows. Approval writes the state AND the ts_outbox rows (the labour journal to gl, the reversal of the replaced revision's journal, the delivery to the order) in ONE transaction; _drain then sends them. A crash after the commit is sent by the next POST /outbox/drain / POST /deliveries/report; a rolled-back approval sends nothing. gl replays the derived journal id, the order the delivery_ref.
  • States never move back. domain/sheet.py MOVES is the only table of arrows. A returned week continues as revision n+1; an approved one is superseded by n+1 and never edited. Cost and delivery move by the DIFFERENCE between revisions (a no-change revision tells the order nothing).
  • Nobody approves, returns or reopens their own week, under either login (Identity.same_as is email OR sub); the approver is hr's manager on the week's Monday, fixed at submission; a manager who is the person goes up a level.
  • Business date = the date in business_timezone, never the UTC date.
  • Bounds are 422 {gate: bounds}, never a 500 (hours in steps of the increment, a day at most 24.00, rates, dates, ids, text lengths).
  • Statements stay flat: opening, booking, submitting, approving and the lists do not grow with the entries of a week or with other people's sheets (tests/test_statement_budget.py).
  • A neighbour answer is a reply, never an exception; tests/conftest.py checks every call against the callee's signed spec (checked_seams).
  • A billing claim per line, once (ts_billing_claim purpose ar_run, plain INSERT, PK sheet+revision+line+purpose): a re-run, a retry or a second worker bills a line at most once; a correction is billed as the DIFFERENCE from the claims already taken (negative when hours were taken away, priced at the price the line was first billed at); cancel_time_billing_run removes the run's claims so the lines fall into the next run. The invoice request leaves a released run only from a pending ar outbox row written in the release's transaction; ar replays the run's source id. A run is never released with a negative total (not_negative) or by a person whose hours are in it (email AND sub).
  • Reminders are keyed (kind, ref, business_date) in the outbox (target notify), sent through the mail.send capability after the commit (no provider: a task for a person, status manual).
  • The Odoo migration sends nothing: no journal, no delivery, no invoice. A week is carried only when every Odoo line of it is validated; a SIGNED run is frozen; a run is discarded only while nothing has been done to its weeks.
  • Machine permissions (ts:hours:read_approved - payroll's and time-off's clients alone; the rest the worker) are exact grants; see roles.yaml.

What is left (slices A and B are done)

Done: TS-01 to TS-22 (TS-09 get_project_hours, TS-13 and TS-14 the billing run, TS-15 tick_ts_reminders, TS-22 the Odoo slice) and the three workflows' transitions. Not built: the draft_week AI step (spec ai_steps, off by default; the signed process has its task) - no consent, model call or eval set yet; automatic opening of every employee's week (ts:sheet:open_auto is declared; a person opens theirs); scenarios SC-TS-01.. (as one end-to-end harness); the permission-matrix probes; the ts_migration_run statement budget; live runs against a real Odoo (the reader's field lists are Odoo 17's, not verified live).

Spec gaps found while building

  • hr lists new callers under callers: and time-off's TOF-20 (with: payroll) names timesheets only in status; erp_platform.contract reads only with, so tests/conftest.py shims it. gl v8 does not list GET /cost-centres/{cost_centre} and GET /periods/open for timesheets (conftest UNLISTED).

  • gl v8 does not list timesheets as a caller of GET /cost-centres/{cost_centre} and GET /periods/open (conftest UNLISTED, shim kept). product v6's PRD-31 price entry names with: sales-order only, not timesheets (same shim, GET /products/{product_id}/price).

  • The billing run's rules the spec leaves open (built as the smallest honest thing): a line is billed in the month of its FIRST work day (a week straddling two months is billed whole in the first); it is priced on that day; a late-approved line of an earlier month falls into the next run; a run belongs to the period's gl month YYYY-MM; the month must have started; a second run of a month is ...:<n>. There is no write-off story: a write-off is a correction that takes the hours away (a negative line); a negative run waits for finance, who cancels it and credits in ar by hand. A billing-run reader other than finance (a project lead) is not offered. Hours on a sales-order project are never billed here: the order bills them.

  • TS-09 project hours: billed counts only released ar runs (sales-order hours are delivered, not billed here); a project lead is recognised by the lead on the project's lines (no hr call per project); cost is each line's hours x the cost rate copied at submission, rounded once per line, in the sheets' currency.

  • TS-15 reminders: weeks that were never opened cannot be chased (the brick keeps no staff list); the address is the one on the sheet (person_email, approver_email); public holidays are not business-day aware (weekdays only).

  • TS-22 migration: hr offers no migration map, projects offers none, sales-order's line map is unspecified - so employee_map, project_map and so_line_map are doors the caller answers (as expenses does for employees). upstream_signed is attested. A migrated week has a blank person identity (no email or sub: hr cannot be asked by person id), so finance can reopen it but its person cannot edit the correction until hr offers identity by person id. Odoo's cost is carried as the entry's rate to four places (a day's pennies can differ); costs are not a reconciliation check. Tasks (items) are not carried. The spec names no AI step for migration.

Backlog (decided 2026-10-26, pending the spec PR)

seemo decided these on 2026-10-26; they are written into erp-method branch spec/gaps-wave-4. Build them once that PR is signed (the module's processes need a re-sign first), not before; until then the spec on main stands.

  • Split billing by work date. A line bills in the month of its work date, so a week across a month end splits between the two runs (TS-13), each line priced on its work date; today the whole week bills in the month of its first work day. The billing claim is per line and work date.
  • hr identity by person id. Read the person's and the approver's email and company from GET /persons/{person_id}/assignment (HR-63), so a migrated week gets its person (today blank); the reminder pass covers everyone employed through GET /roster (HR-64); the Odoo load matches employees by work email, not the employee_map door.
  • Shims to remove once the spec is signed: gl now lists timesheets for GET /cost-centres/{cost_centre} and GET /periods/open, product PRD-31's price lists timesheets, time-off's GET /absences has a with: timesheets row, and hr lists each caller with with: (conftest UNLISTED and the callers shim go).
  • Gate name. sales-order's over-delivery gate is within_tolerance everywhere (was within_ordered for timesheets).

Before you commit

ERP_METHOD_DIR=../erp-method pytest -q --tb=short     # stories, invariants, doors, seams, budget, process, schema, composition
ruff check src tests
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_timesheets/roles.yaml