diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 44be07550..bdb4c5ff5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -144,15 +144,16 @@ jobs: run: ./scripts/deploy/create-npmrc.sh - name: Resolve env file path for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | case "${{ matrix.service }}" in + freight-api) echo "SERVICE_ENV_FILE=apps/edr-freight-api/.env" >> "$GITHUB_ENV" ;; passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;; payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;; esac - name: Build migration image for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | set -euo pipefail docker build \ @@ -163,7 +164,7 @@ jobs: . - name: Run migrations for ${{ matrix.service }} - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service) run: | set -euo pipefail docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md index 11075e9f0..a99f64d0a 100644 --- a/CLAUDE_NEW.md +++ b/CLAUDE_NEW.md @@ -154,13 +154,32 @@ no timeout and will wait forever. Migrations are the most dangerous surface in this repo. Two production-grade incidents have already come from it. -- `migrationsRun: true` — **migrations run automatically on API boot**, with - `migrationsTransactionMode: 'each'`. +- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate + one-shot step, via the Dockerfile's `migration` build target (`docker build --target + migration`), with `migrationsTransactionMode: 'each'`. + - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it + (`docker run --rm --env-file ...`) *before* building/deploying the app image. + - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and + `freight-api-e2e` depends on it (`condition: service_completed_successfully`). + - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run + migrations yourself before `docker compose up freight-api`, e.g. + `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` + then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't + use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled + output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. + It silently applies zero freight migrations while exiting 0. - Consequences you must design for: - - Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent - data migration can execute twice. Keep one instance. - A watch-mode hot reload does **not** re-run migrations. If you add a column that new code reads, apply it to the dev database yourself (idempotently) or fully restart. + - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a + hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never + notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own + `forFeature()` registrations), but the standalone migration `DataSource` + (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing + entity throws `Entity metadata for X#y was not found` at `initialize()`, before a + single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate + for this to break again** — diff the package's entity classes against `iamEntities` + when bumping it. - **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before adding one, check the filename prefix is unused *and* higher than the newest recorded row. diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index 3c05c8a9f..a1d86723c 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -29,7 +29,12 @@ COPY --from=builder /app/ . RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy +FROM deployer AS migration +WORKDIR /deploy +CMD ["node", "dist/scripts/migrate.js"] + FROM base AS runner +RUN apk add --no-cache libc6-compat ENV NODE_ENV=production WORKDIR /app diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 20bfa1bad..b3ed1c802 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -27,6 +27,7 @@ "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", + "backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", @@ -36,6 +37,7 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "migration:run": "node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { @@ -57,8 +59,8 @@ "@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.15.tgz", + "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index dcf09fbd9..0591cd0b1 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -1,5 +1,6 @@ import { registerAs } from "@nestjs/config"; import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { DataSourceOptions } from "typeorm"; import { join, dirname } from "path"; import { DefaultPosition, @@ -44,8 +45,30 @@ import { NotificationTemplate, } from "@tria-plc/iamapi-common"; import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; +import { UnitSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-setting.entity"; +import { UnitDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-detail.entity"; +import { OrganizationDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-detail.entity"; +import { UnitCluster } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-cluster.entity"; +import { Location } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location.entity"; +import { LocationType } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location-type.entity"; +import { DelegationTerminationReason } from "@tria-plc/iamapi-common/entities/iam/organization-structure/delegation-termination-reason.entity"; +import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee-position-active-period.entity"; +import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity"; +import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity"; +import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity"; const iamEntities = [ + UnitSetting, + UnitDetail, + OrganizationDetail, + UnitCluster, + Location, + LocationType, + DelegationTerminationReason, + EmployeePositionActivePeriod, + UnitConfiguration, + Site, + SiteSetting, DefaultPosition, DefaultUnit, EmployeePosition, @@ -95,7 +118,7 @@ const iamMigrationsGlob = join( ); const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); -export default registerAs("database", (): TypeOrmModuleOptions => { +export function buildDataSourceOptions(): DataSourceOptions { return { type: "postgres", host: process.env.DB_HOST ?? "localhost", @@ -111,17 +134,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => { // 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: [ - // IAM schema + tables must be created before freight migrations iamMigrationsGlob, freightMigrationsGlob, ], - migrationsRun: true, migrationsTransactionMode: "each", - // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, logging: process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; -}); +} + +export default registerAs("database", (): TypeOrmModuleOptions => ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + migrationsRun: false, +})); diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index 2ae202ebd..4b46912b7 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -1,21 +1,8 @@ // apps/edr-freight-api/src/data-source.ts -import 'dotenv/config'; -import { DataSource } from 'typeorm'; -//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed +import "dotenv/config"; +import { DataSource } from "typeorm"; +import { buildDataSourceOptions } from "./config/database.config"; -export const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5433), - username: process.env.DB_USER ?? 'postgres', - password: process.env.DB_PASSWORD ?? '', - database: process.env.DB_NAME ?? 'edr_freight', - schema: 'freight', // default schema for entities without an explicit schema - entities: [__dirname + '/**/*.entity{.ts,.js}'], - migrations: [__dirname + '/migrations/*{.ts,.js}'], - synchronize: false, - logging: process.env.TYPEORM_LOGGING === 'true', -}); +export const AppDataSource = new DataSource(buildDataSourceOptions()); -// Optional: call ensurePostgresSchemas before initializing -// But you can also run it separately. +export default AppDataSource; diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts new file mode 100644 index 000000000..976aca798 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Pending→Active is the only company-level approval event; `updatedAt` can't + * stand in for it since any field edit bumps that too. Nullable — existing + * companies (approved before this column existed) have no recorded moment. + */ +export class AddApprovedAtToCompanies3120000000000 implements MigrationInterface { + name = "AddApprovedAtToCompanies3120000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS approved_at timestamptz`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies + DROP COLUMN IF EXISTS approved_at`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts new file mode 100644 index 000000000..153928b04 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Break-bulk (PER_ITEM) cargo needs a physical items-fit per allowed wagon + * type (e.g. cars → NW5: 4, NW7: 6): a wagon runs out of floor space before it + * runs out of rated tonnage, so allocation must respect BOTH limits. Stored as + * a jsonb map { [wagonTypeId]: itemsFit } on cargo_types — keys mirror the + * cargo_type_wagon_types join rows, kept in sync by the cargo-types service. + */ +export class AddCargoTypeItemsPerWagonMap3120000000000 implements MigrationInterface { + name = 'AddCargoTypeItemsPerWagonMap3120000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts new file mode 100644 index 000000000..01c1c6991 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Append-only audit of company edits made before the company reaches Active + * (the onboarding phase) — that write path has no approval gate and, until + * now, left no trace of what changed (e.g. a phone number or a document). + */ +export class CreateCompanyRevisions3130000000000 implements MigrationInterface { + name = 'CreateCompanyRevisions3130000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'company_revisions', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'company_id', type: 'uuid' }, + { name: 'actor_id', type: 'uuid', isNullable: true }, + { name: 'summary', type: 'varchar', length: '255' }, + { name: 'changes', type: 'jsonb', default: "'[]'::jsonb" }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['company_id'], + referencedSchema: 'freight', + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.company_revisions', + new TableIndex({ name: 'idx_company_revisions_company', columnNames: ['company_id'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.company_revisions', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts new file mode 100644 index 000000000..b2a931542 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Rates created before their commodity's unit_of_measure was flipped kept the + * old bulk-quantity unit, so bookings of a PER_ITEM commodity (e.g. Machinery) + * quoted "per ton". PER_TON and PER_ITEM bill the same stored quantity — only + * the name differs — so renaming is safe. Going forward the cargo-types + * service syncs rates on every uom change; this backfills the drift. + */ +export class SyncBulkRateUnitsToCargoUom3140000000000 implements MigrationInterface { + name = 'SyncBulkRateUnitsToCargoUom3140000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "freight"."rates" r + SET "rate_unit" = 'PER_ITEM' + FROM "freight"."cargo_types" ct + WHERE ct."id" = r."cargo_type_id" + AND ct."unit_of_measure" = 'PER_ITEM' + AND r."rate_unit" = 'PER_TON'`, + ); + await queryRunner.query( + `UPDATE "freight"."rates" r + SET "rate_unit" = 'PER_TON' + FROM "freight"."cargo_types" ct + WHERE ct."id" = r."cargo_type_id" + AND ct."unit_of_measure" = 'PER_TON' + AND r."rate_unit" = 'PER_ITEM'`, + ); + } + + public async down(): Promise { + // Irreversible rename-by-join: the pre-sync unit is not recorded. Both + // units bill identically, so rolling back the code needs no data change. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 0eb65bf5d..b44c1c5e0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -16,7 +16,7 @@ import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; -import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util'; +import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -1188,8 +1188,9 @@ export class BookingPricingService { ); if (!(capacity > 0)) return null; // Break-bulk (PER_ITEM): `tons` above is the item count; size by - // indivisible items instead of pretending the count is tonnage. - const byItems = bulkItemWagonsRequired(booking, capacity); + // indivisible items instead of pretending the count is tonnage. Best + // count across allowed wagon types, each capped by its items-fit. + const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity); if (byItems > 0) return byItems; return Math.max(1, Math.ceil(tons / capacity)); } catch { diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index ee75460c9..7665d82eb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -62,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; import { RequestDocumentChangeDto } from "./dto/request-document-change.dto"; import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; +import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -685,6 +686,17 @@ export class CompaniesController { return requests.map((r) => new ChangeRequestResponseDto(r)); } + @Get(":companyId/revisions") + @BookingStaff(FREIGHT_PERMS.customers.view) + @ApiOperation({ summary: "Onboarding-phase edit history (version history)" }) + async listCompanyRevisions( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const revisions = + await this.companiesService.listCompanyRevisions(companyId); + return revisions.map((r) => new CompanyRevisionResponseDto(r)); + } + @Post("change-requests/:id/approve") @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ @@ -719,6 +731,25 @@ export class CompaniesController { return new ChangeRequestResponseDto(request); } + @Post("change-requests/:id/request-changes") + @BookingStaff(FREIGHT_PERMS.customers.verify) + @ApiOperation({ + summary: + "Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", + }) + async requestChangeRequestChanges( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RejectChangeRequestDto, + ): Promise { + const request = await this.companiesService.requestChangeRequestChanges( + id, + dto.note, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + @Post(":companyId/profiles") @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index c13f79f5e..511c3aa25 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -120,6 +120,13 @@ function makeService(overrides: Partial = {}) { })), update: jest.fn(async () => ({ id: "cr-1" })), }, + revisionRepo: { + create: jest.fn(async (row: Record) => ({ + id: "rev-1", + ...row, + })), + findByCompanyId: jest.fn(async () => []), + }, profilesRepo: { findByCompanyId: jest.fn(async () => []), findByUserId: jest.fn(async () => ({ @@ -144,6 +151,7 @@ function makeService(overrides: Partial = {}) { deps.companiesRepo as never, deps.companyProfilesRepo as never, deps.changeRequestRepo as never, + deps.revisionRepo as never, deps.profilesRepo as never, {} as never, deps.filesService as never, diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 450222657..f0a443ba8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -15,9 +15,11 @@ import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile } from "./entities/company-profile.entity"; import { CompanyChangeRequest } from "./entities/company-change-request.entity"; +import { CompanyRevision } from "./entities/company-revision.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { CompanyRevisionRepository } from "./company-revision.repository"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { VerifaydaModule } from "../verifayda/verifayda.module"; @@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; ExternalProfile, CompanyProfile, CompanyChangeRequest, + CompanyRevision, Booking, ]), HttpModule, @@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; ExternalProfileRepository, CompanyProfileRepository, CompanyChangeRequestRepository, + CompanyRevisionRepository, CompanyDashboardRepository, ETradeService, CompanyNotifierService, diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 27c42e581..365bd4639 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -91,6 +91,13 @@ function makeService(overrides: Partial = {}) { })), update: jest.fn(async () => ({ id: "cr-1" })), }, + revisionRepo: { + create: jest.fn(async (row: Record) => ({ + id: "rev-1", + ...row, + })), + findByCompanyId: jest.fn(async () => []), + }, profilesRepo: { findByCompanyId: jest.fn(async () => []), findByUserId: jest.fn(async () => ({ @@ -121,6 +128,7 @@ function makeService(overrides: Partial = {}) { deps.companiesRepo as never, deps.companyProfilesRepo as never, deps.changeRequestRepo as never, + deps.revisionRepo as never, deps.profilesRepo as never, {} as never, deps.filesService as never, diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts index f22123f61..5961be90f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) { companiesRepo as never, companyProfilesRepo as never, {} as never, + {} as never, profilesRepo as never, {} as never, {} as never, diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index c198d149f..d9798af5d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1,5 +1,6 @@ import { Injectable, + Logger, NotFoundException, ConflictException, BadRequestException, @@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { CompanyRevisionRepository } from "./company-revision.repository"; +import { + diffCompanyUpdate, + summarizeCompanyChanges, +} from "./company-revision-diff.util"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository, @@ -64,6 +70,10 @@ import { DocumentChangeIntent, LicenseChangeIntent, } from "./entities/company-change-request.entity"; +import { + CompanyRevision, + CompanyRevisionChange, +} from "./entities/company-revision.entity"; /** FileRecord `resource` + `code` slots for business-license documents. */ const LICENSE_RESOURCE = "company_profiles"; @@ -176,10 +186,13 @@ export interface UserIdentity { @Injectable() export class CompaniesService { + private readonly logger = new Logger(CompaniesService.name); + constructor( private readonly companiesRepo: CompaniesRepository, private readonly companyProfilesRepo: CompanyProfileRepository, private readonly changeRequestRepo: CompanyChangeRequestRepository, + private readonly revisionRepo: CompanyRevisionRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly filesService: FilesService, @@ -682,7 +695,16 @@ export class CompaniesService { async updateCompany(id: string, dto: UpdateCompanyDto): Promise { const before = await this.findCompanyById(id); - const updated = await this.companiesRepo.update(id, dto); + const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto }; + // Staff can also promote Pending -> Active directly through this generic + // endpoint (not just via the first-profile-approval path), so stamp it here too. + if ( + dto.status === CompanyStatus.Active && + before.status !== CompanyStatus.Active + ) { + patch.approvedAt = new Date(); + } + const updated = await this.companiesRepo.update(id, patch); if (!updated) throw new NotFoundException(`Company ${id} not found`); // Suspending or blacklisting locks the customer out, so they must be told. @@ -840,6 +862,34 @@ export class CompaniesService { return companyUpdates; } + /** + * Append a version-history entry for an onboarding-phase edit (the company + * is not yet Active, so the change went straight to the live row with no + * approval gate to carry a record of it). Best-effort: a no-op patch or a + * failure to write history must never break the edit that triggered it. + */ + private async recordCompanyRevision( + before: Company, + patch: Record, + actorId?: string | null, + extraChanges: CompanyRevisionChange[] = [], + ): Promise { + try { + const changes = [...diffCompanyUpdate(before, patch), ...extraChanges]; + if (changes.length === 0) return; + await this.revisionRepo.create({ + companyId: before.id, + actorId: actorId ?? null, + summary: summarizeCompanyChanges(changes), + changes, + }); + } catch (err) { + this.logger.error( + `Failed to record company revision for ${before.id}: ${String(err)}`, + ); + } + } + /** Reject a TIN already registered to a *different* company. */ private async assertTinAvailable( company: Company, @@ -902,6 +952,7 @@ export class CompaniesService { ); if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + await this.recordCompanyRevision(company, companyUpdates, userId); return new ProfileResponseDto(profile, updated); } @@ -945,10 +996,14 @@ export class CompaniesService { if (existing) { request = (await this.changeRequestRepo.update(existing.id, { + // Note is left untouched: if this request was ChangesRequested, the + // reviewer's ask stays visible on the resubmitted (Pending) row — + // clearing it here would hide what was asked for right when the + // reviewer comes back to check whether it was actually addressed. snapshot: { ...(existing.snapshot ?? {}), ...staged }, submittedBy: userId, submittedAt: now, - note: null, + status: ChangeRequestStatus.Pending, })) ?? existing; this.companyNotifier.changeRequestSubmitted(company, request.id, false); } else { @@ -986,6 +1041,53 @@ export class CompaniesService { return this.changeRequestRepo.findByCompanyId(companyId); } + /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ + async listCompanyRevisions(companyId: string): Promise { + await this.findCompanyById(companyId); + return this.revisionRepo.findByCompanyId(companyId); + } + + /** + * Pair adjacent remove-then-add intents into one before/after revision + * change — that's exactly how a "replace" is staged (see + * `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]` + * pushed together, and later merges only ever append after that pair, so + * adjacency is preserved). A remove or add with no adjacent partner (a pure + * add, or a pure removal) stands alone. + */ + private pairReplaceIntents< + T extends { op: "add" | "remove"; fileId: string; fileName?: string }, + >(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] { + const changes: CompanyRevisionChange[] = []; + let i = 0; + while (i < intents.length) { + const current = intents[i]; + const next = intents[i + 1]; + if (current.op === "remove" && next?.op === "add") { + changes.push({ + field: `document:${current.fileId}`, + label: labelFor(next), + from: current.fileName ?? null, + to: next.fileName ?? null, + fromFileId: current.fileId, + toFileId: next.fileId, + }); + i += 2; + continue; + } + changes.push({ + field: `document:${current.fileId}`, + label: labelFor(current), + from: current.op === "remove" ? (current.fileName ?? null) : null, + to: current.op === "add" ? (current.fileName ?? null) : null, + fromFileId: current.op === "remove" ? current.fileId : null, + toFileId: current.op === "add" ? current.fileId : null, + }); + i += 1; + } + return changes; + } + /** * Approve a pending change request: apply its snapshot to the live Company and * mark the request approved. Any staged documents are already attached to the @@ -1015,6 +1117,30 @@ export class CompaniesService { await this.applyLicenseChanges(request); await this.applyDocumentChanges(request); + // This is the ONLY place post-approval FIELD/license/PoA-document changes + // land on the live row — without this call, everything the #419 + // change-request flow does to those is invisible in Version History. + // `documentFileIds` (the general bulk company-documents upload) is + // deliberately NOT re-recorded here — those documents go live immediately + // at upload time and are already recorded there (see + // `uploadCompanyDocuments`); redoing it here would double the entry. + const documentChanges: CompanyRevisionChange[] = [ + ...this.pairReplaceIntents( + request.documents?.licenseChanges ?? [], + () => "Business license", + ), + ...this.pairReplaceIntents( + request.documents?.documentChanges ?? [], + (intent) => intent.code, + ), + ]; + await this.recordCompanyRevision( + company, + companyUpdates, + reviewerId, + documentChanges, + ); + return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Approved, @@ -1025,12 +1151,62 @@ export class CompaniesService { ); } + /** + * A fresh upload under a single-file document slot (`isMultiple: false`) + * replaces whatever was there, not adds to it — soft-delete the prior live + * file(s) for that code, and describe each replacement (plus each genuinely + * new upload) as a revision change carrying both file ids, so the reviewer + * can open the previous and current file. Multi-file slots are left alone + * (genuinely additive, no single "the" document to diff against). Unrecognised + * codes (no matching field in the nationality's document setting) are also + * left alone — safer to under-clean than to guess wrong. Independent of the + * change-request review outcome: nothing else in this flow ever retires a + * superseded document, on approve OR reject — these documents go live the + * moment they're uploaded. + */ + private async replaceSingleFileCompanyDocuments( + company: Company, + before: FileRecord[], + uploaded: FileRecord[], + ): Promise { + const setting = await this.fileUploadSettingsService + .getByCode(this.documentSettingCodeFor(company.nationality)) + .catch(() => null); + const fields = setting?.fields ?? []; + const singleFileCodes = new Set( + fields.filter((f) => !f.isMultiple).map((f) => f.fileKey), + ); + const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel])); + const uploadedIds = new Set(uploaded.map((f) => f.id)); + + const changes: CompanyRevisionChange[] = []; + const toRemove: FileRecord[] = []; + for (const file of uploaded) { + if (!singleFileCodes.has(file.code)) continue; + const prior = before.find( + (f) => f.code === file.code && !uploadedIds.has(f.id), + ); + changes.push({ + field: `document:${file.code}`, + label: labelByCode.get(file.code) ?? file.code, + from: prior?.name ?? null, + to: file.name, + fromFileId: prior?.id ?? null, + toFileId: file.id, + }); + if (prior) toRemove.push(prior); + } + await Promise.all(toRemove.map((f) => this.filesService.remove(f.id))); + return changes; + } + /** * Upload company documents. For an approved company this also opens/updates a * pending change request (recording the uploaded file ids) so the upload is * reviewed and the customer is locked until it clears — consistent with the * field-edit review. During onboarding (company not yet active) it's a plain - * upload with no review. + * upload with no review. Either way the documents go live immediately, so + * the revision history is recorded right away too, not gated on a decision. */ async uploadCompanyDocuments( companyId: string, @@ -1038,11 +1214,20 @@ export class CompaniesService { submittedBy?: string, ): Promise { const company = await this.findCompanyById(companyId); + const before = await this.filesService.findByResource( + companyId, + "companies", + ); const uploaded = await this.filesService.uploadMany( companyId, "companies", files, ); + const documentChanges = await this.replaceSingleFileCompanyDocuments( + company, + before, + uploaded, + ); await this.resolveDocumentChangeRequests( companyId, "companies", @@ -1056,6 +1241,9 @@ export class CompaniesService { submittedBy, ); } + if (documentChanges.length > 0) { + await this.recordCompanyRevision(company, {}, submittedBy, documentChanges); + } return uploaded; } @@ -1170,7 +1358,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); if (company) { this.companyNotifier.changeRequestSubmitted(company, existing.id, false); @@ -1231,6 +1420,37 @@ export class CompaniesService { ); } + /** + * Ask for specific fixes without rejecting outright: unlike + * {@link rejectChangeRequest}, staged license/document intents are kept (the + * row stays open), so the customer's next edit is appended to this SAME + * request — via the merge branches in `updateProfile`/`stageDocumentChange`/ + * `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead + * of starting a fresh cycle. + */ + async requestChangeRequestChanges( + id: string, + note: string, + reviewerId?: string, + ): Promise { + const request = await this.changeRequestRepo.findById(id); + if (!request) + throw new NotFoundException(`Change request ${id} not found`); + if (request.status !== ChangeRequestStatus.Pending) { + throw new BadRequestException( + `Change request ${id} is already ${request.status}`, + ); + } + return ( + (await this.changeRequestRepo.update(id, { + status: ChangeRequestStatus.ChangesRequested, + note, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + })) ?? request + ); + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); @@ -1472,6 +1692,7 @@ export class CompaniesService { ) { await companyRepo.update(updated.companyId, { status: CompanyStatus.Active, + approvedAt: new Date(), }); this.companyNotifier.companyApproved(company); } @@ -2264,7 +2485,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); } else { await this.changeRequestRepo.create({ @@ -2590,7 +2812,8 @@ export class CompaniesService { snapshot, submittedBy: userId, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); this.companyNotifier.changeRequestSubmitted(company, existing.id, false); return; @@ -2870,7 +3093,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); } else { await this.changeRequestRepo.create({ diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts index 73271e0af..1dc4b5a84 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -1,4 +1,4 @@ -import { Repository } from "typeorm"; +import { FindOperator, Repository } from "typeorm"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { @@ -10,6 +10,16 @@ type Row = Pick & { createdAt: Date }; const COMPANY_ID = "company-1"; +/** Matches a row's status against either a plain value or an `In([...])` operator. */ +function statusMatches( + rowStatus: ChangeRequestStatus, + where: ChangeRequestStatus | FindOperator | undefined, +): boolean { + if (where === undefined) return true; + if (where instanceof FindOperator) return where.value.includes(rowStatus); + return rowStatus === where; +} + /** * Stands in for the TypeORM repository over a fixed set of rows, honouring the * `where.status` filter and the `createdAt DESC` ordering findOne relies on. @@ -17,13 +27,17 @@ const COMPANY_ID = "company-1"; function mockRepositoryOver(rows: Row[]) { return { findOne: jest.fn( - ({ where }: { where: Partial & { companyId: string } }) => + ({ + where, + }: { + where: { companyId: string; status?: Row["status"] | FindOperator }; + }) => Promise.resolve( rows .filter( (row) => where.companyId === COMPANY_ID && - (where.status === undefined || row.status === where.status), + statusMatches(row.status, where.status), ) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null, @@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => { expect(result?.id).toBe("pending"); }); + it("treats a changes-requested request as open, same as pending", async () => { + const changesRequested: Row = { + id: "changes-requested", + status: ChangeRequestStatus.ChangesRequested, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([ + rejected, + changesRequested, + ]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result?.id).toBe("changes-requested"); + }); + it("returns the latest rejected request when nothing is pending", async () => { const result = await subject([rejected]).findLatestOpenByCompanyId( COMPANY_ID, diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index eb44d56cb..8fb81e6f5 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -1,12 +1,18 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { In, Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; import { ChangeRequestStatus, CompanyChangeRequest, } from "./entities/company-change-request.entity"; +/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */ +const OPEN_FOR_EDIT_STATUSES = [ + ChangeRequestStatus.Pending, + ChangeRequestStatus.ChangesRequested, +]; + @Injectable() export class CompanyChangeRequestRepository extends BaseRepository { constructor( @@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository { return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Pending }, + where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) }, order: { createdAt: "DESC" }, }); } diff --git a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts new file mode 100644 index 000000000..83fa539e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts @@ -0,0 +1,90 @@ +import type { Company } from "./entities/company.entity"; +import type { CompanyRevisionChange } from "./entities/company-revision.entity"; + +/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */ +export const COMPANY_FIELD_LABELS: Record = { + name: "Company name", + phone: "Phone", + email: "Email", + address: "Address", + country: "Country", + tin: "TIN", + vatNumber: "VAT number", + fanNumber: "FAN number", + nationality: "Nationality", + website: "Website", + licenceNumber: "Licence number", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", + houseNo: "House No", + contactPersonName: "Contact person name", + contactPersonPhone: "Contact person phone", + contactPersonEmail: "Contact person email", + contactPersonPosition: "Contact person position", + generalManagerName: "General manager name", + generalManagerPhone: "General manager phone", + generalManagerEmail: "General manager email", + poaName: "PoA name", + poaPhone: "PoA phone", + poaEmail: "PoA email", + poaLocation: "PoA location", + poaAddress: "PoA address", + documents: "Document", +}; + +function displayValue(value: unknown): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value === "boolean") return value ? "Yes" : "No"; + return String(value); +} + +/** + * Compare the company row before a write against the patch about to be + * applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar + * columns plus a merged `attributes` blob). Only fields with a known label + * are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never + * shows up as noise. + */ +export function diffCompanyUpdate( + before: Company, + patch: Record, +): CompanyRevisionChange[] { + const changes: CompanyRevisionChange[] = []; + const { attributes: attrPatch, ...columnPatch } = patch; + + for (const [field, nextRaw] of Object.entries(columnPatch)) { + const label = COMPANY_FIELD_LABELS[field]; + if (!label) continue; + const next = displayValue(nextRaw); + const previous = displayValue((before as unknown as Record)[field]); + if (next === previous) continue; + changes.push({ field, label, from: previous, to: next }); + } + + if (attrPatch) { + const beforeAttrs = before.attributes ?? {}; + for (const [field, nextRaw] of Object.entries(attrPatch)) { + const label = COMPANY_FIELD_LABELS[field]; + if (!label) continue; + const next = displayValue(nextRaw); + const previous = displayValue(beforeAttrs[field]); + if (next === previous) continue; + changes.push({ field, label, from: previous, to: next }); + } + } + + return changes; +} + +/** Short human summary of a change set, e.g. "phone, address changed". */ +export function summarizeCompanyChanges( + changes: CompanyRevisionChange[], +): string { + if (changes.length === 0) return "No changes"; + const labels = changes.map((c) => c.label.toLowerCase()); + return labels.length <= 3 + ? `${labels.join(", ")} changed` + : `${labels.length} fields changed`; +} diff --git a/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts new file mode 100644 index 000000000..b04bb35de --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { CompanyRevision } from "./entities/company-revision.entity"; + +@Injectable() +export class CompanyRevisionRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyRevision) + repo: Repository, + ) { + super(repo); + } + + /** Revision history for a company, newest first. */ + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + order: { createdAt: "DESC" }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 2634e0943..34071e813 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -13,9 +13,12 @@ export class CompanyInfoResponseDto { /** * Open profile-edit review, if any. Drives the portal-wide lock (pending → * settings + new-contract/booking creation disabled) and the reapply banner. + * `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit + * call to action, but the customer's edit appends to this SAME request + * instead of starting a fresh one. */ review: { - status: 'pending' | 'rejected'; + status: 'pending' | 'rejected' | 'changes_requested'; note: string | null; } | null; @@ -30,12 +33,13 @@ export class CompanyInfoResponseDto { const open = changeRequest && (changeRequest.status === ChangeRequestStatus.Pending || - changeRequest.status === ChangeRequestStatus.Rejected) + changeRequest.status === ChangeRequestStatus.Rejected || + changeRequest.status === ChangeRequestStatus.ChangesRequested) ? changeRequest : null; this.review = open ? { - status: open.status as 'pending' | 'rejected', + status: open.status as 'pending' | 'rejected' | 'changes_requested', note: open.note ?? null, } : null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts new file mode 100644 index 000000000..c93c7387c --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -0,0 +1,23 @@ +import { + CompanyRevision, + CompanyRevisionChange, +} from "../entities/company-revision.entity"; + +/** One version-history entry, shown on the backoffice customer detail page. */ +export class CompanyRevisionResponseDto { + id: string; + companyId: string; + actorId: string | null; + summary: string; + changes: CompanyRevisionChange[]; + createdAt: Date; + + constructor(revision: CompanyRevision) { + this.id = revision.id; + this.companyId = revision.companyId; + this.actorId = revision.actorId ?? null; + this.summary = revision.summary; + this.changes = revision.changes ?? []; + this.createdAt = revision.createdAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 6072268dc..f0a19dad7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -68,10 +68,12 @@ export class ProfileResponseDto { /** * Open profile-edit review, if any. `reviewStatus === "pending"` locks the - * settings page; `"rejected"` surfaces the note and prefills the (declined) - * proposed values from `pendingChanges` so the customer can amend & resubmit. + * settings page; `"rejected"`/`"changes_requested"` both surface the note and + * prefill the proposed values from `pendingChanges` so the customer can amend + * & resubmit — `"changes_requested"` just appends the edit to this same + * request instead of starting a fresh one. */ - reviewStatus: "pending" | "rejected" | null; + reviewStatus: "pending" | "rejected" | "changes_requested" | null; reviewNote: string | null; pendingChanges: Record | null; @@ -127,7 +129,8 @@ export class ProfileResponseDto { const openReview = changeRequest && (changeRequest.status === ChangeRequestStatus.Pending || - changeRequest.status === ChangeRequestStatus.Rejected) + changeRequest.status === ChangeRequestStatus.Rejected || + changeRequest.status === ChangeRequestStatus.ChangesRequested) ? changeRequest : null; this.reviewStatus = @@ -135,7 +138,9 @@ export class ProfileResponseDto { ? "pending" : openReview?.status === ChangeRequestStatus.Rejected ? "rejected" - : null; + : openReview?.status === ChangeRequestStatus.ChangesRequested + ? "changes_requested" + : null; this.reviewNote = openReview?.note ?? null; this.pendingChanges = openReview?.snapshot ?? null; this.identity = buildCompanyIdentityState(company); diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 60f9f9a34..d705e7323 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -97,6 +97,7 @@ export class ResponseCompanyDto { createdAt: Date; updatedAt: Date; + approvedAt: Date | null; constructor(company: Company) { this.id = company.id; @@ -135,5 +136,6 @@ export class ResponseCompanyDto { this.identity = buildCompanyIdentityState(company); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; + this.approvedAt = company.approvedAt ?? null; } } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 5ee6739de..2ba39ecad 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -5,14 +5,18 @@ import { Company } from "./company.entity"; /** * Lifecycle of a customer's proposed profile change. Edits made on the portal * settings page by an already-approved company are staged here (not written to - * the live Company row) until a backoffice reviewer approves — at which point - * the snapshot is applied — or rejects with a note, after which the customer can - * amend and resubmit. + * the live Company row) until a backoffice reviewer resolves it: + * - Approved — the snapshot is applied to the live Company row. + * - Rejected — terminal for this row; the customer's next edit starts a fresh one. + * - ChangesRequested — soft: the row stays open with the reviewer's note attached, + * so the customer's next edit is appended (merged) into this SAME row instead + * of starting a new cycle. */ export enum ChangeRequestStatus { Pending = "pending", Approved = "approved", Rejected = "rejected", + ChangesRequested = "changes_requested", } /** diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts new file mode 100644 index 000000000..222a8364f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Company } from "./company.entity"; + +/** + * One recorded field/document change, as shown on the customer's version + * history. A document change carries `fromFileId`/`toFileId` alongside the + * display names, so the reviewer can open the previous and current file — + * not just read that "a document changed." + */ +export interface CompanyRevisionChange { + field: string; + label: string; + from: string | null; + to: string | null; + fromFileId?: string | null; + toFileId?: string | null; +} + +/** + * Append-only audit of edits made to a company record BEFORE it reaches + * `Active` (the onboarding phase), where {@link CompaniesService.updateProfile} + * and {@link CompaniesService.uploadCompanyDocuments} write straight to the + * live row with no approval gate — and, until this entity, no trace at all. + * Post-approval edits already get history via `CompanyChangeRequest`; this + * covers the gap before that gate exists. + */ +@Entity({ schema: "freight", name: "company_revisions" }) +@Index(["companyId"]) +export class CompanyRevision extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, { onDelete: "CASCADE" }) + @JoinColumn({ name: "company_id" }) + company?: Company; + + @Column({ name: "actor_id", type: "uuid", nullable: true }) + actorId?: string | null; + + @Column({ name: "summary", type: "varchar", length: 255 }) + summary!: string; + + @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) + changes!: CompanyRevisionChange[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 5fe3a3f67..f254e0121 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -61,6 +61,10 @@ export class Company extends BaseEntity { }) status!: CompanyStatus; + /** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */ + @Column({ name: "approved_at", type: "timestamptz", nullable: true }) + approvedAt?: Date | null; + @Column({ name: "tin", type: "varchar", length: 10, unique: true }) tin!: string; diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index 6978ad446..fc58a731c 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -51,7 +51,11 @@ export class FilesController { @Query("download") download: string | undefined, @Res() res: Response, ) { - const record = await this.filesService.findById(fileId); + // Includes soft-deleted records: a superseded document (replaced via a + // single-file document slot, or resolved as part of a license/PoA swap) + // is only reachable by UUID through the change-request/version-history + // diff, where reviewers need to open the "previous" file to compare it. + const record = await this.filesService.findByIdIncludingDeleted(fileId); // Chat attachments are cross-tenant sensitive and this route has no // ownership check, so a leaked/guessed UUID would hand one company's file to @@ -63,7 +67,9 @@ export class FilesController { ); } - const { stream } = await this.filesService.streamById(fileId); + const { stream } = await this.filesService.streamById(fileId, { + includeDeleted: true, + }); const forceDownload = download === "1" || download === "true"; const disposition = forceDownload ? "attachment" : "inline"; diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index bc446b508..e76240704 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -245,6 +245,23 @@ export class FilesService { return record; } + /** + * Same as {@link findById}, but also matches a soft-deleted record — a + * superseded document (replaced via a single-file slot, or a resolved + * license/PoA swap) is exactly this: gone from every live listing, but its + * id is still handed to reviewers in the change-request/version-history + * diff so they can open the "previous" file for comparison. Only the + * preview/download route should use this; every other caller wants the + * default (soft-deleted = not found). + */ + async findByIdIncludingDeleted(id: string): Promise { + const record = await this.filesRepository.findById(id, { + withDeleted: true, + }); + if (!record) throw new NotFoundException(`File ${id} not found`); + return record; + } + /** * Record a reviewer verdict on one document. `change_requested` keeps the note * (the customer sees it verbatim); any other verdict clears it, so a stale @@ -360,8 +377,11 @@ export class FilesService { async streamById( id: string, + opts: { includeDeleted?: boolean } = {}, ): Promise<{ stream: Readable; record: FileRecord }> { - const record = await this.findById(id); + const record = opts.includeDeleted + ? await this.findByIdIncludingDeleted(id) + : await this.findById(id); const objectName = this.minioService.getObjectNameFromUrl(record.url); const stream = await this.minioService.getFileStream(objectName); return { stream, record }; diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 6f7882c76..6d9cb82a2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CargoUnitOfMeasure } from '@edr/types'; -import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -32,6 +32,18 @@ export class CreateCargoTypeDto { @IsUUID('4', { each: true }) wagonTypeIds?: string[]; + @ApiPropertyOptional({ + description: + 'PER_ITEM cargo only: items that physically fit one wagon, keyed by wagon-type id ' + + '(e.g. { "": 4, "": 6 }). Required for every wagonTypeId when ' + + 'unitOfMeasure is PER_ITEM.', + type: 'object', + additionalProperties: { type: 'integer', minimum: 1 }, + }) + @IsOptional() + @IsObject() + itemsPerWagonMap?: Record | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 037bf08be..b096613bf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -50,6 +50,16 @@ export class CargoType extends BaseEntity { }) wagonTypes?: WagonType[]; + /** + * PER_ITEM (break-bulk) only: how many whole items physically fit each + * allowed wagon type, keyed by wagon-type id (e.g. cars → { NW5: 4, NW7: 6 }). + * Allocation loads min(this fit, floor(capacityTons / perItemTons)) per + * wagon — floor space and rated tonnage bind independently. Keys are kept a + * subset of the wagonTypes join rows by the cargo-types service. + */ + @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) + itemsPerWagonMap?: Record | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index b3ee54d20..1570e0d23 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -28,6 +28,13 @@ export interface IRatesRepository { create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; + /** + * Flip a commodity's PER_TON↔PER_ITEM rates to match its unit of measure. + * Both units bill the same stored quantity — only the name differs — so a + * uom change must rename the units or bookings keep quoting "per ton" for + * counted cargo. Returns the number of rates flipped. + */ + syncBulkQuantityUnit(cargoTypeId: string, unitOfMeasure: 'PER_TON' | 'PER_ITEM'): Promise; } export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index abd10cd98..43c716a3b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -177,4 +177,16 @@ export class RatesRepository implements IRatesRepository { async softDelete(id: string): Promise { await this.repo.softDelete(id); } + + async syncBulkQuantityUnit( + cargoTypeId: string, + unitOfMeasure: 'PER_TON' | 'PER_ITEM', + ): Promise { + const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM'; + const result = await this.repo.update( + { cargoTypeId, rateUnit: from }, + { rateUnit: unitOfMeasure }, + ); + return result.affected ?? 0; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 8a72cf1f8..34287a023 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -1,5 +1,11 @@ -import { PaginatedResponse } from '@edr/types'; -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoUnitOfMeasure, PaginatedResponse } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -11,6 +17,7 @@ import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, } from '../interfaces/cargo-types.repository.interface'; +import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; import { DisplayOrderService } from './display-order.service'; @Injectable() @@ -18,6 +25,8 @@ export class CargoTypesService { constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly repository: ICargoTypesRepository, + @Inject(RATES_REPOSITORY) + private readonly ratesRepository: IRatesRepository, private readonly displayOrder: DisplayOrderService, ) {} @@ -38,6 +47,34 @@ export class CargoTypesService { return this.repository.findByCode(code); } + /** + * PER_ITEM (break-bulk) cargo must carry a whole-items-fit for EVERY allowed + * wagon type — allocation caps each wagon at min(fit, tonnage) and a missing + * fit would silently fall back to tonnage-only loading. Returns the map + * trimmed to the allowed ids (stale keys from a removed wagon type drop out); + * null when the cargo is not PER_ITEM or has no wagon types. + */ + private resolveItemsPerWagonMap(input: { + unitOfMeasure?: CargoUnitOfMeasure | null; + wagonTypeIds: string[]; + itemsPerWagonMap?: Record | null; + }): Record | null { + if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) { + return null; + } + const map: Record = {}; + for (const wagonTypeId of input.wagonTypeIds) { + const fit = Number(input.itemsPerWagonMap?.[wagonTypeId]); + if (!Number.isInteger(fit) || fit < 1) { + throw new BadRequestException( + `itemsPerWagonMap must define how many items fit wagon type ${wagonTypeId} (integer >= 1) for PER_ITEM cargo`, + ); + } + map[wagonTypeId] = fit; + } + return map; + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -62,26 +99,58 @@ export class CargoTypesService { unitOfMeasure: dto.unitOfMeasure ?? null, // Join rows are written by the save (RESTRICT FK rejects unknown ids). wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), + itemsPerWagonMap: this.resolveItemsPerWagonMap({ + unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeIds: dto.wagonTypeIds ?? [], + itemsPerWagonMap: dto.itemsPerWagonMap, + }), displayOrder, }); } /** Update an existing cargo type. */ async update(id: string, dto: UpdateCargoTypeDto): Promise { - await this.findById(id); + const existing = await this.findById(id); if (dto.parentGroupId) { if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent'); const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto; + const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto; + // Re-validate the fit map whenever anything it depends on moves — a partial + // update merges with the stored values so e.g. adding a wagon type without + // its fit still 400s. Untouched fields leave the stored map alone. + const touchesItemsFit = + wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; const updated = await this.repository.update(id, { ...columns, ...(wagonTypeIds ? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } : {}), + ...(touchesItemsFit + ? { + itemsPerWagonMap: this.resolveItemsPerWagonMap({ + unitOfMeasure: + dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure, + wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id), + itemsPerWagonMap: + itemsPerWagonMap !== undefined ? itemsPerWagonMap : existing.itemsPerWagonMap, + }), + } + : {}), }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); + // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the + // same stored quantity) — sync them or bookings keep quoting "per ton" for + // counted cargo. + if ( + dto.unitOfMeasure !== undefined && + dto.unitOfMeasure !== existing.unitOfMeasure && + (dto.unitOfMeasure === CargoUnitOfMeasure.PerTon || + dto.unitOfMeasure === CargoUnitOfMeasure.PerItem) + ) { + await this.ratesRepository.syncBulkQuantityUnit(id, dto.unitOfMeasure); + } return updated; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 5ce75abd2..5203c3a14 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -69,6 +69,7 @@ import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, + bulkItemsFitFor, bulkItemWagonsRequired, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, @@ -4060,7 +4061,13 @@ export class BookingBatchService implements OnModuleInit { // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw // tonnage suggests (floor items-per-wagon loses the fractional capacity). - const byItems = bulkItemWagonsRequired(booking, capacityTons); + // `dimsFor` resolved dims from the first allowed wagon type, so charge that + // same type's configured items-fit alongside its capacity. + const byItems = bulkItemWagonsRequired( + booking, + capacityTons, + bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id), + ); return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 631078ef6..bea01af0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -1,4 +1,4 @@ -import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util'; +import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -53,8 +53,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n if (booking.freightType === 'BULK') { const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; // Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm` - // holds the item count there, not tons. - const byItems = bulkItemWagonsRequired(booking, capacity); + // holds the item count there, not tons. No wagon type is fixed yet, so use + // the best count across the cargo's allowed types (per-type items-fit + // respected); falls back to `capacity` when the relation isn't loaded. + const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity); if (byItems > 0) return byItems; const weight = Number(booking.cargoTotalWeightVgm ?? 0); return Math.max(1, Math.ceil(weight / capacity)); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index fd5fe05ed..ead3a4e8b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -2,6 +2,7 @@ import { bookingCargoTons, bookingGrossWeightTons, bookingTrainLengthMeters, + bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, consistUsage, consistViolations, @@ -76,6 +77,62 @@ describe('train-capacity.util', () => { expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0); expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0); }); + + describe('configured items-fit (floor space vs tonnage)', () => { + it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => { + // floor(70/20) = 3 by tonnage < 4 by floor space. + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17); + }); + + it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => { + // floor(70/10) = 7 by tonnage, but only 4 fit physically. + expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13); + }); + + it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => { + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17); + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17); + // floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons. + expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8); + }); + }); + }); + + describe('bulkItemWagonsForAllowedTypes', () => { + const breakBulk = (quantity: number, weightTons: number) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: quantity, + bulkTotalWeightTons: weightTons, + }); + + it('picks the fewest-wagon allowed type, each capped by its own fit', () => { + const cargoType = { + wagonTypes: [ + { id: 'nw5', capacityTons: 70 }, + { id: 'nw7', capacityTons: 80 }, + ], + itemsPerWagonMap: { nw5: 4, nw7: 6 }, + }; + // 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons; + // NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13. + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13); + }); + + it('equals the old max-capacity estimate when no fits are configured', () => { + const cargoType = { + wagonTypes: [ + { id: 'a', capacityTons: 50 }, + { id: 'b', capacityTons: 70 }, + ], + }; + // Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17. + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17); + }); + + it('falls back to the given capacity when the relation is missing', () => { + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17); + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17); + }); }); describe('bookingCargoTons (break-bulk weight preference)', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 061dc0dbd..867da564b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -120,6 +120,12 @@ export function bookingCargoTons(booking: { * 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons. * Returns 0 when the booking is not item-counted (PER_TON bulk, containers) — * callers then fall back to the pooled-tonnage math. + * + * `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from + * cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon + * that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T + * (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls + * back to tonnage-only (legacy cargo types without a configured fit). */ export function bulkItemWagonsRequired( booking: { @@ -128,6 +134,7 @@ export function bulkItemWagonsRequired( bulkTotalWeightTons?: number | string | null; }, capacityTons: number, + itemsFit?: number | null, ): number { if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0; const quantity = num(booking.cargoTotalWeightVgm); @@ -136,10 +143,56 @@ export function bulkItemWagonsRequired( const perItemTons = totalWeightTons / quantity; // ponytail: an item heavier than a whole wagon still charges 1 wagon per // item; reject such bookings at creation time if the case turns real. - const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons)); + const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons)); + const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity; + const itemsPerWagon = Math.min(byTonnage, byFloor); return Math.max(1, Math.ceil(quantity / itemsPerWagon)); } +type ItemFitCargoType = { + wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; + itemsPerWagonMap?: Record | null; +} | null; + +/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */ +export function bulkItemsFitFor( + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, +): number | null { + const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN; + return Number.isFinite(fit) && fit >= 1 ? fit : null; +} + +/** + * Break-bulk wagon count when no single wagon type is fixed yet: the best + * (fewest-wagon) count across the cargo type's allowed wagon types, each + * respecting its own items-fit. With no fits configured this equals the old + * max-capacity estimate; with no allowed types it degrades to + * `fallbackCapacityTons` tonnage-only. + */ +export function bulkItemWagonsForAllowedTypes( + booking: { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); + if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons); + let best = 0; + for (const wagonType of allowed) { + const wagons = bulkItemWagonsRequired( + booking, + num(wagonType.capacityTons), + bulkItemsFitFor(cargoType, wagonType.id), + ); + if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; + } + return best; +} + /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ export function grossWagonWeightTons(slot: Pick): number { return num(slot.tareWeightTons) + num(slot.cargoTons); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 619ebbde6..ef3089925 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util'; +import { + bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsRequired, + consistViolations, +} from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -175,7 +180,11 @@ export function buildBulkWagonPlan( // Break-bulk (PER_ITEM) bookings size by indivisible items per booking — // their tonnage must NOT pool with PER_TON cargo (an item can't split // across wagons the way loose tonnage can). - const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity)); + const itemSlotsByBooking = bookings.map((b) => + // The plan fixed THIS wagon type, so its configured items-fit binds — not + // the best fit across the cargo's allowed types. + bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)), + ); const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); const totalWeight = roundTons( bookings.reduce( diff --git a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts new file mode 100644 index 000000000..9d51107fe --- /dev/null +++ b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts @@ -0,0 +1,63 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.TYPEORM_LOGGING = 'false'; + +import { AppModule } from '../app.module'; +import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service'; + +/** + * One-off backfill for bookings caught by the autoArriveAtFinalYard bug + * (fixed in booking-journey.service.ts): the bulk final-yard arrival used to + * flip booking status to ARRIVED/COMPLETED without ever emitting + * booking.unloadedAtYard, so WarehouseInventoryService never created their + * warehouse_inventory row. Reuses the same idempotent listener the live + * event now calls, so it's safe to re-run. + */ +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn'], + }); + + try { + const dataSource = app.get(DataSource); + const inventory = app.get(WarehouseInventoryService); + + const bookings: { id: string; tradeDirection: string }[] = await dataSource.query( + `SELECT b.id, b.trade_direction AS "tradeDirection" + FROM freight.bookings b + WHERE b.deleted_at IS NULL + AND b.trade_direction IN ('IMPORT', 'DOMESTIC') + AND b.status IN ('ARRIVED', 'COMPLETED') + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory wi + WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL + )`, + ); + + if (bookings.length === 0) { + console.log('No bookings missing their unload inventory row.'); + return; + } + + console.log(`Backfilling ${bookings.length} booking(s)...`); + for (const booking of bookings) { + await inventory.handleBookingUnloadedAtYard({ + bookingId: booking.id, + tradeDirection: booking.tradeDirection, + }); + console.log(` - ${booking.id} (${booking.tradeDirection})`); + } + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/edr-freight-api/src/scripts/migrate.ts b/apps/edr-freight-api/src/scripts/migrate.ts new file mode 100644 index 000000000..7f331e1d3 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/migrate.ts @@ -0,0 +1,24 @@ +import "dotenv/config"; +import { AppDataSource } from "../data-source"; +import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas"; +import { buildDataSourceOptions } from "../config/database.config"; + +async function main(): Promise { + await ensurePostgresSchemas(buildDataSourceOptions()); + + await AppDataSource.initialize(); + try { + const applied = await AppDataSource.runMigrations(); + for (const migration of applied) { + console.log(`applied: ${migration.name}`); + } + if (applied.length === 0) console.log("no pending migrations"); + } finally { + await AppDataSource.destroy(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx index 8f213e4ec..852a89cc1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx @@ -29,7 +29,6 @@ import { Upload, UserCheck, } from "lucide-react"; -import dayjs from "dayjs"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; import { isViewable } from "@edr/ui-common"; @@ -299,7 +298,14 @@ function DocumentRow({ {doc.file.name} · {formatBytes(doc.file.size)} ·{" "} {doc.uploadedByName ?? "Global Logistics"} ·{" "} - {dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")} + {new Date(doc.uploadedAt).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + })} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 253d8aea5..196161f92 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -2,7 +2,6 @@ import { Alert, Anchor, Badge, - Box, Button, Card, Group, @@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { fetchViewableFile } from "@/services/files.service"; import { api } from "@/services/api"; -import type { Company, CompanyChangeRequest } from "@/types/customer"; +import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; /** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */ -const FIELD_LABELS: Record = { +export const FIELD_LABELS: Record = { companyName: "Company name", companyEmail: "Company email", companyPhone: "Company phone", @@ -69,7 +68,7 @@ const FIELD_LABELS: Record = { }; /** Best-effort current value on the live company for a proposed field key. */ -function currentValue(company: Company, key: string): string { +export function currentValue(company: Company, key: string): string { const c = company as unknown as Record; const attrs = (company.attributes ?? {}) as Record; const map: Record = { @@ -160,7 +159,7 @@ function FaydaIdentityDiff({ ); } -function DiffRow({ +export function DiffRow({ label, from, to, @@ -201,8 +200,9 @@ function DiffRow({ /** * Backoffice review surface for a customer's staged profile edits. Shows the - * pending change request as a proposed-vs-current diff with Approve / Reject - * (with note) actions, plus a short history of past decisions. + * pending change request as a proposed-vs-current diff with Approve / Reject / + * Request changes actions. Past decisions live in the History tab's unified + * timeline (see {@link CompanyTimeline}), not here. */ export function ChangeRequestReview({ company }: { company: Company }) { const { user } = useAuth(); @@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) { const reject = useMutation( api.customers.rejectChangeRequest.mutationOptions(), ); + const requestChanges = useMutation( + api.customers.requestChangeRequestChanges.mutationOptions(), + ); const { view, viewer } = useFileViewer(); - const [rejectId, setRejectId] = useState(null); + const [actionTarget, setActionTarget] = useState<{ + id: string; + kind: "reject" | "request-changes"; + } | null>(null); const [note, setNote] = useState(""); const requests = query.data ?? []; const pending = requests.find((r) => r.status === "pending"); - const history = requests.filter((r) => r.status !== "pending").slice(0, 5); - if (!pending && history.length === 0) return null; + if (!pending) return null; const proposedKeys = pending ? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity") @@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) { const licenseChanges = pending?.licenseChanges ?? []; const documentChanges = pending?.documentChanges ?? []; - const confirmReject = () => { - if (!rejectId) return; - reject.mutate( - { id: rejectId, note: note.trim() }, + const confirmAction = () => { + if (!actionTarget) return; + const mutation = actionTarget.kind === "reject" ? reject : requestChanges; + mutation.mutate( + { id: actionTarget.id, note: note.trim() }, { onSuccess: () => { - setRejectId(null); + setActionTarget(null); setNote(""); }, }, @@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) { + {pending.note && ( + } + > + Changes were requested on an earlier round of this same + submission: {pending.note} — check whether + this resubmission actually addresses it before approving. + + )} + {proposedKeys.length > 0 ? ( {proposedKeys.map((key) => ( @@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) { variant="light" color="red" onClick={() => { - setRejectId(pending.id); + setActionTarget({ id: pending.id, kind: "reject" }); setNote(""); }} > Reject +