dodil/erp-brick-projects

README.md

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

StoryHow
PJ-01, PJ-20, PJ-21 what was sold becomes the planplan_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 slippageblock 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 deadlinethe 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 proofaccept without evidence: "Attach the evidence first: a link, a file or a sign-off"
PJ-05 the client sees statusissue_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 deliveredreconcile (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 makegooda new item with no sold line, never billed; one per short item
PJ-08 the boardoverdue, 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 descopingchanges 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 handoversAFTER 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 gateGET /acceptance?source_kind=&source_id=: per sold line, accepted or not, how and when
PJ-13 and the AI stepsai_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.

PermissionForDefault role(s)
pj:project:read / pj:item:readthe projects one leads, the items one is givenevery role
pj:project:read_allevery project and its itemsproject_manager, billing, scheduler
pj:project:planplan the work a sale needsproject_lead, project_manager, scheduler
pj:project:writecancel, client links, the AI plan draft — on the projects one leadsproject_lead, project_manager
pj:project:manageact as the lead of every projectproject_manager
pj:project:reconcileclose a project's window (the daily pass) — a machine permissionscheduler (a service client)
pj:item:workstart, block, unblock, evidence, submit — on one's own itemsteam_member
pj:item:write / pj:item:acceptre-plan and descope / record the client's answer — on the projects one leads (or manages)project_lead, project_manager
pj:item:accept_ownalso record the acceptance of work one submitted oneself (a firm of one) — wildcards such as pj:*:* reach itno default role; the customer grants it per role
pj:settings:writesettings and state namesproject_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