Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

104
CLAUDE.md
View File

@@ -32,6 +32,10 @@ copying a pattern across:
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
| `edr-hr-api` | `@edr/hr-api` | NestJS + **TypeORM** | 3005 |
| `edr-hr-web` | `@edr/hr-web` | React + **Vite** | 5185 |
| `finance-api` | `@edr/finance-api` | NestJS + **TypeORM** | 3004 |
| `finance-web` | `@edr/finance-web` | React + **Vite** | 5186 |
Those are the **fallbacks compiled into the code**, not what you will be running. Every
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
@@ -48,6 +52,58 @@ package and is not built, linted, or type-checked. Leave it alone unless asked.
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
`apps/edr-hr-api/` owns the `hr` schema and is the HR extension of IAM: employee
profiles hang off `iam.employees`, "departments" are `iam.units` with an HR
satellite (`hr.unit_hr_profiles`), and job positions extend `iam.positions`. HR
stores no copy of an employee's name, unit or organization — those are read
through and joined at query time.
IAM access is split in two, and the split is the design:
- **Reads** go through `IamDirectoryService` — raw SQL projections over `iam.*`,
no entities involved.
- **Writes** go through `IamOperationsService`, which resolves IAM's own
services out of the container (`ModuleRef.get(..., {strict: false})`) and
calls them. HR never writes an `iam.*` table itself, so IAM's validation,
transactions and audit trail all still apply.
That means hr-api **does** import `IamModule` (`IamModule.forRoot({...})`) and
**does** register `@tria-plc/iamapi-common` entities. It sidesteps the stale
`iamEntities` trap below by registering them as **globs over the package's
`dist/`** rather than a hand-maintained class list, with `autoLoadEntities: false`
— see `apps/edr-hr-api/src/config/database.config.ts`. A package bump cannot
leave that list stale, because there is no list.
One consequence worth knowing before touching lifecycle code: IAM's
`deactivateEmployee` ends every position the employee holds, and
`activateEmployee` does **not** put them back. HR surfaces this rather than
hiding it (`accessAlignment` on the profile response).
`apps/finance-api/` owns the `finance` schema — general ledger, chart of
accounts, receivables, payables, budgets and fixed assets. It follows hr-api's
shape exactly (embedded `IamModule.forRoot`, IAM entities registered as globs
over both package dists, `autoLoadEntities: false`, migrations in
`finance.migrations` run only by `migration:run`).
Two rules define it, and neither is negotiable:
- **Finance writes only `finance.*`.** Revenue, cash and payroll are projected
**read-only** out of `freight`, `passenger`, `edr_payment` and `hr` with
schema-qualified raw SQL, plus payment events off `PAYMENT_EVENTS_EXCHANGE`.
No source app writes `finance.*`, and Finance writes none of theirs.
- **Money is `numeric(14,2)` in major units.** The column NAMES lie upstream:
everything on the payment-intent path is called `*Minor` but holds MAJOR
amounts, while passenger's Prisma `Int` columns really are minor. Never infer
the unit from the name — `apps/finance-api/src/common/money.ts` is the
authority, and it also records which upstream rows are known bad.
Note both finance-api and hr-api serve `POST /api/v1/auth/login`: embedding
`IamModule` brings IAM's auth controller with it. finance-web therefore
authenticates against finance-api itself and needs no `x-client-app` header
(both verified against the running services). `edr-hr-web` points its login at
freight-api instead, on the assumption that an IAM-embedding app has no auth
routes — that assumption is wrong, and the coupling is unnecessary.
## Packages
| Package | Location | Purpose |
@@ -112,23 +168,44 @@ gate-pass scenarios). Read the script before running one; several write real row
## Environment & database
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
no port `5433`/`5434` is published anywhere in the repo.
- **One database, schema-separated.** Every app connects to the SAME Postgres database
and is isolated by schema, not by database — `iam`, `freight`, `passenger`,
`edr_payment`, `audit`. This mirrors production, where the Smart Office database holds
the `freight` schema alongside the rest (see `dump-smart_office_prod-*.sql`). There is
no longer a per-domain database; do not add one.
- Postgres is **external** to `docker-compose.yaml` (no service there). For a local one,
`infrastructure/docker/docker-compose.db.dev.yml` starts a single `postgres` on 5432
(`edr_database`) and creates every schema via `infrastructure/docker/initdb/`.
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
remote database.
`DB_NAME` (defaults: `localhost:5432`, `edr_database`). Development points these at a
remote database. `edr-gps-tracker` and `edr-payment-api` read the same DB_* convention;
`edr-passenger-api` uses `DATABASE_URL` (Prisma, `?schema=passenger`) plus
`DATABASE_*`/`DATABASE_SCHEMA=iam` for its read-only TypeORM IAM connection — all three
must resolve to this one database.
- The connection sits behind a **connection pooler**. Do **not** pass
`extra.options: '-c search_path=…'` — the pooler rejects it with
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
per-connection in a pool `connect` handler instead. See
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
through API calls or message queues.
- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`),
freight tables in `freight`.
- `psql` is not installed on the dev machine. To query the database, use the `edr-db`
skill (below) or write a short Node script using `pg` and run it from
`apps/edr-freight-api`, where `pg` resolves.
- Each app owns its own **schema**, and its own migration history in that schema:
`iam.typeorm_migrations` and `freight.migrations` (both applied by freight-api),
`passenger._prisma_migrations` (Prisma), and payment-api's own table in `edr_payment`.
An app never writes another app's schema.
- IAM tables live in the `iam` schema (`iam.users`, `iam.user_credentials`), freight
tables in `freight`. **`edr-freight-api` is the authoritative owner of the `iam`
schema** — it ships the `iam:migration:run|show|revert` scripts. Passenger's IAM
connection is read-only (`synchronize: false`, `migrationsRun: false`).
- **Cross-schema references stay soft.** Co-location makes hard FKs possible, but there
are zero FKs from `freight.*` into `iam.*` and that is deliberate: references are plain
UUID columns plus denormalized display fields, so IAM stays independently deployable and
the audit trail outlives a deleted user. Keep it that way in new modules.
- The e2e harnesses are the **one exception** and stay hermetic: `edr_freight_e2e` on 5533
(`e2e/freight/`) and the passenger test DB on 5544 (`e2e/docker-compose.yml`). Do not
point them at the shared database.
- `psql` **is** installed (Homebrew, v17.9 — verified 2026-08-19); the earlier claim that
it was not is out of date. Either use it directly against the local database, or use the
`edr-db` skill (below) / a short Node script using `pg` run from `apps/edr-freight-api`,
where `pg` resolves.
## Hard rules
@@ -340,6 +417,11 @@ hand; do not assume the hook caught it.
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
| Binding `payment.*` on the payment exchange | Silently receives NOTHING — wire keys are three words (`payment.passenger.succeeded`) and AMQP `*` matches exactly one | Bind `payment.<service>.*` for one service, or `payment.#` for all |
| Summing money across currencies | Passenger bookings are charged in ETB, DJF **and** USD; adding them overstates ETB revenue (6.3M on the dev replica) | Group by currency; convert only at an explicitly recorded rate |
| Reading a `DATE` column in raw SQL | node-postgres parses it to LOCAL midnight, so `toISOString().slice(0,10)` returns the PREVIOUS day east of UTC — a month-end lands in the wrong period | Cast in SQL: `period_end::text`. Never round-trip a DATE through a JS `Date` |
| A `CHECK` listing enum values wider than the column | `varchar(16)` accepted every status until the 17-character one was first used, then failed mid-operation | Size the column for the LONGEST permitted value |
| A service opening its own transaction inside a caller's | The inner write commits independently; a later failure leaves an orphaned posted row | Pass the caller's `EntityManager` through (see `JournalsService.createPosted`) |
## Project skills