mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 14:50:57 +00:00
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -170,7 +170,12 @@ jobs:
|
||||
- name: Build ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}"
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
|
||||
# Tag with git SHA for rollback capability
|
||||
CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1)
|
||||
docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Deploy ${{ matrix.service }}
|
||||
run: |
|
||||
|
||||
@@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
|
||||
|
||||
### Runtime
|
||||
|
||||
The final image runs:
|
||||
The final image uses Next.js `output: 'standalone'` and runs:
|
||||
|
||||
```bash
|
||||
npx next start
|
||||
node server.js
|
||||
```
|
||||
|
||||
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on.
|
||||
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`.
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-<service>:<sha8>`).
|
||||
|
||||
### Rollback a single service
|
||||
|
||||
```bash
|
||||
# 1. Find the last known-good image tag
|
||||
docker images | grep passenger-api
|
||||
|
||||
# 2. Re-tag it as the current image
|
||||
docker tag edr-passenger-main-passenger-api:<previous-sha> edr-passenger-main-passenger-api:latest
|
||||
|
||||
# 3. Restart the container from the previous image
|
||||
docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate
|
||||
```
|
||||
|
||||
### Rollback via re-run
|
||||
|
||||
Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit.
|
||||
|
||||
## Production Security Checklist
|
||||
|
||||
Before deploying to production, verify:
|
||||
|
||||
- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`)
|
||||
- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10`
|
||||
- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production)
|
||||
- [ ] `NODE_ENV=production` is set
|
||||
- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token
|
||||
- [ ] No `.env` files are committed to the repository (`git status` should show none)
|
||||
|
||||
## Data Retention Policy
|
||||
|
||||
The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes:
|
||||
|
||||
| Table | Retention |
|
||||
|---|---|
|
||||
| `OtpCode` | 1 hour after expiry or verification |
|
||||
| `FaydaVerificationSession` | 1 hour after expiry or completion |
|
||||
| `AuditLog` | 365 days |
|
||||
| `PaymentWebhookEvent` | 90 days |
|
||||
| `GateValidationLog` | 180 days |
|
||||
|
||||
No manual intervention is required. Monitor the `TasksService` log output for purge counts.
|
||||
|
||||
## GitHub Actions Deployment Flow
|
||||
|
||||
@@ -147,9 +193,10 @@ For each service:
|
||||
- Computes branch slug and sets:
|
||||
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
|
||||
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
|
||||
- Runs:
|
||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
|
||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
|
||||
- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image.
|
||||
- Builds the service image and tags it with the short git SHA.
|
||||
- Runs `docker compose up -d <service> --force-recreate`.
|
||||
- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy.
|
||||
- Cleans `.npmrc`/`.npmrc_temp`.
|
||||
|
||||
## Branch/Environment Isolation
|
||||
|
||||
@@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed
|
||||
- 3 User accounts (Admin, Passenger, Agent)
|
||||
- Fare rules for ADULT and CHILD passenger categories
|
||||
- Currency exchange rates (ETB, DJF, USD)
|
||||
- Baggage allowance rules
|
||||
- Luggage allowance rules
|
||||
- Notification templates
|
||||
- Promotions and FAQ content
|
||||
- Menu items and station crowd signals
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copy to .env for local/docker compose (not committed).
|
||||
PORT=3001
|
||||
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
|
||||
GT06_TCP_PORT=5023
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5433
|
||||
DB_USER=postgres
|
||||
@@ -49,14 +51,40 @@ MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
# Preset region so signed URLs are generated locally (no GetBucketLocation
|
||||
# network call per sign). MinIO's default is us-east-1.
|
||||
MINIO_REGION=us-east-1
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# --- Notification broker (RabbitMQ) ---------------------------------------------
|
||||
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service).
|
||||
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker).
|
||||
# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared
|
||||
# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely
|
||||
# (dev without a local broker).
|
||||
RABBITMQ_ENABLED=false
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
SMS_QUEUE=sms_queue
|
||||
|
||||
# ── VeriFayda 2.0 (eSignet OIDC) identity verification ──────────────────────
|
||||
# Disabled by default; /fayda/verification/start returns 503 until enabled.
|
||||
FAYDA_ENABLED=false
|
||||
FAYDA_CLIENT_ID=
|
||||
FAYDA_AUTHORIZATION_ENDPOINT=
|
||||
FAYDA_TOKEN_ENDPOINT=
|
||||
FAYDA_USERINFO_ENDPOINT=
|
||||
# Base64-encoded RSA private JWK used for the private_key_jwt client assertion
|
||||
FAYDA_PRIVATE_KEY_BASE64=
|
||||
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
|
||||
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
||||
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
||||
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
||||
FAYDA_SCOPE=openid profile email phone address
|
||||
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||
FAYDA_CLAIMS_LOCALES=en am
|
||||
FAYDA_SESSION_TTL_MINUTES=10
|
||||
EXPIRATION_TIME=15
|
||||
ALGORITHM=RS256
|
||||
EMAIL_QUEUE=email_queue
|
||||
|
||||
@@ -40,4 +40,6 @@ RUN addgroup --system --gid 1001 nodejs \
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
USER nestjs
|
||||
EXPOSE 3001
|
||||
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
|
||||
EXPOSE 5023
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
604
apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md
Normal file
604
apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md
Normal file
@@ -0,0 +1,604 @@
|
||||
# EDR Freight — Major Flow Variants (each self-contained)
|
||||
|
||||
The single master graph lives in [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md). This file breaks the
|
||||
business logic into **one comprehensive, self-contained diagram per major scenario**, each organised with
|
||||
phase **subgraphs** so it can be read on its own.
|
||||
|
||||
**Axes covered**
|
||||
|
||||
| Axis | Values |
|
||||
| --------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Origin | **One-time booking** · **General contract** (Path A transport-only / Path B GENERAL+customs) |
|
||||
| Trade direction | **Export** · **Import** · **Intercity / Domestic** |
|
||||
| Customs | **With customs** · **Without customs** |
|
||||
|
||||
**Legend** — (P) Portal (customer) · (B) Backoffice (staff) · (sys) System/event · (green) rounded = success end · (red) rounded = fail end · <> decision.
|
||||
|
||||
**Which diagram do I read?**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef box fill:#e0e7ff,stroke:#3730a3,color:#111
|
||||
A{"Origin?"}:::dec
|
||||
A -->|"one-time"| B{"Trade direction?<br/>(gate is direction-driven, NOT a customs toggle)"}:::dec
|
||||
A -->|"framework agreement"| C{"Contract type?"}:::dec
|
||||
B -->|"DOMESTIC"| D1["§1 One-time · DOMESTIC (no gate)"]:::box
|
||||
B -->|"IMPORT / EXPORT<br/>(with or without customs)"| D2["§2 One-time · IMPORT/EXPORT (gate)"]:::box
|
||||
C -->|"transport-only (self-clearance)"| D3["§3 Contract · Path A"]:::box
|
||||
C -->|"GENERAL + customs"| D4["§4 Contract · Path B"]:::box
|
||||
D1 --> E{"Physical direction?"}:::dec
|
||||
D2 --> E
|
||||
D3 --> E
|
||||
D4 --> E
|
||||
E -->|"export"| F5["§5 EXPORT operations"]:::box
|
||||
E -->|"import"| F6["§6 IMPORT operations"]:::box
|
||||
E -->|"domestic"| F7["§7 INTERCITY operations"]:::box
|
||||
```
|
||||
|
||||
> **How the two halves connect:** §1–§4 are the **commercial** journeys (intake → approval → contract →
|
||||
> clearance → operation → payment). §5–§7 are the **physical** journeys (mile legs → warehouse → train →
|
||||
> delivery). A shipment = one commercial variant **+** one physical variant. Each diagram fully details its
|
||||
> own half and summarises the other so it stands alone.
|
||||
|
||||
---
|
||||
|
||||
## §1 — One-time booking · DOMESTIC (no clearance gate)
|
||||
|
||||
The commercial lifecycle when the counter-sign gate resolves to **no clearance** — which, in code, means
|
||||
**trade direction = DOMESTIC** (not a customs toggle). Counter-sign goes straight to `FULLY_EXECUTED` and the
|
||||
booking is enqueued **directly into the scheduling batch pipeline, skipping the operation-request/clearance
|
||||
phase**. NOTE: Import/export bookings — _even with customs off_ — do **not** land here; they always hit the
|
||||
clearance gate (§2, just with a lighter "without customs" document set).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Company ACTIVE (approved profile)"]):::sys
|
||||
|
||||
subgraph DRAFT["1 · Create & price"]
|
||||
direction TB
|
||||
d1["POST /bookings → DRAFT<br/>reference, containers, cargo modifiers, files (P)"]:::port
|
||||
d2["POST /bookings/:id/generate-price<br/>rule-engine: LIVE rates + surcharges (P)"]:::port
|
||||
dW{"weight-limit-rules"}:::dec
|
||||
dWx(["HARD BLOCK 400 — VGM > capacity"]):::bad
|
||||
d3["POST /bookings/:id/submit → SUBMITTED<br/>freeze booking_rate_snapshot (P)"]:::port
|
||||
dP{"price moved?"}:::dec
|
||||
d3c["confirm-submit → SUBMITTED (P)"]:::port
|
||||
d1 --> d2 --> dW
|
||||
dW -->|"over capacity"| dWx
|
||||
dW -->|"ok / warn+surcharge"| d3 --> dP
|
||||
dP -->|"yes"| d3c
|
||||
dP -->|"no"| out1
|
||||
d3c --> out1
|
||||
d1 -.->|"delete draft"| ddx(["removed"]):::bad
|
||||
d3 -.->|"reject price"| drx(["REJECTED"]):::bad
|
||||
end
|
||||
out1[" "]:::sys
|
||||
|
||||
subgraph INTAKE["2 · Staff intake & approval"]
|
||||
direction TB
|
||||
g{"government?"}:::dec
|
||||
gexp["governmentExpedite → PAID + Eligible (B)"]:::back
|
||||
i{"staff/accept | request-changes | reject (B)"}:::dec
|
||||
ir["CHANGES_REQUESTED (B)"]:::back
|
||||
irx(["REJECTED"]):::bad
|
||||
ia["→ PENDING_APPROVAL<br/>instantiate approval steps + validity window (B)"]:::back
|
||||
ac{"chain: LINE_STAFF → DIRECTOR → CEO (B)"}:::dec
|
||||
acx(["rejectStep → REJECTED"]):::bad
|
||||
g -->|"yes"| gexp
|
||||
g -->|"no"| i
|
||||
i -->|"request-changes"| ir
|
||||
i -->|"reject"| irx
|
||||
i -->|"accept"| ia --> ac
|
||||
ac -->|"rejectStep"| acx
|
||||
end
|
||||
|
||||
subgraph SIGN["3 · Contract doc & sign (DOMESTIC → no gate)"]
|
||||
direction TB
|
||||
s1["contract/generate → CONTRACT_READY (B)"]:::back
|
||||
s2["customer sign → SIGNED_CUSTOMER (P)"]:::port
|
||||
s3["staff counter-sign (DOMESTIC) → FULLY_EXECUTED<br/>enqueueScheduleProcessing (no op-request) (B)(sys)"]:::back
|
||||
s1 --> s2 --> s3
|
||||
end
|
||||
|
||||
subgraph OPPAY["4 · Batch pipeline & payment"]
|
||||
direction TB
|
||||
fe["FULLY_EXECUTED enters day batch pool (sys)"]:::sys
|
||||
b1["batch engine offers wagons → SELECTED_FOR_BATCH<br/>invoice generated (sys)"]:::sys
|
||||
p1["customer pays → gateway → PAID (P)"]:::port
|
||||
pexp(["pay window lapses → reservation EXPIRED"]):::bad
|
||||
fe --> b1 --> p1
|
||||
p1 -.->|"unpaid"| pexp
|
||||
end
|
||||
|
||||
phys(["Physical execution:<br/>§7 intercity → COMPLETED (done)"]):::good
|
||||
|
||||
pre --> DRAFT
|
||||
out1 --> INTAKE
|
||||
ac -->|"APPROVED"| SIGN
|
||||
SIGN --> OPPAY
|
||||
p1 --> phys
|
||||
gexp -.->|"gov → PAID/Eligible"| phys
|
||||
```
|
||||
|
||||
> **Road-mode note:** a domestic booking billed by road (truck) instead of rail goes through
|
||||
> `operation/review` → `ROAD_DISPATCH_PENDING` (the road branch shown in the master graph), not the rail
|
||||
> batch pool above.
|
||||
|
||||
---
|
||||
|
||||
## §2 — One-time booking · IMPORT / EXPORT (clearance gate)
|
||||
|
||||
Every IMPORT/EXPORT one-time booking traverses the clearance gate — **whether or not customs is enabled**
|
||||
(the customs flag only selects a heavier vs lighter `clearance_*` document set; both go through
|
||||
`AWAITING_DOCUMENTS`). Commercial spine as §1 (phases 1–3) **plus** the gate: counter-sign → `AWAITING_DOCUMENTS`
|
||||
→ document review loop → `CLEARANCE_READY`, phased ET/DJ actions, then operation-request → payment.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
a0(["Booking APPROVED & signed by customer<br/>(see §1 phases 1–3)"]):::sys
|
||||
|
||||
subgraph CS["Counter-sign with customs"]
|
||||
direction TB
|
||||
cs1["staff counter-sign (IMPORT/EXPORT) → AWAITING_DOCUMENTS (B)"]:::back
|
||||
end
|
||||
|
||||
subgraph DOCS["5 · Document clearance gate"]
|
||||
direction TB
|
||||
x1["customer clearance/documents<br/>→ DOCUMENTS_UNDER_REVIEW (P)"]:::port
|
||||
x2{"GL clearance/review each doc (B)"}:::dec
|
||||
x2q["doc Queried → customer re-uploads (B)"]:::back
|
||||
x3["clearance/finalize (100% approved) → CLEARANCE_READY (B)"]:::back
|
||||
x1 --> x2
|
||||
x2 -->|"query"| x2q --> x1
|
||||
x2 -->|"approve all"| x3
|
||||
end
|
||||
|
||||
subgraph PHASED["6 · Phased ET / DJ clearance (as applicable)"]
|
||||
direction TB
|
||||
ph1["upload declaration (serial) (B)"]:::back
|
||||
ph2["duty/tax advise → customer duty-slip (P)(B)"]:::back
|
||||
ph3["transit permit (ET) (B)"]:::back
|
||||
ph4["delivery order / release order (DJ) (B)"]:::back
|
||||
ph5["T1 docs → T1 close (B)"]:::back
|
||||
ph6["export release / finalize-pre-clearance (B)"]:::back
|
||||
ph1 --> ph2 --> ph3 --> ph4 --> ph5 --> ph6
|
||||
end
|
||||
|
||||
subgraph OPPAY2["7 · Operation request & payment"]
|
||||
direction TB
|
||||
o1["clearance/proceed → OPERATION_REQUEST_PENDING (P)"]:::port
|
||||
o2{"operation/review (B)"}:::dec
|
||||
o2c["OPERATION_CHANGES_REQUESTED (B)"]:::back
|
||||
om{"mode?"}:::dec
|
||||
ot["accept=train: invoice → FULLY_EXECUTED<br/>→ batch offer → SELECTED_FOR_BATCH (B)(sys)"]:::back
|
||||
orr["accept=road: invoice → ROAD_DISPATCH_PENDING (B)"]:::back
|
||||
p1["customer pays → PAID (sys)(P)"]:::sys
|
||||
pexp(["pay window lapses → EXPIRED"]):::bad
|
||||
o1 --> o2
|
||||
o2 -->|"request-changes"| o2c --> o1
|
||||
o2 -->|"accept"| om
|
||||
om -->|"train"| ot --> p1
|
||||
om -->|"road"| orr --> p1
|
||||
p1 -.->|"unpaid"| pexp
|
||||
end
|
||||
|
||||
cancel(["CANCELLED — staff-only, only from<br/>OPERATION_REQUEST_PENDING here (not from<br/>AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW)"]):::bad
|
||||
phys(["Physical execution:<br/>§5 export · §6 import → COMPLETED (done)"]):::good
|
||||
|
||||
a0 --> CS --> DOCS
|
||||
x3 --> PHASED
|
||||
ph6 --> OPPAY2
|
||||
p1 --> phys
|
||||
o1 -.->|"cancel"| cancel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §3 — General contract · Path A (transport-only, self-clearance)
|
||||
|
||||
Framework agreement where the customer clears customs independently. After the contract is active and
|
||||
operations verify self-clearance (`SELF_CLEARED`), the **customer books directly** under the contract; each
|
||||
booking then runs the operation/payment/physical flow.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Company ACTIVE"]):::sys
|
||||
|
||||
subgraph CTR["1 · Contract lifecycle"]
|
||||
direction TB
|
||||
c1["POST /contracts → DRAFT<br/>routes + cargo scope + unit rates (NO quantities) (P)"]:::port
|
||||
c2["generate-price → submit → SUBMITTED<br/>freeze contract_rate_snapshots (P)"]:::port
|
||||
c3{"staff/accept | request-changes | reject (B)"}:::dec
|
||||
c3r["CHANGES_REQUESTED (B)"]:::back
|
||||
c3x(["Contract REJECTED"]):::bad
|
||||
c4["→ PENDING_APPROVAL (B)"]:::back
|
||||
c4a{"approval chain (B)"}:::dec
|
||||
c4x(["rejectStep → REJECTED"]):::bad
|
||||
c5["generate-contract → CONTRACT_READY (B)"]:::back
|
||||
c6["customer sign → SIGNED_CUSTOMER (P)"]:::port
|
||||
c7["staff counter-sign (IMPORT/EXPORT self-clearance) →<br/>AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back
|
||||
c1 --> c2 --> c3
|
||||
c3 -->|"request-changes"| c3r --> c2
|
||||
c3 -->|"reject"| c3x
|
||||
c3 -->|"accept"| c4 --> c4a
|
||||
c4a -->|"reject"| c4x
|
||||
c4a -->|"approve"| c5 --> c6 --> c7
|
||||
c7 -.->|"lapse"| cexp(["EXPIRED"]):::bad
|
||||
c7 -.->|"renew"| cren(["RENEWAL_DRAFT → new cycle"]):::bad
|
||||
end
|
||||
|
||||
subgraph SELF["2 · Self-clearance verification"]
|
||||
direction TB
|
||||
o1["customer uploads self-clearance docs (P)"]:::port
|
||||
o2{"ops-review each doc (B)"}:::dec
|
||||
o2q["query → re-upload (B)"]:::back
|
||||
o3["ops-finalize → clearanceStatus SELF_CLEARED (B)"]:::back
|
||||
o1 --> o2
|
||||
o2 -->|"query"| o2q --> o1
|
||||
o2 -->|"approve"| o3
|
||||
end
|
||||
|
||||
subgraph BK["3 · Book directly under contract"]
|
||||
direction TB
|
||||
b1["customer POST /contracts/:id/bookings (P)"]:::port
|
||||
bv{"validate-shipment:<br/>window + capacity draw-down + pairing"}:::dec
|
||||
bvx(["rejected: over capacity /<br/>20ft pairing hard-block"]):::bad
|
||||
b2["Booking created under contract<br/>(bookings.contract_id) (sys)"]:::sys
|
||||
b1 --> bv
|
||||
bv -->|"fail"| bvx
|
||||
bv -->|"ok"| b2
|
||||
end
|
||||
|
||||
op(["Booking runs operation + payment<br/>(see §1 phase 4) then §5/§6/§7 → COMPLETED (done)"]):::good
|
||||
|
||||
pre --> CTR
|
||||
c7 --> SELF
|
||||
o3 --> BK
|
||||
b2 --> op
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §4 — General contract · Path B (GENERAL + customs)
|
||||
|
||||
Framework agreement **with** customs. The customer cannot book directly — they submit a **BookingRequest**
|
||||
(date + quantities only); GL Ethiopia accepts it and creates the booking, which then runs **per-booking
|
||||
phased customs** on the `/contracts/bookings/:bookingId/*` surface.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Company ACTIVE"]):::sys
|
||||
|
||||
subgraph CTR["1 · Contract lifecycle (customs)"]
|
||||
direction TB
|
||||
c1["POST /contracts → DRAFT → submit → approve → sign (P)(B)"]:::port
|
||||
c7["staff counter-sign (GENERAL + customs) → CONTRACT_ACTIVE<br/>(contract clearance cycle SKIPPED — runs per-booking) (B)"]:::back
|
||||
c1 --> c7
|
||||
c1 -.->|"reject / lapse"| cx(["REJECTED / EXPIRED"]):::bad
|
||||
end
|
||||
|
||||
subgraph REQ["2 · Booking request → GL creates booking"]
|
||||
direction TB
|
||||
r1["customer POST /contracts/:id/booking-requests<br/>(date + quantities, no per-unit data) (P)"]:::port
|
||||
r2{"GL booking-request queue (B)"}:::dec
|
||||
r2x(["reject / customer cancel →<br/>REJECTED / CANCELLED"]):::bad
|
||||
r3["GL accept → GL creates booking under contract<br/>(ct:create_booking) (B)"]:::back
|
||||
r1 --> r2
|
||||
r2 -->|"reject/cancel"| r2x
|
||||
r2 -->|"accept"| r3
|
||||
end
|
||||
|
||||
subgraph GLC["3 · Per-booking GL clearance & milestones"]
|
||||
direction TB
|
||||
g1["station-assign (route + bind staff) (B)"]:::back
|
||||
g2["declaration → duty advise (GREEN/YELLOW/RED risk) (B)"]:::back
|
||||
g3["customer duty-slip → transit / delivery / release order (P)(B)"]:::back
|
||||
g4["T1 docs → T1 close (B)"]:::back
|
||||
g5["final-invoice → customer slip → confirm paid (P)(B)"]:::back
|
||||
g6["second-duty (post-arrival import) → slip (P)(B)"]:::back
|
||||
gi["incident reports (photos) as needed (B)"]:::back
|
||||
g1 --> g2 --> g3 --> g4 --> g5 --> g6
|
||||
g4 -.-> gi
|
||||
end
|
||||
|
||||
op(["Booking runs operation + payment (see §1 phase 4)<br/>then §5/§6 physical → COMPLETED (done)"]):::good
|
||||
|
||||
pre --> CTR
|
||||
c7 --> REQ
|
||||
r3 --> GLC
|
||||
g6 --> op
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §5 — EXPORT operations (physical execution)
|
||||
|
||||
Given a PAID, scheduled **export** booking: optional first-mile road leg → origin warehouse inbound → train
|
||||
build & dispatch → corridor transit → Djibouti port unload → interchange handover. Cargo leaves the country
|
||||
at the port; the booking's terminal here is **dispatched/handed-over at Djibouti**.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Export booking PAID & Eligible<br/>(see §1/§2/§3/§4)"]):::sys
|
||||
|
||||
subgraph FM["1 · First-mile (if EXPORT + requested)"]
|
||||
direction TB
|
||||
fmq{"first-mile requested?"}:::dec
|
||||
fm1["leg auto-created firstMile.acceptBooking<br/>READY_TO_TRANSIT (sys)"]:::sys
|
||||
fm2["setVehicles → vehicle BUSY, SMS driver, fleet_events (B)"]:::back
|
||||
fm3["IN_TRANSIT (needs vehicle) → RECEIVED_TO_PORT (B)"]:::back
|
||||
fm4["first-mile invoice (FIRST_MILE fee); distances lock once invoiced (B)"]:::back
|
||||
fmq -->|"yes"| fm1 --> fm2 --> fm3 --> fm4
|
||||
fmq -->|"no"| fmskip[" "]:::sys
|
||||
end
|
||||
|
||||
subgraph WH["2 · Origin warehouse inbound"]
|
||||
direction TB
|
||||
w1["receive / bulkReceive → RECEIVED<br/>capacity assert, GRN, notify owner SMS (B)"]:::back
|
||||
w2{"inspection"}:::dec
|
||||
w2f["FAILED / NEEDS_REVIEW → hold + re-inspect (B)"]:::back
|
||||
w3["store (allocation rule → yard/zone) → STORED (B)"]:::back
|
||||
w4["reserve (booking PAID) → RESERVED (B)"]:::back
|
||||
w5["mark-ready-for-loading (inspection PASSED) → READY_FOR_LOADING (B)"]:::back
|
||||
w6["load onto wagon → LOADED (+ warehouse_loadings) (B)"]:::back
|
||||
w1 --> w2
|
||||
w2 -->|"fail"| w2f --> w2
|
||||
w2 -->|"PASSED"| w3 --> w4 --> w5 --> w6
|
||||
end
|
||||
|
||||
subgraph SCHED["3 · Train build & schedule"]
|
||||
direction TB
|
||||
s1["schedule DRAFT (≥2 locos, derive EXPORT direction) (B)"]:::back
|
||||
s2["assign-bookings + run-allocation (wagons) (B)"]:::back
|
||||
s3["pin wagons → finalize → SCHEDULED (bookings Scheduled) (B)"]:::back
|
||||
s3x["cancel schedule → bookings Eligible (B)"]:::back
|
||||
s1 --> s2 --> s3
|
||||
s3 -.->|"cancel"| s3x -.-> s1
|
||||
s3 -.->|"gov preempt / maintenance"| sr["reschedule: retained/displaced/readmitted (B)"]:::back
|
||||
sr -.-> s2
|
||||
end
|
||||
|
||||
subgraph RUN["4 · Dispatch → Djibouti"]
|
||||
direction TB
|
||||
r1["dispatch → DISPATCHED<br/>train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED (B)"]:::back
|
||||
r2["checkpoints (corridor) → train_checkpoint_events (B)"]:::back
|
||||
rc["customer tracking page GET /tracking/:id (JWT) (P)<br/>NOTE: tracking_events has no writer — timeline empty"]:::port
|
||||
r3["arrive → ARRIVED (bookings IN_TRANSIT, wagons/locos freed) (B)"]:::back
|
||||
ru["export/auto-unload-at-djibouti →<br/>UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back
|
||||
ri["interchange document generate-from-schedule → GENERATED (B)"]:::back
|
||||
ria{"port acknowledges?"}:::dec
|
||||
r1 --> r2 --> r3 --> ru --> ri --> ria
|
||||
r2 -.-> rc
|
||||
end
|
||||
|
||||
done(["Export dispatched & handed over at Djibouti (done)"]):::good
|
||||
disp(["interchange DISPUTED → remarks / re-issue"]):::bad
|
||||
|
||||
pre --> FM
|
||||
fm4 --> WH
|
||||
fmskip --> WH
|
||||
w6 --> SCHED
|
||||
s3 --> RUN
|
||||
ria -->|"acknowledge"| done
|
||||
ria -->|"dispute"| disp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §6 — IMPORT operations (physical execution)
|
||||
|
||||
Given a PAID, scheduled **import** booking arriving by train from Djibouti: destination warehouse unload →
|
||||
inspection → import customs finalization → optional last-mile → fee gate-clearance → release → delivery →
|
||||
**COMPLETED**.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Import booking PAID & scheduled<br/>(see §1/§2/§4)"]):::sys
|
||||
|
||||
subgraph RAIL["1 · Import train arrival"]
|
||||
direction TB
|
||||
t1["import train dispatched from Djibouti → DISPATCHED (B)"]:::back
|
||||
t2["checkpoints → train_checkpoint_events (B)"]:::back
|
||||
tc["customer tracking page GET /tracking/:id (JWT) (P)<br/>NOTE: tracking_events has no writer — timeline empty"]:::port
|
||||
t3["arrive → ARRIVED (bookings IN_TRANSIT) → warehouse arrival automation (B)"]:::back
|
||||
t1 --> t2 --> t3
|
||||
t2 -.-> tc
|
||||
end
|
||||
|
||||
subgraph WH["2 · Destination warehouse"]
|
||||
direction TB
|
||||
w1["import/auto-unload-arrived-bookings (assign WH/yard/zone) → UNLOADED (B)"]:::back
|
||||
w2{"inspection PASSED?"}:::dec
|
||||
w2f["FAILED → hold / re-inspect / djibouti-incident report (B)"]:::back
|
||||
w3["→ READY_FOR_PICKUP (IMPORT) (B)"]:::back
|
||||
w1 --> w2
|
||||
w2 -->|"no"| w2f --> w2
|
||||
w2 -->|"yes"| w3
|
||||
end
|
||||
|
||||
subgraph CUST["3 · Import customs finalization (timestamp-driven)"]
|
||||
direction TB
|
||||
u1["upload docs (IM4/IM5/T1_CLOSURE/TRANSIT_PERMIT/…) (B)"]:::back
|
||||
u2["record declaration serial (B)"]:::back
|
||||
u3["notify duties/taxes (B)"]:::back
|
||||
u4["mark duties paid (needs CUSTOMER_PAYMENT_SLIP) (B)"]:::back
|
||||
u5["assign risk (GREEN/YELLOW/BLUE/RED) (B)"]:::back
|
||||
u6{"release gates satisfied?<br/>T1 + release permit + declaration + risk + paid"}:::dec
|
||||
u6x["blocked — missing gate → resolve (B)"]:::back
|
||||
u7["release-permitted → completedAt (B)"]:::back
|
||||
u1 --> u2 --> u3 --> u4 --> u5 --> u6
|
||||
u6 -->|"no"| u6x --> u6
|
||||
u6 -->|"yes"| u7
|
||||
end
|
||||
|
||||
subgraph LM["4 · Last-mile (if requested)"]
|
||||
direction TB
|
||||
lq{"last-mile requested?"}:::dec
|
||||
l1["leg auto-created lastMile.acceptBooking<br/>READY_TO_TRANSIT (sys)"]:::sys
|
||||
l2["setVehicles → IN_TRANSIT → DELIVERED (free vehicles) (B)"]:::back
|
||||
l3["last-mile invoice (LAST_MILE fee) (B)"]:::back
|
||||
lq -->|"yes"| l1 --> l2 --> l3
|
||||
lq -->|"no"| lskip[" "]:::sys
|
||||
end
|
||||
|
||||
subgraph DEL["5 · Release & delivery"]
|
||||
direction TB
|
||||
d0{"warehouse/storage fees fully PAID?"}:::dec
|
||||
d0x["gate-clearance BLOCKED (findBlockingInvoice) (B)"]:::back
|
||||
d0p["customer pays storage/demurrage online (P)"]:::port
|
||||
d1["release order (DO) + gate-clearance → deliver (B)"]:::back
|
||||
d2["customer approve-delivery (saved signature) → POD (P)"]:::port
|
||||
d3["inventory DELIVERED, POD to cargo, container freed (sys)"]:::sys
|
||||
d0 -->|"no"| d0x --> d0p --> d0
|
||||
d0 -->|"yes"| d1 --> d2 --> d3
|
||||
end
|
||||
|
||||
ecr(["empty-container-return chain (post-import):<br/>RETURNED → … → HANDOVER_ISSUED → COMPLETED"]):::sys
|
||||
done(["Booking COMPLETED (done) (operations/complete)"]):::good
|
||||
|
||||
pre --> RAIL
|
||||
t3 --> WH
|
||||
w3 --> CUST
|
||||
u7 --> LM
|
||||
l3 --> DEL
|
||||
lskip --> DEL
|
||||
d3 --> done
|
||||
done -.-> ecr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §7 — INTERCITY / DOMESTIC operations (no cross-border customs)
|
||||
|
||||
Rail movement **between Ethiopian yards** (e.g. inland dry ports). No import/export customs, no Djibouti
|
||||
port unload or interchange handover. Optional road mile legs if the service includes door delivery.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Domestic booking PAID & Eligible<br/>(see §1 — customs OFF)"]):::sys
|
||||
|
||||
subgraph FMD["1 · Optional origin pickup (road)"]
|
||||
direction TB
|
||||
fq{"door pickup / first-mile requested?"}:::dec
|
||||
f1["first-mile leg → vehicle assign → RECEIVED_TO_PORT (B)"]:::back
|
||||
fq -->|"yes"| f1 --> fnext[" "]:::sys
|
||||
fq -->|"no (drop at origin yard)"| fnext
|
||||
end
|
||||
|
||||
subgraph WHO["2 · Origin warehouse"]
|
||||
direction TB
|
||||
w1["receive → RECEIVED (B)"]:::back
|
||||
w2{"inspection PASSED?"}:::dec
|
||||
w2f["hold + re-inspect (B)"]:::back
|
||||
w3["store → STORED → reserve (PAID) → RESERVED (B)"]:::back
|
||||
w4["ready-for-loading → LOADED onto wagon (B)"]:::back
|
||||
w1 --> w2
|
||||
w2 -->|"no"| w2f --> w2
|
||||
w2 -->|"yes"| w3 --> w4
|
||||
end
|
||||
|
||||
subgraph RUN["3 · Train (ET yard → ET yard)"]
|
||||
direction TB
|
||||
s1["schedule DRAFT (direction DOMESTIC) → assign → finalize → SCHEDULED (B)"]:::back
|
||||
r1["dispatch → DISPATCHED (unpaid EXPIRED) (B)"]:::back
|
||||
r2["checkpoints → train_checkpoint_events; customer tracking (JWT) (P)(B)"]:::back
|
||||
r3["arrive → ARRIVED (bookings IN_TRANSIT) (B)"]:::back
|
||||
s1 --> r1 --> r2 --> r3
|
||||
end
|
||||
|
||||
subgraph WHD["4 · Destination warehouse & delivery"]
|
||||
direction TB
|
||||
d1["auto-unload arrived → UNLOADED / RECEIVED (B)"]:::back
|
||||
di{"inspection PASSED?"}:::dec
|
||||
dif["hold + re-inspect (B)"]:::back
|
||||
d2["READY_FOR_PICKUP (B)"]:::back
|
||||
fee{"storage fees paid?"}:::dec
|
||||
feex["gate-clearance blocked → customer pays (P)"]:::port
|
||||
d3["release order → deliver (B)"]:::back
|
||||
lq{"door delivery / last-mile?"}:::dec
|
||||
l1["last-mile leg → DELIVERED (B)"]:::back
|
||||
d4["customer approve-delivery → POD → inventory DELIVERED (P)(sys)"]:::sys
|
||||
d1 --> di
|
||||
di -->|"no"| dif --> di
|
||||
di -->|"yes"| d2 --> fee
|
||||
fee -->|"no"| feex --> fee
|
||||
fee -->|"yes"| d3 --> lq
|
||||
lq -->|"yes"| l1 --> d4
|
||||
lq -->|"no (pickup at yard)"| d4
|
||||
end
|
||||
|
||||
done(["Booking COMPLETED (done)"]):::good
|
||||
|
||||
pre --> FMD
|
||||
fnext --> WHO
|
||||
w4 --> RUN
|
||||
r3 --> WHD
|
||||
d4 --> done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-reference
|
||||
|
||||
| Variant | Distinctive gate(s) | Terminal ends unique to it |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| §1 One-time · DOMESTIC | counter-sign → `FULLY_EXECUTED` (skips clearance **and** op-request; enters batch directly) | HARD BLOCK, price/intake/approval REJECTED, EXPIRED |
|
||||
| §2 One-time · IMPORT/EXPORT | `AWAITING_DOCUMENTS` → review loop → phased ET/DJ → op-request (gate applies with **or without** customs) | + doc-query loop, CANCELLED (only from OPERATION_REQUEST_PENDING) |
|
||||
| §3 Contract · Path A | `SELF_CLEARED` via ops-review; customer books direct | contract REJECTED/EXPIRED/RENEWAL, capacity/pairing block |
|
||||
| §4 Contract · Path B | BookingRequest → GL creates booking; per-booking milestones | BookingRequest REJECTED/CANCELLED |
|
||||
| §5 Export ops | first-mile → Djibouti unload → interchange handover | dispatched@Djibouti (success), interchange DISPUTED |
|
||||
| §6 Import ops | import customs finalization gates → last-mile → gate-clearance | COMPLETED (+ empty-container-return chain) |
|
||||
| §7 Intercity ops | ET→ET rail, no cross-border customs, optional mile legs | COMPLETED |
|
||||
|
||||
Full endpoint tables & per-domain state machines: [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md).
|
||||
Single all-in-one branching graph: [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md).
|
||||
260
apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md
Normal file
260
apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# EDR Freight — One Master Flow (signup → every end)
|
||||
|
||||
Single comprehensive graph of the entire freight business logic: from customer signup through every
|
||||
branch to every terminal state. Actor-coloured, endpoint-labelled.
|
||||
|
||||
**Legend**
|
||||
(P) Portal (customer) · (B) Backoffice (staff) · (sys) System/auto (event, cron, service-to-service)
|
||||
Rounded green = success end · Red = failure/terminal end · Diamond = decision · Hexagon = domain event.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef start fill:#e0e7ff,stroke:#3730a3,color:#111
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
%% ================= PHASE 1: IDENTITY & ONBOARDING =================
|
||||
S0(["Customer visits portal"]):::start
|
||||
S0 --> S1["Signup via IAM<br/>GET /auth/check-availability @Public<br/>POST /otp/send + /otp/verify (P)"]:::port
|
||||
S1 --> S2{"Identity proofing<br/>(VeriFayda)?"}:::dec
|
||||
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
|
||||
S2 -->|"No"| S4
|
||||
S3 --> S4["POST /companies/onboarding/start<br/>draft company (placeholder TIN, PENDING) (P)"]:::port
|
||||
S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,<br/>upload license + docs<br/>GET /onboarding/requirements (P)"]:::port
|
||||
S4b --> S5["POST /companies/onboarding/complete<br/>re-validate → company+profiles = PENDING (P)"]:::port
|
||||
S5 --> S6{"Backoffice reviews profile<br/>PATCH /company-profiles/:id/status (B)"}:::dec
|
||||
S6 -->|"Reject / suspend"| S6x(["SUSPENDED / BLACKLISTED<br/>cannot transact"]):::bad
|
||||
S6 -->|"Approve"| S7["Mint reference (EX-#####),<br/>company → ACTIVE (B)"]:::back
|
||||
S7 --> S8{"Start a shipment?"}:::dec
|
||||
S8 -->|"idle"| S8x(["No booking (dormant account)"]):::bad
|
||||
S8 -->|"Yes"| MODE
|
||||
|
||||
%% ================= PHASE 2: COMMERCIAL ORIGIN =================
|
||||
MODE{"Booking origin?"}:::dec
|
||||
MODE -->|"One-time shipment"| B0
|
||||
MODE -->|"Framework agreement"| C1
|
||||
|
||||
%% ---- Contract track ----
|
||||
C1["Create CONTRACT DRAFT<br/>POST /contracts (routes + cargo scope) (P)"]:::port
|
||||
C1 --> C1d{"Abandon draft?"}:::dec
|
||||
C1d -->|"delete"| C1x(["Contract removed (soft-delete)"]):::bad
|
||||
C1d -->|"continue"| C2["generate-price → submit → SUBMITTED<br/>freeze contract_rate_snapshots (P)"]:::port
|
||||
C2 --> C2p{"Price changed?"}:::dec
|
||||
C2p -->|"Yes"| C2c["confirm-submit → SUBMITTED (P)"]:::port
|
||||
C2p -->|"No"| C3
|
||||
C2c --> C3
|
||||
C3{"Staff intake<br/>POST /contracts/:id/staff/* (B)"}:::dec
|
||||
C3 -->|"request-changes"| C3r["CHANGES_REQUESTED (B)"]:::back
|
||||
C3r -->|"edit + resubmit"| C2
|
||||
C3 -->|"reject"| C3x(["Contract REJECTED"]):::bad
|
||||
C3 -->|"accept"| C4["→ PENDING_APPROVAL<br/>instantiate approval steps (B)"]:::back
|
||||
C4 --> C4a{"Approval chain<br/>line → director → ceo (B)"}:::dec
|
||||
C4a -->|"rejectStep"| C4x(["Contract REJECTED"]):::bad
|
||||
C4a -->|"all approve"| C5["generate-contract → CONTRACT_READY (B)"]:::back
|
||||
C5 --> C6["Customer sign → SIGNED_CUSTOMER<br/>POST /contracts/:id/contract/sign (P)"]:::port
|
||||
C6 --> C7{"Staff counter-sign branch"}:::dec
|
||||
C7 -->|"customs on"| C7a["AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back
|
||||
C7 -->|"GENERAL"| C7b["CONTRACT_ACTIVE (B)"]:::back
|
||||
C7 -->|"ONE_TIME"| C7c["FULLY_EXECUTED (B)"]:::back
|
||||
C7a --> CPATH
|
||||
C7b --> CPATH
|
||||
C7c --> CPATH
|
||||
C7b -.->|"renew"| C7renew(["RENEWAL_DRAFT → new cycle"]):::bad
|
||||
C7b -.->|"lapse"| C7exp(["Contract EXPIRED"]):::bad
|
||||
|
||||
CPATH{"How are shipments booked<br/>under the contract?"}:::dec
|
||||
CPATH -->|"Path A: transport-only"| CPA["Ops review self-clearance<br/>ops-review → ops-finalize → SELF_CLEARED (B)<br/>then customer books direct<br/>POST /contracts/:id/bookings (P)"]:::port
|
||||
CPATH -->|"Path B: GENERAL + customs"| CPB["Customer submits BookingRequest<br/>POST /contracts/:id/booking-requests (P)"]:::port
|
||||
CPB --> CPBq{"GL queue decision (B)"}:::dec
|
||||
CPBq -->|"reject / customer cancels"| CPBx(["BookingRequest REJECTED / CANCELLED"]):::bad
|
||||
CPBq -->|"accept → GL creates booking"| B0u
|
||||
CPA --> B0u["Booking created UNDER contract<br/>(window + capacity draw-down check) (sys)"]:::sys
|
||||
B0u --> BFLOW
|
||||
|
||||
%% ---- One-time booking ----
|
||||
B0["Create BOOKING DRAFT<br/>POST /bookings (reference, containers,<br/>cargo modifiers, files) (P)"]:::port
|
||||
B0 --> B0d{"Abandon draft?"}:::dec
|
||||
B0d -->|"delete"| B0x(["Booking removed (soft-delete)"]):::bad
|
||||
B0d -->|"continue"| BFLOW
|
||||
B0 -.->|"consolidation"| BCONS(["PENDING_CONSOLIDATION<br/>waits for partner shipment<br/>(shares a wagon) → rejoins"]):::sys
|
||||
BCONS -.-> BFLOW
|
||||
|
||||
%% ================= PHASE 3: PRICING & SUBMIT =================
|
||||
BFLOW["Configure shipment<br/>freight type + trade direction"]:::sys
|
||||
BFLOW --> FT{"Freight type?"}:::dec
|
||||
FT -->|"CONTAINER"| DIR
|
||||
FT -->|"BULK"| DIR
|
||||
DIR{"Trade direction?"}:::dec
|
||||
DIR -->|"EXPORT"| B1
|
||||
DIR -->|"IMPORT"| B1
|
||||
DIR -->|"DOMESTIC"| B1
|
||||
B1["POST /bookings/:id/generate-price<br/>rule-engine: LIVE rates + surcharges<br/>HAZARDOUS / REEFER / OVERWEIGHT /<br/>SHIPPING_LINE / CONSOLIDATION (P)"]:::port
|
||||
B1 --> B1w{"weight-limit-rules check"}:::dec
|
||||
B1w -->|"VGM > maxCapacity"| B1x(["HARD BLOCK (400)<br/>cannot submit"]):::bad
|
||||
B1w -->|"over maxVgm, within cap"| B1warn["warning + OVERWEIGHT surcharge"]:::sys
|
||||
B1w -->|"ok"| B2
|
||||
B1warn --> B2
|
||||
B2["POST /bookings/:id/submit → SUBMITTED<br/>create booking_rate_snapshot (P)"]:::port
|
||||
B2 --> B2p{"Price moved since draft?"}:::dec
|
||||
B2p -->|"Yes → PRICE_CHANGED_PENDING_CONFIRM"| B2c["confirm-submit → SUBMITTED (P)"]:::port
|
||||
B2p -->|"No"| GOV
|
||||
B2c --> GOV
|
||||
B2 -.->|"customer rejects price"| B2x(["Booking REJECTED"]):::bad
|
||||
|
||||
%% ================= PHASE 4: INTAKE & APPROVAL =================
|
||||
GOV{"Government booking?"}:::dec
|
||||
GOV -->|"Yes"| GEXP["governmentExpedite →<br/>PAID + schedulingStatus Eligible (B)"]:::back
|
||||
GOV -->|"No (commercial)"| BI{"Staff intake<br/>POST /bookings/:id/staff/* (B)"}:::dec
|
||||
BI -->|"request-changes"| BIr["CHANGES_REQUESTED (B)"]:::back
|
||||
BIr -->|"edit + resubmit"| B2
|
||||
BI -->|"reject"| BIx(["Booking REJECTED"]):::bad
|
||||
BI -->|"accept"| BA["→ PENDING_APPROVAL<br/>instantiate approval steps<br/>(set validity window) (B)"]:::back
|
||||
BA --> BAc{"Approval chain<br/>LINE_STAFF → DIRECTOR → CEO (B)"}:::dec
|
||||
BAc -->|"rejectStep"| BAx(["Booking REJECTED"]):::bad
|
||||
BAc -->|"all approve → APPROVED"| BC1
|
||||
|
||||
%% ================= PHASE 5: CONTRACT DOC & SIGN =================
|
||||
BC1["contract/generate → CONTRACT_READY (B)"]:::back
|
||||
BC1 --> BC2["Customer sign → SIGNED_CUSTOMER<br/>POST /bookings/:id/contract/sign (P)"]:::port
|
||||
BC2 --> BC3{"Staff counter-sign:<br/>trade direction?"}:::dec
|
||||
BC3 -->|"IMPORT / EXPORT<br/>(clearance gate, even if customs off)"| CL1
|
||||
BC3 -->|"DOMESTIC"| FEXD["counter-sign → FULLY_EXECUTED<br/>enqueue batch (skips clearance + op-request) (sys)(B)"]:::back
|
||||
FEXD --> FEB
|
||||
|
||||
%% ================= PHASE 6: CUSTOMS CLEARANCE =================
|
||||
CL1["AWAITING_DOCUMENTS → customer uploads<br/>POST /bookings/:id/clearance/documents<br/>→ DOCUMENTS_UNDER_REVIEW (P)"]:::port
|
||||
CL1 --> CL2{"GL reviews each doc<br/>clearance/review (B)"}:::dec
|
||||
CL2 -->|"Query"| CL2q["doc queried → customer re-uploads (B)"]:::back
|
||||
CL2q --> CL1
|
||||
CL2 -->|"Approve all"| CL3["finalize (100% approved) → CLEARANCE_READY (B)"]:::back
|
||||
CL3 --> CLph["Phased ET/DJ (as applicable):<br/>declaration → duty advise → duty slip →<br/>transit permit → delivery/release order →<br/>T1 docs/close → export release (sys)(B)"]:::back
|
||||
CLph --> OP1
|
||||
|
||||
%% ================= PHASE 7: OPERATION REQUEST =================
|
||||
OP1["clearance/proceed: pick binding schedule day<br/>→ OPERATION_REQUEST_PENDING (P)"]:::port
|
||||
OP1 --> OP2{"Operations review<br/>POST /bookings/:id/operation/review (B)"}:::dec
|
||||
OP2 -->|"REQUEST_CHANGES"| OP2c["OPERATION_CHANGES_REQUESTED (B)"]:::back
|
||||
OP2c --> OP1
|
||||
OP2 -->|"ACCEPT"| OPM{"Operation mode?"}:::dec
|
||||
OPM -->|"TRAIN (rail)"| OP3t["invoice generated → FULLY_EXECUTED<br/>(day batch pool) (B)"]:::back
|
||||
OPM -->|"ROAD (truck)"| OP3r["invoice generated →<br/>ROAD_DISPATCH_PENDING (billed by KM) (B)"]:::back
|
||||
OP3t --> FEB["batch engine offers wagons →<br/>SELECTED_FOR_BATCH (sys)"]:::sys
|
||||
|
||||
%% ================= PHASE 8: INVOICE & PAYMENT =================
|
||||
GEXP --> SCH
|
||||
FEB --> PAY1
|
||||
OP3r --> PAY1
|
||||
PAY1["Invoice (source=booking, INV-YYYYMMDD-#####, due +14d)<br/>booking invoice starts DRAFT → ISSUED at operation-accept (sys)"]:::sys
|
||||
PAY1 --> PAY2["Customer pays<br/>POST /billing/my-invoices/:id/pay →<br/>billing.payInvoice → payment-api initiate (P)"]:::port
|
||||
PAY2 --> PAYp{"Provider result<br/>(Telebirr/CBE/EBirr/Waafi/DMoney/Card/CAC)"}:::dec
|
||||
PAYp -->|"FAILED"| PAYf["invoice stays OPEN (retry)"]:::sys
|
||||
PAYf --> PAY2
|
||||
PAYp -->|"pay window lapses"| PAYexp(["Booking/reservation EXPIRED"]):::bad
|
||||
PAYp -->|"SUCCEEDED"| PAYok["webhook → payment outbox →<br/>POST /internal/payments/mark-paid →<br/>settleByPaymentId → invoice PAID (sys)"]:::sys
|
||||
PAYok --> EVT1{{"booking.invoice.paid event"}}:::sys
|
||||
EVT1 --> PAID["Booking → PAID"]:::sys
|
||||
PAYok -.->|"post-pay"| PAYref(["REFUNDED (terminal)"]):::bad
|
||||
PAID --> FMQ0
|
||||
EVT1 -.->|"if EXPORT + first-mile"| FM1
|
||||
PAID --> SCH
|
||||
|
||||
%% ================= PHASE 9: SCHEDULING & ALLOCATION =================
|
||||
SCH["schedulingStatus = Eligible (sys)"]:::sys
|
||||
SCH --> SC2["Train schedule DRAFT<br/>POST /train-scheduling/{container|bulk}/schedules<br/>≥2 locomotives, derive direction (B)"]:::back
|
||||
SC2 --> SC3["assign-bookings + run-allocation<br/>(wagon_booking_allocations) (B)"]:::back
|
||||
SC3 --> SC4["pin physical wagons → finalize → SCHEDULED<br/>bookings → Scheduled (B)"]:::back
|
||||
SC4 -.->|"cancel schedule"| SC4x["bookings back to Eligible (B)"]:::back
|
||||
SC4x -.-> SC2
|
||||
SC4 -.->|"gov preempt / maintenance"| RESCH["reschedule: retained / displaced /<br/>readmitted (priority: gov first) (B)"]:::back
|
||||
RESCH -.-> SC3
|
||||
SC4 --> FMQ0
|
||||
|
||||
%% ================= PHASE 10: FIRST-MILE (export origin road leg) =================
|
||||
FMQ0{"EXPORT + first-mile requested?"}:::dec
|
||||
FMQ0 -->|"Yes"| FM1["first-mile leg auto-created<br/>firstMile.acceptBooking (READY_TO_TRANSIT) (sys)"]:::sys
|
||||
FMQ0 -->|"No"| WO1
|
||||
FM1 --> FM2["setVehicles → vehicle BUSY, SMS driver,<br/>fleet_events (B)"]:::back
|
||||
FM2 --> FM3["IN_TRANSIT (needs vehicle) →<br/>RECEIVED_TO_PORT (free vehicles) (B)"]:::back
|
||||
FM3 --> FM4["first-mile invoice (FIRST_MILE fee) (B)"]:::back
|
||||
FM4 --> WO1
|
||||
|
||||
%% ================= PHASE 11: WAREHOUSE ORIGIN (export) =================
|
||||
WO1["receive / bulkReceive → RECEIVED<br/>capacity assert, GRN, notify owner (B)"]:::back
|
||||
WO1 --> WO2{"inspection outcome"}:::dec
|
||||
WO2 -->|"FAILED / NEEDS_REVIEW"| WO2f["hold + re-inspect (B)"]:::back
|
||||
WO2f --> WO2
|
||||
WO2 -->|"PASSED"| WO3["store (allocation rule picks yard/zone) → STORED (B)"]:::back
|
||||
WO3 --> WO4["reserve (booking PAID) → RESERVED (B)"]:::back
|
||||
WO4 --> WO5["mark-ready-for-loading → READY_FOR_LOADING (B)"]:::back
|
||||
WO5 --> WO6["load onto wagon → LOADED<br/>(+ warehouse_loadings) (B)"]:::back
|
||||
WO6 --> TR1
|
||||
|
||||
%% ================= PHASE 12: DISPATCH & TRANSIT =================
|
||||
TR1["dispatch → DISPATCHED<br/>assign train_number, locos ASSIGNED,<br/>window CLOSED, unpaid reservations EXPIRED (B)"]:::back
|
||||
TR1 --> TR2["record checkpoints (corridor stations) →<br/>train_checkpoint_events (B)"]:::back
|
||||
TR2 --> TRC["Customer tracking page<br/>GET /tracking/:consignmentId (JWT) (P)"]:::port
|
||||
TR2 --> TR3["arrive (final checkpoint) → ARRIVED<br/>bookings IN_TRANSIT, locos+wagons freed,<br/>warehouse arrival automation (B)"]:::back
|
||||
TR3 --> WD1
|
||||
|
||||
%% ================= PHASE 13: WAREHOUSE DEST + IMPORT CUSTOMS =================
|
||||
WD1["destination warehouse: auto-unload arrived<br/>→ UNLOADED / RECEIVED (B)"]:::back
|
||||
WD1 --> WD2{"inspection PASSED?"}:::dec
|
||||
WD2 -->|"No"| WD2f["hold + re-inspect / incident report (B)"]:::back
|
||||
WD2f --> WD2
|
||||
WD2 -->|"Yes"| DIRW{"trade direction at destination"}:::dec
|
||||
DIRW -->|"IMPORT"| WD3["READY_FOR_PICKUP (B)"]:::back
|
||||
DIRW -->|"EXPORT (Djibouti)"| WDX["auto-unload-at-djibouti → DISPATCHED /<br/>UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back
|
||||
WD3 --> IMP1["Import customs finalization:<br/>upload docs → declaration → notify duties →<br/>duties paid (needs slip) → assign risk →<br/>release-permitted (all gates) (sys)(B)"]:::back
|
||||
IMP1 --> LMQ
|
||||
WDX --> ICD["interchange document (handover manifest)<br/>generate-from-schedule → GENERATED →<br/>ACKNOWLEDGED / DISPUTED (B)"]:::back
|
||||
ICD --> DE1
|
||||
|
||||
%% ================= PHASE 14: LAST-MILE (import destination road leg) =================
|
||||
LMQ{"IMPORT + last-mile requested?"}:::dec
|
||||
LMQ -->|"Yes"| LM1["last-mile leg auto-created<br/>(IMPORT inspection PASSED only) (sys)"]:::sys
|
||||
LMQ -->|"No"| DE1
|
||||
LM1 --> LM2["setVehicles → IN_TRANSIT → DELIVERED<br/>(free vehicles) (B)"]:::back
|
||||
LM2 --> LM3["last-mile invoice (LAST_MILE fee) (B)"]:::back
|
||||
LM3 --> DE1
|
||||
|
||||
%% ================= PHASE 15: DELIVERY & COMPLETION =================
|
||||
DE1{"Warehouse/storage fees fully PAID?"}:::dec
|
||||
DE1 -->|"No"| DE1x["gate-clearance BLOCKED<br/>findBlockingInvoice / assertClearanceAllowed (B)"]:::back
|
||||
DE1x --> DE1p["Customer pays storage/demurrage<br/>warehouse-fee-invoices/:id/pay-online (P)"]:::port
|
||||
DE1p --> DE1
|
||||
DE1 -->|"Yes"| DE2["release order (DO) + gate-clearance →<br/>deliver (B)"]:::back
|
||||
DE2 --> DE3["Customer approves delivery (saved signature)<br/>POST /warehouse-inventory/bookings/:id/approve-delivery (P)"]:::port
|
||||
DE3 --> DE4["inventory DELIVERED, POD to cargo,<br/>container freed, capacity released (sys)"]:::sys
|
||||
DE4 --> DONE(["Booking COMPLETED (done) <br/>operations/complete"]):::good
|
||||
|
||||
%% ================= GLOBAL EXITS =================
|
||||
GEXIT(["CANCELLED — POST /bookings/:id/cancel (staff-only)<br/>ONLY from DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,<br/>CHANGES_REQUESTED, PENDING_APPROVAL, CONTRACT_READY,<br/>OPERATION_REQUEST_PENDING"]):::bad
|
||||
B2 -.->|"cancel"| GEXIT
|
||||
BA -.->|"cancel"| GEXIT
|
||||
OP1 -.->|"cancel"| GEXIT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reading notes
|
||||
|
||||
- **Solid arrows** = the primary progression. **Dotted arrows** = optional / event-driven / exit hops
|
||||
(consolidation, reschedule, cancel, the first-mile event branch).
|
||||
- **Every terminal** is a rounded red or green node:
|
||||
`SUSPENDED/BLACKLISTED`, `dormant`, `booking/contract removed`, `REJECTED` (customer price, staff intake,
|
||||
approval step, GL booking-request), `HARD BLOCK` (VGM), `EXPIRED` (pay window / contract), `REFUNDED`,
|
||||
`RENEWAL_DRAFT`, `CANCELLED`, and the single success end **`COMPLETED (done) `**.
|
||||
- **Branch axes** captured: identity-proofing (Fayda / skip), origin (one-time vs contract Path A / Path B),
|
||||
freight type (container / bulk), trade direction (import / export / domestic), customer type
|
||||
(government expedite vs commercial approval chain), customs on/off, operation mode (rail / road),
|
||||
provider outcome (success / fail-retry / expire / refund), first-mile (export), last-mile (import),
|
||||
Djibouti export unload + interchange handover.
|
||||
- **Actors**: (P) portal customer, (B) backoffice staff, (sys) system (events like `booking.invoice.paid`,
|
||||
`warehouse.invoice.paid`, auto leg creation, payment webhook/outbox settlement).
|
||||
|
||||
> Endpoint-level tables, per-domain state machines, and the payment-microservice sequence live in
|
||||
> [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md). This file is the single end-to-end picture.
|
||||
872
apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md
Normal file
872
apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md
Normal file
@@ -0,0 +1,872 @@
|
||||
# EDR Freight System — End-to-End Flow Map (FE → Backend)
|
||||
|
||||
> Comprehensive flow documentation for the **Freight Management** slice of the EDR Platform:
|
||||
> the two frontends (**Customer Portal** + **Backoffice**) and how every action reaches the
|
||||
> **freight API**, the **payment microservice**, Postgres, MinIO and RabbitMQ.
|
||||
>
|
||||
> Generated by mapping all ~45 API modules (~300 routes / 60 controllers) against both FE apps.
|
||||
> Layered on purpose: a **master business-flow** view first, then **per-domain endpoint-level** charts.
|
||||
|
||||
---
|
||||
|
||||
## 0. How to read this document
|
||||
|
||||
- **Master view** (§3) is the freight journey at business altitude — state transitions across domains.
|
||||
- **Per-domain views** (§5–§13) drop to endpoint altitude — every `Method /path`, its guard, and the FE caller.
|
||||
- Diagrams are [Mermaid](https://mermaid.js.org). GitHub / VS Code (Markdown Preview Mermaid) render them inline.
|
||||
- **Legend** used throughout:
|
||||
- (P) **Portal** = `@edr/freight-portal` (customer users, port `5173`)
|
||||
- (B) **Backoffice** = `@edr/freight-backoffice` (EDR employees, port `5183`)
|
||||
- (green) **API** = `@edr/freight-api` (NestJS, port `3001`)
|
||||
- (red) **Payment** = `@edr/payment-api` (NestJS microservice, port `3003`)
|
||||
|
||||
---
|
||||
|
||||
## 1. System architecture & apps
|
||||
|
||||
| Layer | Package | Port | Base URL / notes |
|
||||
| ----- | ------- | ---- | ---------------- |
|
||||
| Customer Portal | `@edr/freight-portal` | 5173 | axios `utils/api.ts`, baseURL `VITE_BASE_API_URL`, React Query |
|
||||
| Backoffice | `@edr/freight-backoffice` | 5183 | axios `auth/http.ts`, baseURL `${VITE_BASE_API_URL}/api`, React Query |
|
||||
| Freight API | `@edr/freight-api` | 3001 | NestJS, global prefix `/api`, Postgres schema `freight` |
|
||||
| Payment API | `@edr/payment-api` | 3003 | NestJS, separate schema `edr_payment`, providers in `@edr/payment-providers` |
|
||||
| Datastores | — | 5433 | Postgres `edr_freight`; MinIO (files); RabbitMQ (SMS/email/payment events) |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph FE["Frontends (React + Vite + React Query)"]
|
||||
P["(P) Customer Portal :5173"]
|
||||
B["(B) Backoffice :5183"]
|
||||
end
|
||||
|
||||
subgraph EDGE["Freight API edge (NestJS :3001)"]
|
||||
direction TB
|
||||
CORS["CORS (reflect origin,<br/>credentials, IAM headers)"]
|
||||
JWT["JwtGuard (global APP_GUARD)<br/>+ HasActiveDelegationGuard"]
|
||||
PERM["FreightPermissionGuard<br/>(per-route perms)"]
|
||||
VP["ValidationPipe<br/>(implicitConversion OFF)"]
|
||||
RTI["ResponseTransformInterceptor<br/>→ { success, data }"]
|
||||
HEF["HttpExceptionFilter"]
|
||||
end
|
||||
|
||||
subgraph DOM["Domain modules (~45)"]
|
||||
direction TB
|
||||
D1["Identity / Companies / Auth"]
|
||||
D2["Bookings + Contracts"]
|
||||
D3["Rule Engine"]
|
||||
D4["Train Scheduling"]
|
||||
D5["Warehouse"]
|
||||
D6["Field Ops (mile/import)"]
|
||||
D7["Billing"]
|
||||
D8["Notifications / Inbox"]
|
||||
end
|
||||
|
||||
subgraph INFRA["Backing services"]
|
||||
PG[("Postgres<br/>schema: freight")]
|
||||
MINIO[("MinIO<br/>object store")]
|
||||
MQ{{"RabbitMQ"}}
|
||||
PAY["(red) Payment API :3003<br/>schema: edr_payment"]
|
||||
end
|
||||
|
||||
P -->|"Bearer token (cookie)<br/>axios interceptor"| CORS
|
||||
B -->|"Bearer token (cookie)<br/>axios interceptor"| CORS
|
||||
CORS --> JWT --> PERM --> VP --> DOM
|
||||
DOM --> RTI
|
||||
DOM --> PG
|
||||
DOM --> MINIO
|
||||
DOM -->|"send-sms / send-email"| MQ
|
||||
DOM -->|"POST /payments/initiate<br/>x-service-token"| PAY
|
||||
PAY -->|"payment.succeeded webhook<br/>→ /api/internal/payments/mark-paid"| DOM
|
||||
PAY -.->|"or RabbitMQ payment events"| MQ --> DOM
|
||||
D8 -->|"socket.io push"| FE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. The request pipeline (every FE call)
|
||||
|
||||
Both frontends wrap each API method in an `endpoint(service, action, fn)` helper feeding React Query.
|
||||
The axios client attaches the JWT and transparently refreshes on `401`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant C as React component / hook
|
||||
participant Q as React Query
|
||||
participant AX as axios client (interceptors)
|
||||
participant API as Freight API (:3001)
|
||||
participant DB as Postgres
|
||||
|
||||
C->>Q: useQuery / useMutation(endpoint)
|
||||
Q->>AX: call(input)
|
||||
AX->>AX: request interceptor →<br/>Authorization: Bearer [auth-token cookie]
|
||||
AX->>API: HTTP /api/<controller>/<path>
|
||||
API->>API: CORS → JwtGuard → PermissionGuard → ValidationPipe
|
||||
alt token valid & permitted
|
||||
API->>DB: repository query (schema freight)
|
||||
DB-->>API: rows
|
||||
API->>API: ResponseTransformInterceptor → { success:true, data }
|
||||
API-->>AX: 200 { success, data }
|
||||
AX->>AX: backoffice interceptor unwraps .data<br/>(portal returns raw envelope; callers read .data)
|
||||
AX-->>Q: payload
|
||||
Q-->>C: data (+ cache by queryKey)
|
||||
else 401 Unauthorized
|
||||
API-->>AX: 401
|
||||
AX->>API: POST /api/auth/refresh-token { refreshToken cookie }
|
||||
alt refresh ok
|
||||
API-->>AX: { token, refreshToken }
|
||||
AX->>AX: set cookies, retry original request (_retry)
|
||||
AX-->>Q: payload
|
||||
else refresh fails
|
||||
AX->>AX: clear cookies →<br/>portal: reject · backoffice: redirect /auth
|
||||
end
|
||||
else 4xx/5xx
|
||||
API->>API: HttpExceptionFilter → { success:false, message }
|
||||
API-->>AX: error
|
||||
AX-->>Q: throw → onError toast
|
||||
end
|
||||
```
|
||||
|
||||
**Auth model (important):** `SharedAuthModule` (`@tria-plc/api-common`) registers `JwtGuard` +
|
||||
`HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it
|
||||
carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks
|
||||
on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`,
|
||||
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints,
|
||||
`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`.
|
||||
Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s
|
||||
`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.)
|
||||
|
||||
---
|
||||
|
||||
## 3. MASTER FLOW — the freight journey (business altitude)
|
||||
|
||||
This is the spine. A shipment travels **customer intake → pricing → approval → contract → customs
|
||||
clearance → operation request → payment → scheduling/allocation → first-mile → warehouse → train →
|
||||
arrival → warehouse → last-mile → delivery → tracking**, branching on _container vs bulk_,
|
||||
_import vs export_, _commercial vs government_, and _one-time vs contract_.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
start(["Customer signs up<br/>(IAM + Fayda + company onboarding)"]) --> mode{"Booking origin?"}
|
||||
|
||||
mode -->|"One-time shipment"| draft["Create BOOKING (DRAFT) (P)"]
|
||||
mode -->|"Framework agreement"| cdraft["Create CONTRACT (DRAFT) (P)"]
|
||||
|
||||
%% Contract branch
|
||||
cdraft --> cprice["Generate price → Submit → Approvals → Sign"]
|
||||
cprice --> cactive{"Contract type / customs?"}
|
||||
cactive -->|"Path A: transport-only,<br/>self-clearance"| bookA["Customer books directly<br/>POST /contracts/:id/bookings (P)"]
|
||||
cactive -->|"Path B: GENERAL + customs"| breq["Customer submits BookingRequest (P)<br/>→ GL accepts → GL creates booking (B)"]
|
||||
bookA --> draft2["Booking created under contract"]
|
||||
breq --> draft2
|
||||
|
||||
%% Booking spine
|
||||
draft --> price["Generate price (P)<br/>(rule-engine: rates + surcharges)"]
|
||||
draft2 --> price
|
||||
price --> submit["Submit → SUBMITTED (P)"]
|
||||
submit --> intake["Staff accept → PENDING_APPROVAL (B)<br/>(instantiate approval steps)"]
|
||||
intake --> appr["Approval chain:<br/>LINE_STAFF → DIRECTOR → CEO (B)"]
|
||||
appr --> gen["Generate contract → CONTRACT_READY (B)"]
|
||||
gen --> sign["Customer signs (P) → Staff counter-signs (B)"]
|
||||
sign --> customs{"customsClearingEnabled?"}
|
||||
|
||||
customs -->|"No"| ready["FULLY_EXECUTED"]
|
||||
customs -->|"Yes"| clr["AWAITING_DOCUMENTS →<br/>customer uploads docs (P) →<br/>GL review/finalize (B) → CLEARANCE_READY"]
|
||||
clr --> opreq["Customer requests operation (P)<br/>(binding schedule date)"]
|
||||
ready --> opreq
|
||||
opreq --> oprev{"Operations review (B)"}
|
||||
oprev -->|"Request changes"| clr
|
||||
oprev -->|"Accept: train"| inv["Invoice generated →<br/>enters day batch pool"]
|
||||
oprev -->|"Accept: road/truck"| road["ROAD_DISPATCH_PENDING<br/>(billed by KM)"]
|
||||
|
||||
inv --> pay["Customer pays invoice (P)<br/>→ (red) gateway → PAID"]
|
||||
road --> pay
|
||||
pay --> gov{"Government?"}
|
||||
gov -->|"Yes"| expedite["governmentExpedite → PAID/Eligible (B)"]
|
||||
gov -->|"No"| eligible["schedulingStatus = Eligible"]
|
||||
expedite --> sched
|
||||
eligible --> sched
|
||||
|
||||
subgraph JOURNEY["Physical movement"]
|
||||
direction TB
|
||||
sched["Train schedule: DRAFT → assign bookings →<br/>allocate wagons → finalize → SCHEDULED (B)"]
|
||||
fm{"EXPORT + first-mile?"}
|
||||
fmleg["First-mile leg: truck pickup →<br/>RECEIVED_TO_PORT (B)"]
|
||||
wh_in["Warehouse receive → inspect →<br/>STORED → READY_FOR_LOADING → LOADED (B)"]
|
||||
disp["Dispatch train → DISPATCHED<br/>(locos ASSIGNED, unpaid EXPIRED) (B)"]
|
||||
track["Checkpoints logged →<br/>tracking events (customer sees) (P)"]
|
||||
arrive["Arrive → ARRIVED<br/>(bookings IN_TRANSIT, wagons freed) (B)"]
|
||||
wh_out["Destination warehouse:<br/>unload → inspect → READY_FOR_PICKUP (B)"]
|
||||
lm{"IMPORT + last-mile?"}
|
||||
lmleg["Last-mile leg: truck delivery →<br/>DELIVERED (B)"]
|
||||
imp["Import customs finalization<br/>(declaration/duty/risk/release) (B)"]
|
||||
end
|
||||
|
||||
sched --> fm
|
||||
fm -->|"Yes"| fmleg --> wh_in
|
||||
fm -->|"No"| wh_in
|
||||
wh_in --> disp --> track --> arrive --> wh_out
|
||||
wh_out --> imp
|
||||
imp --> lm
|
||||
lm -->|"Yes"| lmleg --> deliver
|
||||
lm -->|"No"| deliver
|
||||
|
||||
deliver["Release order + gate clearance<br/>(warehouse fees must be PAID) (B)"]
|
||||
deliver --> pod["Customer approves delivery /<br/>POD captured → DELIVERED (P)"]
|
||||
pod --> complete(["Booking COMPLETED"])
|
||||
|
||||
track -.->|"public timeline"| custview["Customer tracking page (P)"]
|
||||
```
|
||||
|
||||
**Cross-cutting truth:** almost every hop between domains is fired by a **domain event** (`@OnEvent`),
|
||||
not a direct call. See §13 for the event web (e.g. `booking.invoice.paid` → advance booking → auto-create
|
||||
first-mile; `warehouse inspection PASSED` → auto-create last-mile; payment webhook → settle invoice).
|
||||
|
||||
---
|
||||
|
||||
## 4. Domain map (where each module lives in the journey)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph INTAKE["Intake & Identity"]
|
||||
auth[auth / otp / verifayda]
|
||||
comp[companies]
|
||||
sig[signatures]
|
||||
end
|
||||
subgraph COMMERCIAL["Commercial"]
|
||||
bk[bookings]
|
||||
ct[contracts]
|
||||
re[rule-engine]
|
||||
end
|
||||
subgraph OPS["Rail Operations"]
|
||||
ts[train-scheduling]
|
||||
resch[scheduling-reschedule]
|
||||
fleet[trains/wagons/locomotives/wagon-types]
|
||||
track[tracking]
|
||||
end
|
||||
subgraph GROUND["Ground Operations"]
|
||||
fmlm[first-mile / last-mile]
|
||||
imp[import-operations / interchange-documents]
|
||||
dv[drivers / vehicles]
|
||||
end
|
||||
subgraph WH["Warehouse"]
|
||||
wh[warehouses + inventory + loadings + inspection + rules]
|
||||
whinv[warehouse-fee-invoices]
|
||||
end
|
||||
subgraph MONEY["Money"]
|
||||
bill[billing]
|
||||
paymod[payment]
|
||||
payapi[edr-payment-api]
|
||||
end
|
||||
subgraph PLATFORM["Platform / Config"]
|
||||
dd[dropdown-settings]
|
||||
fus[file-upload-settings]
|
||||
files[files / minio]
|
||||
fac[facilities / routes]
|
||||
notif[notifications / inbox]
|
||||
ov[overview]
|
||||
bo[backoffice/IAM]
|
||||
end
|
||||
|
||||
INTAKE --> COMMERCIAL --> OPS --> GROUND --> WH --> MONEY
|
||||
re -.->|rates/approval/priority| COMMERCIAL
|
||||
COMMERCIAL -.->|invoices| MONEY
|
||||
WH -.->|storage invoices| MONEY
|
||||
GROUND -.->|mile invoices| MONEY
|
||||
PLATFORM -.-> COMMERCIAL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Identity, Access & Onboarding
|
||||
|
||||
### 5.1 Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
su["Signup (external IAM)"] --> chk["GET /auth/check-availability (P)<br/>(email/phone taken?) @Public"]
|
||||
chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
|
||||
otp --> fayda{"Identity proofing?"}
|
||||
fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start<br/>→ eSignet authorize URL"]
|
||||
fstart --> fcb["Fayda redirect → GET /callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
|
||||
fcb --> onb
|
||||
fayda -->|"skip"| onb
|
||||
|
||||
onb["POST /companies/onboarding/start (P)<br/>(draft company, placeholder TIN, PENDING)"]
|
||||
onb --> wiz["Wizard saves incrementally (P):<br/>PATCH /profile · /onboarding-step ·<br/>upload license & docs"]
|
||||
wiz --> reqs["GET /companies/onboarding/requirements<br/>(server-driven checklist)"]
|
||||
reqs --> comp["POST /companies/onboarding/complete<br/>→ profiles + company = PENDING"]
|
||||
comp --> review["Backoffice approves (B):<br/>PATCH /companies/company-profiles/:id/status<br/>→ mint reference, company → ACTIVE"]
|
||||
review --> book(["Can now book<br/>(assertCompanyProfileApprovedForBooking)"])
|
||||
```
|
||||
|
||||
Company status: `PENDING → ACTIVE` (+ `SUSPENDED`, `BLACKLISTED`). Nationality (`ethiopian`/`foreign`)
|
||||
drives the required document set. Booking guards elsewhere `403` if the acting profile is not `ACTIVE`.
|
||||
|
||||
### 5.2 Endpoints
|
||||
|
||||
| Method | Path | Action | Guard | FE |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| GET | `/api/auth/check-availability` | email/phone dedupe | `@Public` | (P) auth.service |
|
||||
| GET | `/api/me` | enriched profile + `permissionKeys` + catalog | JwtGuard | (B) auth/api |
|
||||
| POST | `/api/otp/send` · `/api/otp/verify` | send / verify 6-digit code | `@Public` | (P) auth.service |
|
||||
| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service |
|
||||
| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service |
|
||||
| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — |
|
||||
| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
|
||||
| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service |
|
||||
| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages |
|
||||
| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service |
|
||||
| POST | `/api/companies/fetch-etrade-info` | pull reg data by TIN | JwtGuard | (P) |
|
||||
| PATCH | `/api/companies/profile` | update profile (JSONB attrs) | JwtGuard | (P) |
|
||||
| POST | `/api/companies/onboarding/start` · `/complete` | onboarding lifecycle | JwtGuard | (P) |
|
||||
| PATCH | `/api/companies/onboarding-step` · `/active-mode` | wizard state / mode switch | JwtGuard | (P) |
|
||||
| GET | `/api/companies/onboarding/requirements` | server checklist | JwtGuard | (P) |
|
||||
| POST·GET | `/api/companies/company-profiles/:id/license` | license file up/list | JwtGuard | (P) |
|
||||
| POST | `/api/companies/company-profiles` · `/company-profile` | add operational profile(s) | JwtGuard | (P) |
|
||||
| POST·GET·PATCH·DELETE | `/api/companies` (+`/:id`) | company CRUD | `@FreightAdmin` writes | (B) customers.service |
|
||||
| GET | `/api/companies/stats` | KPI strip | JwtGuard | (B) |
|
||||
| PATCH | `/api/companies/company-profiles/:id/status` | approve profile → mint ref | `@FreightAdmin` | (B) |
|
||||
| GET·POST | `/api/companies/:companyId/documents` | company docs (signed URLs) | JwtGuard | (P)(B) |
|
||||
| GET | `/api/notifications` (+`/unread-count`) | inbox list / unread | JwtGuard | (P)(B) notificationsApi |
|
||||
| PATCH·POST | `/api/notifications/:id/read` · `/read-all` | mark read (WS re-emit) | JwtGuard | (P)(B) |
|
||||
| WS | `NOTIFICATION_WS_NAMESPACE` | live push (server→client) | WsAuth handshake | (P)(B) useNotificationSocket |
|
||||
|
||||
`notifications` module = SMS/email transport over RabbitMQ (no HTTP routes). Inbox fan-out writes one
|
||||
`notifications` row + WS push per recipient; **HIGH** priority also emails + SMSs (best-effort).
|
||||
|
||||
---
|
||||
|
||||
## 6. Bookings — the core state machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: create (P) (staff commercial auto price+submit)
|
||||
DRAFT --> SUBMITTED: submit (P)
|
||||
DRAFT --> PRICE_CHANGED_PENDING_CONFIRM: price moved
|
||||
PRICE_CHANGED_PENDING_CONFIRM --> SUBMITTED: confirm-submit (P)
|
||||
SUBMITTED --> REJECTED: customer reject (price) (P)
|
||||
SUBMITTED --> CHANGES_REQUESTED: staff request-changes (B)
|
||||
CHANGES_REQUESTED --> SUBMITTED: edit + resubmit (P)
|
||||
SUBMITTED --> PENDING_APPROVAL: staff/accept (B) (instantiate approval steps)
|
||||
SUBMITTED --> REJECTED: staff/reject (B)
|
||||
PENDING_APPROVAL --> APPROVED_PENDING_SIGNATURE: line-staff approve (B)
|
||||
APPROVED_PENDING_SIGNATURE --> APPROVED: director + ceo approve (B)
|
||||
PENDING_APPROVAL --> REJECTED: rejectStep (B)
|
||||
APPROVED --> CONTRACT_READY: contract/generate (B)
|
||||
CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P)
|
||||
SIGNED_CUSTOMER --> AWAITING_DOCUMENTS: counter-sign, IMPORT/EXPORT (B)
|
||||
SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, DOMESTIC (B)
|
||||
AWAITING_DOCUMENTS --> DOCUMENTS_UNDER_REVIEW: clearance/documents (P)
|
||||
DOCUMENTS_UNDER_REVIEW --> CLEARANCE_READY: review + finalize (B)
|
||||
CLEARANCE_READY --> OPERATION_REQUEST_PENDING: clearance/proceed (P)
|
||||
OPERATION_REQUEST_PENDING --> OPERATION_CHANGES_REQUESTED: review=REQUEST_CHANGES (B)
|
||||
OPERATION_CHANGES_REQUESTED --> OPERATION_REQUEST_PENDING: re-proceed (P)
|
||||
OPERATION_REQUEST_PENDING --> ROAD_DISPATCH_PENDING: accept=road (B) (invoice, KM billed)
|
||||
OPERATION_REQUEST_PENDING --> FULLY_EXECUTED: accept=train (B) (invoice)
|
||||
FULLY_EXECUTED --> SELECTED_FOR_BATCH: batch engine offers wagons (sys)
|
||||
SELECTED_FOR_BATCH --> PAID: pay invoice (P)
|
||||
ROAD_DISPATCH_PENDING --> PAID: pay invoice (P)
|
||||
PAID --> IN_TRANSIT: operations/start-transit (B)
|
||||
IN_TRANSIT --> COMPLETED: operations/complete (B)
|
||||
DRAFT --> PENDING_CONSOLIDATION: requestConsolidation (usually set on submit)
|
||||
DRAFT --> CANCELLED: cancel (B)
|
||||
PENDING_APPROVAL --> CANCELLED: cancel (B)
|
||||
OPERATION_REQUEST_PENDING --> CANCELLED: cancel (B)
|
||||
note right of PAID
|
||||
governmentExpedite (B) jumps
|
||||
gov bookings straight to PAID/Eligible
|
||||
end note
|
||||
note left of CANCELLED
|
||||
cancel is staff-only and allowed ONLY from
|
||||
DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,
|
||||
CHANGES_REQUESTED, PENDING_APPROVAL,
|
||||
CONTRACT_READY, OPERATION_REQUEST_PENDING
|
||||
end note
|
||||
REJECTED --> [*]
|
||||
CANCELLED --> [*]
|
||||
COMPLETED --> [*]
|
||||
```
|
||||
|
||||
> **Accuracy notes (verified against code):**
|
||||
> - Counter-sign branch is keyed on **trade direction**, not a customs flag: `IMPORT`/`EXPORT` →
|
||||
> `AWAITING_DOCUMENTS` (even when customs is off — a lighter "without customs" clearance doc-set still
|
||||
> applies); only `DOMESTIC` → `FULLY_EXECUTED`.
|
||||
> - **Domestic** bookings skip the operation-request/clearance phase entirely — counter-sign enqueues
|
||||
> them straight into the scheduling batch pipeline (`enqueueScheduleProcessing`).
|
||||
> - Train `operation/review` accept sets **`FULLY_EXECUTED`** (day batch holding pool). `SELECTED_FOR_BATCH`
|
||||
> is set **later** by the batch engine when a wagon offer/reservation is made — not at accept.
|
||||
> - `APPROVED_PENDING_SIGNATURE` is a real intermediate (line-staff approves first, then director+CEO).
|
||||
> - Full `BOOKING_STATUSES` has 35 values; this diagram is the live commercial subset (legacy statuses
|
||||
> like `WAGON_ASSIGNED`, `INVOICED`, `PNR_GENERATED` are unused).
|
||||
|
||||
Key endpoints (customer (P) / staff (B), `bk:` = `bookings:` perms):
|
||||
|
||||
| Method | Path | Action | Guard |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/bookings` | create | in-body (gov→`bk:staff_accept`) |
|
||||
| PATCH·DELETE | `/api/bookings/:id` | update / soft-delete DRAFT | company-scoped |
|
||||
| POST | `/api/bookings/:id/generate-price` | price preview (rule-engine) | company-scoped |
|
||||
| POST | `/api/bookings/:id/submit` · `/confirm-submit` | submit (rate snapshot) | company-scoped |
|
||||
| POST | `/api/bookings/:id/reject` | customer rejects price | company-scoped |
|
||||
| POST | `/api/bookings/:id/staff/accept` | → PENDING_APPROVAL | `bk:staff_accept` |
|
||||
| POST | `/api/bookings/:id/staff/request-changes` · `/staff/reject` | intake outcomes | `bk:request_changes` / `bk:reject` |
|
||||
| POST | `/api/bookings/:id/approval-steps/:stepId/approve` · `/reject` | approval chain | role perms |
|
||||
| POST | `/api/bookings/:id/contract/generate` | → CONTRACT_READY | `bk:generate_contract` |
|
||||
| POST | `/api/bookings/:id/contract/sign` · `/marketing/approve` | sign / counter-sign | in-body / `bk:sign_staff` |
|
||||
| GET | `/api/bookings/:id/contract/{view,document}` | HTML / PDF | company-scoped |
|
||||
| GET·POST | `/api/bookings/:id/clearance` (+`/documents`,`/review`,`/finalize`,…) | customs clearance | `bk:*` / `ct:clearance_*` |
|
||||
| POST | `/api/bookings/:id/clearance/proceed` | request operation | company-scoped |
|
||||
| POST | `/api/bookings/:id/operation/review` | accept/changes (+invoice) | `bk:operations` |
|
||||
| POST | `/api/bookings/:id/government-expedite` | gov shortcut → PAID | `bk:staff_accept` |
|
||||
| POST | `/api/bookings/:id/operations/start-transit` · `/complete` | transit lifecycle | `bk:operations` |
|
||||
| POST | `/api/bookings/:id/allocate-containers` | assign containers↔vehicles | `allocation:manage` |
|
||||
| POST·GET·DELETE | `/api/bookings/:id/consolidation` | pair/unpair wagon-share | company-scoped |
|
||||
| POST | `/api/bookings/:id/customer-truck-assignment` | external truck for pickup | company-scoped |
|
||||
| GET | `/api/bookings/:id/tracking` | consignment + tracking events | company-scoped |
|
||||
| GET | `/api/bookings` · `/list-summary` · `/queues/:queue` | lists (branch by perm) | `bk:view` / `bk:clearance_view` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Contracts — framework agreements & booking paths
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: create (P)
|
||||
DRAFT --> SUBMITTED: submit (P) (freeze rate snapshots)
|
||||
SUBMITTED --> PENDING_APPROVAL: staff/accept (B)
|
||||
SUBMITTED --> CHANGES_REQUESTED: request-changes (B)
|
||||
CHANGES_REQUESTED --> SUBMITTED: resubmit (P)
|
||||
SUBMITTED --> REJECTED: reject (B)
|
||||
PENDING_APPROVAL --> APPROVED: approve chain (B)
|
||||
APPROVED --> CONTRACT_READY: contract/generate (B)
|
||||
CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P)
|
||||
SIGNED_CUSTOMER --> AWAITING_CLEARANCE_DOCUMENTS: counter-sign, IMPORT/EXPORT (not GENERAL+customs) (B)
|
||||
SIGNED_CUSTOMER --> CONTRACT_ACTIVE: counter-sign, GENERAL+customs or DOMESTIC (B)
|
||||
SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, ONE_TIME DOMESTIC (B)
|
||||
CONTRACT_ACTIVE --> [*]: renew → RENEWAL_DRAFT
|
||||
```
|
||||
|
||||
> Diagram shows the live path; `CONTRACT_STATUSES` has **24 values** total (adds APPROVED_PENDING_SIGNATURE,
|
||||
> CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED,
|
||||
> RENEWAL_SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED — see §15). **GENERAL+customs skips the
|
||||
> contract clearance cycle → `CONTRACT_ACTIVE` directly** (clearance runs per-booking, Path B); only IMPORT/EXPORT
|
||||
> one-time or self-clearance opens the contract-level `AWAITING_CLEARANCE_DOCUMENTS` cycle.
|
||||
|
||||
**Two ways a contract spawns shipment bookings:**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
active["Contract ACTIVE / SELF_CLEARED"] --> path{"Contract path"}
|
||||
path -->|"Path A: transport-only"| a1["Ops reviews self-clearance docs (B)<br/>(ops-review → ops-finalize → SELF_CLEARED)"]
|
||||
a1 --> a2["Customer books directly (P)<br/>POST /contracts/:id/bookings"]
|
||||
path -->|"Path B: GENERAL + customs"| b1["Customer submits BookingRequest (P)<br/>POST /contracts/:id/booking-requests"]
|
||||
b1 --> b2["GL queue → accept (B)<br/>(ct:create_booking) → GL creates booking"]
|
||||
a2 --> cap["createUnderContract:<br/>window + capacity draw-down check"]
|
||||
b2 --> cap
|
||||
cap --> spawn(["New Booking under contract<br/>(bookings.contract_id)"])
|
||||
```
|
||||
|
||||
Contract clearance is **phased** (ET vs DJ permissioned): declaration → duty advice → duty slip →
|
||||
transit permit → delivery/release order → T1 docs/close → final invoice → incidents. `clearanceStatus`
|
||||
is a separate axis (`AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY_FOR_BOOKING /
|
||||
SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS`). ~60 contract routes total. Per-booking clearance
|
||||
actions (`duty`, `risk`, `t1-documents`, `t1-close`, `final-invoice`, `second-duty`, `transport-document`,
|
||||
`station-assign`, `incidents`) hang off `/api/contracts/bookings/:bookingId/*`; note **`declaration` is a
|
||||
contract-level route** (`/api/contracts/:id/clearance/declaration`), not a booking-scoped one.
|
||||
|
||||
### Consignment / Cargo / Container (fleet-side records)
|
||||
|
||||
| Entity | Controller | Guard | Lifecycle | Callers |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Consignment | `/api/consignments` (create/list/get) | `FleetView`/`Manage` | `Pending → …` (downstream shipment record; booking `:id/tracking` reads it) | (P) read-only |
|
||||
| Cargo | `/api/cargoes` (+ `/load` `/unload` `/deliver`) | `FleetView`/`Manage` | `PENDING → LOADED → UNLOADED / DELIVERED` (POD) | (B) cargoService |
|
||||
| Container | `/api/containers` (+ `/assign-wagon` `/unassign-wagon`) | `FleetView`/`Manage` | `AVAILABLE → LOADED → IN_TRANSIT …` | (B) containerService |
|
||||
|
||||
---
|
||||
|
||||
## 8. Rule Engine — the pricing & approval brain
|
||||
|
||||
`RuleEngineService.evaluate(input)` is injected into bookings & contracts pricing. One pass pulls all
|
||||
rule tables and returns `{ priorityScore, appliedModifiers, containerWeightResults, warnings,
|
||||
hardBlocked, requiresDirectorApproval }`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ev["evaluate(BookingEvaluationInput)"] --> rates["rates.findLiveRates()<br/>(only LIVE)"]
|
||||
rates --> sur{"surcharge triggers"}
|
||||
sur -->|"HAZARDOUS/REEFER/OVERWEIGHT/<br/>SHIPPING_LINE/CONSOLIDATION"| mods["appliedModifiers →<br/>surcharge line-items"]
|
||||
mods --> snap["snapshotRates → booking_rate_snapshots<br/>(freeze exact rate used)"]
|
||||
ev --> wlr["weight-limit-rules"]
|
||||
wlr --> block{"VGM > maxCapacityTons?"}
|
||||
block -->|Yes| hard["HARD BLOCK (400)"]
|
||||
block -->|"over maxVgm, within cap"| warn["warning + OVERWEIGHT surcharge"]
|
||||
ev --> appr["approval-rules →<br/>instantiateApprovalSteps<br/>(requiredRole/blocksRole/stepOrder)"]
|
||||
ev --> prio["priority-configs + serviceType bonus +<br/>GOVERNMENT_PRIORITY_BONUS → priorityScore"]
|
||||
prio --> schedorder["train-scheduling orders by priorityScore"]
|
||||
```
|
||||
|
||||
**Rate lifecycle:** `DRAFT → (submit) PENDING_APPROVAL → (CEO approve) LIVE`. Only LIVE rates apply.
|
||||
|
||||
| Resource | Base path | Guard | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| approval-rules | `/api/approval-rules` (+`/chain`,`/reorder`,`/:id/move-order`) | `ruleEngine.view/manage(approval-rules)` | ordered chain |
|
||||
| cargo-types | `/api/cargo-types` | `…(cargo-types)` | portal reads via `bookings/reference-data` |
|
||||
| container-types | `/api/container-types` | `…(container-types)` | portal indirect |
|
||||
| priority-configs | `/api/priority-configs` | `…(priority-configs)` | WAGON / CURRENCY bands |
|
||||
| rates | `/api/rates` (+`/live`,`/:id/submit`,`/approve`) | `…(rates)` | DRAFT→PENDING→LIVE |
|
||||
| service-types | `/api/service-types` | `…(service-types)` | `includesFirstMile/LastMile` flags |
|
||||
| shipping-lines | `/api/shipping-lines` | `…(shipping-lines)` | surcharge trigger |
|
||||
| weight-limit-rules | `/api/weight-limit-rules` | `…(weight-limit-rules)` | VGM hard-block |
|
||||
| yards | `/api/yards` | `…(yards)` | routes/warehouses read |
|
||||
|
||||
**Settings & files** (reads open, writes `@FreightAdmin`):
|
||||
`/api/dropdown-settings/*`, `/api/file-upload-settings/*` (config that drives portal forms;
|
||||
server enforces *required-doc presence* at clearance, not size/MIME — those are client-side).
|
||||
`GET /api/files/:fileId` is `@Public` (browsers load `<img>/<a>` without a bearer); upload is **direct
|
||||
multipart** (multer memory → `FilesService` → MinIO `putObject`), presigned URLs used only for authenticated
|
||||
reads (300 s TTL). `routes` (`route_milestones`) and `facilities` are config readers with **no permission
|
||||
decorator** (behind the global JwtGuard only). NOTE: Note `FacilitiesModule` is never imported into `AppModule`,
|
||||
so `/api/facilities` is unmounted/dead.
|
||||
|
||||
---
|
||||
|
||||
## 9. Train Scheduling & Rail Operations
|
||||
|
||||
Two consist models: **`trains`** = static fleet inventory (never created by scheduling);
|
||||
**`train_sets`** = the operational consist scheduling builds per departure (**≥2 locomotives** +
|
||||
wagon-type slots, physical wagons *pinned* later).
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: create schedule (B) (≥2 locos, derive direction, freeze window rule)
|
||||
DRAFT --> DRAFT: assign-bookings / run-allocation / pin-wagons (B)
|
||||
DRAFT --> SCHEDULED: finalize (B) (bookings → Scheduled, ≥1 booking)
|
||||
SCHEDULED --> DISPATCHED: dispatch (B) (train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED)
|
||||
DISPATCHED --> DISPATCHED: recordCheckpoint (B) (corridor stations)
|
||||
DISPATCHED --> ARRIVED: arrive (B) (bookings IN_TRANSIT, locos+wagons freed, warehouse arrival automation)
|
||||
DRAFT --> CANCELLED: cancel (B)
|
||||
SCHEDULED --> CANCELLED: cancel (B)
|
||||
ARRIVED --> [*]
|
||||
```
|
||||
|
||||
**Reschedule** (`DRAFT`/`SCHEDULED` only): `preview` merges current + incoming bookings, sorts by
|
||||
`compareSchedulingPriority` (**government first, then priorityScore**), greedily keeps those that still
|
||||
fit → *retained*; overflow → *displaced*; commercial displaced are *readmitted* if room remains.
|
||||
`execute` re-verifies, optionally sets new departure, unassigns displaced, re-assigns final, writes a
|
||||
`scheduling_events` audit row. Triggers: `GOVERNMENT_PREEMPT`, `TRAIN_MAINTENANCE`.
|
||||
|
||||
Endpoint groups (`train-scheduling` prefix, `trainScheduling.view/manage`):
|
||||
|
||||
| Group | Representative routes |
|
||||
| --- | --- |
|
||||
| Discovery (customer (P), unguarded) | `bookable-schedules`, `available-days`, `available-days-for-cargo`, `my-booking-windows`, `contracts/:id/booking-windows` |
|
||||
| Board (staff (B)) | `batch-board`, `batch-board/:id`, `eligible-bookings`, `container|bulk/eligible-bookings`, `global-rules` |
|
||||
| Build | `container|bulk/preview`, `container|bulk/schedules` (create), `:id/assign-bookings`, `:id/assign-unassigned-booking`, `:id/pin-wagons`, `:id/run-allocation`, `:id/run-batch` |
|
||||
| Composition edits | `:id/wagons/:wagonId` (remove slot), `:id/container-items/:itemId`, `bookings/:bookingId/{mark-paid,expire,move-schedule}` |
|
||||
| Lifecycle | `schedules/:id/{finalize,dispatch,arrive,booking-window,doc-review-complete}`, `{container\|bulk}/schedules/:id/cancel` |
|
||||
| Tracking | `schedules/:id/checkpoints` (GET/POST) → writes `train_checkpoint_events` |
|
||||
| Djibouti import ops | `:id/import-djibouti/*` (gatepass, ready-for-loading, loaded, depart, load-list) |
|
||||
| Reschedule | `:id/reschedule/preview`, `:id/reschedule/execute`, `:id/maintenance` |
|
||||
|
||||
**Fleet master data** (`FleetView/Manage`): `/api/trains` (+`/:trainId/reorder-wagons`), `/api/wagons`
|
||||
(+`/assign-train`,`/unassign-train`), `/api/locomotives` (+`/decommission`), `/api/wagon-types`
|
||||
(rule-engine guarded). **Asset records** (unguarded, keyed by *road* `vehicleId`): `/api/maintenance/*`,
|
||||
`/api/fuel/*`. **`fleet_events`** is an append-only audit read via `drivers/:id/history` &
|
||||
`vehicles/:id/history`.
|
||||
|
||||
**Tracking (customer-facing):** `GET /api/tracking/:consignmentId` → `tracking_events` timeline
|
||||
(`location`, `ConsignmentStatus`, `occurredAt`), consumed by portal `TrackingPage`. It is **JWT-guarded**
|
||||
(no permission decorator — not public). Distinct from staff train **checkpoints** (`train_checkpoint_events`,
|
||||
written by `recordCheckpoint`/`arrive`). NOTE: **`TrackingService.record()` has no caller anywhere in the
|
||||
codebase — nothing writes `tracking_events`, so the customer tracking timeline is currently unpopulated;
|
||||
only the staff `train_checkpoint_events` store is written.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Warehouse — inventory lifecycle
|
||||
|
||||
Hierarchy `Facility → Warehouse → Yard → Zone`; every level tracks weight/volume/container capacity kept
|
||||
in sync by `applyCapacityDelta`. `warehouse_inventory` carries nullable FKs to `booking`, `cargo`,
|
||||
`container` — the join point between warehouse and the shipment.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> RECEIVED: receive / bulkReceive (B)
|
||||
[*] --> UNLOADED: auto-unload-arrived (import train) (B)
|
||||
RECEIVED --> STORED: store (B) (allocation rule picks yard/zone)
|
||||
UNLOADED --> STORED: store (B)
|
||||
UNLOADED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B)
|
||||
RECEIVED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B)
|
||||
STORED --> RESERVED: reserve (B) (booking PAID)
|
||||
RESERVED --> READY_FOR_LOADING: mark-ready-for-loading (B) (inspection PASSED)
|
||||
READY_FOR_LOADING --> LOADED: load onto wagon (B) (+ warehouse_loadings)
|
||||
LOADED --> DISPATCHED: dispatch / bulk-dispatch-export (B)
|
||||
DISPATCHED --> UNLOADED_AT_DJIBOUTI_PORT: auto-unload-at-djibouti (B)
|
||||
READY_FOR_PICKUP --> DELIVERED: release → deliver (B) (POD, fees PAID via gate-clearance)
|
||||
READY_FOR_PICKUP --> STORED: re-store import item (B)
|
||||
READY_FOR_PICKUP --> DISPATCHED: dispatch out (B)
|
||||
UNLOADED_AT_DJIBOUTI_PORT --> [*]
|
||||
DELIVERED --> [*]
|
||||
```
|
||||
|
||||
**Export branch:** receive → STORED → RESERVED (booking PAID) → READY_FOR_LOADING → LOADED → DISPATCHED
|
||||
→ UNLOADED_AT_DJIBOUTI_PORT. Note `store()` does **not** check inspection — `RECEIVED → STORED` is legal
|
||||
without it; inspection **PASSED** is enforced only at `mark-ready-for-loading`. **Import branch:** UNLOADED
|
||||
→ inspect PASSED → READY_FOR_PICKUP (auto-creates
|
||||
last-mile if requested) → release → DELIVERED. Every transition writes `warehouse_activity_log`.
|
||||
|
||||
**Storage billing** is *not* a separate table — `WarehouseInvoiceService` is a thin layer over the
|
||||
central **billing** module (global `Invoice` rows, `source='warehouse'`, `sourceId=inventoryId`). Fees =
|
||||
`STORAGE_FEE` + `DEMURRAGE` via `warehouse_fee_rules` (free-days grace, tiers, FX-converted). **Unpaid
|
||||
warehouse fees block exit:** `gateClearance`/`release` call `findBlockingInvoice` / `assertClearanceAllowed`.
|
||||
|
||||
Controller families (84 routes, all called by backoffice `warehouse.service.ts`; **no per-route
|
||||
permission decorators → behind the global JwtGuard only**): `warehouses` · `warehouse-yards` ·
|
||||
`warehouse-zones` · `warehouse-inventory` (queries + 25 mutation actions incl. bulk + import/export
|
||||
queues, and the gate release `warehouse-inventory/:id/gate-clearance`) · `warehouse-loadings` ·
|
||||
inspection (`…/inspection-reports`) · fee-invoices (`…/generate-fee-invoice`, `warehouse-fee-invoices/*`
|
||||
incl. `pay`, `pay-online`) · rules (`warehouse-allocation-rules`, `warehouse-fee-rules`,
|
||||
`warehouse-allocation/preview`).
|
||||
|
||||
> Portal touches only: `bookings/:id/warehouse-fee-invoices`, invoice `by-id`/`document`/`receipt`/
|
||||
> `pay-online`, and `bookings/:bookingId/approve-delivery` (customer signs handover). Everything else (B).
|
||||
|
||||
---
|
||||
|
||||
## 11. Field Operations — first/last mile & import customs
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
binv["booking.invoice.paid event"] --> advance["advanceBookingOnPayment → booking PAID"]
|
||||
advance --> fmreq{"EXPORT + first-mile requested?"}
|
||||
fmreq -->|Yes| fmaccept["firstMileService.acceptBooking<br/>(auto-create leg)"]
|
||||
fmreq -->|No| skip1["—"]
|
||||
fmaccept --> fmleg
|
||||
|
||||
subgraph FM["First-mile (EXPORT origin road leg)"]
|
||||
fmleg["READY_TO_TRANSIT"] --> fmveh["setVehicles → vehicle BUSY,<br/>SMS driver, fleet_events"]
|
||||
fmveh --> fmtransit["IN_TRANSIT (needs assigned vehicle)"]
|
||||
fmtransit --> fmdone["RECEIVED_TO_PORT (free vehicles)"]
|
||||
fmleg --> fminv["generate-invoice → FIRST_MILE fee<br/>(locks distances once invoiced)"]
|
||||
end
|
||||
|
||||
whrcv["warehouse inspection PASSED (IMPORT only)"] --> lmaccept["lastMileService.acceptBooking(reference)"]
|
||||
lmaccept --> lmleg
|
||||
subgraph LM["Last-mile (IMPORT destination road leg)"]
|
||||
lmleg["READY_TO_TRANSIT"] --> lmtransit["IN_TRANSIT"] --> lmdone["DELIVERED (free vehicles)"]
|
||||
lmleg --> lminv["generate-invoice → LAST_MILE fee"]
|
||||
end
|
||||
|
||||
subgraph IMP["Import customs finalization (per booking, timestamp-driven)"]
|
||||
direction TB
|
||||
up["upload docs (IM4/IM5/T1_CLOSURE/…)"] --> decl["record declaration serial"]
|
||||
decl --> notify["notify duties/taxes"]
|
||||
notify --> paid["mark duties paid (needs CUSTOMER_PAYMENT_SLIP)"]
|
||||
paid --> risk["assign risk (GREEN/YELLOW/BLUE/RED)"]
|
||||
risk --> rel["release-permitted (asserts T1 + release permit + declaration + risk + paid)"]
|
||||
end
|
||||
```
|
||||
|
||||
| Module | Base | Guard | Terminal state |
|
||||
| --- | --- | --- | --- |
|
||||
| first-mile | `/api/first-mile` (+`/accept/:ref`,`/:id/{vehicles,distances,invoice}`) | `trainScheduling.view/manage` | `RECEIVED_TO_PORT` |
|
||||
| last-mile | `/api/last-mile` (same shape) | `trainScheduling.view/manage` | `DELIVERED` |
|
||||
| import-operations | `/api/import-operations/{customs,djibouti-incidents,empty-container-returns}/*` | none (global JwtGuard) | `completedAt` |
|
||||
| interchange-documents | `/api/interchange-documents` (+`/generate-from-schedule`,`/:id/{acknowledge,dispute,cancel}`) | none | `ACKNOWLEDGED / DISPUTED` |
|
||||
| drivers | `/api/drivers` (+`/:id/history`) | `FleetView/Manage` | soft-delete |
|
||||
| vehicles | `/api/vehicles` (+`/:id/history`) | `FleetView/Manage` | soft-delete |
|
||||
|
||||
Interchange documents are the **rail↔port handover manifest** — generated from a train schedule,
|
||||
snapshotting booking/container/cargo lines with per-item `conditionStatus` derived from warehouse
|
||||
inspection flags. Status `DRAFT → GENERATED → ACKNOWLEDGED | DISPUTED | CANCELLED`. All FE callers (B).
|
||||
|
||||
**Mile trigger precision (verified):** first-mile is created only on `booking.invoice.paid` →
|
||||
`advanceBookingOnPayment` → `firstMile.acceptBooking(bookingId)` when `EXPORT` + first-mile requested.
|
||||
Last-mile is created **only on IMPORT inspection PASSED** (two call sites: `warehouse-inspection.service`
|
||||
and the bulk-inspect branch of `warehouse-inventory.service`) — **not** on warehouse *receive*. The IMPORT
|
||||
constraint is enforced by the warehouse caller, not inside `lastMile.acceptBooking(reference)`. Auto-created
|
||||
legs enter at **`READY_TO_TRANSIT`** (the `PAYMENT_PENDING` entity default is bypassed).
|
||||
|
||||
---
|
||||
|
||||
## 12. Billing & Payment
|
||||
|
||||
### 12.1 Invoice lifecycle
|
||||
|
||||
Invoices are **source-agnostic** — `BillingService.generateInvoice()` is the single factory called by
|
||||
domain services (never a controller): `source ∈ {booking, warehouse, first_mile, last_mile}` (the enum
|
||||
also defines an unused `demurrage`), numbered `INV-YYYYMMDD-#####`, `dueAt = now + 14d`. **Initial status
|
||||
varies by source:** booking invoices start `DRAFT` (issued at operation-accept via `billing.updateStatus`);
|
||||
the `generateInvoice` default is `PENDING`; warehouse + contract-GL invoices start `ISSUED`.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: booking invoice (issued at operation-accept)
|
||||
[*] --> PENDING: generateInvoice default
|
||||
[*] --> ISSUED: warehouse / contract-GL invoice
|
||||
DRAFT --> ISSUED: billing.updateStatus
|
||||
PENDING --> PARTIALLY_PAID: recordPayment (partial, offline)
|
||||
ISSUED --> PARTIALLY_PAID: recordPayment (partial, offline)
|
||||
PENDING --> PAID: markInvoiceAsPaid (gateway, full)
|
||||
ISSUED --> PAID: markInvoiceAsPaid (gateway, full)
|
||||
PARTIALLY_PAID --> PAID: final payment
|
||||
PENDING --> EXPIRED: expirePayable (pay window lapses)
|
||||
ISSUED --> CANCELLED: cancelInvoice (no payments)
|
||||
PAID --> REFUNDED: markInvoiceAsRefunded (paidAmount>0)
|
||||
PAID --> [*]
|
||||
note right of PAID
|
||||
emits ${source}.invoice.paid
|
||||
(sources use first_mile / last_mile, underscores)
|
||||
→ domain listeners advance booking / mile / warehouse
|
||||
end note
|
||||
note left of ISSUED
|
||||
OVERDUE exists in the enum but NO code sets it
|
||||
(no cron / setter) — effectively unused
|
||||
end note
|
||||
```
|
||||
|
||||
`OPEN_STATUSES = {Issued, Pending, PartiallyPaid, Overdue}` are payable. Transitions lock the row
|
||||
(`pessimistic_write`); the event fires **after commit** for self-managed transitions, but **inline before
|
||||
commit** when the transition is enlisted in a caller-supplied transaction `manager`.
|
||||
|
||||
### 12.2 freight-api ↔ payment-api integration
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant U as Customer (P)
|
||||
participant FB as Freight billing (:3001)
|
||||
participant PA as Payment API (red) (:3003)
|
||||
participant PV as Provider (Telebirr/Waafi/…)
|
||||
participant OB as Payment outbox
|
||||
participant FI as Freight internal ctrl
|
||||
|
||||
U->>FB: POST /billing/my-invoices/:id/pay
|
||||
FB->>FB: payInvoice → validate OPEN, balance>0
|
||||
FB->>PA: POST /payments/initiate (x-service-token)<br/>service=FREIGHT, referenceId=sourceId, amountMinor
|
||||
PA-->>FB: { intentId, clientAction (REDIRECT/LAUNCH_APP/COLLECT_OTP) }
|
||||
FB->>FB: store paymentId on invoice (correlation)
|
||||
FB-->>U: clientAction → redirect to provider
|
||||
U->>PV: authorize payment
|
||||
PV->>PA: webhook POST /webhooks/{provider}
|
||||
PA->>PA: verify signature, dedupe, intent state machine
|
||||
PA->>OB: write PaymentEvent (payment.succeeded)
|
||||
OB->>FI: POST /api/internal/payments/mark-paid (@Public, x-service-token)
|
||||
Note over OB,FI: or RabbitMQ → PaymentEventsConsumer
|
||||
FI->>FB: handlePaymentEvent → settleByPaymentId → markInvoiceAsPaid
|
||||
FB->>FB: emit booking.invoice.paid
|
||||
FB-->>U: invoice PAID (poll / notification)
|
||||
```
|
||||
|
||||
> NOTE: **Demo shortcut in `payInvoice`:** today, if the provider doesn't settle synchronously, freight
|
||||
> self-fires `handlePaymentEvent(payment.succeeded)` inline (marked TODO/remove) — invoices settle at
|
||||
> pay-time without a real webhook. Providers: **Telebirr / CBE_BIRR / EBIRR** (ET), **Waafi / DMoney**
|
||||
> (DJ), **CARD** (intl), **CAC_BANK** (OTP). Payment DB is a **separate `edr_payment` schema** — no
|
||||
> cross-schema FKs; `referenceId` is a soft link.
|
||||
|
||||
### 12.3 Endpoints
|
||||
|
||||
| Method | Path | Facing | Guard |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/billing/invoices` (+`/:id`,`/:id/document`,`/receipt`) | (B) backoffice | `bookings.view` |
|
||||
| GET | `/api/billing/my-invoices` (+`/:id`,`/document`,`/receipt`) | (P) portal | `@CurrentUser` ownership |
|
||||
| POST | `/api/billing/my-invoices/:id/pay` | (P) portal | ownership |
|
||||
| POST | `/api/payments/initiate` | central | JwtGuard (no permission — **not** public) |
|
||||
| GET | `/api/payments/checkout` | redirect | `@Public` |
|
||||
| GET | `/api/payments/{summary,all}` | (B) backoffice | `bookings.view` |
|
||||
| GET | `/api/payments/by-company/:companyId/customer-view` | (B) | none |
|
||||
| GET | `/api/payments/intents/:bookingId` · `/receipt/:orderId` | reconcile / receipt | none / `@Public` |
|
||||
| POST | `/api/internal/payments/mark-paid` | (red) service→service | `@Public` (NOTE: currently unauthenticated) |
|
||||
| POST·GET·PUT | `/api/backoffice/organizations/:orgId/*` | (B) IAM user/role mgmt (NOT billing) | `@FreightAdmin` |
|
||||
|
||||
`GET /api/overview*` (7 tabs, `bookings.view`) is the backoffice dashboard aggregator over 11 repos.
|
||||
|
||||
---
|
||||
|
||||
## 13. Cross-cutting event web (`@OnEvent`)
|
||||
|
||||
The domains are stitched together by events, not direct calls. This is why the master flow "just happens".
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
pay["Payment webhook / demo shortcut"] --> settle["billing.settleByPaymentId → markInvoiceAsPaid"]
|
||||
settle --> ev1{{"${source}.invoice.paid"}}
|
||||
ev1 -->|source=booking| adv["booking-invoice: advanceBookingOnPayment → PAID"]
|
||||
adv --> fm["firstMile.acceptBooking (EXPORT + requested)"]
|
||||
adv --> batch["train-scheduling batch: markPaid → allocate"]
|
||||
ev1 -->|source=first_mile| e2{{"first_mile.invoice.paid"}} --> fmp["NOTE: intended: leg paid=true<br/>(listener typo 'firstmile.invoice.paid' → never fires)"]
|
||||
ev1 -->|source=last_mile| e3{{"last_mile.invoice.paid"}} --> lmp["last-mile → DELIVERED, paid=true"]
|
||||
ev1 -->|source=warehouse| e4{{"warehouse.invoice.paid"}} --> whp["settle warehouse fee → unblock gate"]
|
||||
|
||||
arrive["train arrive"] --> whauto["warehouse arrival automation:<br/>auto-unload arrived bookings"]
|
||||
insp["warehouse inspection PASSED (IMPORT)"] --> lmacc["lastMile.acceptBooking → READY_FOR_PICKUP"]
|
||||
|
||||
assign["mile setVehicles"] --> veh["vehicle BUSY + SMS driver + fleet_events"]
|
||||
release["mile terminal / delete"] --> free["vehicle FREE (releaseIfUnused)"]
|
||||
|
||||
notify["notification-inbox.notify"] --> ws["WebSocket push (always)"]
|
||||
notify -->|HIGH priority| smsemail["+ SMS + email via RabbitMQ"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Portal vs Backoffice — who does what
|
||||
|
||||
| Capability | (P) Portal (customer) | (B) Backoffice (employee) |
|
||||
| --- | --- | --- |
|
||||
| Identity / onboarding | signup, OTP, Fayda, company onboarding, profile | company approval, IAM user/role mgmt |
|
||||
| Bookings | create, price, submit, sign, upload docs, pay, approve delivery, track | accept, approve chain, generate contract, clearance review, operation review, allocate, dispatch |
|
||||
| Contracts | create, submit, sign, booking-requests, self-clearance slips | approve, generate, phased clearance, GL booking creation |
|
||||
| Rule engine | reads only via `bookings/reference-data` | full CRUD (rates approval, approval rules, priorities) |
|
||||
| Scheduling | discover bookable days/schedules | build/finalize/dispatch/arrive/reschedule trains |
|
||||
| Warehouse | pay storage fees, view invoices, approve handover | full inventory lifecycle + fees + rules |
|
||||
| Field ops | (none direct) | first/last mile, import customs, interchange docs, fleet |
|
||||
| Billing | pay own invoices, download docs/receipts | invoice + payment dashboards, per-customer views |
|
||||
| Notifications | inbox + WS | inbox + WS |
|
||||
|
||||
---
|
||||
|
||||
## 15. Status / state reference
|
||||
|
||||
| Entity | States (happy path → terminal) |
|
||||
| --- | --- |
|
||||
| Company | `PENDING → ACTIVE` (+ SUSPENDED, BLACKLISTED) |
|
||||
| Booking | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → [IMPORT/EXPORT: AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → OPERATION_REQUEST_PENDING → (train) FULLY_EXECUTED → SELECTED_FOR_BATCH / (road) ROAD_DISPATCH_PENDING] · [DOMESTIC: → FULLY_EXECUTED → SELECTED_FOR_BATCH] → PAID → IN_TRANSIT → COMPLETED` (branches: REJECTED, CANCELLED, CHANGES_REQUESTED, OPERATION_CHANGES_REQUESTED, PENDING_CONSOLIDATION). 35 statuses total; ~10 legacy ones unused. |
|
||||
| Contract | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → AWAITING_CLEARANCE_DOCUMENTS / CONTRACT_ACTIVE / FULLY_EXECUTED` (+ CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, RENEWAL_DRAFT/SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED, EXPIRED — **24 total**). Separate `clearanceStatus` axis: NOT_APPLICABLE / AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW / CLEARANCE_READY_FOR_BOOKING / SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS. |
|
||||
| Rate | `DRAFT → PENDING_APPROVAL → LIVE` (+ SUPERSEDED) |
|
||||
| Train schedule | `DRAFT → SCHEDULED → DISPATCHED → ARRIVED` (+ CANCELLED) |
|
||||
| Warehouse inventory | export: `RECEIVED → STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED → UNLOADED_AT_DJIBOUTI_PORT` · import: `UNLOADED → READY_FOR_PICKUP → DELIVERED` (READY_FOR_PICKUP may also → STORED / DISPATCHED) |
|
||||
| First-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT` (auto-created legs enter at READY_TO_TRANSIT) |
|
||||
| Last-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → DELIVERED` (auto-created legs enter at READY_TO_TRANSIT) |
|
||||
| Invoice | `DRAFT / PENDING / ISSUED → PARTIALLY_PAID → PAID` (+ EXPIRED, CANCELLED, REFUNDED; **OVERDUE defined but never set**) |
|
||||
| Interchange doc | `DRAFT → GENERATED → ACKNOWLEDGED / DISPUTED / CANCELLED` |
|
||||
| Empty container return | `RETURNED → ASSIGNED_STORAGE → DOCUMENTATION_CLEARED → WAGON_ALLOCATED → TRANSPORTED_TO_DJIBOUTI → HANDOVER_ISSUED → COMPLETED` |
|
||||
|
||||
---
|
||||
|
||||
## 16. Notable gaps & caveats (verified against code)
|
||||
|
||||
- **Counter-sign is direction-based, not customs-based** (bookings *and* contracts): `IMPORT`/`EXPORT` open a
|
||||
clearance gate even with customs off; only `DOMESTIC` skips it. The customs flag only selects the clearance
|
||||
document set.
|
||||
- **`SELECTED_FOR_BATCH` is set by the batch engine, not at operation-accept** — train accept sets
|
||||
`FULLY_EXECUTED` first.
|
||||
- **`FacilitiesModule` is never imported into `AppModule`** — `/api/facilities` is **unmounted / dead** (not
|
||||
reachable at all). `tracking`, `maintenance`, `fuel` carry no permission decorator but *are* mounted — behind
|
||||
the **global JwtGuard**, just not permission-gated.
|
||||
- **`tracking_events` has no writer anywhere** — `TrackingService.record()` is never called, so the customer
|
||||
tracking timeline is unpopulated. Only staff `train_checkpoint_events` are written (by `recordCheckpoint`/`arrive`).
|
||||
- **First-mile paid-flag listener is a dead code path** — `@OnEvent("firstmile.invoice.paid")` (no underscore)
|
||||
never fires because the emitted event is `first_mile.invoice.paid`; the first-mile `paid` flag is never flipped by settlement.
|
||||
- **Invoice `OVERDUE` status is never set** — no cron/setter transitions to it, though it is in the enum and `OPEN_STATUSES`.
|
||||
- `POST /api/internal/payments/mark-paid` is `@Public` with **no service auth** (ServiceAuthGuard removed; noted in code).
|
||||
- **Demo settlement shortcut** in `billing.payInvoice` bypasses real webhooks (TODO/remove) — invoices settle inline at pay-time.
|
||||
- Server does **not** validate upload size/MIME (client-side only); it enforces *required-doc presence* at clearance.
|
||||
- Dead / unwired FE calls: `trains/:id/details` (no route), portal `consignments` service hits `/consignments` without `/api`, and list endpoints `bookings/my`, `queues/:queue`, `by-company/customer-view` have no active caller.
|
||||
- NOTE: The repo `CLAUDE.md` states auth is stubbed/unwired — **this is stale**: auth is live via `@tria-plc/api-common` (global `JwtGuard` + `HasActiveDelegationGuard` `APP_GUARD`s).
|
||||
|
||||
---
|
||||
|
||||
*Generated from `apps/edr-freight-api`, `apps/edr-freight-web/{portal,backoffice}`, `apps/edr-payment-api`
|
||||
on branch `freight/feat/fixes-v1`. Reflects code at scan time; regenerate after major module changes.*
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||
"predev": "pnpm run clean",
|
||||
"dev": "nest start --watch",
|
||||
"dev": "nest start --watch --clearScreen false",
|
||||
"prebuild": "pnpm run clean",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
@@ -22,7 +22,9 @@
|
||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||
"seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts",
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
@@ -32,7 +34,8 @@
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -47,9 +50,11 @@
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/microservices": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.1.27",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
@@ -61,12 +66,14 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jose": "^5.10.0",
|
||||
"libphonenumber-js": "^1.13.6",
|
||||
"minio": "7.1.3",
|
||||
"pg": "^8.13.0",
|
||||
"puppeteer": "^24.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"typeorm": "^0.3.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -84,13 +91,16 @@
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.6.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/vorpal": "^1.12.8",
|
||||
"jest": "^29.7.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.5.4",
|
||||
"vorpal": "^1.12.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
67
apps/edr-freight-api/py/README.md
Normal file
67
apps/edr-freight-api/py/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Contract seed driver
|
||||
|
||||
Creates freight contracts across every flow variant by driving the freight API
|
||||
over HTTP end-to-end — from DRAFT through **both signatures** (customer sign +
|
||||
staff counter-sign). It stops right after the staff counter-sign; no clearance
|
||||
or booking steps are run.
|
||||
|
||||
## What it builds
|
||||
|
||||
20 real flows (movement × kind × customs × freight), each created twice → **40
|
||||
contracts** on `all`.
|
||||
|
||||
| Movement | Kind | Customs | Freight | Count |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| intercity (DOMESTIC) | one-time / general | without only¹ | bulk / container | 4 |
|
||||
| import (IMPORT) | one-time / general | with / without | bulk / container | 8 |
|
||||
| export (EXPORT) | one-time / general | with / without | bulk / container | 8 |
|
||||
|
||||
¹ intercity + customs is not a real combo — DOMESTIC has no clearance gate, so
|
||||
the customs flag is ignored. Those four are skipped, leaving 20 (16 working + 4
|
||||
`with-customs + bulk`).
|
||||
|
||||
The four `with-customs + bulk` flows are still built here: the known break is
|
||||
downstream in clearance (customs output docs are container-only), which this
|
||||
script does not reach, so all 20 reach a signed state.
|
||||
|
||||
## Terminal status after both signatures (by dimension)
|
||||
|
||||
- DOMESTIC one-time → `FULLY_EXECUTED`
|
||||
- any GENERAL, and DOMESTIC general → `CONTRACT_ACTIVE`
|
||||
- IMPORT/EXPORT one-time (customs or self-clearance) → `AWAITING_CLEARANCE_DOCUMENTS`
|
||||
(fully signed; clearance not driven)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd apps/edr-freight-api/py
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # then fill it in
|
||||
```
|
||||
|
||||
Fill `.env`: customer + admin IAM credentials (admin should be a **super_admin**),
|
||||
`OTP_PHONE`, and the Postgres connection (used only to read the sign-OTP).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python create_contracts.py # all 20 flows
|
||||
python create_contracts.py intercity # only DOMESTIC flows (4)
|
||||
python create_contracts.py import # only IMPORT flows (8)
|
||||
python create_contracts.py import export # IMPORT + EXPORT (16)
|
||||
```
|
||||
|
||||
Filters are by movement: `intercity`, `import`, `export` (pass one or many);
|
||||
no arg or `all` runs everything.
|
||||
|
||||
## How auth + OTP work
|
||||
|
||||
- **Login**: `POST /api/auth/login` with `{ email, password }` returns a JWT
|
||||
(`token`), sent as `Authorization: Bearer <token>`. MFA accounts are not
|
||||
supported — the script errors out clearly if MFA is required.
|
||||
- **Actors**: the customer token does create/submit/customer-sign; the admin
|
||||
token does staff-accept/approve/generate/counter-sign.
|
||||
- **Sign OTP**: customer sign needs a fresh 6-digit OTP. The script calls
|
||||
`POST /api/otp/send { phone }`, reads the plaintext code from
|
||||
`<schema>.otp_verifications` in Postgres, then signs within the 5-minute TTL.
|
||||
Binary file not shown.
504
apps/edr-freight-api/py/create_contracts.py
Normal file
504
apps/edr-freight-api/py/create_contracts.py
Normal file
@@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed freight contracts across every flow variant, driven end-to-end over HTTP.
|
||||
|
||||
For each flow the script logs in, creates a DRAFT contract, and pushes it through
|
||||
the lifecycle up to and INCLUDING both signatures (customer sign + staff
|
||||
counter-sign). It STOPS after the staff counter-sign — no clearance, no booking.
|
||||
|
||||
Flow dimensions (3 x 2 x 2 x 2 = 24 combos, but only the 20 real ones are built):
|
||||
movement : intercity(DOMESTIC) | import(IMPORT) | export(EXPORT)
|
||||
kind : one-time(ONE_TIME) | general(GENERAL)
|
||||
customs : without | with (customsClearingEnabled)
|
||||
freight : bulk(BULK) | container(CONTAINER)
|
||||
|
||||
intercity + customs is dropped (DOMESTIC ignores customs → no real combo), which
|
||||
removes 4 dead combos and leaves 20 flows (16 working + 4 customs+bulk whose
|
||||
break is downstream in clearance). Each is created twice → 40 contracts on `all`.
|
||||
|
||||
CLI (filter by movement, pass one or many):
|
||||
python create_contracts.py # all 20 flows
|
||||
python create_contracts.py all # all 20 flows
|
||||
python create_contracts.py intercity # only DOMESTIC flows
|
||||
python create_contracts.py import # only IMPORT flows
|
||||
python create_contracts.py import export # IMPORT + EXPORT flows
|
||||
|
||||
Config comes from .env (see .env.example). Requires: requests, psycopg,
|
||||
python-dotenv (see requirements.txt).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
load_dotenv(HERE / ".env")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Config
|
||||
# --------------------------------------------------------------------------- #
|
||||
API_URL = os.getenv("FREIGHT_API_URL", "http://localhost:3001/api").rstrip("/")
|
||||
|
||||
CUSTOMER_EMAIL = os.getenv("CUSTOMER_EMAIL", "")
|
||||
CUSTOMER_PASSWORD = os.getenv("CUSTOMER_PASSWORD", "")
|
||||
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
|
||||
|
||||
# Phone the sign-OTP is sent to and read back from Postgres. Resolved at runtime
|
||||
# from the customer's own IAM profile (GET /api/me → phoneNumber). OTP_PHONE is an
|
||||
# optional override / fallback used only when the customer has no phone on file.
|
||||
# The sign endpoint keys the OTP purely on this number, so it just has to be the
|
||||
# same value for "send" and "read".
|
||||
OTP_PHONE = os.getenv("OTP_PHONE", "")
|
||||
OTP_PHONE_FALLBACK = os.getenv("OTP_PHONE_FALLBACK", "251900000000")
|
||||
|
||||
# DB connection used ONLY to read the plaintext sign-OTP from freight.otp_verifications.
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = os.getenv("DB_PORT", "5432")
|
||||
DB_NAME = os.getenv("DB_NAME", "edr_dev")
|
||||
DB_USER = os.getenv("DB_USER", "postgres")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
|
||||
DB_SCHEMA = os.getenv("DB_SCHEMA", "freight")
|
||||
|
||||
WAAFI = HERE / "waafi.jpeg"
|
||||
|
||||
VALIDITY_DAYS = int(os.getenv("VALIDITY_DAYS", "365"))
|
||||
CONTRACTS_PER_FLOW = int(os.getenv("CONTRACTS_PER_FLOW", "2"))
|
||||
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "60"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Flow matrix — the 20 real flows
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class Flow:
|
||||
movement: str # intercity | import | export
|
||||
trade_direction: str # DOMESTIC | IMPORT | EXPORT
|
||||
kind: str # ONE_TIME | GENERAL
|
||||
customs: bool # customsClearingEnabled
|
||||
freight: str # BULK | CONTAINER
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return (
|
||||
f"{self.movement}+{'general' if self.kind == 'GENERAL' else 'one-time'}"
|
||||
f"+{'with' if self.customs else 'no'}-customs"
|
||||
f"+{self.freight.lower()}"
|
||||
)
|
||||
|
||||
|
||||
def build_flow_matrix() -> list[Flow]:
|
||||
movements = [
|
||||
("intercity", "DOMESTIC"),
|
||||
("import", "IMPORT"),
|
||||
("export", "EXPORT"),
|
||||
]
|
||||
kinds = ["ONE_TIME", "GENERAL"]
|
||||
freights = ["BULK", "CONTAINER"]
|
||||
|
||||
flows: list[Flow] = []
|
||||
for movement, direction in movements:
|
||||
# DOMESTIC ignores customs (no clearance gate) → customs=True is not a
|
||||
# real combo. Only build without-customs for intercity.
|
||||
customs_options = [False] if direction == "DOMESTIC" else [False, True]
|
||||
for kind in kinds:
|
||||
for customs in customs_options:
|
||||
for freight in freights:
|
||||
flows.append(Flow(movement, direction, kind, customs, freight))
|
||||
return flows
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HTTP client
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ApiError(RuntimeError):
|
||||
def __init__(self, method: str, path: str, resp: requests.Response):
|
||||
body = resp.text
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
super().__init__(f"{method} {path} -> {resp.status_code}: {body}")
|
||||
self.status_code = resp.status_code
|
||||
|
||||
|
||||
class Client:
|
||||
"""Thin wrapper that carries a bearer token."""
|
||||
|
||||
def __init__(self, name: str, token: str | None = None):
|
||||
self.name = name
|
||||
self.token = token
|
||||
|
||||
def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
h: dict[str, str] = {}
|
||||
if self.token:
|
||||
h["Authorization"] = f"Bearer {self.token}"
|
||||
if extra:
|
||||
h.update(extra)
|
||||
return h
|
||||
|
||||
def get(self, path: str, params: dict | None = None) -> Any:
|
||||
r = requests.get(
|
||||
f"{API_URL}{path}",
|
||||
headers=self._headers(),
|
||||
params=params,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not r.ok:
|
||||
raise ApiError("GET", path, r)
|
||||
return r.json() if r.content else None
|
||||
|
||||
def post_json(self, path: str, body: dict | None = None) -> Any:
|
||||
r = requests.post(
|
||||
f"{API_URL}{path}",
|
||||
headers=self._headers({"Content-Type": "application/json"}),
|
||||
json=body or {},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not r.ok:
|
||||
raise ApiError("POST", path, r)
|
||||
return r.json() if r.content else None
|
||||
|
||||
def post_multipart(
|
||||
self, path: str, data: dict[str, str], files: list[tuple] | None = None
|
||||
) -> Any:
|
||||
r = requests.post(
|
||||
f"{API_URL}{path}",
|
||||
headers=self._headers(), # requests sets multipart Content-Type
|
||||
data=data,
|
||||
files=files or [],
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not r.ok:
|
||||
raise ApiError("POST", path, r)
|
||||
return r.json() if r.content else None
|
||||
|
||||
|
||||
def login(email: str, password: str, who: str) -> Client:
|
||||
r = requests.post(
|
||||
f"{API_URL}/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not r.ok:
|
||||
raise ApiError("POST", "/auth/login", r)
|
||||
payload = r.json()
|
||||
if payload.get("mfaRequired"):
|
||||
raise RuntimeError(
|
||||
f"{who} login requires MFA — this script cannot complete an MFA login. "
|
||||
"Disable MFA for the seed account or supply a non-MFA account."
|
||||
)
|
||||
token = payload.get("token")
|
||||
if not token:
|
||||
raise RuntimeError(f"{who} login returned no token: {payload}")
|
||||
return Client(who, token)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OTP — send + read from Postgres
|
||||
# --------------------------------------------------------------------------- #
|
||||
def resolve_otp_phone(customer: Client) -> str:
|
||||
"""Phone the sign-OTP is sent to. Prefer the customer's own IAM profile phone
|
||||
(GET /api/me → phoneNumber); fall back to OTP_PHONE, then OTP_PHONE_FALLBACK.
|
||||
The value only has to be consistent between send + DB read."""
|
||||
phone = ""
|
||||
try:
|
||||
me = customer.get("/me") or {}
|
||||
phone = (me.get("phoneNumber") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
phone = phone or OTP_PHONE or OTP_PHONE_FALLBACK
|
||||
if not phone:
|
||||
raise RuntimeError(
|
||||
"Could not resolve an OTP phone (customer has none, and neither "
|
||||
"OTP_PHONE nor OTP_PHONE_FALLBACK is set)."
|
||||
)
|
||||
return phone
|
||||
|
||||
|
||||
def send_otp(customer: Client, phone: str) -> None:
|
||||
# POST /api/otp/send is @Public — no token needed, but sending one is harmless.
|
||||
customer.post_json("/otp/send", {"phone": phone})
|
||||
|
||||
|
||||
def read_otp_from_db(phone: str) -> str:
|
||||
"""Read the freshest plaintext OTP for `phone` from freight.otp_verifications."""
|
||||
dsn = (
|
||||
f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} "
|
||||
f"user={DB_USER} password={DB_PASSWORD}"
|
||||
)
|
||||
with psycopg.connect(dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f'SELECT otp FROM "{DB_SCHEMA}".otp_verifications '
|
||||
"WHERE phone = %s ORDER BY updated_at DESC LIMIT 1",
|
||||
(phone,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError(f"No OTP row found for phone {phone} in {DB_SCHEMA}.otp_verifications")
|
||||
return str(row[0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reference-data lookups (yards / service types / cargo types)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass
|
||||
class RefData:
|
||||
yards: list[dict] = field(default_factory=list)
|
||||
service_types: list[dict] = field(default_factory=list)
|
||||
cargo_types: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _as_items(resp: Any) -> list[dict]:
|
||||
if isinstance(resp, list):
|
||||
return resp
|
||||
if isinstance(resp, dict):
|
||||
return resp.get("items") or resp.get("data") or []
|
||||
return []
|
||||
|
||||
|
||||
def load_ref_data(client: Client) -> RefData:
|
||||
ref = RefData(
|
||||
yards=_as_items(client.get("/yards")),
|
||||
service_types=_as_items(client.get("/service-types")),
|
||||
cargo_types=_as_items(client.get("/cargo-types")),
|
||||
)
|
||||
if len(ref.yards) < 2:
|
||||
raise RuntimeError(f"Need >=2 yards, got {len(ref.yards)}. Seed yards first.")
|
||||
if not ref.service_types:
|
||||
raise RuntimeError("No service types found. Seed service types first.")
|
||||
if not ref.cargo_types:
|
||||
raise RuntimeError("No cargo types found. Seed cargo types first.")
|
||||
return ref
|
||||
|
||||
|
||||
def pick_service_type(ref: RefData, wants_customs: bool) -> str:
|
||||
"""Prefer a service type whose includesCustoms matches the flow's customs need."""
|
||||
for st in ref.service_types:
|
||||
if bool(st.get("includesCustoms")) == wants_customs:
|
||||
return st["id"]
|
||||
# Fall back to any — customsClearingEnabled on the contract still drives the flow.
|
||||
return ref.service_types[0]["id"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Contract payload builder
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_create_payload(flow: Flow, ref: RefData, idx: int) -> dict[str, str]:
|
||||
"""Return multipart form fields. Booleans as 'true'/'false' strings; nested
|
||||
arrays as JSON strings (implicit conversion is off in the API)."""
|
||||
import json
|
||||
|
||||
origin = ref.yards[0]["id"]
|
||||
destination = ref.yards[1]["id"]
|
||||
service_type_id = pick_service_type(ref, flow.customs)
|
||||
|
||||
# Cargo scope: CONTAINER -> >=1 size row; BULK -> exactly one cargo-type row.
|
||||
if flow.freight == "CONTAINER":
|
||||
cargo_scope = [{"containerSize": "20ft"}]
|
||||
if flow.kind == "GENERAL":
|
||||
cargo_scope[0]["quantityCap"] = 10
|
||||
else: # BULK
|
||||
cargo_scope = [{"cargoTypeId": ref.cargo_types[0]["id"]}]
|
||||
if flow.kind == "GENERAL":
|
||||
cargo_scope[0]["quantityCap"] = 1000
|
||||
|
||||
# Routes: ONE_TIME -> exactly 1; GENERAL -> 1..N (one is fine).
|
||||
routes = [{"originYardId": origin, "destinationYardId": destination, "sortOrder": 0}]
|
||||
|
||||
fields: dict[str, str] = {
|
||||
"contractKind": flow.kind,
|
||||
"tradeDirection": flow.trade_direction,
|
||||
"freightType": flow.freight,
|
||||
"serviceTypeId": service_type_id,
|
||||
"paymentCurrency": "ETB",
|
||||
"customsClearingEnabled": "true" if flow.customs else "false",
|
||||
"contractType": "SPOT",
|
||||
"cargoScope": json.dumps(cargo_scope),
|
||||
"routes": json.dumps(routes),
|
||||
}
|
||||
if flow.customs:
|
||||
fields["customsClearingAgent"] = "Seed Agent"
|
||||
return fields
|
||||
|
||||
|
||||
def signature_b64() -> str:
|
||||
return base64.b64encode(WAAFI.read_bytes()).decode()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lifecycle driver — create → submit → accept → approve → generate → sign x2
|
||||
# --------------------------------------------------------------------------- #
|
||||
def waafi_file_tuple(field_name: str) -> tuple:
|
||||
return (field_name, (WAAFI.name, WAAFI.read_bytes(), "image/jpeg"))
|
||||
|
||||
|
||||
def drive_flow(
|
||||
flow: Flow, idx: int, customer: Client, admin: Client, ref: RefData, otp_phone: str
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"flow": flow.label, "n": idx, "status": None}
|
||||
|
||||
# S1 — create (customer, multipart, waafi attached as intake doc)
|
||||
fields = build_create_payload(flow, ref, idx)
|
||||
contract = customer.post_multipart(
|
||||
"/contracts", data=fields, files=[waafi_file_tuple("intake_document")]
|
||||
)
|
||||
cid = contract["id"]
|
||||
result["contractId"] = cid
|
||||
result["reference"] = contract.get("reference")
|
||||
|
||||
# S2 — submit (customer). May go to PRICE_CHANGED_PENDING_CONFIRM → confirm.
|
||||
contract = customer.post_json(f"/contracts/{cid}/submit")
|
||||
if (contract or {}).get("status") == "PRICE_CHANGED_PENDING_CONFIRM":
|
||||
contract = customer.post_json(f"/contracts/{cid}/confirm-submit")
|
||||
|
||||
# S3 — staff accept (admin) → PENDING_APPROVAL + approval chain
|
||||
admin.post_json(f"/contracts/{cid}/staff/accept", {"validityDays": VALIDITY_DAYS})
|
||||
|
||||
# S4 — approve every pending step IN ORDER with its exact requiredRole (admin)
|
||||
approve_all_steps(admin, cid)
|
||||
|
||||
# S5 — generate contract document (admin) → CONTRACT_READY
|
||||
admin.post_json(f"/contracts/{cid}/contract/generate")
|
||||
|
||||
# S6 — customer sign (needs OTP) → SIGNED_CUSTOMER
|
||||
send_otp(customer, otp_phone)
|
||||
time.sleep(1.0) # let the OTP row land
|
||||
otp = read_otp_from_db(otp_phone)
|
||||
customer.post_json(
|
||||
f"/contracts/{cid}/contract/sign",
|
||||
{
|
||||
"role": "CUSTOMER",
|
||||
"signatureImageBase64": signature_b64(),
|
||||
"signerDisplayName": "Seed Customer",
|
||||
"consentText": "I agree.",
|
||||
"otp": otp,
|
||||
"otpPhone": otp_phone,
|
||||
},
|
||||
)
|
||||
|
||||
# S7 — staff counter-sign (admin) → FULLY_EXECUTED / CONTRACT_ACTIVE /
|
||||
# AWAITING_CLEARANCE_DOCUMENTS depending on dimension. STOP HERE.
|
||||
signed = admin.post_json(
|
||||
f"/contracts/{cid}/contract/sign",
|
||||
{
|
||||
"role": "STAFF",
|
||||
"signatureImageBase64": signature_b64(),
|
||||
"signerDisplayName": "Seed Staff",
|
||||
"consentText": "Countersigned.",
|
||||
},
|
||||
)
|
||||
result["status"] = (signed or {}).get("status")
|
||||
return result
|
||||
|
||||
|
||||
def approve_all_steps(admin: Client, cid: str) -> None:
|
||||
"""Read the contract, approve each PENDING approval step in order. Superadmin
|
||||
can approve any role, but the endpoint still checks step.requiredRole == body,
|
||||
so we echo the step's own requiredRole back."""
|
||||
guard = 0
|
||||
while True:
|
||||
guard += 1
|
||||
if guard > 12:
|
||||
raise RuntimeError(f"Approval loop exceeded 12 iterations for {cid}")
|
||||
contract = admin.get(f"/contracts/{cid}")
|
||||
steps = contract.get("approvalSteps") or []
|
||||
pending = [s for s in steps if s.get("status") == "PENDING"]
|
||||
if not pending:
|
||||
return
|
||||
# findNextPendingApprovalStep orders by sequence; sort the same way.
|
||||
pending.sort(key=lambda s: s.get("sequence", s.get("sortOrder", 0)))
|
||||
step = pending[0]
|
||||
admin.post_json(
|
||||
f"/contracts/{cid}/approval-steps/{step['id']}/approve",
|
||||
{"requiredRole": step["requiredRole"]},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Main
|
||||
# --------------------------------------------------------------------------- #
|
||||
VALID_FILTERS = {"all", "intercity", "import", "export"}
|
||||
|
||||
|
||||
def parse_filters(argv: list[str]) -> set[str]:
|
||||
args = [a.lower() for a in argv[1:]]
|
||||
if not args or "all" in args:
|
||||
return {"intercity", "import", "export"}
|
||||
unknown = set(args) - VALID_FILTERS
|
||||
if unknown:
|
||||
raise SystemExit(
|
||||
f"Unknown filter(s): {', '.join(sorted(unknown))}. "
|
||||
f"Valid: {', '.join(sorted(VALID_FILTERS))}"
|
||||
)
|
||||
return set(args)
|
||||
|
||||
|
||||
def require_config() -> None:
|
||||
missing = [
|
||||
name
|
||||
for name, val in [
|
||||
("CUSTOMER_EMAIL", CUSTOMER_EMAIL),
|
||||
("CUSTOMER_PASSWORD", CUSTOMER_PASSWORD),
|
||||
("ADMIN_EMAIL", ADMIN_EMAIL),
|
||||
("ADMIN_PASSWORD", ADMIN_PASSWORD),
|
||||
]
|
||||
if not val
|
||||
]
|
||||
if missing:
|
||||
raise SystemExit(f"Missing required .env keys: {', '.join(missing)}")
|
||||
if not WAAFI.exists():
|
||||
raise SystemExit(f"Missing signature/upload image: {WAAFI}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
require_config()
|
||||
wanted = parse_filters(sys.argv)
|
||||
|
||||
flows = [f for f in build_flow_matrix() if f.movement in wanted]
|
||||
total = len(flows) * CONTRACTS_PER_FLOW
|
||||
print(f"API : {API_URL}")
|
||||
print(f"Filters : {', '.join(sorted(wanted))}")
|
||||
print(f"Flows : {len(flows)} x {CONTRACTS_PER_FLOW} = {total} contracts\n")
|
||||
|
||||
print("Logging in...")
|
||||
customer = login(CUSTOMER_EMAIL, CUSTOMER_PASSWORD, "customer")
|
||||
admin = login(ADMIN_EMAIL, ADMIN_PASSWORD, "admin")
|
||||
|
||||
otp_phone = resolve_otp_phone(customer)
|
||||
print(f"OTP phone: {otp_phone}")
|
||||
|
||||
print("Loading reference data...")
|
||||
ref = load_ref_data(admin)
|
||||
|
||||
results: list[dict] = []
|
||||
for flow in flows:
|
||||
for n in range(1, CONTRACTS_PER_FLOW + 1):
|
||||
tag = f"[{flow.label} #{n}]"
|
||||
try:
|
||||
res = drive_flow(flow, n, customer, admin, ref, otp_phone)
|
||||
print(f" OK {tag} {res['reference']} -> {res['status']}")
|
||||
results.append(res)
|
||||
except Exception as exc: # noqa: BLE001 — report and continue
|
||||
print(f" FAIL {tag} {exc}")
|
||||
results.append({"flow": flow.label, "n": n, "error": str(exc)})
|
||||
|
||||
ok = [r for r in results if not r.get("error")]
|
||||
bad = [r for r in results if r.get("error")]
|
||||
print(f"\nDone. {len(ok)} created, {len(bad)} failed, {total} attempted.")
|
||||
if bad:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal file
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
-- ============================================================================
|
||||
-- Production DB drift check + fix for the batch/window flow.
|
||||
--
|
||||
-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which
|
||||
-- queries freight.invoices.payments (a jsonb ledger added by migration
|
||||
-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on
|
||||
-- production (snapshot/restore drift — the migration can read as "applied" in
|
||||
-- freight.migrations while the DDL never took effect), every reserve() throws
|
||||
-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass,
|
||||
-- and you see exactly:
|
||||
-- * only ONE booking gets a pay window (the loop dies after the first reserve
|
||||
-- whose invoice sync throws), and
|
||||
-- * reservations never expire cleanly (the settle path hits the same query).
|
||||
--
|
||||
-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2
|
||||
-- (idempotent, additive — safe to run even if partially applied).
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing,
|
||||
-- production has the drift and STEP 2 is required.
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name IN (
|
||||
'payments', 'subtotal_amount', 'tax_amount',
|
||||
'paid_amount', 'balance_amount', 'paid_at'
|
||||
)
|
||||
ORDER BY column_name;
|
||||
-- Also confirm the enum has the partial-payment statuses:
|
||||
SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status;
|
||||
-- Expect ISSUED and PARTIALLY_PAID to be present.
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns.
|
||||
-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so
|
||||
-- re-running is safe. Wrapped so the enum additions (which cannot run inside a
|
||||
-- transaction block with immediate use) are applied first, then the columns.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Enum values (no-op if they already exist).
|
||||
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';
|
||||
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';
|
||||
|
||||
-- Money-tracking + payments ledger columns.
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
|
||||
|
||||
-- Backfill derived money fields for existing rows (only rows not already set).
|
||||
UPDATE freight.invoices
|
||||
SET subtotal_amount = total_amount,
|
||||
balance_amount = total_amount
|
||||
WHERE subtotal_amount = 0 AND balance_amount = 0;
|
||||
|
||||
UPDATE freight.invoices
|
||||
SET paid_amount = total_amount,
|
||||
balance_amount = 0,
|
||||
paid_at = COALESCE(paid_at, updated_at)
|
||||
WHERE status = 'PAID' AND paid_amount = 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should
|
||||
-- now be present. After this, deploy the freight_feature/usermanagement branch
|
||||
-- and the batch will reserve ALL fitting bookings + expire non-payers + top up.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid
|
||||
-- invoices.payments can hide OTHER columns the batch flow selects. reserve()
|
||||
-- and settleReserved() load the FULL Booking entity, so ANY missing booking
|
||||
-- column throws mid-loop (e.g. we already hit
|
||||
-- `column Booking.consolidation_resume_status does not exist`). This lists every
|
||||
-- booking column the entity expects that is MISSING from production — expect
|
||||
-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied.
|
||||
-- ============================================================================
|
||||
WITH expected(col) AS (
|
||||
SELECT unnest(ARRAY[
|
||||
'reference','customer_id','company_id','company_profile_id','is_government',
|
||||
'government_institution','train_id','status','contract_id','contract_route_id',
|
||||
'booking_type','contract_kind','created_by_role','created_by_user_id',
|
||||
'scheduled_date','estimated_shipment_date','expires_at','total_amount',
|
||||
'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason',
|
||||
'contract_validity_days','contract_valid_from','contract_valid_until',
|
||||
'payment_status','contract_type','service_type_id','customs_clearing_enabled',
|
||||
'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id',
|
||||
'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id',
|
||||
'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity',
|
||||
'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at',
|
||||
'pricing_breakdown','locked_at','priority_score','consolidation_partner_id',
|
||||
'consolidation_resume_status','wagons_required','scheduling_status',
|
||||
'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id',
|
||||
'loaded_at','arrived_at','payment_deadline','selected_for_batch_at',
|
||||
'gl_station_yard_id','clearance_current_phase','duty_required',
|
||||
'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason',
|
||||
'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at'
|
||||
])
|
||||
)
|
||||
SELECT e.col AS missing_booking_column
|
||||
FROM expected e
|
||||
LEFT JOIN information_schema.columns c
|
||||
ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col
|
||||
WHERE c.column_name IS NULL
|
||||
ORDER BY e.col;
|
||||
-- If any rows come back, tell me which columns — I'll give you the exact
|
||||
-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent).
|
||||
3
apps/edr-freight-api/py/requirements.txt
Normal file
3
apps/edr-freight-api/py/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
requests>=2.31
|
||||
psycopg[binary]>=3.1
|
||||
python-dotenv>=1.0
|
||||
BIN
apps/edr-freight-api/py/waafi.jpeg
Normal file
BIN
apps/edr-freight-api/py/waafi.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,10 +1,17 @@
|
||||
import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
OnApplicationBootstrap,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||
import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
@@ -12,6 +19,7 @@ import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
import telebirrConfig from "./config/telebirr.config";
|
||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||
import faydaConfig from "./config/fayda.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||
@@ -30,6 +38,7 @@ import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
|
||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
@@ -42,6 +51,7 @@ import {
|
||||
EDR_FREIGHT_PERMISSIONS,
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
@@ -59,26 +69,36 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
import { ContainersModule } from './modules/container-management/containers.module';
|
||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
import { RoutesModule } from './modules/routes/routes.module';
|
||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
import { RoutesModule } from "./modules/routes/routes.module";
|
||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||
import { OverviewModule } from "./modules/overview/overview.module";
|
||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||
import { FuelModule } from "./modules/fuel/fuel.module";
|
||||
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
||||
import { ComplianceModule } from "./modules/compliance/compliance.module";
|
||||
import { IncidentsModule } from "./modules/incidents/incidents.module";
|
||||
import { ProcurementModule } from "./modules/procurement/procurement.module";
|
||||
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
|
||||
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
@@ -92,7 +112,27 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
}
|
||||
await ensurePostgresSchemas(options as DataSourceOptions);
|
||||
const dataSource = new DataSource(options as DataSourceOptions);
|
||||
return dataSource.initialize();
|
||||
await dataSource.initialize();
|
||||
|
||||
// The remote edr_dev DB sits behind a connection pooler/proxy that rejects
|
||||
// the Postgres `options` startup parameter (08P01). Instead of setting
|
||||
// search_path at connect time, apply it per physical connection: the pg
|
||||
// Pool emits `connect` for every new client (initial fill, pool growth,
|
||||
// reconnect), so every backend session gets the schema search order.
|
||||
const pool = (dataSource.driver as { master?: unknown }).master as
|
||||
| { on?: (event: string, cb: (client: unknown) => void) => void }
|
||||
| undefined;
|
||||
if (pool?.on) {
|
||||
pool.on("connect", (client) => {
|
||||
(client as { query: (sql: string) => Promise<unknown> })
|
||||
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
|
||||
.catch(() => {
|
||||
/* connection will be validated on first real query */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return dataSource;
|
||||
},
|
||||
}),
|
||||
SharedAuthModule,
|
||||
@@ -115,6 +155,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
OtpModule,
|
||||
@@ -133,13 +174,22 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
FuelModule,
|
||||
MaintenanceModule,
|
||||
ComplianceModule,
|
||||
IncidentsModule,
|
||||
ProcurementModule,
|
||||
GpsTrackingModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
FleetHistoryModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
PricingDataSeeder,
|
||||
@@ -156,12 +206,14 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
@@ -183,6 +235,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
@@ -207,4 +260,8 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||
await this.govCompaniesSeeder.run();
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LoggerMiddleware).forRoutes("*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
/** Container allocation on a booking (allocate-containers endpoint). */
|
||||
export const AllocationManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.allocation.manage);
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import type { ScheduleTradeDirection } from '@edr/types';
|
||||
import { YardCountry, type ScheduleTradeDirection } from '@edr/types';
|
||||
|
||||
type YardLike = { country?: string | null };
|
||||
|
||||
/** Derive booking/schedule trade direction from origin and destination yard countries. */
|
||||
/**
|
||||
* Derive trade direction from origin and destination yard countries.
|
||||
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country =
|
||||
* DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now).
|
||||
* Comparison is strict against the YardCountry enum values the yards table is
|
||||
* constrained to; the trim/case fold only shields legacy rows.
|
||||
*/
|
||||
export function deriveTradeDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim().toLowerCase();
|
||||
const destinationCountry = destinationYard.country?.trim().toLowerCase();
|
||||
const origin = normalizeCountry(originYard.country);
|
||||
const destination = normalizeCountry(destinationYard.country);
|
||||
|
||||
if (originCountry === 'djibouti') {
|
||||
if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
|
||||
if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
}
|
||||
|
||||
function normalizeCountry(country: string | null | undefined): YardCountry | null {
|
||||
const folded = country?.trim().toLowerCase();
|
||||
if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA;
|
||||
if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
NotificationTemplate,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
|
||||
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
|
||||
|
||||
const iamEntities = [
|
||||
DefaultPosition,
|
||||
@@ -105,9 +104,12 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_freight",
|
||||
schema: "public",
|
||||
extra: {
|
||||
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
|
||||
},
|
||||
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
|
||||
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
|
||||
// proxies fronting the remote edr_dev DB) reject with
|
||||
// `08P01 unsupported startup parameter in options: search_path`.
|
||||
// The search_path is instead applied per-connection via a pool `connect`
|
||||
// handler in app.module.ts (see setPoolSearchPath).
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
autoLoadEntities: true,
|
||||
migrations: [
|
||||
@@ -116,8 +118,10 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
freightMigrationsGlob,
|
||||
],
|
||||
migrationsRun: true,
|
||||
migrationsTransactionMode: "each",
|
||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||
synchronize: false,
|
||||
logging: process.env.NODE_ENV === "development",
|
||||
logging:
|
||||
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
||||
};
|
||||
});
|
||||
|
||||
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export interface FaydaJwk {
|
||||
kty: 'RSA';
|
||||
use?: string;
|
||||
kid?: string;
|
||||
alg?: string;
|
||||
n: string;
|
||||
e: string;
|
||||
d: string;
|
||||
p?: string;
|
||||
q?: string;
|
||||
dp?: string;
|
||||
dq?: string;
|
||||
qi?: string;
|
||||
}
|
||||
|
||||
export type FaydaPlatform = 'WEB' | 'MOBILE';
|
||||
|
||||
export interface FaydaConfig {
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userInfoEndpoint: string;
|
||||
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
||||
redirectUri: string;
|
||||
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
|
||||
webRedirectUri: string;
|
||||
privateJwk: FaydaJwk;
|
||||
scope: string;
|
||||
acrValues: string;
|
||||
claimsLocales: string;
|
||||
sessionTtlMinutes: number;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = [
|
||||
'FAYDA_CLIENT_ID',
|
||||
'FAYDA_AUTHORIZATION_ENDPOINT',
|
||||
'FAYDA_TOKEN_ENDPOINT',
|
||||
'FAYDA_USERINFO_ENDPOINT',
|
||||
'FAYDA_PRIVATE_KEY_BASE64',
|
||||
] as const;
|
||||
|
||||
function decodePrivateJwk(base64: string): FaydaJwk {
|
||||
let jwk: unknown;
|
||||
try {
|
||||
const json = Buffer.from(base64, 'base64').toString('utf8');
|
||||
jwk = JSON.parse(json);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (!jwk || typeof jwk !== 'object') {
|
||||
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
|
||||
}
|
||||
const candidate = jwk as Partial<FaydaJwk>;
|
||||
if (candidate.kty !== 'RSA') {
|
||||
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
|
||||
}
|
||||
if (!candidate.n || !candidate.e || !candidate.d) {
|
||||
throw new Error(
|
||||
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
|
||||
);
|
||||
}
|
||||
return candidate as FaydaJwk;
|
||||
}
|
||||
|
||||
export default registerAs('fayda', (): FaydaConfig => {
|
||||
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
|
||||
// `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address`
|
||||
// are needed so the matching essential claims aren't rejected as out-of-scope.
|
||||
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address';
|
||||
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
|
||||
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
||||
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
clientId: process.env.FAYDA_CLIENT_ID ?? '',
|
||||
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
|
||||
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
|
||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||
redirectUri,
|
||||
webRedirectUri,
|
||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||
scope,
|
||||
acrValues,
|
||||
claimsLocales,
|
||||
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
|
||||
};
|
||||
}
|
||||
|
||||
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (!redirectUri) {
|
||||
throw new Error(
|
||||
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
|
||||
);
|
||||
}
|
||||
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
|
||||
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
clientId: process.env.FAYDA_CLIENT_ID!,
|
||||
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
|
||||
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
|
||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||
redirectUri,
|
||||
webRedirectUri,
|
||||
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||
scope,
|
||||
acrValues,
|
||||
claimsLocales,
|
||||
sessionTtlMinutes: sessionTtl,
|
||||
};
|
||||
});
|
||||
@@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder {
|
||||
serviceType: this.valueOrDash(
|
||||
contract.serviceType?.serviceName ?? contract.serviceType?.code,
|
||||
),
|
||||
scheduledDate: this.formatDate(contract.estimatedShipmentDate),
|
||||
// Estimated shipment date was removed from the contract wizard; the
|
||||
// binding scheduled date is set per-booking, not on the contract.
|
||||
scheduledDate: this.formatDate(null),
|
||||
contractType: this.valueOrDash(contract.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
totalWeightVgm: '—',
|
||||
|
||||
21
apps/edr-freight-api/src/logger.middleware.ts
Normal file
21
apps/edr-freight-api/src/logger.middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class LoggerMiddleware implements NestMiddleware {
|
||||
private readonly logger = new Logger("HTTP");
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const start = Date.now();
|
||||
|
||||
res.on("finish", () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
this.logger.log(
|
||||
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ async function bootstrap() {
|
||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||
});
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
// /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
|
||||
app.setGlobalPrefix("api", { exclude: ["callback"] });
|
||||
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
||||
// coercion turns any non-empty multipart/form-data string (including the
|
||||
// literal "false") into `true`, silently corrupting flags like isHazardous
|
||||
|
||||
@@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
|
||||
);
|
||||
|
||||
// Create indexes for service_types
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
const table = await queryRunner.getTable("freight.service_types");
|
||||
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) {
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) {
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Create cargo_types table
|
||||
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
|
||||
|
||||
@@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration
|
||||
{ name: 'zone_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'free_days', type: 'int', default: 0 },
|
||||
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'tiers', type: 'jsonb', default: "'[]'" },
|
||||
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
|
||||
@@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
|
||||
`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(),
|
||||
ADD COLUMN IF NOT EXISTS invoice_number varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS company_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS company_profile_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
|
||||
ADD COLUMN IF NOT EXISTS source varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS source_id varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS type varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS issued_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payment_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS due_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||
`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET due_at = COALESCE(due_at, issued_at, created_at, now())
|
||||
WHERE due_at IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE contype = 'p'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uq_invoices_invoice_number'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_company'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company
|
||||
FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_company_profile'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile
|
||||
FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_payment'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment
|
||||
FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.invoice_lines (
|
||||
CREATE TABLE IF NOT EXISTS freight.invoice_lines (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
invoice_id uuid NOT NULL,
|
||||
charge_type varchar NOT NULL,
|
||||
@@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous
|
||||
* or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item
|
||||
* count for PER_ITEM). These two columns hold that amount on the booking; they
|
||||
* stay 0 for container freight (which tracks it per line on booking_container)
|
||||
* and for bulk cargo with no hazardous/reefer portion. The existing
|
||||
* is_hazardous / is_reefer booleans remain the surcharge trigger.
|
||||
*/
|
||||
export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface {
|
||||
name = 'AddBulkHazmatReeferQuantity1828000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Extend `freight.invoices` into the billing record of record for every source
|
||||
* (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be
|
||||
* centralized onto it instead of the parallel `warehouse_fee_invoices` table.
|
||||
*
|
||||
* Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
|
||||
* a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
|
||||
* statuses the warehouse flow uses.
|
||||
*
|
||||
* Matches billing/entities/invoice.entity.ts. All columns are additive with
|
||||
* defaults, so existing booking/demurrage rows are unaffected.
|
||||
*/
|
||||
export class ExtendInvoicesForPartialPayment1828000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "ExtendInvoicesForPartialPayment1828000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
|
||||
// as the value is not referenced in the same transaction (it is not here).
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
|
||||
`);
|
||||
|
||||
// Backfill existing rows: subtotal mirrors the total (no tax was modeled),
|
||||
// the outstanding balance is the full total for unpaid invoices.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET subtotal_amount = total_amount,
|
||||
balance_amount = total_amount;
|
||||
`);
|
||||
|
||||
// Already-settled invoices: fully paid, zero balance, stamped from updated_at.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET paid_amount = total_amount,
|
||||
balance_amount = 0,
|
||||
paid_at = updated_at
|
||||
WHERE status = 'PAID';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS payments,
|
||||
DROP COLUMN IF EXISTS paid_at,
|
||||
DROP COLUMN IF EXISTS balance_amount,
|
||||
DROP COLUMN IF EXISTS paid_amount,
|
||||
DROP COLUMN IF EXISTS tax_amount,
|
||||
DROP COLUMN IF EXISTS subtotal_amount;
|
||||
`);
|
||||
// Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
|
||||
// left on freight.invoices_status_enum (harmless, unused after down).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fold warehouse fee invoices into the central billing system.
|
||||
*
|
||||
* Warehouse fee invoices are no longer a standalone aggregate: each becomes a
|
||||
* global `freight.invoices` row (`source = 'warehouse'`, `source_id =
|
||||
* inventory_id`) with its items as `freight.invoice_lines`. The warehouse
|
||||
* service is now a thin layer over `BillingService`. This migration backfills the
|
||||
* existing rows (preserving ids, numbers, status, amounts and payment history),
|
||||
* then drops the two legacy tables.
|
||||
*
|
||||
* Rows that cannot be billed centrally — no company to bill (`company_id` /
|
||||
* `company_profile_id` underivable from the customer or the booking) — are not
|
||||
* migrated; they could never have been charged through the gateway and are
|
||||
* dropped with the table.
|
||||
*/
|
||||
export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface {
|
||||
name = 'CentralizeWarehouseInvoices1829000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name = 'booking_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name = 'amount'
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// 1. Invoice headers. Keep the same id so items still link, and so any
|
||||
// external reference to the invoice id stays valid.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.invoices (
|
||||
id, invoice_number, company_id, company_profile_id,
|
||||
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||
currency, status, source, source_id, type,
|
||||
issued_at, paid_at, payments, payment_id, due_at,
|
||||
created_at, updated_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
fee.id,
|
||||
fee.invoice_number,
|
||||
COALESCE(fee.customer_id, b.company_id),
|
||||
COALESCE(
|
||||
b.company_profile_id,
|
||||
(SELECT cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
|
||||
AND cp.deleted_at IS NULL
|
||||
ORDER BY cp.created_at ASC
|
||||
LIMIT 1)
|
||||
),
|
||||
fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount,
|
||||
fee.currency,
|
||||
fee.status::freight.invoices_status_enum,
|
||||
'warehouse',
|
||||
fee.inventory_id,
|
||||
fee.invoice_type,
|
||||
fee.issued_at,
|
||||
fee.paid_at,
|
||||
COALESCE(fee.payments, '[]'::jsonb),
|
||||
NULL,
|
||||
COALESCE(fee.due_date, fee.issued_at, fee.created_at),
|
||||
fee.created_at, fee.updated_at, fee.deleted_at
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id
|
||||
WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL
|
||||
AND COALESCE(
|
||||
b.company_profile_id,
|
||||
(SELECT cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
|
||||
AND cp.deleted_at IS NULL
|
||||
ORDER BY cp.created_at ASC
|
||||
LIMIT 1)
|
||||
) IS NOT NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 2. Invoice lines — only for items whose parent invoice migrated. Warehouse
|
||||
// fee fields (fee_rule_id / chargeable_days / free_days) move into the
|
||||
// line's jsonb metadata.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.invoice_lines (
|
||||
id, invoice_id, charge_type, description, quantity, unit_rate, amount,
|
||||
currency, metadata, created_at, updated_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
item.id,
|
||||
item.invoice_id,
|
||||
item.fee_type,
|
||||
item.description,
|
||||
item.quantity,
|
||||
item.unit_rate,
|
||||
item.amount,
|
||||
item.currency,
|
||||
jsonb_build_object(
|
||||
'feeRuleId', item.fee_rule_id,
|
||||
'chargeableDays', item.chargeable_days,
|
||||
'freeDays', item.free_days
|
||||
),
|
||||
item.created_at, item.updated_at, item.deleted_at
|
||||
FROM freight.warehouse_fee_invoice_items item
|
||||
JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 3. Drop the legacy tables (items first — FK to invoices).
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Recreate the legacy tables …
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
invoice_number varchar(40) NOT NULL,
|
||||
booking_id uuid,
|
||||
customer_id uuid,
|
||||
inventory_id uuid NOT NULL,
|
||||
facility_id uuid,
|
||||
warehouse_id uuid,
|
||||
yard_id uuid,
|
||||
zone_id uuid,
|
||||
invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES',
|
||||
status varchar(20) NOT NULL DEFAULT 'DRAFT',
|
||||
subtotal_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
tax_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
total_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
paid_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
balance_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
currency varchar(8) NOT NULL DEFAULT 'USD',
|
||||
period_start timestamptz,
|
||||
period_end timestamptz,
|
||||
issued_at timestamptz,
|
||||
due_date timestamptz,
|
||||
paid_at timestamptz,
|
||||
cancelled_at timestamptz,
|
||||
payments jsonb NOT NULL DEFAULT '[]',
|
||||
notes text,
|
||||
CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id),
|
||||
CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
invoice_id uuid NOT NULL,
|
||||
fee_rule_id uuid,
|
||||
fee_type varchar(32) NOT NULL,
|
||||
description varchar(255) NOT NULL,
|
||||
quantity numeric(12,2) NOT NULL DEFAULT 1,
|
||||
unit_rate numeric(14,2) NOT NULL DEFAULT 0,
|
||||
amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
currency varchar(8) NOT NULL DEFAULT 'USD',
|
||||
chargeable_days int,
|
||||
free_days int,
|
||||
CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id),
|
||||
CONSTRAINT "FK_warehouse_fee_invoice_items_invoice"
|
||||
FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`,
|
||||
);
|
||||
|
||||
// … then copy the warehouse-source invoices back, deriving the typed FKs and
|
||||
// period from the linked inventory item.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_fee_invoices (
|
||||
id, created_at, updated_at, deleted_at, invoice_number,
|
||||
booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id,
|
||||
invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||
currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes
|
||||
)
|
||||
SELECT
|
||||
i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number,
|
||||
inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id,
|
||||
i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount,
|
||||
i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at,
|
||||
CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END,
|
||||
i.payments, NULL
|
||||
FROM freight.invoices i
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_fee_invoice_items (
|
||||
id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type,
|
||||
description, quantity, unit_rate, amount, currency, chargeable_days, free_days
|
||||
)
|
||||
SELECT
|
||||
l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id,
|
||||
NULLIF(l.metadata->>'feeRuleId', '')::uuid,
|
||||
l.charge_type,
|
||||
COALESCE(l.description, ''),
|
||||
l.quantity, l.unit_rate, l.amount, l.currency,
|
||||
NULLIF(l.metadata->>'chargeableDays', '')::int,
|
||||
NULLIF(l.metadata->>'freeDays', '')::int
|
||||
FROM freight.invoice_lines l
|
||||
JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// Remove the migrated rows from the central tables.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.invoice_lines
|
||||
WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse');
|
||||
`);
|
||||
await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface {
|
||||
name = 'PhasedClearanceCycleMeta1829000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** Admin-configurable minimum days between today and export RO vessel departure. */
|
||||
export class SeedRoVesselMinDays1829000000001 implements MigrationInterface {
|
||||
name = 'SeedRoVesselMinDays1829000000001';
|
||||
private readonly code = 'ro_vessel_min_days';
|
||||
private readonly options: Array<{ value: string; label: string }> = [
|
||||
{ value: '2', label: '2 days' },
|
||||
{ value: '3', label: '3 days' },
|
||||
];
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||
[this.code],
|
||||
);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const inserted = await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
|
||||
VALUES ($1, $2, $3, false)
|
||||
RETURNING id;`,
|
||||
[
|
||||
this.code,
|
||||
'RO vessel minimum lead time (days)',
|
||||
'Minimum days between today and the vessel departure date on an export Release Order.',
|
||||
],
|
||||
);
|
||||
const settingId = inserted[0].id;
|
||||
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
const opt = this.options[i];
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||
VALUES ($1, $2, $3, $4);`,
|
||||
[settingId, opt.value, opt.label, i],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
|
||||
this.code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class BookingClearanceMeta1829000000002 implements MigrationInterface {
|
||||
name = 'BookingClearanceMeta1829000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add the `EXPIRED` invoice status. An invoice expires when its source's pay
|
||||
* window closes before settlement (e.g. a booking whose `paymentDeadline`
|
||||
* lapses) — driven event-style from the domain via `BillingService.expirePayable`,
|
||||
* which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out
|
||||
* of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and
|
||||
* `OVERDUE` (still payable).
|
||||
*
|
||||
* Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and
|
||||
* not referenced in this same transaction, so it is PG 12+ safe.
|
||||
*/
|
||||
export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface {
|
||||
name = "AddExpiredInvoiceStatus1830000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Postgres cannot drop individual enum values; EXPIRED is left on
|
||||
// freight.invoices_status_enum (harmless, unused after down).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DropCargoTypeShowFreeTextBox1830000000000 implements MigrationInterface {
|
||||
name = 'DropCargoTypeShowFreeTextBox1830000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
DROP COLUMN IF EXISTS show_free_text_box
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS show_free_text_box boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface {
|
||||
name = 'RouteStatusAndSegmentKm1830000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes
|
||||
SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_routes_name"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS name
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.route_milestones
|
||||
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS name varchar(120)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes SET name = id::text WHERE name IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes
|
||||
SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS status
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_routes_status"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface {
|
||||
name = 'PreClearanceFinalizedAt1830000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface {
|
||||
name = 'AddWarehouseFeeRuleTiers1831000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
DROP COLUMN IF EXISTS tiers;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface {
|
||||
name = 'AddCustomerTruckAssignmentToBookings1832000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS customer_truck_arrived_at,
|
||||
DROP COLUMN IF EXISTS customer_truck_assigned_at,
|
||||
DROP COLUMN IF EXISTS customer_truck_container_number,
|
||||
DROP COLUMN IF EXISTS customer_truck_type,
|
||||
DROP COLUMN IF EXISTS customer_truck_driver_name,
|
||||
DROP COLUMN IF EXISTS customer_truck_plate_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateFuelTables1840000000000 implements MigrationInterface {
|
||||
name = "CreateFuelTables1840000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const fuelPurchasesExists = await queryRunner.query(
|
||||
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`,
|
||||
);
|
||||
|
||||
if (!fuelPurchasesExists.length) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.fuel_purchases (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
purchase_date timestamptz NOT NULL,
|
||||
liters numeric(10, 2) NOT NULL,
|
||||
cost_per_liter numeric(10, 2) NOT NULL,
|
||||
total_cost numeric(14, 2) NOT NULL,
|
||||
fuel_station varchar(255) NULL,
|
||||
payment_method varchar(50) DEFAULT 'CASH',
|
||||
odometer_reading numeric(10, 2) NULL,
|
||||
driver_id uuid NULL,
|
||||
receipt_number varchar(255) NULL,
|
||||
notes text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
|
||||
REFERENCES freight.vehicles (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`,
|
||||
);
|
||||
}
|
||||
|
||||
const fuelConsumptionExists = await queryRunner.query(
|
||||
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`,
|
||||
);
|
||||
|
||||
if (!fuelConsumptionExists.length) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.fuel_consumption (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
month date NOT NULL,
|
||||
total_liters numeric(10, 2) NOT NULL,
|
||||
total_cost numeric(14, 2) NOT NULL,
|
||||
total_distance_km numeric(10, 2) NOT NULL,
|
||||
fuel_efficiency_km_per_l numeric(10, 2) NULL,
|
||||
number_of_purchases integer DEFAULT 0,
|
||||
average_cost_per_liter numeric(10, 2) NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
|
||||
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateMaintenanceTables1850000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create maintenance_schedules table
|
||||
const scheduleTableExists = await queryRunner.query(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules'
|
||||
)
|
||||
`);
|
||||
|
||||
if (!scheduleTableExists[0].exists) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "freight"."maintenance_schedules" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"vehicle_id" uuid NOT NULL,
|
||||
"maintenance_type" varchar NOT NULL,
|
||||
"description" varchar NOT NULL,
|
||||
"scheduled_date" timestamptz NOT NULL,
|
||||
"completed_date" timestamptz,
|
||||
"estimated_cost" numeric(14,2),
|
||||
"actual_cost" numeric(14,2),
|
||||
"status" varchar NOT NULL DEFAULT 'SCHEDULED',
|
||||
"odometer_reading" numeric,
|
||||
"service_provider" varchar,
|
||||
"notes" text,
|
||||
"next_due_km" numeric,
|
||||
"next_due_date" timestamptz,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")`
|
||||
);
|
||||
}
|
||||
|
||||
// Create maintenance_costs table
|
||||
const costsTableExists = await queryRunner.query(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'freight' AND table_name = 'maintenance_costs'
|
||||
)
|
||||
`);
|
||||
|
||||
if (!costsTableExists[0].exists) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "freight"."maintenance_costs" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"vehicle_id" uuid NOT NULL,
|
||||
"maintenance_schedule_id" uuid,
|
||||
"incurred_date" timestamptz NOT NULL,
|
||||
"cost_amount" numeric(14,2) NOT NULL,
|
||||
"cost_type" varchar NOT NULL,
|
||||
"description" varchar NOT NULL,
|
||||
"service_provider" varchar,
|
||||
"invoice_number" varchar,
|
||||
"notes" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id")
|
||||
REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add paid column to first_mile and last_mile tables to track invoice payment status.
|
||||
*/
|
||||
export class AddPaidToFirstAndLastMile1860000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddPaidToFirstAndLastMile1860000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.first_mile
|
||||
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile
|
||||
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.first_mile
|
||||
DROP COLUMN IF EXISTS paid;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile
|
||||
DROP COLUMN IF EXISTS paid;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface {
|
||||
name = "AddBookingWindowGlobalRules1861000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3,
|
||||
ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24,
|
||||
ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8,
|
||||
ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
|
||||
ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30,
|
||||
ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60,
|
||||
ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS import_window_lead_days,
|
||||
DROP COLUMN IF EXISTS export_booking_lead_hours,
|
||||
DROP COLUMN IF EXISTS window_open_hour,
|
||||
DROP COLUMN IF EXISTS window_duration_hours,
|
||||
DROP COLUMN IF EXISTS doc_review_minutes,
|
||||
DROP COLUMN IF EXISTS payment_window_minutes,
|
||||
DROP COLUMN IF EXISTS reopen_delay_minutes;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* arriveSchedule used to release only the primary locomotive of a train set, leaving
|
||||
* secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out
|
||||
* on a dispatched train — release every ASSIGNED locomotive that is not attached to a
|
||||
* currently-DISPATCHED schedule.
|
||||
*/
|
||||
export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface {
|
||||
name = "ReleaseStuckAssignedLocomotives1861000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives l
|
||||
SET status = 'AVAILABLE'
|
||||
WHERE l.status = 'ASSIGNED'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
JOIN (
|
||||
SELECT tsl.train_set_id, tsl.locomotive_id
|
||||
FROM freight.train_set_locomotives tsl
|
||||
WHERE tsl.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT t.id AS train_set_id, t.locomotive_id
|
||||
FROM freight.train_sets t
|
||||
WHERE t.locomotive_id IS NOT NULL
|
||||
) loco ON loco.train_set_id = tset.id
|
||||
WHERE ts.status = 'DISPATCHED'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND loco.locomotive_id = l.id
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Data fix — not reversible.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddScheduleWindowPhases1862000000000 implements MigrationInterface {
|
||||
name = "AddScheduleWindowPhases1862000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN window_phase varchar(20) NULL,
|
||||
ADD COLUMN window_opens_at timestamptz NULL,
|
||||
ADD COLUMN window_closes_at timestamptz NULL,
|
||||
ADD COLUMN doc_review_ends_at timestamptz NULL,
|
||||
ADD COLUMN doc_review_completed_at timestamptz NULL,
|
||||
ADD COLUMN payment_phase_ends_at timestamptz NULL,
|
||||
ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_train_schedules_window_phase
|
||||
ON freight.train_schedules (window_phase)
|
||||
WHERE window_phase IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS window_phase,
|
||||
DROP COLUMN IF EXISTS window_opens_at,
|
||||
DROP COLUMN IF EXISTS window_closes_at,
|
||||
DROP COLUMN IF EXISTS doc_review_ends_at,
|
||||
DROP COLUMN IF EXISTS doc_review_completed_at,
|
||||
DROP COLUMN IF EXISTS payment_phase_ends_at,
|
||||
DROP COLUMN IF EXISTS booking_cycle_no;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateBookingBatchOffers1863000000000 implements MigrationInterface {
|
||||
name = "CreateBookingBatchOffers1863000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.booking_batch_offers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
offered_wagons integer NOT NULL,
|
||||
total_wagons integer NOT NULL,
|
||||
offered_lines jsonb NULL,
|
||||
offered_weight_tons numeric(12, 3) NOT NULL,
|
||||
offered_amount numeric(14, 2) NOT NULL,
|
||||
offered_pricing_breakdown jsonb NULL,
|
||||
invoice_id uuid NULL,
|
||||
payment_deadline timestamptz NOT NULL,
|
||||
status varchar(10) NOT NULL DEFAULT 'OFFERED',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add location_id column to vehicles table to track vehicle base location.
|
||||
*/
|
||||
export class AddLocationToVehicles1870000000000 implements MigrationInterface {
|
||||
name = "AddLocationToVehicles1870000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS location_id uuid;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS location_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Repairs schema drift on databases that were originally built by TypeORM
|
||||
* `synchronize` (at an older entity snapshot) and never had their migration
|
||||
* history recorded. Such databases have `freight.migrations` empty while most
|
||||
* of the schema already exists, so a from-scratch migration run aborts on the
|
||||
* first non-idempotent statement and never reaches the columns/tables added
|
||||
* after synchronize was last used.
|
||||
*
|
||||
* The deployment procedure for those databases is:
|
||||
* 1. Baseline every pre-existing migration into `freight.migrations`.
|
||||
* 2. Run migrations — this file is the only pending one and back-fills the
|
||||
* objects the drift scan found missing.
|
||||
*
|
||||
* Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is
|
||||
* also safe on a clean database where the earlier migrations already created
|
||||
* these objects — it simply no-ops.
|
||||
*/
|
||||
export class RepairSynchronizeDrift1870000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'RepairSynchronizeDrift1870000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// --- enum types (derived from entities that never had a source migration) ---
|
||||
await queryRunner.query(`DO $$ BEGIN
|
||||
CREATE TYPE freight.consignments_cargo_type_enum AS ENUM (
|
||||
'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
|
||||
await queryRunner.query(`DO $$ BEGIN
|
||||
CREATE TYPE freight.consignments_status_enum AS ENUM (
|
||||
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
|
||||
await queryRunner.query(`DO $$ BEGIN
|
||||
CREATE TYPE freight.tracking_events_status_enum AS ENUM (
|
||||
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
|
||||
|
||||
// --- missing tables ---
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL,
|
||||
tracking_number varchar(64) NOT NULL,
|
||||
cargo_type freight.consignments_cargo_type_enum NOT NULL,
|
||||
weight_kg numeric(12, 2) NOT NULL,
|
||||
status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
origin_station varchar(128) NOT NULL,
|
||||
destination_station varchar(128) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_consignments PRIMARY KEY (id),
|
||||
CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number)
|
||||
);`);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
consignment_id uuid NOT NULL,
|
||||
location varchar(256) NOT NULL,
|
||||
status freight.tracking_events_status_enum NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
description text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_tracking_events PRIMARY KEY (id)
|
||||
);`);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
purchase_date timestamptz NOT NULL,
|
||||
liters numeric(10, 2) NOT NULL,
|
||||
cost_per_liter numeric(10, 2) NOT NULL,
|
||||
total_cost numeric(14, 2) NOT NULL,
|
||||
fuel_station varchar(255) NULL,
|
||||
payment_method varchar(50) DEFAULT 'CASH',
|
||||
odometer_reading numeric(10, 2) NULL,
|
||||
driver_id uuid NULL,
|
||||
receipt_number varchar(255) NULL,
|
||||
notes text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
|
||||
REFERENCES freight.vehicles (id) ON DELETE CASCADE
|
||||
);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
month date NOT NULL,
|
||||
total_liters numeric(10, 2) NOT NULL,
|
||||
total_cost numeric(14, 2) NOT NULL,
|
||||
total_distance_km numeric(10, 2) NOT NULL,
|
||||
fuel_efficiency_km_per_l numeric(10, 2) NULL,
|
||||
number_of_purchases integer DEFAULT 0,
|
||||
average_cost_per_liter numeric(10, 2) NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
|
||||
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
|
||||
);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
maintenance_type varchar NOT NULL,
|
||||
description varchar NOT NULL,
|
||||
scheduled_date timestamptz NOT NULL,
|
||||
completed_date timestamptz,
|
||||
estimated_cost numeric(14,2),
|
||||
actual_cost numeric(14,2),
|
||||
status varchar NOT NULL DEFAULT 'SCHEDULED',
|
||||
odometer_reading numeric,
|
||||
service_provider varchar,
|
||||
notes text,
|
||||
next_due_km numeric,
|
||||
next_due_date timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
PRIMARY KEY (id)
|
||||
);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
maintenance_schedule_id uuid,
|
||||
incurred_date timestamptz NOT NULL,
|
||||
cost_amount numeric(14,2) NOT NULL,
|
||||
cost_type varchar NOT NULL,
|
||||
description varchar NOT NULL,
|
||||
service_provider varchar,
|
||||
invoice_number varchar,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id)
|
||||
REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL
|
||||
);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
phone varchar NOT NULL,
|
||||
otp varchar NOT NULL,
|
||||
verified boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_otp_verifications PRIMARY KEY (id),
|
||||
CONSTRAINT uq_otp_verifications_phone UNIQUE (phone)
|
||||
);`);
|
||||
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
offered_wagons integer NOT NULL,
|
||||
total_wagons integer NOT NULL,
|
||||
offered_lines jsonb NULL,
|
||||
offered_weight_tons numeric(12, 3) NOT NULL,
|
||||
offered_amount numeric(14, 2) NOT NULL,
|
||||
offered_pricing_breakdown jsonb NULL,
|
||||
invoice_id uuid NULL,
|
||||
payment_deadline timestamptz NOT NULL,
|
||||
status varchar(10) NOT NULL DEFAULT 'OFFERED',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`);
|
||||
|
||||
// --- missing columns on existing tables ---
|
||||
await queryRunner.query(`ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_at timestamptz;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME',
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40),
|
||||
ADD COLUMN IF NOT EXISTS duty_required boolean,
|
||||
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
|
||||
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
|
||||
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.cargoes
|
||||
ADD COLUMN IF NOT EXISTS receiver_name varchar,
|
||||
ADD COLUMN IF NOT EXISTS delivered_at timestamp,
|
||||
ADD COLUMN IF NOT EXISTS delivery_remarks text;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles
|
||||
ADD COLUMN IF NOT EXISTS duty_required boolean,
|
||||
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
|
||||
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
|
||||
ADD COLUMN IF NOT EXISTS current_phase varchar(40),
|
||||
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.first_mile
|
||||
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile
|
||||
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.route_milestones
|
||||
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL,
|
||||
ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase
|
||||
ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3,
|
||||
ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24,
|
||||
ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8,
|
||||
ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
|
||||
ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30,
|
||||
ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60,
|
||||
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// No-op: this migration only repairs drift by additively creating objects
|
||||
// that other migrations own. Rolling it back would drop objects those
|
||||
// migrations legitimately created. Revert individual feature migrations
|
||||
// instead if needed.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add FREE and BUSY statuses to vehicle status enum.
|
||||
*/
|
||||
export class AddVehicleStatuses1880000000000 implements MigrationInterface {
|
||||
name = "AddVehicleStatuses1880000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create enum type if it doesn't exist
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN
|
||||
CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE');
|
||||
ELSE
|
||||
-- Add values if enum already exists but doesn't have them
|
||||
ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE';
|
||||
ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Note: Postgres cannot drop individual enum values, so the down migration is a no-op
|
||||
// The enum values FREE and BUSY will remain but will be unused after downgrade
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Split the mixed vehicle status into two fields:
|
||||
* - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE)
|
||||
* - availability: assignment state (FREE, BUSY)
|
||||
*
|
||||
* Existing FREE/BUSY statuses are moved to availability and the status is
|
||||
* normalized back to ACTIVE.
|
||||
*/
|
||||
export class SeparateVehicleAvailability1890000000000 implements MigrationInterface {
|
||||
name = "SeparateVehicleAvailability1890000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY')
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Fold availability back into status before dropping the column
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET status = availability
|
||||
WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY')
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add code, power_plate_no and trailer_plate_no columns to vehicles.
|
||||
* These fields existed in the DTO and UI form but had no entity columns,
|
||||
* so submitted values were silently dropped.
|
||||
*/
|
||||
export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface {
|
||||
name = "AddVehicleCodeAndPlates1890000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS code varchar,
|
||||
ADD COLUMN IF NOT EXISTS power_plate_no varchar,
|
||||
ADD COLUMN IF NOT EXISTS trailer_plate_no varchar
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS code,
|
||||
DROP COLUMN IF EXISTS power_plate_no,
|
||||
DROP COLUMN IF EXISTS trailer_plate_no
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Session store for the VeriFayda 2.0 OIDC verification flow (ported from
|
||||
* passenger-api). One row per started verification; `state` is the
|
||||
* single-use CSRF token linking the eSignet redirect back to the session.
|
||||
*/
|
||||
export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface {
|
||||
name = "AddFaydaVerificationSessions1890000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
state varchar NOT NULL UNIQUE,
|
||||
code_verifier varchar NOT NULL,
|
||||
purpose varchar NOT NULL DEFAULT 'VERIFY',
|
||||
platform varchar NOT NULL DEFAULT 'WEB',
|
||||
save_to_account boolean NOT NULL DEFAULT false,
|
||||
status varchar NOT NULL DEFAULT 'PENDING',
|
||||
error_code varchar,
|
||||
error_description text,
|
||||
iam_user_id uuid,
|
||||
expires_at timestamptz NOT NULL,
|
||||
completed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_EXPIRES_AT"
|
||||
ON freight.fayda_verification_sessions (expires_at)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID"
|
||||
ON freight.fayda_verification_sessions (iam_user_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Track Fayda identity verification on drivers: whether the driver's
|
||||
* identity was verified through VeriFayda and the OIDC subject it was
|
||||
* verified against.
|
||||
*/
|
||||
export class AddDriverFaydaVerification1890000000003 implements MigrationInterface {
|
||||
name = "AddDriverFaydaVerification1890000000003";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.drivers
|
||||
ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS fayda_sub varchar
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.drivers
|
||||
DROP COLUMN IF EXISTS fayda_verified,
|
||||
DROP COLUMN IF EXISTS fayda_sub
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Allow more than one vehicle per last-mile delivery. Junction table joins
|
||||
* last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as
|
||||
* the first assignment so nothing is lost.
|
||||
*/
|
||||
export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface {
|
||||
name = "AddLastMileVehicleAssignments1890000000004";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE,
|
||||
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE"
|
||||
ON freight.last_mile_vehicle_assignments (vehicle_id)
|
||||
`);
|
||||
// Backfill: existing single-vehicle assignments become the first row
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id)
|
||||
SELECT id, vehicle_id FROM freight.last_mile
|
||||
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
|
||||
ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Store the driver's gender. Prefilled from the Fayda VERIFY response
|
||||
* (Male/Female) but editable; nullable so existing rows and manual,
|
||||
* non-Fayda driver records stay valid.
|
||||
*/
|
||||
export class AddDriverGender1890000000005 implements MigrationInterface {
|
||||
name = "AddDriverGender1890000000005";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.drivers
|
||||
ADD COLUMN IF NOT EXISTS gender varchar
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.drivers
|
||||
DROP COLUMN IF EXISTS gender
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Enforce one driver record per verified Fayda identity. A unique index on
|
||||
* fayda_sub blocks a second driver from being created against the same Fayda
|
||||
* OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected.
|
||||
*/
|
||||
export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface {
|
||||
name = "AddDriverFaydaSubUnique1890000000006";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
|
||||
ON freight.drivers (fayda_sub)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Make driver uniqueness soft-delete aware. The original table used plain
|
||||
* column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted
|
||||
* rows, so deleting a driver then re-adding the same email/phone/license/Fayda
|
||||
* identity failed at the DB with a raw 500 — even though the service's own
|
||||
* (deleted_at-excluding) duplicate check saw nothing. Replace them with partial
|
||||
* unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches
|
||||
* what the service enforces and freed values become reusable after deletion.
|
||||
*/
|
||||
export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface {
|
||||
name = "DriverUniquePartialSoftDelete1890000000007";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop the full-table unique constraints from CreateDriversTable...
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.drivers
|
||||
DROP CONSTRAINT IF EXISTS drivers_email_key,
|
||||
DROP CONSTRAINT IF EXISTS drivers_phone_number_key,
|
||||
DROP CONSTRAINT IF EXISTS drivers_license_number_key
|
||||
`);
|
||||
// ...and the plain fayda_sub unique index from 1890000000006.
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`);
|
||||
|
||||
// Re-add each as a partial unique index scoped to non-deleted rows.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE"
|
||||
ON freight.drivers (email) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE"
|
||||
ON freight.drivers (phone_number) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE"
|
||||
ON freight.drivers (license_number) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE"
|
||||
ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
|
||||
ON freight.drivers (fayda_sub)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.drivers
|
||||
ADD CONSTRAINT drivers_email_key UNIQUE (email),
|
||||
ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number),
|
||||
ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle
|
||||
* status/availability transitions, first/last-mile vehicle assignments + mile
|
||||
* status changes). Queried by vehicle_id or driver_id to build a per-record
|
||||
* timeline. Populated going forward — existing records have no back-history.
|
||||
*/
|
||||
export class AddFleetEvents1890000000008 implements MigrationInterface {
|
||||
name = "AddFleetEvents1890000000008";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.fleet_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_type varchar NOT NULL,
|
||||
vehicle_id uuid,
|
||||
driver_id uuid,
|
||||
first_mile_id uuid,
|
||||
last_mile_id uuid,
|
||||
from_value varchar,
|
||||
to_value varchar,
|
||||
label varchar,
|
||||
metadata jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE"
|
||||
ON freight.fleet_events (vehicle_id, created_at)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER"
|
||||
ON freight.fleet_events (driver_id, created_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Container number carried by each vehicle on a last-mile delivery. Auto-filled
|
||||
* from the booking's container number when present, else entered by the operator
|
||||
* at assignment time.
|
||||
*/
|
||||
export class AddLastMileAssignmentContainerNumber1890000000009
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddLastMileAssignmentContainerNumber1890000000009";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
ADD COLUMN IF NOT EXISTS container_number varchar
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS container_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Per-vehicle actual distance on a last-mile delivery. A booking served by
|
||||
* several trucks records each truck's km; the record's total (last_mile.exact_km)
|
||||
* is their sum and drives the invoice.
|
||||
*/
|
||||
export class AddLastMileAssignmentDistance1890000000010
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddLastMileAssignmentDistance1890000000010";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS distance_km
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Support email as a second OTP channel alongside phone (e.g. signup lets the
|
||||
* user choose which one to verify). `phone` becomes nullable since an
|
||||
* email-channel row has none, and `email` is added as a nullable unique column
|
||||
* mirroring `phone`'s shape.
|
||||
*/
|
||||
export class AddEmailToOtpVerifications1900000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddEmailToOtpVerifications1900000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// The table lives in the `freight` schema (the OtpVerification entity pins
|
||||
// schema: "freight"). An earlier version of this migration targeted
|
||||
// `public.otp_verifications`, which does not exist there — leaving the real
|
||||
// freight table without an `email` column and OTP send failing with
|
||||
// `column OtpVerification.email does not exist`. Target `freight` explicitly.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ALTER COLUMN phone DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
DROP COLUMN IF EXISTS email
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ALTER COLUMN phone SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings.
|
||||
* Tracking only — does not gate dispatch.
|
||||
*/
|
||||
export class AddLoadingStatusToTrainScheduleBookings1900000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddLoadingStatusToTrainScheduleBookings1900000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedule_bookings
|
||||
ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedule_bookings
|
||||
DROP COLUMN IF EXISTS loading_status
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Simplify the rate + weight-limit configuration model:
|
||||
*
|
||||
* 1. Drop the effective_from / effective_to validity window from both
|
||||
* `rates` and `weight_limit_rules`. Rates are now activated purely by
|
||||
* the approval workflow (status = LIVE) and weight limits are always
|
||||
* active for their container + direction. No time-travel scheduling.
|
||||
*
|
||||
* 2. Enforce "one rate per pattern" with partial unique indexes so the same
|
||||
* configuration (e.g. FIRST_MILE for a given container type) cannot be
|
||||
* duplicated. NULL scope columns are COALESCE-normalised because Postgres
|
||||
* treats NULLs as distinct in a plain unique index.
|
||||
*
|
||||
* This migration is destructive on the date columns — existing effective_*
|
||||
* values are dropped.
|
||||
*/
|
||||
export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface {
|
||||
name = 'SimplifyRatesAndWeightLimitRules1900000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── 1. De-duplicate existing data so the unique indexes can be created ──
|
||||
// Keep the most recently-created row per pattern, soft-delete the rest.
|
||||
await queryRunner.query(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
row_number() OVER (
|
||||
PARTITION BY rate_type,
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''),
|
||||
rate_unit
|
||||
ORDER BY created_at DESC, id DESC
|
||||
) AS rn
|
||||
FROM freight.rates
|
||||
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'
|
||||
)
|
||||
UPDATE freight.rates r
|
||||
SET deleted_at = now()
|
||||
FROM ranked
|
||||
WHERE r.id = ranked.id AND ranked.rn > 1;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
row_number() OVER (
|
||||
PARTITION BY container_type_id, trade_direction
|
||||
ORDER BY created_at DESC, id DESC
|
||||
) AS rn
|
||||
FROM freight.weight_limit_rules
|
||||
WHERE deleted_at IS NULL
|
||||
)
|
||||
UPDATE freight.weight_limit_rules w
|
||||
SET deleted_at = now()
|
||||
FROM ranked
|
||||
WHERE w.id = ranked.id AND ranked.rn > 1;
|
||||
`);
|
||||
|
||||
// ── 2. Drop the effective-date indexes + columns ───────────────────────
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`);
|
||||
// Indexes created by TypeORM's @Index carry generated hashed names — drop
|
||||
// any index that references the effective_from column defensively.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE idx record;
|
||||
BEGIN
|
||||
FOR idx IN
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE schemaname = 'freight'
|
||||
AND tablename IN ('rates', 'weight_limit_rules')
|
||||
AND indexdef ILIKE '%effective_from%'
|
||||
LOOP
|
||||
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`);
|
||||
|
||||
// ── 3. One-rate-per-pattern partial unique indexes ─────────────────────
|
||||
// The unit is part of the identity so a surcharge can legitimately carry two
|
||||
// rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON),
|
||||
// while still blocking a true duplicate (same rateType + scope + unit).
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
|
||||
ON freight.rates (
|
||||
rate_type,
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''),
|
||||
rate_unit
|
||||
)
|
||||
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern"
|
||||
ON freight.weight_limit_rules (container_type_id, trade_direction)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`);
|
||||
await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`);
|
||||
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2)
|
||||
* to numeric(6,4). The UI now lets staff enter the booking-window duration in
|
||||
* minutes / hours / days and converts to the column's native hours unit; a
|
||||
* 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min).
|
||||
* Four decimals store sub-minute durations exactly (0.0667h → 4.00 min).
|
||||
*/
|
||||
export class WidenWindowDurationHoursPrecision1910000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "WidenWindowDurationHoursPrecision1910000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_duration_hours TYPE numeric(6, 4);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_duration_hours TYPE numeric(4, 2);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Snapshot the booking-window rule onto each train schedule.
|
||||
*
|
||||
* A schedule's window (open time + reopen cycles) must be frozen to the rule it
|
||||
* was created with: a later global-rules edit applies only to FUTURE schedules,
|
||||
* while an already-open schedule keeps its base rule. Previously the batch board
|
||||
* recomputed windows from the LIVE global config, so editing the rule redrew the
|
||||
* board for open schedules (a synthetic grid that no longer matched the window
|
||||
* the customer was shown). These columns give the board a per-schedule rule to
|
||||
* derive its display windows from.
|
||||
*
|
||||
* Existing rows are backfilled from the current global-rules singleton — the best
|
||||
* available base, since they never stored one. Their stamped windowOpensAt/
|
||||
* windowClosesAt are still real, so only projected reopen cycles rely on the
|
||||
* backfill.
|
||||
*/
|
||||
export class AddScheduleWindowRuleSnapshot1920000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddScheduleWindowRuleSnapshot1920000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS rule_window_open_hour integer,
|
||||
ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4),
|
||||
ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer,
|
||||
ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer,
|
||||
ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer;
|
||||
`);
|
||||
|
||||
// Backfill from the global-rules singleton so pre-existing schedules render.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_schedules ts
|
||||
SET
|
||||
rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour),
|
||||
rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours),
|
||||
rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes),
|
||||
rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days),
|
||||
rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours)
|
||||
FROM freight.train_scheduling_global_rules r
|
||||
WHERE ts.rule_window_open_hour IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS rule_window_open_hour,
|
||||
DROP COLUMN IF EXISTS rule_window_duration_hours,
|
||||
DROP COLUMN IF EXISTS rule_reopen_delay_minutes,
|
||||
DROP COLUMN IF EXISTS rule_import_window_lead_days,
|
||||
DROP COLUMN IF EXISTS rule_export_booking_lead_hours;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add a hard per-unit weight ceiling to weight limit rules.
|
||||
*
|
||||
* maxVgmTons stays the soft "overweight" threshold (surcharge + warning);
|
||||
* max_capacity_tons is the absolute ceiling above which a booking cannot be
|
||||
* created at all. Null means no ceiling (existing behavior).
|
||||
*/
|
||||
export class AddMaxCapacityToWeightLimitRules1930000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddMaxCapacityToWeightLimitRules1930000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
DROP COLUMN IF EXISTS max_capacity_tons;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Allow more than one vehicle per first-mile pickup. Junction table joins
|
||||
* first_mile ⇄ vehicles, with each truck's container number + actual distance;
|
||||
* existing single vehicle_id values are backfilled as the first assignment so
|
||||
* nothing is lost. Mirrors the last-mile vehicle-assignment schema.
|
||||
*/
|
||||
export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface {
|
||||
name = "AddFirstMileVehicleAssignments1940000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE,
|
||||
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
|
||||
container_number varchar,
|
||||
distance_km numeric(10,2),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE"
|
||||
ON freight.first_mile_vehicle_assignments (vehicle_id)
|
||||
`);
|
||||
// Backfill: existing single-vehicle assignments become the first row
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id)
|
||||
SELECT id, vehicle_id FROM freight.first_mile
|
||||
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
|
||||
ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Replace load-type string matching with a real wagon-type foreign key.
|
||||
*
|
||||
* Before this migration, train scheduling picked a wagon type by matching
|
||||
* strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …)
|
||||
* and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on
|
||||
* `cargo_types` and `container_types` so scheduling resolves the wagon type
|
||||
* through the relation instead.
|
||||
*
|
||||
* The columns are NULLABLE: cargo grouping rows and container/legacy cargo that
|
||||
* never ship in bulk have no wagon type, and forcing one onto them is
|
||||
* meaningless. Scheduling enforces the requirement at run time (it throws when a
|
||||
* scheduled bulk cargo type or a container type in the batch has no wagon type).
|
||||
*
|
||||
* Backfill reproduces the old hardcoded resolution one final time so existing
|
||||
* bulk cargo + container rows are not left unset. After this, the runtime map is
|
||||
* removed — the FK is the single source of truth.
|
||||
*/
|
||||
export class AddWagonTypeFkToCargoAndContainerTypes1940000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── Columns + FKs ────────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD CONSTRAINT fk_cargo_types_wagon_type
|
||||
FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ADD CONSTRAINT fk_container_types_wagon_type
|
||||
FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id
|
||||
ON freight.cargo_types (wagon_type_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id
|
||||
ON freight.container_types (wagon_type_id);
|
||||
`);
|
||||
|
||||
// ── Backfill: old cargo-code → wagon-code map (one last time) ─────────────
|
||||
// COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2,
|
||||
// COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default).
|
||||
const cargoCodeToWagon: Record<string, string> = {
|
||||
COFFEE: "KW2",
|
||||
GRAIN: "KW2",
|
||||
WHEAT: "KW2",
|
||||
SORGHUM: "KW2",
|
||||
CORN: "KW2",
|
||||
FERTILIZER: "PW2",
|
||||
SUGAR: "PW2",
|
||||
COAL: "KW3",
|
||||
STEEL: "CW3",
|
||||
ORE: "CW3",
|
||||
};
|
||||
|
||||
for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.cargo_types ct
|
||||
SET wagon_type_id = wt.id
|
||||
FROM freight.wagon_types wt
|
||||
WHERE wt.code = $1
|
||||
AND UPPER(TRIM(ct.code)) = $2
|
||||
AND ct.wagon_type_id IS NULL;
|
||||
`,
|
||||
[wagonCode, cargoCode],
|
||||
);
|
||||
}
|
||||
|
||||
// Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.cargo_types ct
|
||||
SET wagon_type_id = wt.id
|
||||
FROM freight.wagon_types wt
|
||||
WHERE wt.code = 'CW3'
|
||||
AND ct.wagon_type_id IS NULL
|
||||
AND ct.unit_of_measure = 'PER_TON';
|
||||
`);
|
||||
|
||||
// All container types → the old container default wagon NW5.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types ct
|
||||
SET wagon_type_id = wt.id
|
||||
FROM freight.wagon_types wt
|
||||
WHERE wt.code = 'NW5'
|
||||
AND ct.wagon_type_id IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Multi-truck customer (self-haul) assignment. Replaces the single
|
||||
* booking.customer_truck_* fields with a per-booking list of trucks, each
|
||||
* carrying 1–2 containers and tracking its own arrival. The legacy
|
||||
* booking.customer_truck_* columns are kept as a synced booking-level flag
|
||||
* (any truck assigned / all trucks arrived) so the warehouse exit-gate and
|
||||
* delivery-approval logic keep working.
|
||||
*/
|
||||
export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface {
|
||||
name = 'AddCustomerTruckAssignments1950000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
plate_number varchar(32) NOT NULL,
|
||||
driver_name varchar(120) NOT NULL,
|
||||
truck_type varchar(60) NOT NULL,
|
||||
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||
arrived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.customer_truck_containers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE,
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
container_number varchar(64) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`,
|
||||
);
|
||||
// One container number can be loaded onto exactly one truck per booking.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number"
|
||||
ON freight.customer_truck_containers (booking_id, container_number)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Vehicle Compliance & Expiry Alerts.
|
||||
* - Adds expiry-tracking columns to freight.vehicles.
|
||||
* - Creates freight.compliance_records for per-document compliance tracking.
|
||||
*/
|
||||
export class AddVehicleCompliance1950000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Vehicle expiry / compliance columns.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS vin VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS ownership VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS insurance_expiry DATE,
|
||||
ADD COLUMN IF NOT EXISTS registration_expiry DATE,
|
||||
ADD COLUMN IF NOT EXISTS next_inspection_date DATE;
|
||||
`);
|
||||
|
||||
// Compliance records table.
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.compliance_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vehicle_id UUID NOT NULL REFERENCES freight.vehicles(id),
|
||||
type VARCHAR NOT NULL,
|
||||
document_number VARCHAR,
|
||||
issued_date DATE,
|
||||
expiry_date DATE NOT NULL,
|
||||
status VARCHAR NOT NULL DEFAULT 'VALID',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_compliance_records_vehicle_id ON freight.compliance_records(vehicle_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_compliance_records_expiry_date ON freight.compliance_records(expiry_date);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_compliance_records_type ON freight.compliance_records(type);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.compliance_records CASCADE;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS vin,
|
||||
DROP COLUMN IF EXISTS ownership,
|
||||
DROP COLUMN IF EXISTS insurance_expiry,
|
||||
DROP COLUMN IF EXISTS registration_expiry,
|
||||
DROP COLUMN IF EXISTS next_inspection_date;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add the daily booking-desk close hour.
|
||||
*
|
||||
* The import booking window used to reopen only within the same EAT calendar day
|
||||
* as its close; a cycle whose reopen crossed midnight died at CLOSED_FOR_DAY with
|
||||
* capacity still free. The window now runs a daily office range [openHour,
|
||||
* closeHour): a not-yet-full train pauses at closeHour and resumes the next
|
||||
* morning at openHour, every day until it fills or departs. openHour === closeHour
|
||||
* means a 24-hour desk.
|
||||
*
|
||||
* `window_close_hour` on the global-rules singleton is the live config; the
|
||||
* matching `rule_window_close_hour` snapshot on each schedule freezes it at
|
||||
* creation so the batch board keeps drawing the window the customer was shown.
|
||||
* Both default/backfill to 17:00 (5 PM), the previous implicit office close.
|
||||
*/
|
||||
export class AddWindowCloseHour1950000000000 implements MigrationInterface {
|
||||
name = "AddWindowCloseHour1950000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN IF NOT EXISTS window_close_hour integer NOT NULL DEFAULT 17;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS rule_window_close_hour integer;
|
||||
`);
|
||||
|
||||
// Backfill the snapshot from the global-rules singleton so pre-existing
|
||||
// schedules keep projecting reopen cycles.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_schedules ts
|
||||
SET rule_window_close_hour = COALESCE(ts.rule_window_close_hour, r.window_close_hour)
|
||||
FROM freight.train_scheduling_global_rules r
|
||||
WHERE ts.rule_window_close_hour IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS rule_window_close_hour;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS window_close_hour;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* In-app notification inbox. One row per recipient per logical notification;
|
||||
* producers fan out by inserting many rows. Indexed for the two hot queries:
|
||||
* unread-count (recipient + is_read) and the newest-first list (recipient +
|
||||
* created_at). Enum-like columns are stored as varchar to avoid PG enum churn.
|
||||
*/
|
||||
export class CreateNotifications1950000000000 implements MigrationInterface {
|
||||
name = "CreateNotifications1950000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.notifications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
recipient_user_id uuid NOT NULL,
|
||||
audience varchar(20) NOT NULL,
|
||||
type varchar(48) NOT NULL DEFAULT 'GENERIC',
|
||||
title varchar(200) NOT NULL,
|
||||
body text NOT NULL,
|
||||
link varchar,
|
||||
data jsonb,
|
||||
priority varchar(12) NOT NULL DEFAULT 'NORMAL',
|
||||
is_read boolean NOT NULL DEFAULT false,
|
||||
read_at timestamptz,
|
||||
channels_sent jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_UNREAD"
|
||||
ON freight.notifications (recipient_user_id, is_read)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_CREATED"
|
||||
ON freight.notifications (recipient_user_id, created_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_CREATED"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_UNREAD"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.notifications`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-container receive tracking. A booking's containers arrive individually
|
||||
* (on separate self-haul trucks), so each container unit tracks whether it has
|
||||
* been received into the port and, once staff confirm it, the GRN it belongs to.
|
||||
* A single GRN covers the containers received together — so if the whole booking
|
||||
* arrives at once, all its units share one GRN (per-booking GRN).
|
||||
*/
|
||||
export class AddContainerReceiptToBookingContainerUnits1960000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddContainerReceiptToBookingContainerUnits1960000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container_units
|
||||
ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS received_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS grn_number varchar(100)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container_units
|
||||
DROP COLUMN IF EXISTS received_to_port,
|
||||
DROP COLUMN IF EXISTS received_at,
|
||||
DROP COLUMN IF EXISTS grn_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Accident & Incident register for the fleet. Tracks accidents, breakdowns,
|
||||
* traffic violations, thefts and other incidents against a vehicle, driver
|
||||
* and/or booking, with severity, damage estimate, insurance claim tracking and
|
||||
* a lifecycle status. Queried by driver_id for per-driver incident history.
|
||||
*/
|
||||
export class AddIncidents1960000000000 implements MigrationInterface {
|
||||
name = 'AddIncidents1960000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.incidents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
vehicle_id uuid,
|
||||
driver_id uuid,
|
||||
booking_id uuid,
|
||||
type varchar NOT NULL,
|
||||
severity varchar NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
location varchar,
|
||||
description text NOT NULL,
|
||||
damage_estimate numeric(14,2),
|
||||
status varchar NOT NULL DEFAULT 'REPORTED',
|
||||
insurance_claim_number varchar,
|
||||
reported_by varchar
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_DRIVER"
|
||||
ON freight.incidents (driver_id, occurred_at)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_VEHICLE"
|
||||
ON freight.incidents (vehicle_id, occurred_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Import self-haul trucks are weighed on leaving. The customer does not
|
||||
* pre-specify what an import truck takes — staff register the containers loaded
|
||||
* and the weighed gross when the truck departs. These columns capture that.
|
||||
*/
|
||||
export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface {
|
||||
name = 'AddCustomerTruckDeparture1970000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.customer_truck_assignments
|
||||
ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2),
|
||||
ADD COLUMN IF NOT EXISTS departed_at timestamptz
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.customer_truck_assignments
|
||||
DROP COLUMN IF EXISTS gross_weight_kg,
|
||||
DROP COLUMN IF EXISTS departed_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMaintenanceDepth1970000000000 implements MigrationInterface {
|
||||
name = 'AddMaintenanceDepth1970000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.work_orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vehicle_id UUID NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR NOT NULL DEFAULT 'OPEN',
|
||||
priority VARCHAR NOT NULL DEFAULT 'MEDIUM',
|
||||
assigned_to VARCHAR,
|
||||
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
labor_cost NUMERIC(14, 2),
|
||||
parts_cost NUMERIC(14, 2),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.parts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR NOT NULL,
|
||||
sku VARCHAR,
|
||||
category VARCHAR,
|
||||
quantity_in_stock INT NOT NULL DEFAULT 0,
|
||||
reorder_level INT NOT NULL DEFAULT 0,
|
||||
unit_cost NUMERIC(14, 2),
|
||||
location VARCHAR,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warranties (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vehicle_id UUID NOT NULL,
|
||||
component VARCHAR NOT NULL,
|
||||
provider VARCHAR,
|
||||
start_date DATE,
|
||||
expiry_date DATE NOT NULL,
|
||||
coverage_notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_work_orders_vehicle_id_status" ON freight.work_orders (vehicle_id, status)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_parts_category" ON freight.parts (category)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warranties_vehicle_id_expiry_date" ON freight.warranties (vehicle_id, expiry_date)`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.work_orders
|
||||
ADD CONSTRAINT "FK_work_orders_vehicle_id"
|
||||
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.warranties
|
||||
ADD CONSTRAINT "FK_warranties_vehicle_id"
|
||||
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warranties CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.parts CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.work_orders CASCADE`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Structured import handover records. Replaces the ad-hoc handover notes so a
|
||||
* booking can carry one handover (single truck) or several (one per truck when
|
||||
* multiple trucks are used). Timing differs by mile type:
|
||||
* - SELF_HAUL: generated on first truck arrival, signed before the truck leaves.
|
||||
* - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery.
|
||||
*/
|
||||
export class AddBookingHandovers1980000000000 implements MigrationInterface {
|
||||
name = 'AddBookingHandovers1980000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_handovers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL,
|
||||
truck_plate varchar(32),
|
||||
mile_type varchar(20) NOT NULL,
|
||||
reference varchar(100) NOT NULL,
|
||||
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||
signed_at timestamptz,
|
||||
signed_by_user_id uuid,
|
||||
delivered_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`,
|
||||
);
|
||||
// At most one live handover per (booking, customer truck). EDR trucks (which
|
||||
// aren't customer_truck_assignments) and per-booking handovers are de-duped
|
||||
// in the service, since a NULL truck_assignment_id can't be uniquely indexed.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck"
|
||||
ON freight.booking_handovers (booking_id, truck_assignment_id)
|
||||
WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddProcurement1980000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.vendors (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
name varchar NOT NULL,
|
||||
type varchar,
|
||||
contact_person varchar,
|
||||
phone varchar,
|
||||
email varchar,
|
||||
address varchar,
|
||||
is_active boolean NOT NULL DEFAULT true
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.asset_acquisitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
vehicle_id uuid,
|
||||
vendor_id uuid,
|
||||
acquisition_type varchar NOT NULL,
|
||||
acquisition_date date NOT NULL,
|
||||
cost numeric(14,2),
|
||||
useful_life_months integer,
|
||||
salvage_value numeric(14,2),
|
||||
lease_start date,
|
||||
lease_end date,
|
||||
monthly_payment numeric(14,2),
|
||||
status varchar NOT NULL DEFAULT 'ACTIVE',
|
||||
notes text
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_acquisitions_vehicle_date
|
||||
ON freight.asset_acquisitions(vehicle_id, acquisition_date);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.asset_disposals (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
vehicle_id uuid NOT NULL,
|
||||
disposal_date date NOT NULL,
|
||||
method varchar NOT NULL,
|
||||
sale_price numeric(14,2),
|
||||
buyer varchar,
|
||||
notes text
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_disposals_vehicle_date
|
||||
ON freight.asset_disposals(vehicle_id, disposal_date);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_disposals CASCADE;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_acquisitions CASCADE;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.vendors CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route
|
||||
* freezes its trade direction from the yard countries:
|
||||
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT,
|
||||
* same country = DOMESTIC (shown as "Intercity"; disabled for scheduling
|
||||
* and contracts for now).
|
||||
*
|
||||
* Existing yard rows are normalized case-insensitively; anything mentioning
|
||||
* Djibouti maps there, everything else maps to Ethiopia (the line only serves
|
||||
* these two countries). A CHECK constraint keeps future writes honest.
|
||||
*/
|
||||
export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface {
|
||||
name = 'YardCountryEnumAndRouteDirection1980000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.yards
|
||||
SET country = CASE
|
||||
WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti'
|
||||
ELSE 'Ethiopia'
|
||||
END
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yards
|
||||
DROP CONSTRAINT IF EXISTS chk_yards_country,
|
||||
ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti'))
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS direction varchar(10)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes r
|
||||
SET direction = CASE
|
||||
WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT'
|
||||
WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT'
|
||||
ELSE 'DOMESTIC'
|
||||
END
|
||||
FROM freight.yards o, freight.yards d
|
||||
WHERE o.id = r.origin_yard_id
|
||||
AND d.id = r.destination_yard_id
|
||||
`);
|
||||
// Orphan origin/destination (deleted yard) — no way to classify; park as
|
||||
// DOMESTIC, which is blocked everywhere, so nothing can schedule on it.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ALTER COLUMN direction SET NOT NULL,
|
||||
DROP CONSTRAINT IF EXISTS chk_routes_direction,
|
||||
ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC'))
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
DROP CONSTRAINT IF EXISTS chk_routes_direction,
|
||||
DROP COLUMN IF EXISTS direction
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Double-handling fee support. warehouse_fee_rules.basis: how a
|
||||
* DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM
|
||||
* (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from
|
||||
* the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's
|
||||
* unit of measure), so no new booking column is needed.
|
||||
*/
|
||||
export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface {
|
||||
name = 'AddDoubleHandlingBasisAndMachinery1990000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`,
|
||||
);
|
||||
// machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it
|
||||
// if a prior version of this migration added it.
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Per-km haulage rate on a vehicle (mainly trucks) plus the currency it's
|
||||
* quoted in (ETB | USD, default ETB).
|
||||
*/
|
||||
export class AddVehiclePricePerKm1990000000000 implements MigrationInterface {
|
||||
name = "AddVehiclePricePerKm1990000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS price_per_km numeric(14,2),
|
||||
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS price_per_km,
|
||||
DROP COLUMN IF EXISTS currency
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Segment corridor bookings: a booking may ride only part of a train's route
|
||||
* (its own origin→destination leg), so dispatch/arrival become per-booking
|
||||
* facts and wagon capacity is consumed per leg instead of per whole route.
|
||||
*
|
||||
* - bookings.loaded_at / arrived_at (+ by-user): operator-confirmed load at
|
||||
* the booking's origin yard and unload at its destination yard. Clearance
|
||||
* gates read arrived_at, not the train's actual_arrival_at.
|
||||
* - train_set_wagons.board_yard_id / alight_yard_id: the leg a consist slot
|
||||
* occupies; NULL/NULL = whole route (legacy). Non-overlapping legs coexist
|
||||
* without consuming each other's capacity.
|
||||
* - wagon_movements: auditable ledger of every physical wagon relocation
|
||||
* (loaded leg / empty reposition / manual correction) with the acting user.
|
||||
*/
|
||||
export class SegmentCorridorBookings1990000000000 implements MigrationInterface {
|
||||
name = 'SegmentCorridorBookings1990000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS loaded_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS arrived_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS arrived_by_user_id uuid;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD COLUMN IF NOT EXISTS board_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS alight_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_movements (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
wagon_id uuid NOT NULL REFERENCES freight.wagons(id) ON DELETE CASCADE,
|
||||
from_yard_id uuid REFERENCES freight.yards(id),
|
||||
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
|
||||
train_schedule_id uuid REFERENCES freight.train_schedules(id) ON DELETE SET NULL,
|
||||
booking_id uuid REFERENCES freight.bookings(id) ON DELETE SET NULL,
|
||||
kind varchar(30) NOT NULL,
|
||||
moved_by_user_id uuid,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_wagon_occurred" ON freight.wagon_movements (wagon_id, occurred_at);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_schedule" ON freight.wagon_movements (train_schedule_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_movements;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
DROP COLUMN IF EXISTS board_yard_id,
|
||||
DROP COLUMN IF EXISTS alight_yard_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS loaded_at,
|
||||
DROP COLUMN IF EXISTS loaded_by_user_id,
|
||||
DROP COLUMN IF EXISTS arrived_at,
|
||||
DROP COLUMN IF EXISTS arrived_by_user_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Moves service-level priority off the service_types table and onto the
|
||||
* admin-managed priority_configs table as a new CUSTOMS rule type.
|
||||
*
|
||||
* - Drops service_types.priority_bonus_points (replaced by CUSTOMS configs).
|
||||
* - Widens priority_configs.type CHECK to allow 'CUSTOMS' (currency must be
|
||||
* null, same as WAGON).
|
||||
* - Seeds the two customs wagon-count tiers: 1–10 → 7 pts, 11–53 → 15 pts.
|
||||
* CUSTOMS rules apply only when the booking's service type includesCustoms.
|
||||
*/
|
||||
export class AddCustomsPriorityConfig2000000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.service_types DROP COLUMN IF EXISTS priority_bonus_points;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT priority_configs_type_check
|
||||
CHECK (type IN ('WAGON', 'CURRENCY', 'CUSTOMS'));
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL) OR
|
||||
(type = 'CUSTOMS' AND currency IS NULL)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.priority_configs
|
||||
(type, label, currency, min_wagon_count, max_wagon_count, score_points, is_active, display_order)
|
||||
VALUES
|
||||
('CUSTOMS', 'With customs 1–10 wagons', NULL, 1, 10, 7, true, 1),
|
||||
('CUSTOMS', 'With customs 11–53 wagons', NULL, 11, 53, 15, true, 2);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.priority_configs WHERE type = 'CUSTOMS';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT priority_configs_type_check
|
||||
CHECK (type IN ('WAGON', 'CURRENCY'));
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.service_types
|
||||
ADD COLUMN IF NOT EXISTS priority_bonus_points INT NOT NULL DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* GPS tracking: physical trackers (gps_devices, one denormalized latest fix per
|
||||
* device for the live map) + append-only fix history (gps_positions).
|
||||
*/
|
||||
export class AddGpsTracking2000000000000 implements MigrationInterface {
|
||||
name = "AddGpsTracking2000000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.gps_devices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
imei varchar(20) NOT NULL UNIQUE,
|
||||
name varchar,
|
||||
vehicle_id uuid REFERENCES freight.vehicles(id),
|
||||
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
|
||||
last_seen_at timestamptz,
|
||||
last_lat numeric(10,6),
|
||||
last_lng numeric(10,6),
|
||||
last_speed numeric(6,2),
|
||||
last_course int,
|
||||
last_fix_at timestamptz,
|
||||
voltage_level int,
|
||||
gsm_level int,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE"
|
||||
ON freight.gps_devices (vehicle_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.gps_positions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_id uuid NOT NULL,
|
||||
imei varchar(20) NOT NULL,
|
||||
vehicle_id uuid,
|
||||
lat numeric(10,6) NOT NULL,
|
||||
lng numeric(10,6) NOT NULL,
|
||||
speed numeric(6,2) NOT NULL DEFAULT 0,
|
||||
course int NOT NULL DEFAULT 0,
|
||||
satellites int NOT NULL DEFAULT 0,
|
||||
positioned boolean NOT NULL DEFAULT false,
|
||||
gps_time timestamptz NOT NULL,
|
||||
alarm int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME"
|
||||
ON freight.gps_positions (device_id, gps_time)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
|
||||
ON freight.gps_positions (vehicle_id, gps_time)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Truck detention support.
|
||||
* - last_mile.arrived_at / delivered_at: the detention window for an EDR
|
||||
* last-mile vehicle. The clock runs from arrival at destination; the customer
|
||||
* has a grace period (default 3h) to clear/return, after which detention
|
||||
* accrues per truck per day until delivered_at (or now, if still out).
|
||||
* - warehouse_fee_rules.free_hours: configurable grace window (hours) for a
|
||||
* TRUCK_DETENTION_FEE rule; null/0 falls back to the 3-hour default.
|
||||
*/
|
||||
export class AddTruckDetentionTiming2000000000000 implements MigrationInterface {
|
||||
name = 'AddTruckDetentionTiming2000000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS arrived_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS delivered_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS free_hours int`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS free_hours`);
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS delivered_at`);
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS arrived_at`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds bookings.consolidation_resume_status: the status a booking parked in
|
||||
* PENDING_CONSOLIDATION returns to once it pairs with a wagon partner.
|
||||
*
|
||||
* Direct customer bookings leave it NULL (they resume to SUBMITTED, unchanged).
|
||||
* Contract-drawdown bookings (GL shipments) set it to the status
|
||||
* createUnderContract would otherwise have used (OPERATION_REQUEST_PENDING or
|
||||
* AWAITING_DOCUMENTS), so pairing resumes them into the contract-booking flow
|
||||
* instead of wrongly moving them to SUBMITTED.
|
||||
*/
|
||||
export class AddConsolidationResumeStatus2010000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS consolidation_resume_status VARCHAR(40);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS consolidation_resume_status;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER /
|
||||
* TANKER / FLATBED / …), so different truck types carry different detention
|
||||
* rates. Null = applies to any truck type.
|
||||
*/
|
||||
export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface {
|
||||
name = 'AddFeeRuleVehicleType2010000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Repair: AddEmailToOtpVerifications1900000000000 originally altered
|
||||
* `public.otp_verifications`, but the OtpVerification entity pins
|
||||
* schema: "freight". On any DB where that migration already ran (and is recorded
|
||||
* as executed, so it won't run again), the real `freight.otp_verifications` table
|
||||
* never got the `email` column and `phone` was never made nullable — so OTP send
|
||||
* dies with `column OtpVerification.email does not exist`.
|
||||
*
|
||||
* This migration re-applies the change against the correct schema. Idempotent
|
||||
* (IF NOT EXISTS / no-op DROP NOT NULL), and guarded so it's a no-op when the
|
||||
* freight table is absent.
|
||||
*/
|
||||
export class RepairOtpEmailSchema2020000000000 implements MigrationInterface {
|
||||
name = "RepairOtpEmailSchema2020000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable("freight.otp_verifications");
|
||||
if (!exists) return;
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ALTER COLUMN phone DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable("freight.otp_verifications");
|
||||
if (!exists) return;
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
DROP COLUMN IF EXISTS email
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in
|
||||
* kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo
|
||||
* weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes
|
||||
* and is NOT touched; truck gross weight has no data yet. Runs exactly once
|
||||
* (tracked by TypeORM) — re-running would divide again.
|
||||
*/
|
||||
export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface {
|
||||
name = 'WarehouseCapacityKgToTons2020000000000';
|
||||
|
||||
private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones'];
|
||||
private readonly columns = ['capacity_weight', 'current_weight', 'max_weight'];
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const table of this.tables) {
|
||||
for (const column of this.columns) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const table of this.tables) {
|
||||
for (const column of this.columns) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds train_schedules.reference: a human-facing unique schedule number
|
||||
* S-YYYY-NNNNN (per-year sequence, like bookings' BK-YYYY-NNNNNN).
|
||||
*
|
||||
* - Adds the nullable column.
|
||||
* - Backfills existing rows: within each created-at year, numbers rows by
|
||||
* created_at ascending (oldest → S-<year>-00001). Deterministic order.
|
||||
* - Adds a partial unique index (NULLs allowed so a future insert can stage
|
||||
* the row before the app stamps its reference).
|
||||
*/
|
||||
export class AddTrainScheduleReference2030000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS reference VARCHAR(20);
|
||||
`);
|
||||
|
||||
// Backfill per-year, ordered by created_at (oldest = 00001). Uses the row's
|
||||
// own created-at year as the reference year so historical rows keep a
|
||||
// sensible number.
|
||||
await queryRunner.query(`
|
||||
WITH numbered AS (
|
||||
SELECT
|
||||
id,
|
||||
EXTRACT(YEAR FROM created_at)::int AS yr,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY EXTRACT(YEAR FROM created_at)
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS seq
|
||||
FROM freight.train_schedules
|
||||
WHERE reference IS NULL
|
||||
)
|
||||
UPDATE freight.train_schedules ts
|
||||
SET reference = 'S-' || numbered.yr || '-' || LPAD(numbered.seq::text, 5, '0')
|
||||
FROM numbered
|
||||
WHERE ts.id = numbered.id;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_train_schedules_reference
|
||||
ON freight.train_schedules (reference)
|
||||
WHERE reference IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.ux_train_schedules_reference;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS reference;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
|
||||
import { CheckAvailabilityService } from "./check-availability.service";
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
@Public()
|
||||
export class CheckAvailabilityController {
|
||||
constructor(
|
||||
private readonly checkAvailabilityService: CheckAvailabilityService,
|
||||
) {}
|
||||
|
||||
@Get("check-availability")
|
||||
@ApiOperation({
|
||||
summary: "Check whether an email and/or phone number is already registered",
|
||||
})
|
||||
check(@Query("email") email?: string, @Query("phone") phone?: string) {
|
||||
return this.checkAvailabilityService.check({ email, phone });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
export interface CheckAvailabilityQuery {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityResult {
|
||||
emailTaken: boolean;
|
||||
phoneTaken: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CheckAvailabilityService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async check({
|
||||
email,
|
||||
phone,
|
||||
}: CheckAvailabilityQuery): Promise<CheckAvailabilityResult> {
|
||||
if (!email && !phone) {
|
||||
throw new BadRequestException("email or phone is required");
|
||||
}
|
||||
|
||||
const matches = await this.userRepository.find({
|
||||
where: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(phone ? [{ phoneNumber: phone }] : []),
|
||||
],
|
||||
select: { id: true, email: true, phoneNumber: true },
|
||||
});
|
||||
|
||||
return {
|
||||
emailTaken: email ? matches.some((user) => user.email === email) : false,
|
||||
phoneTaken: phone
|
||||
? matches.some((user) => user.phoneNumber === phone)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { FreightMeController } from './freight-me.controller';
|
||||
import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FreightMeController],
|
||||
providers: [FreightMeService],
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [FreightMeController, CheckAvailabilityController],
|
||||
providers: [FreightMeService, CheckAvailabilityService],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
||||
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
|
||||
|
||||
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
|
||||
// Subpath imports (not the package root) so ts-jest can resolve them when this
|
||||
// file lands in a spec's compile graph via the notification recipients chain.
|
||||
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
|
||||
import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity";
|
||||
import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity";
|
||||
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
|
||||
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
@@ -40,6 +44,21 @@ export class BackofficeService {
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* IAM user ids of every current employee across all organizations — used by
|
||||
* the notification recipients resolver's `allBackoffice` selector.
|
||||
*/
|
||||
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
|
||||
const employees = await this.employeeRepository.find({
|
||||
where: { isCurrent: true },
|
||||
});
|
||||
return [
|
||||
...new Set(
|
||||
employees.map((e) => e.userId).filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
async createOrganizationUser(
|
||||
organizationId: string,
|
||||
dto: CreateOrganizationUserDto,
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BookingView } from "../../common/booking-guards";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
@BookingView()
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) { }
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get("invoices")
|
||||
@ApiOperation({ summary: "List all invoices" })
|
||||
findAll() {
|
||||
return this.billingService.findAll();
|
||||
@ApiOperation({
|
||||
summary: "List invoices (paginated, filterable by company/status/search)",
|
||||
})
|
||||
findAll(@Query() query: FilterInvoiceDto) {
|
||||
return this.billingService.findAllPaginated(query);
|
||||
}
|
||||
|
||||
@Get("invoices/:id")
|
||||
@@ -21,4 +33,26 @@ export class BillingController {
|
||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.billingService.findById(id);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.document(id);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/receipt")
|
||||
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
|
||||
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.receipt(id);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream a generated PDF as a file download. */
|
||||
export function sendPdf(res: Response, filename: string, buffer: Buffer): void {
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { PortalBillingController } from "./portal-billing.controller";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { DocumentsModule } from "./documents/documents.module";
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
@@ -16,8 +18,9 @@ import { CompaniesModule } from "../companies/companies.module";
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
CompaniesModule,
|
||||
DocumentsModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
exports: [BillingService],
|
||||
})
|
||||
|
||||
@@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) {
|
||||
}
|
||||
|
||||
function makeEvents() {
|
||||
return { emit: jest.fn() };
|
||||
// BillingService emits via both emit() and emitAsync() (the post-commit async
|
||||
// listener path) — the mock must provide both.
|
||||
return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
|
||||
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||
@@ -76,6 +78,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
});
|
||||
|
||||
@@ -88,7 +91,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
expect(invoice.sourceId).toBe("booking-1");
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
||||
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
|
||||
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -115,12 +118,14 @@ describe("BillingService.generateInvoice", () => {
|
||||
});
|
||||
|
||||
describe("BillingService.markInvoiceAsPaid", () => {
|
||||
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
totalAmount: 1500,
|
||||
paidAt: null,
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
@@ -134,6 +139,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -141,9 +147,24 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
{
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId: "pay-1",
|
||||
paidAt: expect.any(Date),
|
||||
paidAmount: 1500,
|
||||
balanceAmount: 0,
|
||||
payments: [
|
||||
{
|
||||
amount: 1500,
|
||||
method: "GATEWAY",
|
||||
reference: "pay-1",
|
||||
paidAt: expect.any(String),
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.objectContaining({
|
||||
invoiceId: "inv-1",
|
||||
@@ -171,80 +192,188 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.settlePayable", () => {
|
||||
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
describe("BillingService.recordPayment", () => {
|
||||
function serviceFor(invoice: Record<string, unknown> | null) {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
findOne: jest.fn().mockResolvedValue(invoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const dataSource = {
|
||||
manager: mg,
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
|
||||
};
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
source: "warehouse",
|
||||
sourceId: "inv-item-1",
|
||||
totalAmount: 1000,
|
||||
paidAmount: 0,
|
||||
balanceAmount: 1000,
|
||||
payments: [],
|
||||
paidAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
|
||||
const { service, mg, events } = serviceFor(openInvoice());
|
||||
|
||||
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
|
||||
|
||||
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
|
||||
expect(updated.paidAmount).toBe(400);
|
||||
expect(updated.balanceAmount).toBe(600);
|
||||
expect(updated.payments).toHaveLength(1);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
expect.objectContaining({
|
||||
status: Freight.InvoiceStatus.PartiallyPaid,
|
||||
paidAmount: 400,
|
||||
balanceAmount: 600,
|
||||
}),
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.anything(),
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
|
||||
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
|
||||
|
||||
const updated = await service.recordPayment("inv-1", { amount: 600 });
|
||||
|
||||
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect(updated.balanceAmount).toBe(0);
|
||||
expect(updated.paidAt).toBeInstanceOf(Date);
|
||||
expect(mg.update).toHaveBeenCalled();
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"warehouse.invoice.paid",
|
||||
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (returns null) when the source has no open invoice", async () => {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
it("rejects a non-positive amount", async () => {
|
||||
const { service, mg } = serviceFor(openInvoice());
|
||||
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a payment that exceeds the outstanding balance", async () => {
|
||||
const { service, mg } = serviceFor(openInvoice());
|
||||
await expect(
|
||||
service.recordPayment("inv-1", { amount: 1500 }),
|
||||
).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects payment against a cancelled invoice", async () => {
|
||||
const { service, mg } = serviceFor(
|
||||
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
|
||||
);
|
||||
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: `expirePayable` (batch settle path, called when a payment window
|
||||
* lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE.
|
||||
* The bug passed `dataSource.manager` (the non-transactional default) into the
|
||||
* transition, so runTransition skipped opening a transaction and the lock threw
|
||||
* `An open transaction is required for pessimistic lock` — aborting the whole
|
||||
* settle pass (the "settle/reserve one booking at a time" symptom). The locked
|
||||
* write MUST run inside dataSource.transaction.
|
||||
*/
|
||||
describe("BillingService.expirePayable — locked write runs in a transaction", () => {
|
||||
const openInvoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
|
||||
const build = (lookupResult: Record<string, unknown> | null) => {
|
||||
const defaultManager = {
|
||||
findOne: jest.fn().mockResolvedValue(lookupResult),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const txManager = {
|
||||
findOne: jest.fn().mockResolvedValue(openInvoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transaction = jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager));
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{ manager: defaultManager, transaction } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => {
|
||||
const { service, transaction, txManager, defaultManager } = build(openInvoice);
|
||||
|
||||
await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
expect(settled).toBeNull();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txManager.findOne).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
|
||||
);
|
||||
expect(txManager.update).toHaveBeenCalled();
|
||||
// The default manager only does the initial lock-free lookup, never a locked read.
|
||||
for (const call of defaultManager.findOne.mock.calls) {
|
||||
expect(call[1]).not.toHaveProperty("lock");
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op (no transaction) when there is no open invoice", async () => {
|
||||
const { service, transaction } = build(null);
|
||||
|
||||
const result = await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
|
||||
import { applySettlement, round2 } from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||
export interface PayInvoiceOptions {
|
||||
@@ -20,13 +33,25 @@ export interface PayInvoiceOptions {
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/** A single manual/offline settlement to record against an invoice. */
|
||||
export interface RecordPaymentInput {
|
||||
/** Amount settled by this payment; must be > 0. */
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
/** When the settlement occurred; defaults to now. */
|
||||
paidAt?: Date;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||
const DEFAULT_DUE_DAYS = 14;
|
||||
|
||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Draft,
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
@@ -56,7 +81,11 @@ export interface GenerateInvoiceInput {
|
||||
companyProfileId: string;
|
||||
lines: InvoiceLineInput[];
|
||||
currency?: string;
|
||||
/** Explicit total; defaults to the sum of line amounts. */
|
||||
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
||||
subtotalAmount?: number;
|
||||
/** Tax applied on top of the subtotal; defaults to 0. */
|
||||
taxAmount?: number;
|
||||
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
|
||||
totalAmount?: number;
|
||||
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
||||
dueAt?: Date;
|
||||
@@ -95,7 +124,8 @@ export class BillingService {
|
||||
@Inject(forwardRef(() => PaymentService))
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
) { }
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
) {}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,9 +134,56 @@ export class BillingService {
|
||||
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated invoice list for the backoffice — optionally narrowed to a
|
||||
* company (customer detail "Invoices" tab) and/or status/search (global
|
||||
* invoices page).
|
||||
*/
|
||||
async findAllPaginated(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{ items: Invoice[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.companyId) {
|
||||
qb.andWhere("invoice.companyId = :companyId", {
|
||||
companyId: filter.companyId,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/** Invoice header plus its line items. */
|
||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.invoices.findById(id);
|
||||
const invoice = await this.invoices.findById(id, {
|
||||
relations: { company: true, companyProfile: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const lines = await this.invoiceLines.findAll({
|
||||
where: { invoiceId: id },
|
||||
@@ -115,6 +192,89 @@ export class BillingService {
|
||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
}
|
||||
|
||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||
|
||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException(
|
||||
"A receipt is available only after payment is recorded.",
|
||||
);
|
||||
}
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "RECEIPT"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||
private toDocumentModel(
|
||||
invoice: Invoice & { lines: InvoiceLine[] },
|
||||
kind: "INVOICE" | "RECEIPT",
|
||||
): InvoiceDocumentModel {
|
||||
const title = invoice.source
|
||||
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
||||
: "EDR";
|
||||
const totals: InvoiceDocumentModel["totals"] = [
|
||||
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
|
||||
];
|
||||
if (Number(invoice.taxAmount) > 0) {
|
||||
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
|
||||
}
|
||||
totals.push({
|
||||
label: "Total",
|
||||
amount: Number(invoice.totalAmount),
|
||||
grand: true,
|
||||
});
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: "Status", value: invoice.status },
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{
|
||||
label: "Issued",
|
||||
value: invoice.issuedAt
|
||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Due",
|
||||
value: invoice.dueAt
|
||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
],
|
||||
categoryHeader: "Charge type",
|
||||
lines: invoice.lines.map((l) => ({
|
||||
description: l.description ?? l.chargeType,
|
||||
category: l.chargeType,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitRate,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||
|
||||
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||
@@ -127,19 +287,43 @@ export class BillingService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every invoice billed to a company, newest first, with billing relations. */
|
||||
findByCompany(companyId: string): Promise<Invoice[]> {
|
||||
/**
|
||||
* Every invoice billed to a company, newest first, with billing relations.
|
||||
* Optionally narrow to a single source record (e.g. a booking's invoices) via
|
||||
* `{ source, sourceId }`.
|
||||
*/
|
||||
findByCompany(
|
||||
companyId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
return this.invoices.findAll({
|
||||
where: { companyId },
|
||||
where: {
|
||||
companyId,
|
||||
...(filter.source ? { source: filter.source } : {}),
|
||||
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
||||
},
|
||||
relations: { company: true, companyProfile: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for a batch of source records (e.g. many last-mile legs), so a
|
||||
* list can show which records already have an invoice without N+1 queries. */
|
||||
findBySourceIds(source: string, sourceIds: string[]): Promise<Invoice[]> {
|
||||
if (!sourceIds.length) return Promise.resolve([]);
|
||||
return this.invoices.findAll({
|
||||
where: { source, sourceId: In(sourceIds) },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
async findForUser(userId: string): Promise<Invoice[]> {
|
||||
async findForUser(
|
||||
userId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId) : [];
|
||||
return companyId ? this.findByCompany(companyId, filter) : [];
|
||||
}
|
||||
|
||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
@@ -157,36 +341,43 @@ export class BillingService {
|
||||
|
||||
/**
|
||||
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
||||
* ownership, then charges whichever open invoice the source currently has
|
||||
* (see {@link payInvoice}).
|
||||
* ownership, then charges the invoice directly by ID (see {@link payInvoice}).
|
||||
*/
|
||||
async payInvoiceForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
opts: PayInvoiceOptions = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findByIdForUser(id, userId);
|
||||
return this.payInvoice(
|
||||
invoice.source as Freight.InvoiceSource,
|
||||
invoice.sourceId,
|
||||
opts,
|
||||
);
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.payInvoice(id, opts);
|
||||
}
|
||||
|
||||
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async documentForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.document(id);
|
||||
}
|
||||
|
||||
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async receiptForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.receipt(id);
|
||||
}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
||||
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
const now = new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `FRT-${ymd}-`;
|
||||
const [row] = await mg.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||
FROM freight.invoices WHERE invoice_number LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, {
|
||||
table: "freight.invoices",
|
||||
code: "INV",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +395,7 @@ export class BillingService {
|
||||
input: GenerateInvoiceInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
console.log("oooooooooo", input);
|
||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
@@ -230,14 +422,17 @@ export class BillingService {
|
||||
};
|
||||
});
|
||||
|
||||
const totalAmount =
|
||||
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||
const subtotalAmount =
|
||||
input.subtotalAmount ??
|
||||
lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||
const taxAmount = input.taxAmount ?? 0;
|
||||
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
||||
|
||||
const dueAt =
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
@@ -250,7 +445,12 @@ export class BillingService {
|
||||
type: input.type,
|
||||
companyId: input.companyId,
|
||||
companyProfileId: input.companyProfileId,
|
||||
totalAmount,
|
||||
subtotalAmount: round2(subtotalAmount),
|
||||
taxAmount: round2(taxAmount),
|
||||
totalAmount: round2(totalAmount),
|
||||
paidAmount: 0,
|
||||
balanceAmount: round2(totalAmount),
|
||||
payments: [],
|
||||
currency,
|
||||
status,
|
||||
issuedAt: issued ? new Date() : null,
|
||||
@@ -274,28 +474,182 @@ export class BillingService {
|
||||
// ── State transitions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mark an invoice paid and link the gateway payment, then emit
|
||||
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
|
||||
* No-op when the invoice is already paid. Pass `manager` to enlist in a
|
||||
* caller's transaction.
|
||||
* Run `fn` inside a transaction and only emit its returned domain event
|
||||
* after commit. When the caller passes their own `manager`, they own commit
|
||||
* timing — `fn`'s event fires inline as soon as it resolves (the outer
|
||||
* transaction may still roll back afterwards; this is the caller's
|
||||
* documented tradeoff). When no `manager` is given, this opens its own
|
||||
* transaction and defers the emit until after that transaction commits, so
|
||||
* listeners (e.g. booking advancement) can never observe an invoice change
|
||||
* that then rolls back.
|
||||
*/
|
||||
private async runTransition<T>(
|
||||
manager: EntityManager | undefined,
|
||||
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
|
||||
): Promise<T> {
|
||||
if (manager) {
|
||||
const { result, emit } = await fn(manager);
|
||||
emit?.();
|
||||
return result;
|
||||
}
|
||||
let pending: (() => void) | undefined;
|
||||
const result = await this.dataSource.transaction(async (mg) => {
|
||||
const out = await fn(mg);
|
||||
pending = out.emit;
|
||||
return out.result;
|
||||
});
|
||||
pending?.();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
|
||||
* append the settlement to the `payments` ledger, link the gateway payment,
|
||||
* then emit `${source}.invoice.paid`. Full-payment only — no partial
|
||||
* settlement. No-op when the invoice is already paid. Pass `manager` to
|
||||
* enlist in a caller's transaction; otherwise locks the row for update and
|
||||
* emits only after commit (see {@link runTransition}).
|
||||
*/
|
||||
async markInvoiceAsPaid(
|
||||
invoiceId: string,
|
||||
paymentId: string | null = null,
|
||||
manager?: EntityManager,
|
||||
settlement: { providerTxnId?: string; paidAt?: Date } = {},
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Paid,
|
||||
"paid",
|
||||
{ paymentId: paymentId ?? undefined },
|
||||
manager,
|
||||
);
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
return { result: invoice };
|
||||
}
|
||||
|
||||
const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date();
|
||||
const settledAmount = round2(
|
||||
Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0),
|
||||
);
|
||||
const entry: InvoicePayment = {
|
||||
amount: settledAmount,
|
||||
method: "GATEWAY",
|
||||
reference: settlement.providerTxnId ?? paymentId ?? null,
|
||||
paidAt: paidAt.toISOString(),
|
||||
metadata: null,
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
|
||||
const patch = {
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId,
|
||||
paidAt,
|
||||
paidAmount: invoice.totalAmount,
|
||||
balanceAmount: 0,
|
||||
payments,
|
||||
};
|
||||
await mg.update(Invoice, { id: invoiceId }, patch as never);
|
||||
|
||||
const updated = { ...invoice, ...patch } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: () => this.emitInvoiceEvent("paid", updated),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a (possibly partial) settlement against an invoice and sync its
|
||||
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
|
||||
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
|
||||
* balance reaches zero — PAID, stamping `paidAt` and emitting
|
||||
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
|
||||
* at the warehouse counter); gateway settlement goes through
|
||||
* {@link markInvoiceAsPaid}.
|
||||
*
|
||||
* Throws when the invoice is missing, cancelled, refunded, already fully
|
||||
* paid, `amount` is not positive, or `amount` exceeds the outstanding
|
||||
* balance. Pass `manager` to enlist in a caller's transaction; otherwise
|
||||
* locks the row for update and emits only after commit (see
|
||||
* {@link runTransition}).
|
||||
*/
|
||||
async recordPayment(
|
||||
invoiceId: string,
|
||||
input: RecordPaymentInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice> {
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Payment amount must be greater than zero.",
|
||||
);
|
||||
}
|
||||
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException("Cannot pay a cancelled invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Refunded) {
|
||||
throw new BadRequestException("Cannot pay a refunded invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
if (round2(input.amount) > Number(invoice.balanceAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const at = input.paidAt ?? new Date();
|
||||
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||
invoice.totalAmount,
|
||||
invoice.paidAmount,
|
||||
input.amount,
|
||||
);
|
||||
const status = fullyPaid
|
||||
? Freight.InvoiceStatus.Paid
|
||||
: Freight.InvoiceStatus.PartiallyPaid;
|
||||
|
||||
const entry: InvoicePayment = {
|
||||
amount: round2(input.amount),
|
||||
method: input.method ?? null,
|
||||
reference: input.reference ?? null,
|
||||
paidAt: at.toISOString(),
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
|
||||
const patch = {
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch as never);
|
||||
|
||||
const updated = { ...invoice, ...patch } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: fullyPaid
|
||||
? () => this.emitInvoiceEvent("paid", updated)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||
* No-op when already refunded.
|
||||
* No-op when already refunded. Throws when the invoice has no recorded
|
||||
* payment (nothing to refund).
|
||||
*/
|
||||
async markInvoiceAsRefunded(
|
||||
invoiceId: string,
|
||||
@@ -307,12 +661,20 @@ export class BillingService {
|
||||
"refunded",
|
||||
{},
|
||||
manager,
|
||||
(invoice) => {
|
||||
if (!(Number(invoice.paidAmount) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Cannot refund an invoice with no recorded payment.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
||||
* No-op when already cancelled.
|
||||
* No-op when already cancelled. Throws when the invoice has payments
|
||||
* recorded against it (refund it instead).
|
||||
*/
|
||||
async cancelInvoice(
|
||||
invoiceId: string,
|
||||
@@ -324,16 +686,23 @@ export class BillingService {
|
||||
"cancelled",
|
||||
{},
|
||||
manager,
|
||||
(invoice) => {
|
||||
if (Number(invoice.paidAmount) > 0) {
|
||||
throw new BadRequestException(
|
||||
"Cannot cancel an invoice that has payments recorded against it.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the invoice, apply the new status (+ extra columns), then emit
|
||||
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
|
||||
* in the target status. Throws when the invoice does not exist.
|
||||
*
|
||||
* Note: the event fires in-process synchronously. When a `manager` from an
|
||||
* outer transaction is passed, listeners run before that transaction commits.
|
||||
* `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
|
||||
* when it is already in the target status. Throws when the invoice does not
|
||||
* exist or `guard` rejects the current state. Pass `manager` to enlist in a
|
||||
* caller's transaction; otherwise locks the row for update and emits only
|
||||
* after commit (see {@link runTransition}).
|
||||
*/
|
||||
private async transition(
|
||||
invoiceId: string,
|
||||
@@ -341,17 +710,27 @@ export class BillingService {
|
||||
event: string,
|
||||
extra: { paymentId?: string },
|
||||
manager?: EntityManager,
|
||||
guard?: (invoice: Invoice) => void,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
if (invoice.status === status) return invoice;
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === status) return { result: invoice };
|
||||
guard?.(invoice);
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
this.emitInvoiceEvent(event, updated);
|
||||
return updated;
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: () => this.emitInvoiceEvent(event, updated),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
|
||||
@@ -369,22 +748,29 @@ export class BillingService {
|
||||
status: invoice.status,
|
||||
paymentId: invoice.paymentId ?? null,
|
||||
};
|
||||
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||
this.events
|
||||
.emitAsync(`${invoice.source}.invoice.${event}`, payload)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The invoice a gateway payment should settle for a source record, or null if
|
||||
* none. This is the billing document of record for "what is owed" — callers
|
||||
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
|
||||
* recomputing from the source's own total, so discounts/penalties/adjustments
|
||||
* carried on the invoice are honored.
|
||||
* The invoice a source record already has open, or null if it needs a new
|
||||
* one. This is the idempotency check every `ensureInvoiceFor*` (booking,
|
||||
* first-mile, last-mile) runs before generating — it must see DRAFT
|
||||
* invoices too, not just issued ones, otherwise a source that already has
|
||||
* an unissued draft gets a second, duplicate invoice minted alongside it
|
||||
* instead of that draft being reused and then issued.
|
||||
*
|
||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||
* non-cancelled) invoice.
|
||||
* invoice is currently open. Returns the most recent matching draft-or-open
|
||||
* (unpaid, non-cancelled) invoice.
|
||||
*/
|
||||
findPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
@@ -395,7 +781,7 @@ export class BillingService {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
@@ -403,74 +789,140 @@ export class BillingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a source's currently-open invoice as paid and link the gateway
|
||||
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
|
||||
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
||||
* settlement. No-op (returns null) when the source has no open invoice.
|
||||
*
|
||||
* Type-blind by design: settles whichever invoice is due; any per-type reaction
|
||||
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
|
||||
* Pass the caller's transaction `manager` to enlist in its DB transaction.
|
||||
*
|
||||
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
|
||||
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
|
||||
* for callers that settle by source rather than by gateway intent id.
|
||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||
* non-cancelled) invoice.
|
||||
*/
|
||||
async settlePayable(
|
||||
findInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
paymentId: string | null,
|
||||
manager?: EntityManager,
|
||||
type?: string,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
return this.dataSource.getRepository(Invoice).findOne({
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
|
||||
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
|
||||
* No-op (returns null) when the source has no paid invoice.
|
||||
* Expire a source's currently-open invoice (its pay window closed before
|
||||
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
|
||||
* (already paid/cancelled/expired).
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
|
||||
* to enlist in its DB transaction.
|
||||
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||
* the batch engine) to enlist in its DB transaction.
|
||||
*/
|
||||
async refundPayable(
|
||||
async expirePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
// Lookup can use the default manager (no lock). But the pessimistic-lock write
|
||||
// inside `transition` NEEDS an open transaction: pass the caller's `manager`
|
||||
// through untouched (undefined when there is no caller txn) so `runTransition`
|
||||
// opens its own. Passing `this.dataSource.manager` here made `runTransition`
|
||||
// treat it as an already-open transaction and skip wrapping — the lock then
|
||||
// threw `An open transaction is required for pessimistic lock`, aborting the
|
||||
// whole settle pass (the "reservations settle/reserve one at a time" symptom).
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||
return this.transition(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Expired,
|
||||
"expired",
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
||||
* booking invoice is generated before the pay window opens (at booking
|
||||
* creation/approval), so its printed due date is refreshed when the batch engine
|
||||
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
||||
*/
|
||||
async syncPayableDueDate(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
dueAt: Date,
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an invoice to `status`, including issuing a still-DRAFT invoice
|
||||
* (stamping `issuedAt`) — unlike the other transitions here, this is a
|
||||
* blunt admin/workflow override, not a settlement. No-op when the invoice
|
||||
* is missing or already terminal (paid/cancelled/refunded/expired).
|
||||
*/
|
||||
async updateStatus(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
id: invoiceId,
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
},
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(
|
||||
Invoice,
|
||||
{ id: invoice.id },
|
||||
{ status, issuedAt: invoice.issuedAt ?? new Date() },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
* Charge a source's open invoice through the payment gateway. Billing is the
|
||||
* single place that turns "what is owed" (the invoice) into a payment intent —
|
||||
* the domain never talks to the payment service directly. Resolves the open
|
||||
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
|
||||
* the invoice (the settlement correlation key), and returns the client action.
|
||||
* Charge an invoice through the payment gateway. Billing is the single place
|
||||
* that turns "what is owed" (the invoice) into a payment intent — the domain
|
||||
* never talks to the payment service directly. Resolves the invoice by ID,
|
||||
* opens an intent for `invoice.balanceAmount` (so partial payments are honored),
|
||||
* records the intent id on the invoice (the settlement correlation key), and
|
||||
* returns the client action.
|
||||
*
|
||||
* When the provider settles synchronously, the invoice is settled inline here —
|
||||
* after the intent id is stored — so the `payment.succeeded` correlation can
|
||||
* never fire before the link exists. Throws when the source has no open invoice.
|
||||
* never fire before the link exists. Throws when the invoice is not found or
|
||||
* not in an open/payable status.
|
||||
*/
|
||||
async payInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
invoiceId: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
@@ -479,21 +931,31 @@ export class BillingService {
|
||||
failureUrl?: string;
|
||||
} = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findPayable(source, sourceId);
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
|
||||
throw new NotFoundException(
|
||||
`Invoice ${invoiceId} not found or not in a payable status`,
|
||||
);
|
||||
}
|
||||
|
||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
if (!(amountDue > 0)) {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: sourceId,
|
||||
referenceId: invoice.sourceId,
|
||||
source: invoice.source,
|
||||
// Gateway reference type derives from the invoice source by convention
|
||||
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
|
||||
// the domain never supplies it. New sources add their uppercased value to
|
||||
// the PaymentReferenceType enum.
|
||||
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
// Freight payments settle under the generic SHIPMENT reference — how the
|
||||
// payment service attributes them to the freight API. The payment ↔ invoice
|
||||
// link is the intent id (`paymentId`); per-source post-payment reactions live
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber.replace("-", "_"),
|
||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
@@ -502,12 +964,27 @@ export class BillingService {
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
|
||||
//
|
||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||
if (!result.immediateSuccess) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
referenceId: invoice.sourceId,
|
||||
intentId: result.intentId,
|
||||
providerTxnId: result.providerTxnId,
|
||||
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
result.intentId,
|
||||
@@ -528,8 +1005,8 @@ export class BillingService {
|
||||
*/
|
||||
async settleByPaymentId(
|
||||
paymentId: string,
|
||||
_providerTxnId?: string,
|
||||
_paidAt?: Date,
|
||||
providerTxnId?: string,
|
||||
paidAt?: Date,
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||
@@ -537,6 +1014,9 @@ export class BillingService {
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId);
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
|
||||
providerTxnId,
|
||||
paidAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { InvoiceDocumentService } from "./invoice-document.service";
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
|
||||
/**
|
||||
* Standalone document infrastructure — generic HTML→PDF plus the shared
|
||||
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
|
||||
* warehouses, …) can import it to print invoices without coupling to the
|
||||
* billing payment graph.
|
||||
*/
|
||||
@Module({
|
||||
providers: [PdfRenderService, InvoiceDocumentService],
|
||||
exports: [PdfRenderService, InvoiceDocumentService],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
lineOp,
|
||||
rectOp,
|
||||
sealOp,
|
||||
textOp,
|
||||
textOpRight,
|
||||
wrapText,
|
||||
} from "./styled-pdf.util";
|
||||
|
||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
/** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
|
||||
category?: string | null;
|
||||
quantity?: number | null;
|
||||
unitRate?: number | null;
|
||||
amount?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
/** A labelled total row in the totals box; mark `grand` for the headline total. */
|
||||
export interface InvoiceDocumentTotal {
|
||||
label: string;
|
||||
amount: number;
|
||||
grand?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-agnostic description of a printable invoice/receipt. Each billing
|
||||
* source maps its own entity onto this shape; the renderer owns the layout so
|
||||
* every EDR invoice document looks identical regardless of source.
|
||||
*/
|
||||
export interface InvoiceDocumentModel {
|
||||
kind: InvoiceDocumentKind;
|
||||
/** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
|
||||
title: string;
|
||||
documentNumber: string;
|
||||
issuedAt?: Date | string | null;
|
||||
status: string;
|
||||
currency: string;
|
||||
/** Free-form summary grid (label/value pairs). */
|
||||
summary: Array<{ label: string; value: string | null }>;
|
||||
/** Header for the line-item category column; column hidden when omitted. */
|
||||
categoryHeader?: string;
|
||||
lines: InvoiceDocumentLine[];
|
||||
totals: InvoiceDocumentTotal[];
|
||||
/** Override the round seal text; defaults from kind/status. */
|
||||
sealText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central invoice/receipt PDF renderer shared by every billing source. Turns a
|
||||
* {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
|
||||
* via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
|
||||
* `WarehouseInvoiceService`; it now serves all invoices.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvoiceDocumentService {
|
||||
constructor(private readonly pdf: PdfRenderService) {}
|
||||
|
||||
async render(
|
||||
model: InvoiceDocumentModel,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const html = this.buildHtml(model);
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||
label: `${model.title} ${kindLabel}`,
|
||||
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
||||
// summary grid, line-item table, totals) from the model — not a flat
|
||||
// plain-text dump — so it still reads as a proper invoice document.
|
||||
fallback: () => this.buildFallbackPdf(model),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||
* document. Single A4 page; long summaries / line lists are capped to fit.
|
||||
*/
|
||||
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
|
||||
const currency = (cur?: string | null) =>
|
||||
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
|
||||
const money = (amount: unknown, cur?: string | null) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
|
||||
const ops: string[] = [];
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
|
||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
|
||||
const titleSize = heading.length > 34 ? 18 : 22;
|
||||
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
|
||||
|
||||
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
|
||||
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
|
||||
ops.push(
|
||||
textOpRight(
|
||||
`Status ${model.status}`,
|
||||
559,
|
||||
748,
|
||||
8.5,
|
||||
"F1",
|
||||
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
|
||||
),
|
||||
);
|
||||
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
|
||||
|
||||
// ── Seal ──────────────────────────────────────────────────────────────
|
||||
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
|
||||
|
||||
// ── Summary grid (two columns) ────────────────────────────────────────
|
||||
let y = 700;
|
||||
const colX = [36, 300];
|
||||
const colW = 250;
|
||||
model.summary.slice(0, 16).forEach((row, i) => {
|
||||
const x = colX[i % 2];
|
||||
if (i % 2 === 0 && i > 0) y -= 27;
|
||||
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
|
||||
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
|
||||
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
|
||||
});
|
||||
y -= 34;
|
||||
|
||||
// ── Line-item table ───────────────────────────────────────────────────
|
||||
const qtyR = 402;
|
||||
const rateR = 486;
|
||||
const amtR = 555;
|
||||
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
|
||||
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
|
||||
if (showCategory) {
|
||||
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
|
||||
}
|
||||
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
|
||||
y -= 18;
|
||||
|
||||
const descChars = showCategory ? 44 : 66;
|
||||
for (const item of model.lines) {
|
||||
if (y < 190) break; // leave room for totals + footer
|
||||
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
|
||||
const rowH = Math.max(18, descLines.length * 10 + 8);
|
||||
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
|
||||
descLines.forEach((line, k) => {
|
||||
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
|
||||
});
|
||||
if (showCategory) {
|
||||
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
|
||||
}
|
||||
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
|
||||
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
|
||||
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
|
||||
y -= rowH;
|
||||
}
|
||||
|
||||
// ── Totals ────────────────────────────────────────────────────────────
|
||||
let ty = y - 16;
|
||||
for (const total of model.totals) {
|
||||
if (ty < 88) break;
|
||||
if (total.grand) {
|
||||
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
|
||||
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
|
||||
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
|
||||
ty -= 24;
|
||||
} else {
|
||||
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
|
||||
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
|
||||
ty -= 17;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
|
||||
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
|
||||
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
|
||||
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
|
||||
|
||||
return assembleSinglePagePdf(ops);
|
||||
}
|
||||
|
||||
/** Truncate to `max` chars with an ellipsis. */
|
||||
private clip(value: string, max: number): string {
|
||||
const text = String(value ?? "");
|
||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const money = (amount: unknown, currency = model.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||
.join("");
|
||||
|
||||
const itemRows = model.lines
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${esc(item.description)}</td>
|
||||
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
|
||||
<td class="num">${esc(item.quantity ?? 0)}</td>
|
||||
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
|
||||
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
||||
.doc { padding: 16px 8px; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
||||
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
||||
h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
||||
td.num, th.num { text-align: right; }
|
||||
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
||||
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
||||
.grand { font-size: 16px; font-weight: 800; }
|
||||
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Document no.
|
||||
<strong>${esc(model.documentNumber)}</strong>
|
||||
Issued: ${esc(date(model.issuedAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="summary">${summaryRows}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
|
||||
<th class="num">Qty</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemRows}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">${totalRows}</div>
|
||||
<div class="footer">
|
||||
<div class="line">Prepared by EDR finance</div>
|
||||
<div class="line">Authorized seal / signature</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { existsSync } from "fs";
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
const PDF_PRINT_STYLES = `
|
||||
<style id="edr-pdf-print-fix">
|
||||
@media print {
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
|
||||
export interface PdfRenderOptions {
|
||||
/** Label used in logs to identify the document kind. */
|
||||
label?: string;
|
||||
/**
|
||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||
* header). When omitted, a generic single-page fallback is produced.
|
||||
*/
|
||||
fallback?: (preparedHtml: string) => Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic HTML → PDF renderer shared by every document producer (invoices,
|
||||
* receipts, warehouse release orders). Renders via headless Chromium when
|
||||
* available and degrades to a caller-supplied (or generic) hand-built PDF
|
||||
* otherwise. This is pure infrastructure — it knows nothing about invoices.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PdfRenderService {
|
||||
private readonly logger = new Logger(PdfRenderService.name);
|
||||
|
||||
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
|
||||
const label = opts.label ?? "document";
|
||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||
const executablePath = this.resolveExecutablePath();
|
||||
|
||||
try {
|
||||
const puppeteer = await import("puppeteer");
|
||||
const launchOptions: import("puppeteer").LaunchOptions = {
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
};
|
||||
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||
await page.emulateMediaType("print");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
|
||||
}
|
||||
this.logger.log(
|
||||
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
|
||||
);
|
||||
return buffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes("edr-pdf-print-fix")) return html;
|
||||
if (html.includes("</head>")) {
|
||||
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
|
||||
}
|
||||
return `${PDF_PRINT_STYLES}${html}`;
|
||||
}
|
||||
|
||||
private resolveExecutablePath(): string | undefined {
|
||||
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||
|
||||
const candidates = [
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
];
|
||||
return candidates.find((path) => existsSync(path));
|
||||
}
|
||||
|
||||
isValidPdf(buffer: Buffer): boolean {
|
||||
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
||||
}
|
||||
|
||||
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
|
||||
private genericFallbackPdf(html: string): Buffer {
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/[^\x20-\x7e]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 900);
|
||||
|
||||
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
|
||||
const stream =
|
||||
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
|
||||
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
|
||||
"ET";
|
||||
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "latin1"));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n";
|
||||
const xrefOffset = Buffer.byteLength(pdf, "latin1");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, "latin1");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user