Quoting, workforce and intervention planning for a French home-care agency.
What it does · Architecture · Requirements · Getting started · Docker · Kubernetes · Documentation
An agency quotes a family for care at home, a manager approves the price, and
the accepted work becomes a week of visits — who goes where, in what order, with
travel time and a lunch break accounted for. SimpleERP runs all three of those,
and the workforce and customer records behind them.
For a home-care assistant
- Their own record: contact details and address they may change; contract type and qualifications shown but locked, because those are a manager's decision.
- Their week as a calendar, computed from the accepted quotes.
- The customers they serve — not the agency's whole book.
- Quotes they have written, and a button that submits one for validation.
For a manager or administrator
- Every quote, with a validation queue: an assistant's submission waits here until somebody who sets prices agrees to it.
- The workforce, searchable, with the qualification editor.
- A map of every planned intervention over a configurable window, each pin being the assistant's photograph, each tooltip naming the customer.
- A notification centre, fed over the broker and pushed live over SSE.
Four application processes and three stores. The API, both workers and the migrator run the same image with different entry points — one image built four ways cannot drift the way four Dockerfiles do.
┌──────────────┐
browser ────────▶ │ React SPA │ ─────────┐
└──────────────┘ │ REST + SSE
▼
┌────────────────┐ ┌──────────────┐ ┌────────┐
│ worker │ ◀────▶ │ RabbitMQ │◀─│ API │
│ · planning │ events └──────────────┘ └────────┘
│ · notifications│ │
└────────────────┘ │
│ │
└──────────▶ PostgreSQL ◀──────────────────┘
MinIO (photographs)
| Process | Does |
|---|---|
| migrate | Runs Alembic to head, once, and exits. Everything else waits for it |
| api | Serves HTTP and holds the SSE connections |
| worker-planning | Consumes planning-runs: runs the CP-SAT solves |
| worker-notifications | Consumes quote-notifications: writes notifications, sends email |
| frontend | Vite dev server, or nginx serving a built bundle and proxying /api |
A uv workspace of six members with a strict, one-directional dependency graph.
Nothing points back up it.
models ← storage ← service ← api
↑
├── worker
└── seed
models holds the domain and configuration and imports nothing first-party.
storage owns the tables, mappers and repositories — and never commits, because
the session it is handed already belongs to a transaction. service holds the
business logic and raises its own MT* exceptions rather than HTTP ones. api
translates those exceptions to statuses, once, and its endpoints contain no
business logic.
The worker deliberately does not depend on api: a background consumer that
pulled in FastAPI to read one YAML file would make the graph a ring.
A request passes CORS → authentication → transaction → router → service → repository. The order is load-bearing at both ends: CORS outermost so a rejected credential still carries its headers, and the transaction innermost so it commits before the response is written.
An event is published by the API and consumed by the worker. A publish that fails never fails the request that caused it — a quote is submitted whether or not the broker was reachable, because the manager's queue is a database query rather than a message.
To run it, nothing but a container runtime:
| Docker Engine | 24 or newer |
| Docker Compose | v2 (docker compose, not docker-compose) |
| Memory | ~5 GB free — the solver and the broker are the hungry parts, and the observability chain adds about a gigabyte |
| Ports | 5173, 8000, 5432, 5672, 15672, 9000, 9001, 1025, 8025, 9101, 9102, and 3000, 3101, 9090, 4317, 4318 for observability |
The development overlay publishes every one of those ports so you can reach the
database and the broker directly. If something already holds one, either stop it
or drop that mapping from infra/compose/docker-compose.dev.yaml.
To work on the code, a toolchain per language — none of it needed if you only run the stack:
| Backend | Python 3.14 and uv |
uv sync installs the rest |
| Front-end | Node 20 | .nvmrc pins it; npm ci installs the rest |
| GUI campaign | Python 3.9+ and Playwright browsers | pip install -r qa/requirements.txt && rfbrowser init |
The pinned versions live in backend/.python-version and frontend/.nvmrc, and
CI reads both from those files rather than repeating them.
Outbound network is needed in two places, and only two. Nominatim is called when an address is geocoded — which happens during model validation, so it fires when a customer or an assistant is created, and never when one is read. The seeder supplies coordinates precisely so it does not call it forty times. The other is the image and package registries, at build time.
cp infra/compose/.env.example infra/compose/.env
make up-buildmake is the short way in: the compose files live under infra/compose/, so
every raw command needs two -f flags, and the Makefile at the root passes
both for you. Spelled out, that first command is:
docker compose -f infra/compose/docker-compose.yaml -f infra/compose/docker-compose.dev.yaml up --buildThat brings up PostgreSQL, MinIO, RabbitMQ, the API, both workers, Mailpit, the
Vite dev server and the whole observability chain, runs the migrations once
through the migrate service, and seeds a working agency — 12 assistants,
40 customers, a service catalog and 54 quotes spread across every status.
Wait for Bucket simple-erp is ready. and the seeder's credential block. The first
build takes a few minutes, and afterwards a start is seconds.
| Application | http://localhost:5173 |
| API and its docs | http://localhost:8000 · http://localhost:8000/docs · http://localhost:8000/redoc |
| Caught email | http://localhost:8025 |
| Broker management | http://localhost:15672 — simple_erp / simple_erp_dev |
| Object store console | http://localhost:9001 — simple_erp_dev / simple_erp_dev_secret |
Every process serves the same three probes, on the same three paths. /health
answers whether it is alive, /ready whether it can reach what it needs, and
/metrics a Prometheus exposition. A worker that is up but cannot reach the
broker answers 200 on the first and 503 on the second, which is the whole
reason there are two.
| Process | Liveness | Readiness | Metrics |
|---|---|---|---|
| API | http://localhost:8000/health | http://localhost:8000/ready | http://localhost:8000/metrics |
| Planning worker | http://localhost:9101/health | http://localhost:9101/ready | http://localhost:9101/metrics |
| Notification worker | http://localhost:9102/health | http://localhost:9102/ready | http://localhost:9102/metrics |
The dashboards behind them come up with everything else — the development stack always runs the full observability chain, so a panel that has rotted is something you find on a Tuesday rather than the day you needed it. It costs five more containers and roughly a gigabyte of memory, which is the deliberate trade.
| Grafana | http://localhost:3000 — no sign-in, anonymous admin · http://localhost:3000/explore to query the logs |
| Prometheus | http://localhost:9090 · targets · rules · alerts |
| Loki | http://localhost:3101/ready · http://localhost:3101/metrics — an API, not a UI; read the logs in Grafana. Published on 3101 because the RobotCode editor extension holds 3100 |
| OTLP ingest | localhost:4317 (gRPC) · localhost:4318 (HTTP) — endpoints the collector accepts spans on, not pages |
Loki is published for curl, not for a browser — it serves an API and no
interface of its own, and logs are read in Grafana. What the port buys is the
one question Grafana cannot answer. An empty Explore pane looks identical
whether Alloy is shipping nothing, Loki is refusing it, or the query is simply
wrong; /ready and /metrics separate those in one request each.
Tempo and Alloy still publish nothing, on purpose. Grafana reaches Tempo over the compose network, and Alloy scrapes the container runtime's own logs rather than serving anything — so a port for either would be another way to look at data Grafana already shows, and another thing to remember is running.
The Prometheus targets page is the one to open first when a panel is empty — an exporter that is down there explains a blank dashboard faster than the dashboard does. → docs/14
Sign in with any of the seeded accounts, all sharing the password
simple-erp-demo-2026:
| Role | Address | Lands on |
|---|---|---|
| Administrator | admin@simple-erp.fr |
The quote screen |
| Manager, runs the seeded team | manager@simple-erp.fr |
The quote screen |
| Manager, runs no team | manager2@simple-erp.fr |
The quote screen |
| Manager, on the rounds | marc.dubois@simple-erp.fr |
The quote screen |
| Assistant | luc.martin@simple-erp.fr |
Their own planning |
Every seeded assistant signs in as firstname.lastname@simple-erp.fr.
The seeded company operates from two sites — Siège Paris, its head office,
and Antenne Lyon, a branch that holds nobody — and every seeded person and
quote belongs to one team, Equipe principale, run by manager@simple-erp.fr.
Sign in as manager2@simple-erp.fr to see the other side of that: they run no
team, so their quote book, workforce and household list are all empty while the
administrator's are full. That is the team narrowing, not a broken screen.
Marc Dubois is a manager who still covers rounds — they hold both a manager's role and an assistant record. Sign in as them to see the employment section of My account in its editable form, where the contract type, the qualifications and whether this person goes out on rounds can be changed. The same section renders locked for Luc Martin, who holds the assistant role.
Three compose files. The base is never run alone — it describes what the application is, and an overlay says how you want to run it.
| File | Adds |
|---|---|
infra/compose/docker-compose.yaml |
The services themselves: postgres, minio, minio-init, rabbitmq, migrate, backend, worker-planning, worker-notifications, frontend — plus pgbouncer behind a pooled profile, which an ordinary up does not start |
infra/compose/docker-compose.dev.yaml |
Source bind-mounts, live reload, every port published, Mailpit, the seeder, and the whole observability chain — Prometheus, Loki, Tempo, Grafana, Alloy and the OpenTelemetry Collector |
infra/compose/docker-compose.prod.yaml |
Restart policies, per-role resource limits and replicas, nginx, mandatory secrets |
pgbouncer is off by default because it is there to be exercised rather than
needed: asyncpg caches server-side prepared statements per connection, and
under transaction pooling a "connection" is a different backend from one
transaction to the next. The engine passes statement_cache_size=0
unconditionally to prevent that, and --profile pooled is what proves the
setting still works. → docs/13
Observability is part of the dev overlay rather than an overlay of its own —
see Observability above for the addresses. It reads the
same alert rules the Helm chart ships (infra/chart/rules/), so an alert
cannot fire on a laptop and not in the cluster.
→ docs/14
Because two -f flags on every command gets tedious, there are two ways round
it. Either use the Makefile at the root, which passes both on every target:
make # list the targets
make up # start, in the background
make stop # stop, keeping the containers and the data
make down # stop and remove the containers, keeping the data…or export the pair once per shell and use docker compose directly:
export COMPOSE_FILE=infra/compose/docker-compose.yaml:infra/compose/docker-compose.dev.yamlWhichever you pick, use the same two files to go down as you did to come up.
mailpit, seed and the whole observability chain are declared only in the
dev overlay, so a bare docker compose down leaves them running attached to a
network it has just deleted — and the next up fails with
network <id> not found, naming the network rather than the containers that are
really the problem. The Makefile exists mostly to make that unreachable.
Every raw command below assumes COMPOSE_FILE is exported. Without it, spell
both files out.
make |
docker compose |
|
|---|---|---|
make up |
docker compose up -d |
start, in the background |
make up-build |
docker compose up -d --build |
rebuild first — after a dependency change |
make ps |
docker compose ps |
what is running, and is it healthy |
make logs S=backend |
docker compose logs -f backend |
follow one service |
| — | docker compose logs -f backend worker-planning |
…or several |
make stop |
docker compose stop |
stop, keeping the data |
make down |
docker compose down --remove-orphans |
stop and remove the containers |
make seed |
docker compose run --rm seed |
re-run the seeder |
make clean |
docker compose down -v && … up --build |
destroys the data — see Starting over |
make urls |
— | print every address the stack publishes |
make replan-config |
docker compose restart worker-planning |
pick up an edited backend/conf/app.dev.yaml |
make replan-config is worth knowing about because backend/conf/ is
bind-mounted: the solver settings can be changed and tried without a rebuild,
and the planning worker only reads them at start.
Application source is bind-mounted in development: editing a .py reloads
uvicorn, editing a .tsx hot-reloads Vite. You only need --build when a
dependency changes — pyproject.toml, uv.lock, package.json — or when the
Dockerfile does.
The dependency graph is declared, so naming one service starts what it needs:
docker compose up -d backend # …and postgres, minio, rabbitmq
docker compose up -d frontend # …and the whole chain behind it
docker compose up -d postgres rabbitmq # the stores alone, to run the app on the hostdocker compose exec backend alembic -c conf/alembic.ini current
docker compose exec backend alembic -c conf/alembic.ini upgrade head
docker compose exec backend bash # a shell in the API container
docker compose exec postgres psql -U simple_erp simple_erp # a database prompt
docker compose run --rm seed # re-run the seeder (idempotent)The seeder writes nothing on a second run — every identifier is derived from a natural key, so it upserts. Running it again is always safe.
The test suite is not among these, and cannot be: the runtime image carries
no uv and no test framework, and backend/.dockerignore keeps tests/ out of
the build context entirely. That is deliberate — the image that runs the suite
would be the image that reaches production. The suite is hermetic, so it needs
none of this stack anyway: run it on the host, which is also what CI does.
cd backend && uv run pytestmake clean # both steps below, with both -f flagsor, spelled out:
DEV="-f infra/compose/docker-compose.yaml -f infra/compose/docker-compose.dev.yaml"
docker compose $DEV down -v --remove-orphans # containers AND volumes: a clean database
docker compose $DEV up --build # rebuild and reseed from nothing-v destroys postgres-data, minio-data and rabbitmq-data. In development
that is the fastest way back to a known state. Anywhere else it is data loss.
make clean is the same thing and just as destructive — it is not a cache
sweep.
Use the same -f files to go down as you did to come up, which is the one
thing the Makefile guarantees. mailpit and seed are defined only in the
dev overlay, so a bare docker compose down leaves them behind attached to the
network it has just deleted — and the next up fails with
network <id> not found, naming the network rather than the two containers that
are really the problem.
docker compose build backend # the API, both workers and the migrator share this image
docker compose build --no-cache frontend
docker compose up -d --force-recreate backendThe worker runs the same image as the API with a different entry point, so
build backend rebuilds both. That is deliberate: one image built two ways
cannot drift the way two Dockerfiles do.
docker compose ps # look for unhealthy or exited
docker compose logs backend | tail -50
docker compose logs minio-init # bucket creation — photographs and map pins
docker compose logs seed # the credential block prints here| Symptom | Usually |
|---|---|
backend restarts in a loop |
Migrations could not run — read its logs; check POSTGRES_PASSWORD matches |
| Photographs and map pins are broken | minio-init did not complete; re-run docker compose up minio-init |
| No notifications arrive | worker-notifications is down, or RabbitMQ never went healthy — it takes ~20 s on a cold start |
| Login rejects the seeded accounts | The seeder did not run: docker compose run --rm seed |
| A port is already in use | Something else holds 5432, 8000 or 5173; stop it, or drop that port from the dev overlay |
network <id> not found on up |
A previous down used a different -f set — see Starting over |
password authentication failed for user "simple_erp" |
A volume from before the rename — see below |
The base file declares name: rt-erp, so the project name — and every volume
name with it — is unchanged by the move. If you have removed that line, or you
run compose from somewhere that does not pick the base file up, the project is
named after the directory instead and the stack comes up against three empty
volumes.
That does not look like an empty database. The stores bake their credentials in when they first initialise an empty volume, so what you get is the authentication failure below — naming the right user, which reads exactly like a typo in the configuration.
docker volume ls | grep rt-erp_ # postgres-data, minio-data, rabbitmq-dataIf those exist and the stack is not using them, the data is fine and the project
name is wrong. Put name: rt-erp back.
Note also that .env now belongs beside the compose files, in
infra/compose/. Compose reads it from the project directory, which is the
directory of the first -f file; one left at the repository root is silently
not read, and the stack comes up on the development defaults without saying so.
The application was called rt-erp, and its database user, broker user and
bucket were named after it. If your volumes were created before the rename, the
first thing you will see is:
asyncpg.exceptions.InvalidPasswordError: password authentication failed for user "simple_erp"
POSTGRES_USER, RABBITMQ_DEFAULT_USER and MINIO_ROOT_USER are read only
when the store initialises itself. Changing them in compose does nothing to a
volume that already exists — the database still holds rt_erp while the
application now connects as simple_erp. Postgres fails first because the
backend reaches it first. The broker and the object store would fail next.
For a development stack, throw the volumes away — the data is seeded, and reseeding takes seconds:
docker compose -f infra/compose/docker-compose.yaml -f infra/compose/docker-compose.dev.yaml down -v --remove-orphans
docker network rm rt-erp_rt-erp # the network named after the old product
docker compose -f infra/compose/docker-compose.yaml -f infra/compose/docker-compose.dev.yaml up --buildTear down with the same -f files you brought it up with. mailpit and
seed exist only in the dev overlay, so a plain docker compose down -v does
not know about them: it deletes the network while those two are still attached,
and the next up creates a network with a new identifier that they no longer
point at. The result is Error response from daemon: network <id> not found,
which names the network rather than the containers actually at fault.
--remove-orphans covers the case where the overlay has already been forgotten.
To keep the data instead, rename inside each store before bringing the rest up.
Note the order for Postgres: renaming a role clears its password, so it has
to be set again afterwards, and a database cannot be renamed while you are
connected to it — hence -d postgres.
docker compose up -d postgres
docker compose exec postgres psql -U rt_erp -d postgres \
-c "ALTER ROLE rt_erp RENAME TO simple_erp;" \
-c "ALTER ROLE simple_erp WITH PASSWORD 'simple_erp_dev';" \
-c "ALTER DATABASE rt_erp RENAME TO simple_erp;"
docker compose up -d rabbitmq
docker compose exec rabbitmq rabbitmqctl add_user simple_erp simple_erp_dev
docker compose exec rabbitmq rabbitmqctl set_permissions -p / simple_erp ".*" ".*" ".*"
docker compose exec rabbitmq rabbitmqctl set_user_tags simple_erp administrator
# Photographs live in the bucket, and stored URLs name it. Copy, do not just
# create: minio-init makes an empty `simple-erp` bucket quite happily, and the
# map pins would then resolve to nothing.
docker compose up -d minio
docker compose exec minio sh -c "\
mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && \
mc mb --ignore-existing local/simple-erp && \
mc mirror local/rt-erp local/simple-erp"The seeder's identifiers are derived from a namespace that also carried the old
name, so a database seeded before the rename will gain a second set of
seeded rows rather than having the first updated in place. The seeder takes no
arguments — its entry point accepts argv only so the console script matches
the others, and ignores it — so there is no --reset to run. Either drop the
volumes and reseed from nothing, which is the development answer, or clear the
duplicate rows by hand.
docker compose -f infra/compose/docker-compose.yaml -f infra/compose/docker-compose.prod.yaml up -dDifferent in ways that matter:
- Every secret is required. Each is declared
${VAR:?}, so a launch without them fails immediately rather than coming up withchange-me-in-any-real-deploymentas its signing key. - The stores publish no ports. PostgreSQL, MinIO and RabbitMQ are reachable
on the compose network and nowhere else. Administration goes through
docker compose exec. - The front-end is built and served by nginx, which also proxies
/api— so the browser sees one origin. The API's address is not built into the bundle: it is read at start-up from/config.json, which ships in the image naming that same-origin/api. That is what makes one image promotable dev → staging → production rather than one image per environment. - Two planning workers and one notification worker, sized separately: a solve wants whole cores, a fan-out wants none. No bind mounts, no reload.
- The seeder is absent. Production data is not fixture data.
Verify a configuration before deploying it — this renders the merged file and fails on anything missing:
docker compose -f infra/compose/docker-compose.yaml -f infra/compose/docker-compose.prod.yaml config --quietdocker build -t simple-erp-backend ./backend
docker build -t simple-erp-frontend --target production ./frontendThe front-end Dockerfile has three targets: development (Vite), build, and
production (nginx). Tagged images are published to GHCR by
.github/workflows/release.yml on a v* tag.
→ docs/09 — Running and deploying for what each service does and the order they come up in.
Compose is one description of the system; infra/chart is the other, and they
are kept deliberately alike. The same five workloads, the same image built five
ways, the same one-shot migrator ahead of everything else — expressed as a Helm
pre-install,pre-upgrade hook Job rather than a service_completed_successfully
dependency.
helm lint infra/chart --set global.image.tag=$(git rev-parse --short HEAD)
helm template simple-erp infra/chart -f infra/chart/values-dev.yaml \
--set global.image.tag=$(git rev-parse --short HEAD)What the chart does not contain is the part worth knowing: PostgreSQL,
RabbitMQ, MinIO and PgBouncer are not in it. In compose they are services
because a laptop has to run something. In a cluster they are operator-managed
and installed by infra/bootstrap. They outlive
any one release, and a chart that owned them would uninstall cert-manager on a
failed rollback and take every certificate with it. Mailpit and the seeder have
no cluster equivalent at all — one catches development email, the other writes
fixture data.
Argo CD reconciles it, one Application per environment in infra/argocd.
Production syncs manually: the migration hook is irreversible, and a sync
that ran on its own could migrate a schema nobody was watching.
→ docs/13 — Kubernetes · docs/14 — Observability
backend/ uv workspace — six members, strict dependency direction
models/ domain models and configuration (pydantic) ← depends on nothing
storage/ ORM rows, mappers, repositories, migrations ← models
service/ business logic, solver, messaging, email ← models, storage
api/ FastAPI routers, middleware, dependencies ← models, service
worker/ broker consumer: solves plannings, notifies ← models, storage, service
seed/ idempotent development data ← models, storage, service
frontend/ React 19 + TypeScript + MUI, Vite
infra/ everything that runs it
compose/ the four compose files, and .env
chart/ the Helm chart: five workloads, KEDA, alert rules
argocd/ one Application per environment
bootstrap/ the cluster add-ons the chart deliberately does not own
observability/ collector, Prometheus, Loki, Tempo, Grafana — shared with compose
qa/ Robot Framework GUI campaign + coverage
docs/ the documentation set — start at docs/README.md
Makefile dev-stack shortcuts; passes both compose -f flags for you
The Makefile is the one infrastructure file not under infra/, on
purpose: it is the entry point, and an entry point nobody can find is not one.
It drives the dev overlay only — production keeps its explicit -f flags, where
being explicit is worth the length.
# Backend
cd backend
uv sync
uv run pytest # 1,866 test functions, hermetic
uv run ruff check . && uv run ruff format --check .
uv run ty check # type checking — see docs/10-testing.md
# Front-end
cd frontend
npm ci
npm run dev
npm run lint && npm run typecheck && npm run test
# GUI campaign — needs the stack up
pip install -r qa/requirements.txt && rfbrowser init
robot --outputdir qa/results qa/robot/suitesThe full set lives in docs/ — architecture, the domain
model, the quote workflow, the event pipeline, the planning computation,
configuration, deployment, testing and the house conventions.
Start with docs/README.md, which summarises each chapter and
says which one answers which question.
See LICENCE.