erp-brick-projects — Projects & service delivery
Turn what you sold into a delivery plan, see what is late and what is waiting on the client, get each piece of work accepted with proof, and know at the end what was delivered against what was sold, so that billing follows delivery instead of a spreadsheet.
A brick on erp-platform, built from the projects module spec
(erp-method modules/projects, process signed 2026-10-18: item v2 cab50e2c — a manager may also record a
client's acceptance — and project v1 afc6da20). Scaffolded
by erp-compose scaffold (0.9.0), then written. Every action is both an API route and an MCP tool.
What it does
| Story | How |
|---|---|
| PJ-01, PJ-20, PJ-21 what was sold becomes the plan | plan_from_contract (POST /plans) takes a sale — a contract, a sales order, or by hand — with its lines in the call (this brick never reads the seller's tables). Each line is expanded by the customer's rule (plan.expand): by default a one-off single-payment line makes one item per unit, another one-off line one item, and a monthly or usage line none (it elapses; invoicing's schedule bills it). Keys are derived (pj:<kind>:<id>, <project>|<line>|<n>), so planning again creates nothing |
| PJ-02 waiting on the client is not our slippage | block needs a reason ("say what you are waiting for"); unblock adds the days to the item; slippage leaves them out |
| PJ-03, PJ-11 an answer or a deadline | the review clock is derived on read: working days left, whether the warning is due, when acceptance may be deemed. A person sends the warning and records it (send_warning, only after the window); accept_deemed only after its notice, with evidence, recorded as deemed |
| PJ-04 done needs proof | accept without evidence: "Attach the evidence first: a link, a file or a sign-off" |
| PJ-05 the client sees status | issue_client_link hands back a read-only link once; the page shows that project only — any other is the dead-link page. list_client_links shows who has one (never the url); revoke_client_link stops one at once |
| PJ-06, PJ-12 sold against delivered | reconcile (and the daily reconcile_due) closes an active project and stores, per sold line, sold / delivered / short / over / descoped, once. Short is derived, never stored |
| PJ-07 makegood | a new item with no sold line, never billed; one per short item |
| PJ-08 the board | overdue, today, this week, in the caller's (or the business's) time zone; blocked items say what they wait on |
| PJ-09, PJ-10 changes and descoping | changes keep the same item (revision +1, the clock stops); descoped work counts as descoped, not short; done work cannot be descoped |
| PJ-21, PJ-22 the handovers | AFTER an acceptance is saved, and for accepted work only: an order's item tells the order one unit of its line was delivered (SO-23), and a line billed on acceptance asks invoicing for one draft keyed acc:<item_id> (AR-29). A neighbour that is down or not installed leaves delivery_status / billing_status pending; retry_handover finishes each, once. A makegood is never reported or billed |
| PJ-23 acceptance for a contract's phase gate | GET /acceptance?source_kind=&source_id=: per sold line, accepted or not, how and when |
| PJ-13 and the AI steps | ai_draft_plan, ai_chase_brief, ai_flag_shortfall: suggest only, never applied; off in the settings switches each off. No AI capability exists in erp-platform yet, so each answers with its fallback and says so |
Nothing here ever writes to a client: the system shows the clock, drafts, and records what a person sent. Work moves only while its project is open: a cancelled project's items are frozen, and a closed one's only makegoods are worked (a short item is made good, never finished late — or it would be delivered and billed twice).
Who
Every decision is a permission; the brick never compares a role's name, so a composition maps its own roles to
these. roles.yaml (shipped in the package) is the recommended default.
| Permission | For | Default role(s) |
|---|---|---|
pj:project:read / pj:item:read | the projects one leads, the items one is given | every role |
pj:project:read_all | every project and its items | project_manager, billing, scheduler |
pj:project:plan | plan the work a sale needs | project_lead, project_manager, scheduler |
pj:project:write | cancel, client links, the AI plan draft — on the projects one leads | project_lead, project_manager |
pj:project:manage | act as the lead of every project | project_manager |
pj:project:reconcile | close a project's window (the daily pass) — a machine permission | scheduler (a service client) |
pj:item:work | start, block, unblock, evidence, submit — on one's own items | team_member |
pj:item:write / pj:item:accept | re-plan and descope / record the client's answer — on the projects one leads (or manages) | project_lead, project_manager |
pj:item:accept_own | also record the acceptance of work one submitted oneself (a firm of one) — wildcards such as pj:*:* reach it | no default role; the customer grants it per role |
pj:settings:write | settings and state names | project_manager |
A row the caller may not see is 404, never 403; seeing is not acting (403). Whoever submitted work does not record
its acceptance (accept and accept_deemed) unless their role holds pj:item:accept_own: the refusal is a 409, "You
submitted this work, so someone else records its acceptance" (item process v3). The project's lead or a manager does,
so work a lead did is never stuck. A setting that switches a step of the signed process off says so when it is changed, and that
change goes through erp-compose change propose and a new signature.
Neighbours
infrastructure/seams.py is the only place another module is named: invoicing (AR_BASE_URL, AR-29) and sales
orders (SO_BASE_URL, SO-23). Both are optional; absent, the work is kept pending with what to do by hand. Their
paths follow the M1 specs, which are not signed yet.
Settings
GET /settings lists each with what it changes; null is the reference behaviour. plan.expand,
review.window_days (10), review.warning_notice_days (5), review.deemed_acceptance (true),
blocked.chase_days (3), blocked.reasons, evidence.kinds, billing.on_acceptance (one_off/single),
calendar.working_weekdays, calendar.holidays, calendar.timezone, ai.<step>.mode (off | suggest).
State names (POST /states/{state_id}) are the customer's; the states and transitions are the signed process.
Run it
pip install -e '.[test]'
pytest -q # rules, doors, generated probes, the public link, 11 scenarios, process
erp-scenarios run tests/scenarios/catalogue.py
BUCKET=my-bucket erp-migrate --schema src/brick_projects/schema.sql --brick pj=0.1.4
Tables
pj_project, pj_item (with _versions claim tables: a stale save is 409 "changed since you opened it"),
pj_event (history, appended by the save that caused it), pj_state, pj_setting.
For the screens
GET /items/{id}/readiness and GET /projects/{id}/readiness (and the tools item_readiness, project_readiness)
answer, for each next transition the caller could take, its gates as {gate, met, reason, fix} in plain words. A
transition the caller's permissions never allow is left out; a record out of scope is a 404. The project read carries
stage (plan, work, review, reconcile), derived from its items: none started is plan, any in review is review, a
closed window is reconcile, otherwise work. An item carries work_ref (an https link to where the team does the work;
set with assign, pj:item:write; it is not evidence).
Not in 0.1
Time tracking; capacity planning across projects; dependencies or a Gantt; commitments over time ("12 articles across the year"); a client approving from the link (staff record acceptance); change-order pricing; an AI provider for the three AI steps.
License: Apache-2.0, see LICENSE