diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts new file mode 100644 index 000000000..6aaa24a26 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { assertExportReceivedWithGrn } from './export-received-gate'; + +const db = (rows: unknown[]) => + ({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource; + +describe('assertExportReceivedWithGrn', () => { + it('passes when the export booking has a received row with a GRN', async () => { + await expect( + assertExportReceivedWithGrn(db([{ '?column?': 1 }]), { + id: 'b-1', + tradeDirection: 'EXPORT', + }), + ).resolves.toBeUndefined(); + }); + + it('rejects an export booking with nothing received', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('never blocks import — it loads off a train, not out of the warehouse', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }), + ).resolves.toBeUndefined(); + // Import short-circuits before querying. + expect((source.query as jest.Mock)).not.toHaveBeenCalled(); + }); + + it('does not block intercity cargo', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts new file mode 100644 index 000000000..0e1728800 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; + +/** The booking fields the gate needs. */ +export interface ExportLoadGateBooking { + id: string; + tradeDirection?: string | null; +} + +/** + * Export cargo may not be loaded onto its train until it has physically reached + * the warehouse and been issued a GRN — whether it got there by first-mile or by + * the customer's own truck, and even though a wagon is already allocated. An + * allocation is a plan; the GRN is the proof the goods are actually in hand. + * + * Several loading paths (per-yard load, workspace confirm-loaded) marked cargo + * loaded straight off the allocation, skipping the warehouse, so a booking could + * ride the train with nothing ever received. This closes that for export; import + * loads off a train and is unaffected. + * + * "Received with a GRN" = an inventory row that has reached the warehouse + * (RECEIVED or any later stage) and carries a GRN, in the column or the notes + * fallback older rows use. + */ +export async function assertExportReceivedWithGrn( + db: DataSource | EntityManager, + booking: ExportLoadGateBooking, +): Promise { + if (booking.tradeDirection !== 'EXPORT') return; + + const [row] = await db.query( + `SELECT 1 + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + LIMIT 1`, + [booking.id], + ); + + if (!row) { + throw new BadRequestException( + 'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.', + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts new file mode 100644 index 000000000..42352237d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Configured rail distance between two yards (Configuration → Yard Distances). + * Route creation resolves each segment's km from here (symmetric lookup: + * one A↔B row serves both directions) instead of accepting free-text km, + * and snapshots the value onto route_milestones.distance_km. + * + * Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair + * can be re-created. + */ +export class CreateYardDistances2060000000000 implements MigrationInterface { + name = 'CreateYardDistances2060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_distances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + from_yard_id uuid NOT NULL REFERENCES freight.yards(id), + to_yard_id uuid NOT NULL REFERENCES freight.yards(id), + distance_km numeric(10,2) 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_yard_distances_from_yard + ON freight.yard_distances (from_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard + ON freight.yard_distances (to_yard_id); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair + ON freight.yard_distances (from_yard_id, to_yard_id) + WHERE deleted_at IS NULL; + `); + // Backfill from segments already stored on existing routes so editing them + // does not immediately fail the "pair not configured" check. One row per + // unordered pair; where routes disagree the longest segment wins. + await queryRunner.query(` + INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km) + SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id)) + prev_yard_id, yard_id, distance_km + FROM ( + SELECT + yard_id, + distance_km, + LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id + FROM freight.route_milestones + WHERE deleted_at IS NULL + ) segments + WHERE prev_yard_id IS NOT NULL + AND distance_km IS NOT NULL + AND distance_km > 0 + ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC + ON CONFLICT DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts new file mode 100644 index 000000000..833d49fea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-AddFileReviewStatus.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-document review state, so a backoffice reviewer can request a correction + * on one specific onboarding document instead of rejecting the whole role. + * + * Until now `freight.files` carried no status at all: the `pending_add` / + * `pending_remove` badges the portal shows are derived by diffing live rows + * against an open company change request, which says nothing about whether a + * reviewer is happy with a given document. `review_status` is that missing + * verdict — NULL means never reviewed, which is the state every existing row + * correctly starts in, so no backfill is needed. + * + * The partial index serves the approval gate, which asks "does this company (or + * profile) still have any document with an open change request?" on every + * role-status write. + */ +export class AddFileReviewStatus2430000000000 implements MigrationInterface { + name = 'AddFileReviewStatus2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL, + ADD COLUMN IF NOT EXISTS review_note text NULL, + ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL, + ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request" + ON freight.files (resource, resource_id) + WHERE review_status = 'change_requested' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`, + ); + await queryRunner.query(` + ALTER TABLE freight.files + DROP COLUMN IF EXISTS review_status, + DROP COLUMN IF EXISTS review_note, + DROP COLUMN IF EXISTS reviewed_by, + DROP COLUMN IF EXISTS reviewed_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 1bdde71be..caa41f5e9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -168,6 +168,17 @@ export class BookingLifecycleNotifierService { }); } + /** Intercity documents approved → booking waits in the ride-along pool. */ + intercityDocumentsApproved(b: Booking): void { + const msg = + `Documents for intercity booking ${b.reference} are approved. ` + + `Operations will assign your shipment to a passing train; payment opens once it is accepted.`; + void this.notifyContact(b, msg, 'DOCUMENTS APPROVED'); + this.inApp(b, 'Documents approved', msg, { + type: NotificationType.CLEARANCE_DECISION, + }); + } + /** Operations returned the operation request for changes. */ operationChangesRequested(b: Booking, note: string): void { const msg = 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 cbb630794..a42d23859 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 @@ -137,7 +137,7 @@ export class BookingPricingService { const lineItems: PriceLineItemDto[] = []; let total = 0; - const { lineItems: baseLines, usedRates: baseRates } = + const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); for (const line of baseLines) { lineItems.push(line); @@ -247,7 +247,7 @@ export class BookingPricingService { usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, - warnings: ruleResult.warnings, + warnings: [...ruleResult.warnings, ...baseWarnings], hardBlocked: ruleResult.hardBlocked, overweightLines, }; @@ -454,7 +454,7 @@ export class BookingPricingService { booking: Booking, evalInput: BookingEvaluationInput, frozenRates: Map | null = null, - ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; @@ -476,6 +476,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); + const warnings: string[] = []; const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { @@ -487,47 +488,63 @@ export class BookingPricingService { booking.originYardId, booking.destinationYardId, ); - if (!rate) continue; - - usedRatesMap.set(rate.id, rate); - const unitUsd = Number(rate.rateValue); // H15: frozen contract rate for this container size, when present — its // unitPrice is already in the booking currency (no USD→currency convert). + // It also stands on its own: a contract line prices off the agreed rate + // even when nobody configured a live rate for this leg + type yet. const frozen = await this.frozenRateForContainer( frozenRates, container.containerTypeId, paymentCurrency, ); + const label = await this.containerTypeLabel(container.containerTypeId); + if (!rate && !frozen) { + // Never price this line off another container type's (or another + // route's) rate — an unpriced line with a warning is recoverable; a + // silently mischarged one is not. + warnings.push( + `No ${rateType} rate is configured for ${label} on this route — ` + + 'the line was not priced.', + ); + continue; + } + + const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER'; let amount: number; let unitAmount: number; if (frozen) { unitAmount = Number(frozen.unitPrice); amount = this.amountForUnit( - rate.rateUnit, + rateUnit, unitAmount, container.quantity, wagonCount, ); } else { - const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); + const unitUsd = Number(rate!.rateValue); + const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount); amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; } - const label = await this.containerTypeLabel(container.containerTypeId); + if (rate) usedRatesMap.set(rate.id, rate); lines.push({ code: rateType, description: `${label} rail freight`, amount, unitAmount, - unit: rate.rateUnit, - quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), + unit: rateUnit, + quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } - if (lines.length === 0) { + if (lines.length === 0 && evalInput.containers.length === 0) { // Bulk (and any booking with no container lines) still has to price off a // rate configured for this leg — never one belonging to another route. + // Container bookings never reach this fallback: their lines price per + // container type above or stay unpriced with a warning — falling back to + // a corridor rate of a DIFFERENT container type billed once (qty 1) is + // how a 38-container booking was invoiced 40 USD instead of 1900. const fallback = liveRates.find( (r) => r.rateType === rateType && @@ -573,7 +590,7 @@ export class BookingPricingService { } } - return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; + return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings }; } /** diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 29fec7997..1896034cc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -840,6 +840,22 @@ export class BookingTransitionService { } } + // Intercity: there is no shipment-day request step — an approved booking + // goes straight to FULLY_EXECUTED, which is what the intercity ride-along + // pool keys on. Staff then accept it onto a passing train (that accept + // opens the pay window). + if (booking.tradeDirection === "DOMESTIC") { + const now = new Date(); + await this.bookingsRepository.update(bookingId, { + status: "FULLY_EXECUTED", + fullyExecutedAt: now, + lockedAt: booking.lockedAt ?? now, + } as never); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.intercityDocumentsApproved(fresh); + return fresh; + } + await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } as never); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 969de5583..0e858e702 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -1,7 +1,10 @@ import { clearanceSettingCode, clearanceOutputSettingCode, + clearanceCodesForBooking, + INTERCITY_DOCUMENTS_SETTING_CODE, } from './clearance.util'; +import type { Booking } from './entities/booking.entity'; describe('clearance.util — clearanceSettingCode', () => { it('resolves import container with/without customs', () => { @@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => { ); }); - it('returns null for DOMESTIC (no clearance gate)', () => { - expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); - expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull(); + it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => { + expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe( + INTERCITY_DOCUMENTS_SETTING_CODE, + ); + expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe( + INTERCITY_DOCUMENTS_SETTING_CODE, + ); + }); +}); + +describe('clearance.util — clearanceCodesForBooking (intercity)', () => { + const base = { + tradeDirection: 'DOMESTIC', + freightType: 'CONTAINER', + serviceType: null, + customsClearingEnabled: false, + }; + + it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => { + const general = clearanceCodesForBooking({ + ...base, + contractId: 'c1', + contractKind: 'GENERAL', + } as unknown as Booking); + expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); + expect(general.outputCode).toBeNull(); + + const direct = clearanceCodesForBooking({ + ...base, + contractId: null, + contractKind: null, + } as unknown as Booking); + expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); + }); + + it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => { + const drawdown = clearanceCodesForBooking({ + ...base, + contractId: 'c1', + contractKind: 'ONE_TIME', + } as unknown as Booking); + expect(drawdown.inputCode).toBeNull(); + expect(drawdown.outputCode).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 1cc6503df..5a63beca6 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity'; type Op = 'import' | 'export'; type Freight = 'container' | 'bulk'; +/** + * The single (admin-configured) document set intercity shipments upload. + * DOMESTIC has no customs, so one shared set serves contracts and bookings: + * ONE_TIME collects it at contract level, GENERAL per booking — Operations + * reviews either way. + */ +export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents'; + /** Trade direction → clearance operation. DOMESTIC has no customs clearance. */ function operationFor(tradeDirection: string): Op | null { if (tradeDirection === 'IMPORT') return 'import'; if (tradeDirection === 'EXPORT') return 'export'; - return null; // DOMESTIC / intercity — no clearance gate + return null; // DOMESTIC / intercity — no customs operation } function freightFor(freightType: string): Freight { @@ -26,6 +34,9 @@ export function clearanceSettingCode( freightType: string, includesCustoms: boolean, ): string | null { + // Intercity: no customs, but the admin-configured intercity document set is + // still collected and ops-reviewed before the shipment may board a train. + if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); @@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): { const includesCustoms = Boolean(booking.serviceType?.includesCustoms) || Boolean(booking.customsClearingEnabled); + // Intercity drawdowns under a ONE_TIME contract already cleared the intercity + // document set on the CONTRACT (post-signature); only GENERAL drawdowns and + // direct (contract-less) bookings carry the per-booking set. + if ( + booking.tradeDirection === 'DOMESTIC' && + booking.contractId && + booking.contractKind === 'ONE_TIME' + ) { + return { inputCode: null, outputCode: null, includesCustoms: false }; + } return { inputCode: clearanceSettingCode( booking.tradeDirection, 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 8db9eba66..6111bd32a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -48,6 +48,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; 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 { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -494,6 +495,9 @@ export class CompaniesController { mimeType: f.mimeType, size: f.size, uploadedAt: f.createdAt, + reviewStatus: f.reviewStatus, + reviewNote: f.reviewNote, + reviewedAt: f.reviewedAt, // Raw `f.url` is an un-signed MinIO path the browser can't open — sign // it so the file previews/downloads in the client. url: f.url ? await this.filesService.signUrl(f.url) : f.url, @@ -501,6 +505,35 @@ export class CompaniesController { ); } + @Post("documents/:fileId/request-change") + @FreightAdmin() + @ApiOperation({ + summary: "Ask the customer to correct one uploaded document", + description: + "Flags a single document with a reason the customer sees, notifies them, " + + "and blocks role approval until they re-upload. Narrower than rejecting " + + "the whole role.", + }) + async requestDocumentChange( + @CurrentUser() user: CurrentIamUser, + @Param("fileId", ParseUUIDPipe) fileId: string, + @Body() dto: RequestDocumentChangeDto, + ) { + const file = await this.companiesService.requestDocumentChange( + fileId, + dto.note, + user.id, + ); + return { + id: file.id, + name: file.name, + code: file.code, + reviewStatus: file.reviewStatus, + reviewNote: file.reviewNote, + reviewedAt: file.reviewedAt, + }; + } + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index b02f38380..3ac12b11a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository { ) )`; + /** + * A company waiting on a reviewer to decide an edit it submitted after being + * approved. These rows are `status = active`, so the pending-application filter + * can never surface them — the review queue needs its own predicate. + */ + private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS ( + SELECT 1 FROM freight.company_change_request ccr + WHERE ccr.company_id = company.id + AND ccr.status = 'pending' + AND ccr.deleted_at IS NULL + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -67,6 +79,7 @@ export class CompaniesRepository extends BaseRepository { kind, status, onboardingCompleted, + hasPendingChangeRequest, sortBy = 'name', sortOrder = 'ASC', } = query; @@ -99,6 +112,14 @@ export class CompaniesRepository extends BaseRepository { ); } + if (hasPendingChangeRequest !== undefined) { + qb.andWhere( + hasPendingChangeRequest + ? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL + : `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -143,6 +164,12 @@ export class CompaniesRepository extends BaseRepository { .addGroupBy(CompaniesRepository.DRAFT_SQL) .getRawMany(); + const pendingChanges = await this.repository + .createQueryBuilder('company') + .where('company.deleted_at IS NULL') + .andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL) + .getCount(); + const map = new Map(); let onboarding = 0; let total = 0; @@ -160,6 +187,7 @@ export class CompaniesRepository extends BaseRepository { onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, + pendingChanges, }; } } 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 04b8790cd..df0e14998 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, ForbiddenException, } from "@nestjs/common"; +import { DataSource } from "typeorm"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -98,6 +99,7 @@ export class CompaniesService { private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, + private readonly dataSource: DataSource, ) { } /** @@ -748,7 +750,15 @@ export class CompaniesService { submittedAt: now, note: null, })) ?? existing; + this.companyNotifier.changeRequestSubmitted(company, request.id, false); } else { + // Rejecting a request leaves it Rejected rather than reopening it, so a + // customer amending after a rejection lands here with a fresh Pending row. + // That is the resubmission case the reviewer needs flagged. + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); request = await this.changeRequestRepo.create({ companyId: company.id, snapshot: fields, @@ -756,6 +766,11 @@ export class CompaniesService { submittedBy: userId, submittedAt: now, }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); } // Live company is unchanged; surface the pending state for the settings page. @@ -827,6 +842,12 @@ export class CompaniesService { "companies", files, ); + await this.resolveDocumentChangeRequests( + companyId, + "companies", + uploaded.map((f) => f.code), + uploaded.map((f) => f.id), + ); if (company.status === CompanyStatus.Active) { await this.stageDocumentChange( company.id, @@ -837,6 +858,95 @@ export class CompaniesService { return uploaded; } + /** + * Clear the `change_requested` flag from the documents a fresh upload replaces. + * + * Uploading does not overwrite the old row — it adds a new one under the same + * `code` — so the flagged original would otherwise linger and keep the approval + * gate closed even after the customer did exactly what was asked. Only rows of + * the same code are touched, and never the newly uploaded ones. + */ + private async resolveDocumentChangeRequests( + resourceId: string, + resource: string, + codes: string[], + uploadedIds: string[], + ): Promise { + if (codes.length === 0) return; + const replaced = new Set(codes); + const fresh = new Set(uploadedIds); + const open = await this.filesService.findWithOpenChangeRequest( + [resourceId], + resource, + ); + await Promise.all( + open + .filter((f) => replaced.has(f.code) && !fresh.has(f.id)) + .map((f) => this.filesService.clearReview(f.id)), + ); + } + + /** + * Backoffice: ask the customer to correct one specific document, instead of + * rejecting their whole role over it. Mirrors the contract change-request + * flow — a note the customer sees verbatim, plus a block on approval until + * they re-upload. + */ + async requestDocumentChange( + fileId: string, + note: string, + reviewerId?: string, + ): Promise { + const file = await this.filesService.findById(fileId); + const companyId = await this.resolveDocumentCompanyId(file); + const company = await this.findCompanyById(companyId); + + // Flag the document while holding a write lock on its company row. The + // approval gate takes the same lock before it reads the flags, so the two + // serialize: a change request can never land in the window between the gate + // checking "any open corrections?" and writing the profile Active. + const updated = await this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: companyId }, + lock: { mode: "pessimistic_write" }, + }); + return this.filesService.setReviewStatus( + file.id, + "change_requested", + note, + reviewerId, + ); + }); + this.companyNotifier.documentChangeRequested( + company, + file.name, + note, + file.id, + ); + return updated; + } + + /** + * Which company a stored document belongs to. Company documents are keyed by + * the company id directly; profile licences and POA letters hang off a company + * profile, so those resolve through it. + */ + private async resolveDocumentCompanyId(file: FileRecord): Promise { + if (file.resource === "companies") return file.resourceId; + if (file.resource === "company_profiles") { + const profile = await this.companyProfilesRepo.findById(file.resourceId); + if (!profile) { + throw new NotFoundException( + `Company profile ${file.resourceId} not found`, + ); + } + return profile.companyId; + } + throw new BadRequestException( + `Documents on "${file.resource}" do not support change requests`, + ); + } + /** Open or append a pending change request recording staged document uploads. */ private async stageDocumentChange( companyId: string, @@ -847,6 +957,7 @@ export class CompaniesService { const now = new Date(); const existing = await this.changeRequestRepo.findPendingByCompanyId(companyId); + const company = await this.companiesRepo.findById(companyId); if (existing) { const prev = existing.documents?.documentFileIds ?? []; await this.changeRequestRepo.update(existing.id, { @@ -860,8 +971,15 @@ export class CompaniesService { submittedAt: now, note: null, }); + if (company) { + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + } } else { - await this.changeRequestRepo.create({ + const history = await this.changeRequestRepo.findByCompanyId(companyId); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const created = await this.changeRequestRepo.create({ companyId, snapshot: {}, documents: { documentFileIds: fileIds }, @@ -869,6 +987,13 @@ export class CompaniesService { submittedBy: submittedBy ?? null, submittedAt: now, }); + if (company) { + this.companyNotifier.changeRequestSubmitted( + company, + created.id, + resubmitted, + ); + } } } @@ -987,6 +1112,61 @@ export class CompaniesService { } } + // Anything other than approval has no document gate and no concurrency + // hazard — apply it directly. + if (status !== ProfileStatus.Active) { + return this.applyProfileStatus(existing, status, note, reviewerId); + } + + // Approving over an outstanding document correction would silently accept the + // very document a reviewer just rejected, and would strand the customer's + // "please fix this" banner with nothing left to fix. The gate check and the + // status write share a write lock on the company row — `requestDocumentChange` + // takes the same lock, so a fresh correction can never land in the window + // between "any open corrections?" and the profile going Active. Suspend and + // blacklist skip all this — staff must always be able to act against a bad + // account. + return this.dataSource.transaction(async (manager) => { + await manager.findOne(Company, { + where: { id: existing.companyId }, + lock: { mode: "pessimistic_write" }, + }); + + const [companyDocs, profileDocs] = await Promise.all([ + this.filesService.findWithOpenChangeRequest( + [existing.companyId], + "companies", + ), + this.filesService.findWithOpenChangeRequest( + [existing.id], + "company_profiles", + ), + ]); + const pending = [...companyDocs, ...profileDocs]; + if (pending.length > 0) { + const names = pending.map((f) => f.name).join(", "); + throw new BadRequestException( + `This role has ${pending.length} document(s) awaiting customer correction (${names}). ` + + `Approve it once the customer has re-uploaded them, or withdraw the change request first.`, + ); + } + + return this.applyProfileStatus(existing, status, note, reviewerId); + }); + } + + /** + * Write a reviewed profile status (reference minting, note handling, reviewer + * stamp) and promote the company if this is its first approved role. Split out + * of `setCompanyProfileStatus` so the approval path can run it inside the gate + * transaction while every other status skips that overhead. + */ + private async applyProfileStatus( + existing: CompanyProfile, + status: ProfileStatus, + note?: string, + reviewerId?: string, + ): Promise { // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; @@ -1008,9 +1188,9 @@ export class CompaniesService { patch.reviewedAt = new Date(); } - const updated = await this.companyProfilesRepo.update(profileId, patch); + const updated = await this.companyProfilesRepo.update(existing.id, patch); if (!updated) - throw new NotFoundException(`Company profile ${profileId} not found`); + throw new NotFoundException(`Company profile ${existing.id} not found`); // Approving any profile promotes a pending company to active, so the // customer can start working as soon as their first profile is cleared. @@ -1057,6 +1237,13 @@ export class CompaniesService { }); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // The role is back in the pending queue — tell the reviewers, otherwise the + // resubmission is invisible until someone happens to reopen the customer. + const company = await this.companiesRepo.findById(companyId); + if (company) { + this.companyNotifier.roleReapplied(company, updated.id, updated.type); + } return updated; } @@ -1512,6 +1699,15 @@ export class CompaniesService { ); } + // A fresh licence upload answers any correction the reviewer asked for on the + // previous one, so the old row must stop blocking approval. + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + uploaded.map((r) => r.id), + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1593,6 +1789,13 @@ export class CompaniesService { await this.filesService.remove(fileId); } + await this.resolveDocumentChangeRequests( + profileId, + LICENSE_RESOURCE, + [LICENSE_CODE, LICENSE_PENDING_CODE], + [created.id], + ); + return this.getProfileLicenseView(profileId, company.id); } @@ -1689,6 +1892,8 @@ export class CompaniesService { : pendingRemoveIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } @@ -1855,6 +2060,13 @@ export class CompaniesService { for (const r of live) await this.filesService.remove(r.id); } + await this.resolveDocumentChangeRequests( + company.id, + COMPANY_RESOURCE, + [POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE], + [created.id], + ); + return this.getPoaDelegationView(company.id); } @@ -1932,6 +2144,8 @@ export class CompaniesService { : removeIds.has(r.id) ? ("pending_remove" as const) : ("live" as const), + reviewStatus: r.reviewStatus, + reviewNote: r.reviewNote, })); } diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 43d4e9905..f71a67976 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -88,4 +88,106 @@ export class CompanyNotifierService { priority: NotificationPriority.HIGH, }); } + + // ── Backoffice-facing: work has arrived back in the review queue ──────────── + + /** + * Persist + push an in-app item to every backoffice staff user, deep-linked to + * the customer's detail page. + * + * The recipient resolver has no role/permission targeting (see + * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest + * selector available, so marketing is reached by notifying all staff. + */ + private notifyStaff( + company: Company, + title: string, + body: string, + data: Record = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/customers/${company.id}`, + data: { companyId: company.id, companyName: company.name, ...data }, + }); + } + + /** + * A customer resubmitted an operational role after it was rejected for + * adjustment. Without this the role silently flips back to Pending and nobody + * is told there is anything to look at again. + */ + roleReapplied(company: Company, profileId: string, profileType: string): void { + this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`); + this.notifyStaff( + company, + "Customer resubmitted a role for approval", + `${company.name} has adjusted and resubmitted its ${profileType} role. ` + + `It is back in the pending approval queue for review.`, + { profileId, profileType }, + ); + } + + /** + * A customer submitted (or amended and resubmitted) a profile change request. + * `resubmitted` distinguishes the two so the reviewer knows this is a second + * look at something they already sent back. + */ + changeRequestSubmitted( + company: Company, + changeRequestId: string, + resubmitted: boolean, + ): void { + this.logger.log( + `CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`, + ); + this.notifyStaff( + company, + resubmitted + ? "Customer resubmitted profile changes" + : "Customer submitted profile changes", + resubmitted + ? `${company.name} has adjusted the changes you sent back and resubmitted ` + + `them. They are pending your review.` + : `${company.name} has submitted profile changes that are pending review.`, + { changeRequestId }, + ); + } + + // ── Customer-facing: a specific document needs correcting ────────────────── + + /** + * Tell the customer a reviewer wants one specific document corrected. Mirrors + * the contract `changesRequested` flow: SMS + email out, plus an in-app item + * deep-linked to the documents tab where they can re-upload. + */ + documentChangeRequested( + company: Company, + documentName: string, + note: string, + fileId: string, + ): void { + const title = "Document change requested"; + const body = + `A reviewer has asked you to correct "${documentName}". ` + + `Reason: ${note} ` + + `Please upload a corrected version from your settings page.`; + + this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title, + body, + link: "/settings", + data: { companyId: company.id, fileId, documentName }, + priority: NotificationPriority.HIGH, + }); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index c054b3531..e97a21266 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -7,4 +7,10 @@ export class CompanyStatsResponseDto { onboarding!: number; suspended!: number; blacklisted!: number; + /** + * Approved customers with an open profile change request. Counted separately + * because they are `active` and so are invisible to the `pending` KPI, even + * though they are just as much waiting on a reviewer. + */ + pendingChanges!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 8b5083e5f..8d4910ded 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -48,6 +48,17 @@ export class ListCompaniesQueryDto { @IsBoolean() onboardingCompleted?: boolean; + @ApiPropertyOptional({ + description: + "`true` = only companies with an open (pending) profile change request. " + + "These are already-approved customers, so they never appear under " + + "`status=pending` and would otherwise be invisible in the review queue.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + hasPendingChangeRequest?: boolean; + @ApiPropertyOptional({ enum: ["name", "createdAt", "updatedAt"], default: "name", diff --git a/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts new file mode 100644 index 000000000..8119e041d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/request-document-change.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class RequestDocumentChangeDto { + /** What is wrong with this document — shown verbatim to the customer. */ + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + note!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index ebda7a0b9..9bba9396e 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -37,8 +37,20 @@ export interface BusinessLicenseFile { */ export type StagedFileStatus = "live" | "pending_add" | "pending_remove"; +/** + * Reviewer verdict on a document, as surfaced to clients. Distinct from + * {@link StagedFileStatus}: that describes where the file sits in the staged + * add/remove workflow, this describes whether a reviewer wants it corrected. + */ +export interface FileReviewView { + /** `change_requested` while the customer still owes a corrected upload. */ + reviewStatus?: "change_requested" | "approved" | null; + /** The reviewer's reason, shown verbatim to the customer. */ + reviewNote?: string | null; +} + /** A business-license file plus its change-review state, surfaced to clients. */ -export interface ProfileLicenseFileView { +export interface ProfileLicenseFileView extends FileReviewView { id: string; name: string; size: number; @@ -47,7 +59,7 @@ export interface ProfileLicenseFileView { } /** A company-level document (e.g. the PoA letter) with its change-review state. */ -export interface CompanyDocumentFileView { +export interface CompanyDocumentFileView extends FileReviewView { id: string; name: string; size: number; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index e230380f0..df930d805 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -198,11 +198,12 @@ export class ContractBookingService { // GENERAL without customs (Path A) ALSO clears per booking: the customer // uploads his own clearance proof on each booking and Operations reviews it // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → - // requestOperation machine). DOMESTIC has no border, so no gate. + // requestOperation machine). GENERAL intercity (DOMESTIC) follows the same + // per-booking gate with the intercity document set — ops finalize then puts + // the booking straight into the ride-along pool (FULLY_EXECUTED), since + // intercity has no shipment-day request step. const generalSelfClear = - contract.contractKind === 'GENERAL' && - !contract.customsClearingEnabled && - contract.tradeDirection !== 'DOMESTIC'; + contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled; // Intercity (DOMESTIC) bookings ride on a passing import/export train: // there is no window and no date — staff accept them onto a train at @@ -300,48 +301,57 @@ export class ContractBookingService { } as never), ); - // Persist container lines + per-unit container numbers (container freight only). - if (freightType === 'CONTAINER') { - await this.persistContainers(booking.id, contract, dto); - } - - // Reload with containers to compute the total from contract unit rates × qty. - const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); - if (loaded) { + // Everything between the insert and the priced update must be all-or-nothing: + // a throw part-way (container persist, weight rules, pricing) would otherwise + // leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that + // occupies the one-time contract's single active-booking slot until the + // doc-review sweep expires it — and the clearance cycle still points at the + // previous booking, so the hub keeps offering "Rebook" against a dead draft. + try { + // Persist container lines + per-unit container numbers (container freight only). if (freightType === 'CONTAINER') { - await this.applyWeightResults(loaded); + await this.persistContainers(booking.id, contract, dto); } - const computed = await this.bookingPricingService.computePriceForBooking(loaded); - // Reject a zero-price booking outright. A total of 0 means no contract rate - // matched the route/container (or the rate is unset), so the booking is not - // valid to ship or invoice. Roll back the just-inserted row + its lines so it - // does NOT occupy the one-time contract's single active-booking slot — else - // the customer's retry hits "already has an active booking" against a broken - // draft. The customer must fix the contract's rates, then rebook. - if (!(computed.totalAmount > 0)) { - await this.bookingsRepository.deleteContainers(booking.id); - await this.bookingsRepository.hardDelete(booking.id); - throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', - ); - } - await this.bookingsRepository.update(booking.id, { - totalAmount: computed.totalAmount, - priorityScore: computed.priorityScore, - pricingBreakdown: { - lineItems: computed.lineItems, + + // Reload with containers to compute the total from contract unit rates × qty. + const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); + if (loaded) { + if (freightType === 'CONTAINER') { + await this.applyWeightResults(loaded); + } + const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // Reject a zero-price booking outright. A total of 0 means no contract rate + // matched the route/container (or the rate is unset), so the booking is not + // valid to ship or invoice. The catch below rolls back the row + its lines. + if (!(computed.totalAmount > 0)) { + throw new BadRequestException( + 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', + ); + } + await this.bookingsRepository.update(booking.id, { totalAmount: computed.totalAmount, - currency: computed.currency, - generatedAt: new Date().toISOString(), - }, - } as never); - await this.bookingPricingService.createPricingSnapshots( - booking.id, - computed.usedRates, - computed.appliedModifiers, - ); - warnings.push(...computed.warnings); + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + booking.id, + computed.usedRates, + computed.appliedModifiers, + ); + warnings.push(...computed.warnings); + } + } catch (err) { + await this.bookingsRepository + .deleteContainers(booking.id) + .catch(() => undefined); + await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined); + throw err; } // Wagon consolidation gate. A container drawdown whose lines leave a partial @@ -1599,17 +1609,23 @@ export class ContractBookingService { throw new BadRequestException('At least one container line is required.'); } - const allowedSizes = new Set( + // Size strings arrive in mixed formats ("20ft" from the contract scope, + // bare "20" from the rebook seed) — compare numerically so format never + // fails a size that IS in scope. + const allowedSizesFt = new Set( (contract.cargoScope ?? []) - .map((c) => c.containerSize) - .filter((s): s is string => !!s), + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), ); const containerRepo = this.dataSource.getRepository(BookingContainer); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); for (const line of lines) { - if (allowedSizes.size && !allowedSizes.has(line.containerSize)) { + if ( + allowedSizesFt.size && + !allowedSizesFt.has(parseInt(line.containerSize, 10)) + ) { throw new BadRequestException( `Container size ${line.containerSize} is outside the contract scope.`, ); @@ -1746,6 +1762,24 @@ export class ContractBookingService { }), ); + // Same size-scope gate persistContainers enforces at create, surfaced as a + // blocking preview error so the form can't confirm a size the contract does + // not cover. Numeric compare — "20" and "20ft" are the same size. + const allowedSizesFt = new Set( + (contract.cargoScope ?? []) + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), + ); + const scopeErrors = allowedSizesFt.size + ? [ + ...new Set( + lines + .map((l) => l.containerSize) + .filter((s) => !allowedSizesFt.has(parseInt(s, 10))), + ), + ].map((s) => `Container size ${s} is outside the contract scope.`) + : []; + // The unsaved twin of the booking createUnderContract would write: same // denormalized contract fields, same container-line math. No id → the // pricing service derives wagon counts from the in-memory lines. @@ -1858,7 +1892,7 @@ export class ContractBookingService { overweightSurchargeAmount, currency: computed.currency, pairingErrors, - capacityErrors, + capacityErrors: [...scopeErrors, ...capacityErrors], containerClashErrors, spaceErrors, lineItems: computed.lineItems, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index bc1b4ad49..2d26f51dc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -1,4 +1,5 @@ import { Contract } from './entities/contract.entity'; +import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util'; /** * Resolves which seeded clearance FileUploadSetting applies to a contract during @@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight { * own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`, * reviewed by Operations rather than GL. * - * DOMESTIC/intercity has no border, so no clearance gate applies on either path. + * DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still + * collects the admin-configured intercity document set after both signatures + * (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract + * gate and collect the same set per booking instead. */ export function contractClearanceSettingCode( tradeDirection: string, freightType: string, includesCustoms: boolean, ): string | null { + if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index e35bd2bf5..ac4fe2a0f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -143,6 +143,19 @@ export class ContractNotifierService { this.inApp(c, 'Contract rejected', msg); } + /** + * A later approver sent the contract back to an earlier stage of the chain. + * Staff-only: the customer is not involved in an internal send-back — their + * contract simply stays "under approval". + */ + sentBackToStep(c: Contract, targetRole: string, reason: string): void { + this.inAppStaff( + c, + 'Contract returned in approval chain', + `Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`, + ); + } + /** Staff requested changes before approval. */ changesRequested(c: Contract, note: string): void { const msg = diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index ba825dfaa..ba4fe442f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -41,6 +41,7 @@ import { ContractDocumentSnapshotInput, } from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; +import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { SignContractDto } from './dto/sign-contract.dto'; /** The editable contract-document draft returned for the accept/edit dialog. */ @@ -576,17 +577,24 @@ export class ContractTransitionService { /** * Reject one approval step (line staff / director / CEO). The rejecting - * approver must supply a reason. A rejection is terminal: the whole contract - * moves to REJECTED and the customer must create a new one — there is no - * resubmit of the same contract. The reason is recorded both on the step and - * as a REJECTION review note so it is visible to the customer and the rest of - * the approval chain. + * approver must supply a reason, and picks where the rejection lands: + * + * - **To the customer** (`returnToStepId` omitted — the only option for the + * first approver): terminal. The whole contract moves to REJECTED with a + * REJECTION review note visible to the customer, who must resubmit. + * - **To an earlier approver** (`returnToStepId` = an already-APPROVED + * earlier step): internal send-back. That step and everything after it + * reset to PENDING and the chain re-runs from there; the contract stays + * PENDING_APPROVAL and the customer never sees it. E.g. the director can + * return a contract to line staff, who fix it and approve again, after + * which every later stage re-approves in order. */ async rejectStep( contractId: string, stepId: string, actorId: string, reason: string, + returnToStepId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); @@ -594,6 +602,20 @@ export class ContractTransitionService { const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step) throw new BadRequestException('Approval step not found'); + // Only the approver whose turn it is may reject — same ordering rule as + // approveStep. Without this, an already-actioned or future step could be + // "rejected" and wipe chain state it never owned. + const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Only the current pending approval step can be rejected', + ); + } + + if (returnToStepId) { + return this.sendBackToStep(contract, step, actorId, reason, returnToStepId); + } + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); await this.contractsRepository.createReviewNote( @@ -616,6 +638,67 @@ export class ContractTransitionService { return updated; } + /** + * Internal send-back branch of rejectStep: return the contract to an earlier, + * already-approved stage of the chain instead of rejecting it outright. + * Deliberately NOT the terminal path: no clearance-fee expiry (the contract + * is still alive) and no customer-facing REJECTION note — the trail is a + * staff note plus a backoffice inbox ping. + */ + private async sendBackToStep( + contract: Contract, + rejectingStep: ContractApprovalStep, + actorId: string, + reason: string, + returnToStepId: string, + ): Promise { + const target = await this.contractsRepository.findApprovalStepById( + contract.id, + returnToStepId, + ); + if (!target) throw new BadRequestException('Return-to approval step not found'); + if (target.stepOrder >= rejectingStep.stepOrder) { + throw new BadRequestException( + 'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId', + ); + } + if (target.status !== 'APPROVED') { + throw new BadRequestException( + `Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`, + ); + } + + // Staff-visible trail. Written before the reset so the reason survives the + // wipe of per-step notes. + await this.contractsRepository.createReviewNote( + contract.id, + `Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`, + 'STAFF_NOTE', + actorId, + 'STAFF', + ); + + // Chain re-runs from the target stage: it and every later step (including + // the rejecting one) go back to PENDING. Legacy approved-by columns are + // left stale on purpose — approval steps are the source of truth and the + // columns get re-stamped on re-approval. + await this.contractsRepository.resetApprovalStepsFrom( + contract.id, + target.stepOrder, + ); + + // A send-back can only happen mid-chain, so the contract must remain (or + // return to) PENDING_APPROVAL — relevant when rejecting from + // APPROVED_PENDING_SIGNATURE. + await this.contractsRepository.update(contract.id, { + status: 'PENDING_APPROVAL', + } as never); + + const updated = await this.contractsService.findById(contract.id); + this.notifier.sentBackToStep(updated, target.requiredRole, reason); + return updated; + } + /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, @@ -1035,8 +1118,8 @@ export class ContractTransitionService { }; // A clearance gate applies whenever a clearance doc set resolves — Path B - // (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC - // resolves to null on both paths and skips straight to executed. + // (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the + // intercity document set (DOMESTIC, ops-reviewed like Path A). const clearanceCode = contractClearanceSettingCode( contract.tradeDirection, contract.freightType, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 249568046..6ca4473cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -442,7 +442,10 @@ export class ContractsController { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]) - @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + @ApiOperation({ + summary: + 'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)', + }) rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @@ -454,6 +457,7 @@ export class ContractsController { stepId, resolveAuthUserId(user), dto.reason, + dto.returnToStepId, ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 533b51b3b..67a3bd101 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository { // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); await this.attachClearancePhases(items); + await this.attachRejectionNotes(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { @@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository { } } + /** + * Attach the latest REJECTION review-note body to each REJECTED contract so + * list consumers (portal rows, backoffice queues) can show why without a + * per-contract detail fetch. One query per page, like `attachContractFiles`. + */ + private async attachRejectionNotes(contracts: Contract[]): Promise { + const rejected = contracts.filter((c) => c.status === 'REJECTED'); + if (rejected.length === 0) return; + const ids = rejected.map((c) => c.id); + const rows: Array<{ contract_id: string; body: string }> = + await this.dataSource.query( + `SELECT DISTINCT ON (contract_id) contract_id, body + FROM freight.contract_review_notes + WHERE contract_id = ANY($1) + AND note_type = 'REJECTION' + AND deleted_at IS NULL + ORDER BY contract_id, created_at DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r.body])); + for (const contract of rejected) { + contract.latestRejectionNote = byContract.get(contract.id) ?? null; + } + } + async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('contract') @@ -368,6 +394,25 @@ export class ContractsRepository extends BaseRepository { }); } + /** + * Send-back reset: every step at or after `fromStepOrder` returns to PENDING + * with its actor/verdict cleared, so the chain re-runs from that stage. The + * send-back reason lives in the review-note trail, not on the wiped steps. + */ + async resetApprovalStepsFrom( + contractId: string, + fromStepOrder: number, + ): Promise { + await this.dataSource + .getRepository(ContractApprovalStep) + .createQueryBuilder() + .update() + .set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null }) + .where('contract_id = :contractId', { contractId }) + .andWhere('step_order >= :fromStepOrder', { fromStepOrder }) + .execute(); + } + /** Check if all approval steps are approved. */ async allApprovalStepsComplete(contractId: string): Promise { const pending = await this.dataSource.getRepository(ContractApprovalStep).count({ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index c98e24b19..a675adf39 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -642,6 +642,47 @@ export class ContractsService { } } + // Surface the rejection reason. The approval-step note is wiped on + // send-back resets, so the review-note trail is the only durable source. + if (contract.status === 'REJECTED') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'REJECTION', + ); + contract.latestRejectionNote = note?.body ?? null; + } catch { + contract.latestRejectionNote = null; + } + } + + // Surface the send-back reason to the returned-to approver, but only while + // it is still actionable: once any step acts after the send-back the note + // is stale and stays out of the response (the trail keeps it in the DB). + if (contract.status === 'PENDING_APPROVAL') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'STAFF_NOTE', + ); + // Stale when any step acted after it (send-back resolved) or when the + // chain itself is newer than the note (fresh cycle after a resubmit). + const staleAfter = Math.max( + 0, + ...(contract.approvalSteps ?? []).flatMap((s) => [ + s.actedAt ? new Date(s.actedAt).getTime() : 0, + s.createdAt ? new Date(s.createdAt).getTime() : 0, + ]), + ); + contract.latestSendBackNote = + note && new Date(note.createdAt).getTime() > staleAfter + ? note.body + : null; + } catch { + contract.latestSendBackNote = null; + } + } + return contract; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 173857159..9a86a5b4e 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ApproveStepDto { @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) @@ -26,6 +26,22 @@ export class RejectStepDto { @IsString() @MinLength(1) reason!: string; + + /** + * Where the rejection lands. Omitted → the customer: the contract goes to + * REJECTED and the customer must resubmit (unchanged legacy behaviour, and + * the only option for the first approver in the chain). Set to an EARLIER + * approved step's id → send-back: that step and everything after it reset to + * PENDING and the chain re-runs from there; the contract never leaves + * PENDING_APPROVAL and the customer is not involved. + */ + @ApiPropertyOptional({ + description: + 'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.', + }) + @IsOptional() + @IsUUID() + returnToStepId?: string; } export class CancelContractDto { diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b5e0b8fb1..4838d3f52 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -326,4 +326,19 @@ export class Contract extends BaseEntity { * asked them to fix. Lives in contract_review_notes, not a column here. */ latestChangeRequestNote?: string | null; + + /** + * Body of the most recent REJECTION review note, attached by + * ContractsService.findById when status is REJECTED so both backoffice and + * portal can show why. Lives in contract_review_notes, not a column here. + */ + latestRejectionNote?: string | null; + + /** + * Body of the most recent send-back STAFF_NOTE, attached by + * ContractsService.findById while the contract is PENDING_APPROVAL and no + * approval step has acted since the send-back. Lives in + * contract_review_notes, not a column here. + */ + latestSendBackNote?: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 221b7c29b..1fbd1459f 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -1,6 +1,16 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity } from "typeorm"; +/** + * Reviewer verdict on a single stored document. + * + * `null` (the default) means "not reviewed" — the state every file starts in and + * the only state the customer is not blocked by. `change_requested` is raised by + * a backoffice reviewer against one specific document and is what the customer + * must clear by re-uploading; `approved` records an explicit sign-off. + */ +export type FileReviewStatus = "change_requested" | "approved"; + @Entity({ schema: "freight", name: "files" }) export class FileRecord extends BaseEntity { @Column({ name: "resource_id", type: "uuid" }) @@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity { @Column({ name: "mime_type", type: "varchar", length: 255 }) mimeType!: string; + + /** Reviewer verdict, or `null` while the document has never been reviewed. */ + @Column({ + name: "review_status", + type: "varchar", + length: 32, + nullable: true, + default: null, + }) + reviewStatus!: FileReviewStatus | null; + + /** Why a change was requested — shown verbatim to the customer. */ + @Column({ name: "review_note", type: "text", nullable: true }) + reviewNote!: string | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy!: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt!: Date | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index a9e8bf761..583e221ed 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -48,4 +48,24 @@ export class FilesRepository extends BaseRepository { ): Promise { await this.repository.delete({ resourceId, resource, code }); } + + /** + * Documents belonging to any of the given resources that a reviewer has asked + * the customer to correct. Used by the approval gate, so it takes a list of + * resource ids (a company plus each of its company profiles) in one query. + */ + async findWithOpenChangeRequest( + resourceIds: string[], + resource: string, + ): Promise { + if (resourceIds.length === 0) return []; + return this.repository.find({ + where: { + resourceId: In(resourceIds), + resource, + reviewStatus: "change_requested", + }, + order: { createdAt: "ASC" }, + }); + } } 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 c799d896d..e959fa556 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -8,7 +8,7 @@ import { Readable } from "stream"; import { MinioService } from "../minio/minio.service"; import { FilesRepository } from "./files.repository"; -import { FileRecord } from "./entities/file.entity"; +import { FileRecord, FileReviewStatus } from "./entities/file.entity"; export interface CreateFileInput { resourceId: string; @@ -169,6 +169,53 @@ export class FilesService { 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 + * reason can never outlive the request it explained. + */ + async setReviewStatus( + id: string, + status: FileReviewStatus, + note: string | null, + reviewerId?: string, + ): Promise { + const record = await this.findById(id); + const updated = await this.filesRepository.update(record.id, { + reviewStatus: status, + reviewNote: status === "change_requested" ? (note ?? null) : null, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + }); + if (!updated) throw new NotFoundException(`File ${id} not found`); + return updated; + } + + /** + * Drop any reviewer verdict from a document, returning it to "not reviewed". + * Called when a customer re-uploads: the new bytes have not been looked at, so + * carrying the old `change_requested` forward would keep them blocked forever. + */ + async clearReview(id: string): Promise { + await this.filesRepository.update(id, { + reviewStatus: null, + reviewNote: null, + reviewedBy: null, + reviewedAt: null, + }); + } + + /** Documents across these resources still awaiting a customer correction. */ + findWithOpenChangeRequest( + resourceIds: string[], + resource: string, + ): Promise { + return this.filesRepository.findWithOpenChangeRequest( + resourceIds, + resource, + ); + } + /** Soft-delete a stored file row by id (object bytes are left in MinIO). */ async remove(id: string): Promise { await this.filesRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index c634d5efb..a42dcd220 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator'; import { LOCOMOTIVE_STATUSES, @@ -21,4 +22,29 @@ export class FilterLocomotivesDto { @IsOptional() @IsUUID() currentYardId?: string; + + /** + * Drop locomotives already coupled to a built train — the train-builder + * "change locomotives" picker uses this so a loco that belongs to another + * train is never offered (the backend would 409 on save anyway). Combine with + * `excludeTrainId` to keep the CURRENT train's own locos in the list. + */ + @ApiPropertyOptional({ + description: 'Exclude locomotives already coupled to any built train', + }) + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + excludeCoupled?: boolean; + + /** + * When `excludeCoupled` is set, locos coupled to THIS train are still kept + * (they are valid picks — you are editing that train's consist). + */ + @ApiPropertyOptional({ + description: 'Train id whose own coupled locomotives are NOT excluded', + }) + @IsOptional() + @IsUUID() + excludeTrainId?: string; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index 18a42205e..3ad5b3650 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Locomotive } from './entities/locomotive.entity'; +import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity'; +import { TrainLocomotive } from '../trains/entities/train-locomotive.entity'; @Injectable() export class LocomotivesRepository extends BaseRepository { @@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository { super(repository); } + /** + * List locomotives for the train-builder coupling picker: the usual + * status/type/yard filters, plus optional exclusion of any loco already + * coupled to a built train. `keepTrainId` spares that one train's own locos + * from the exclusion so they stay selectable while editing its consist. + */ + findForCoupling(opts: { + status?: LocomotiveStatus; + locomotiveType?: LocomotiveType; + currentYardId?: string; + excludeCoupled?: boolean; + keepTrainId?: string; + }): Promise { + const qb = this.repository + .createQueryBuilder('locomotive') + .leftJoinAndSelect('locomotive.currentYard', 'currentYard') + .orderBy('locomotive.code', 'ASC'); + + if (opts.status) qb.andWhere('locomotive.status = :status', { status: opts.status }); + if (opts.locomotiveType) + qb.andWhere('locomotive.locomotiveType = :type', { type: opts.locomotiveType }); + if (opts.currentYardId) + qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId }); + + if (opts.excludeCoupled) { + // NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the + // consist being edited still lists its current locomotives. + const sub = this.repository.manager + .getRepository(TrainLocomotive) + .createQueryBuilder('tl') + .select('1') + .where('tl.locomotiveId = locomotive.id'); + if (opts.keepTrainId) { + sub.andWhere('tl.trainId != :keepTrainId', { keepTrainId: opts.keepTrainId }); + } + qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters()); + } + + return qb.getMany(); + } + /** * A live locomotive already holding this name, compared the same way the * `UQ_locomotives_name_active` index compares: case- and whitespace- diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index cbf9dfc0c..46e0ae415 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -29,6 +29,17 @@ export class LocomotivesService { } findAll(filter: FilterLocomotivesDto): Promise { + // The coupling picker needs a NOT-EXISTS against the train link table, so it + // takes the query-builder path; the plain list keeps the simple where. + if (filter.excludeCoupled) { + return this.locomotivesRepository.findForCoupling({ + status: filter.status as LocomotiveStatus | undefined, + locomotiveType: filter.locomotiveType as LocomotiveType | undefined, + currentYardId: filter.currentYardId, + excludeCoupled: true, + keepTrainId: filter.excludeTrainId, + }); + } return this.locomotivesRepository.findAll({ where: { ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}), diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts index 3f4d2e4ff..3de24835a 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -4,25 +4,22 @@ import { ArrayMinSize, IsArray, IsEnum, - IsNumber, IsOptional, IsUUID, - Min, ValidateNested, } from 'class-validator'; import { RouteStatus } from '../entities/route.entity'; +/** + * Segment distances are no longer part of the payload — they are resolved + * from the configured yard_distances table (Configuration → Yard Distances) + * and snapshotted onto route_milestones at create/update. + */ export class CreateRouteMilestoneDto { @ApiProperty({ format: 'uuid' }) @IsUUID() yardId!: string; - - @ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' }) - @IsOptional() - @IsNumber() - @Min(0) - distanceKm?: number; } export class CreateRouteDto { diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 4b1bfd08a..96e6c7fd1 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -9,6 +9,7 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { YardDistance } from '../rule-engine/entities/yard-distance.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; @@ -17,6 +18,9 @@ import { RouteMilestone } from './entities/route-milestone.entity'; import { formatRouteLabel, Route } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; +/** Order-insensitive key: distances are symmetric. */ +const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`); + @Injectable() export class RoutesService { constructor( @@ -183,47 +187,63 @@ export class RoutesService { return this.findById(id); } - private async validateMilestones( - milestones: Array<{ yardId: string; distanceKm?: number }>, - ) { + private async validateMilestones(milestones: Array<{ yardId: string }>) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); } - const normalized = milestones.map((milestone, index) => { - const distanceKm = - index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null; - if (index > 0 && (distanceKm == null || distanceKm < 0)) { - throw new BadRequestException( - `Enter segment KM for stop ${index + 1} (from previous yard).`, - ); - } - return { - yardId: milestone.yardId, - sequenceNo: index + 1, - distanceKm, - }; - }); - - const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; + const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))]; const yards = await this.dataSource .getRepository(Yard) .find({ where: uniqueYardIds.map((id) => ({ id })) }); const yardIds = new Set(yards.map((yard) => yard.id)); - for (const milestone of normalized) { + for (const milestone of milestones) { if (!yardIds.has(milestone.yardId)) { throw new BadRequestException(`Yard ${milestone.yardId} does not exist`); } } - if (normalized[0].yardId === normalized[normalized.length - 1].yardId) { + if (milestones[0].yardId === milestones[milestones.length - 1].yardId) { throw new BadRequestException('Origin and destination yards must be different'); } - const originYardId = normalized[0].yardId; - const destinationYardId = normalized[normalized.length - 1].yardId; const yardById = new Map(yards.map((yard) => [yard.id, yard])); + const distanceByPair = await this.loadDistanceLookup(uniqueYardIds); + + // Segment km come from the configured yard-distance table, not the payload + // — a route can only be built over pairs an admin has entered. Distances + // are symmetric, so an A→B row also serves B→A. + const missingPairs: string[] = []; + const normalized = milestones.map((milestone, index) => { + if (index === 0) { + return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 }; + } + const previousYardId = milestones[index - 1].yardId; + const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId)); + if (distanceKm == null) { + const from = yardById.get(previousYardId); + const to = yardById.get(milestone.yardId); + missingPairs.push( + `${from?.label ?? previousYardId} ↔ ${to?.label ?? milestone.yardId}`, + ); + } + return { + yardId: milestone.yardId, + sequenceNo: index + 1, + distanceKm: distanceKm ?? null, + }; + }); + + if (missingPairs.length > 0) { + throw new BadRequestException( + `No distance configured for: ${missingPairs.join(', ')}. ` + + 'Add the missing yard distances in Configuration → Yard Distances first.', + ); + } + + const originYardId = milestones[0].yardId; + const destinationYardId = milestones[milestones.length - 1].yardId; const direction = deriveTradeDirection( yardById.get(originYardId) ?? { country: null }, yardById.get(destinationYardId) ?? { country: null }, @@ -236,4 +256,17 @@ export class RoutesService { milestones: normalized, }; } + + /** Order-insensitive pair → km map over every configured distance touching the yards. */ + private async loadDistanceLookup(yardIds: string[]): Promise> { + const rows = await this.dataSource + .getRepository(YardDistance) + .find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] }); + + const lookup = new Map(); + for (const row of rows) { + lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm)); + } + return lookup; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts new file mode 100644 index 000000000..c43e7e4e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts @@ -0,0 +1,62 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; +import { YardDistancesService } from '../services/yard-distances.service'; + +@ApiTags('yard-distances') +@Controller('yard-distances') +@ApiBearerAuth() +export class YardDistancesController { + constructor(private readonly service: YardDistancesService) {} + + @Get() + @RuleEngineView('yard-distances') + @ApiOperation({ summary: 'List yard distances' }) + findAll(@Query() query: ListYardDistancesQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + @RuleEngineView('yard-distances') + @ApiOperation({ summary: 'Get a yard distance by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('yard-distances') + @ApiOperation({ summary: 'Create a yard distance' }) + create(@Body() dto: CreateYardDistanceDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('yard-distances') + @ApiOperation({ summary: 'Update a yard distance' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('yard-distances') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a yard distance' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts new file mode 100644 index 000000000..0615debdc --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, IsUUID, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +export class CreateYardDistanceDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + fromYardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + toYardId!: string; + + @ApiProperty({ description: 'Rail distance between the two yards in kilometres', example: 445 }) + @Transform(toNumber) + @IsNumber() + @Min(0.01) + distanceKm!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index 5718b0531..30241ddaa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -88,6 +88,18 @@ export class ListYardsQueryDto extends ListRuleEngineQueryDto { sortBy?: string; } +export class ListYardDistancesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Return only distances touching this yard.' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ enum: ['createdAt', 'distanceKm'], default: 'createdAt' }) + @IsOptional() + @IsIn(['createdAt', 'distanceKm']) + sortBy?: string; +} + export class ListApprovalRulesQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts new file mode 100644 index 000000000..8c40876ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateYardDistanceDto } from './create-yard-distance.dto'; + +export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts new file mode 100644 index 000000000..982079f47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * Configured rail distance between two yards. Route creation reads segment + * kilometres from here (symmetric: A→B serves B→A too) instead of taking + * them as free-text input — see RoutesService.validateMilestones. + * + * Uniqueness on (from_yard_id, to_yard_id) is a partial index in the DB + * (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a + * soft-deleted pair can be re-created. + */ +@Entity({ schema: 'freight', name: 'yard_distances' }) +@Index(['fromYardId']) +@Index(['toYardId']) +export class YardDistance extends BaseEntity { + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'from_yard_id' }) + fromYard?: Yard; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'to_yard_id' }) + toYard?: Yard; + + @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 }) + distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts new file mode 100644 index 000000000..ca88ae048 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse } from '@edr/types'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; + +export interface IYardDistancesRepository { + findById(id: string): Promise; + /** Exact or reverse pair — distances are symmetric (A→B serves B→A). */ + findBetween(fromYardId: string, toYardId: string): Promise; + /** All rows touching any of the given yards, for batch segment lookups. */ + findTouchingYards(yardIds: string[]): Promise; + findPaged(query: ListYardDistancesQueryDto): Promise>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts new file mode 100644 index 000000000..379c92c7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts @@ -0,0 +1,87 @@ +import { PaginatedResponse } from '@edr/types'; +import { Injectable } from '@nestjs/common'; +import { Brackets, DataSource, In, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; +import { IYardDistancesRepository } from '../interfaces/yard-distances.repository.interface'; + +@Injectable() +export class YardDistancesRepository implements IYardDistancesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(YardDistance); + } + + findById(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: { fromYard: true, toYard: true }, + }); + } + + findBetween(fromYardId: string, toYardId: string): Promise { + return this.repo.findOne({ + where: [ + { fromYardId, toYardId }, + { fromYardId: toYardId, toYardId: fromYardId }, + ], + }); + } + + findTouchingYards(yardIds: string[]): Promise { + if (!yardIds.length) return Promise.resolve([]); + return this.repo.find({ + where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }], + }); + } + + /** Paged list with server-side search on either yard's label/code. */ + findPaged(query: ListYardDistancesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('yardDistance') + .leftJoinAndSelect('yardDistance.fromYard', 'fromYard') + .leftJoinAndSelect('yardDistance.toYard', 'toYard') + .orderBy(`yardDistance.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'ASC') + .addOrderBy('fromYard.label', 'ASC'); + + if (query.yardId) { + qb.andWhere( + new Brackets((w) => + w + .where('yardDistance.fromYardId = :yardId', { yardId: query.yardId }) + .orWhere('yardDistance.toYardId = :yardId', { yardId: query.yardId }), + ), + ); + } + if (query.search) { + qb.andWhere( + new Brackets((w) => + w + .where('fromYard.label ILIKE :search', { search: `%${query.search}%` }) + .orWhere('fromYard.code ILIKE :search', { search: `%${query.search}%` }) + .orWhere('toYard.label ILIKE :search', { search: `%${query.search}%` }) + .orWhere('toYard.code ILIKE :search', { search: `%${query.search}%` }), + ), + ); + } + + return paginateQuery(qb, query); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + const saved = await this.repo.save(entity); + return (await this.findById(saved.id)) ?? saved; + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 95b5e381d..691992c54 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -11,6 +11,7 @@ import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; +import { YardDistancesController } from './controllers/yard-distances.controller'; import { YardsController } from './controllers/yards.controller'; import { ApprovalRule } from './entities/approval-rule.entity'; @@ -24,6 +25,7 @@ import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; +import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; @@ -34,6 +36,7 @@ import { RATES_REPOSITORY } from './interfaces/rates.repository.interface'; import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface'; import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; +import { YARD_DISTANCES_REPOSITORY } from './interfaces/yard-distances.repository.interface'; import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface'; import { ApprovalRulesRepository } from './repositories/approval-rules.repository'; @@ -44,6 +47,7 @@ import { RatesRepository } from './repositories/rates.repository'; import { ServiceTypesRepository } from './repositories/service-types.repository'; import { ShippingLinesRepository } from './repositories/shipping-lines.repository'; import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; +import { YardDistancesRepository } from './repositories/yard-distances.repository'; import { YardsRepository } from './repositories/yards.repository'; import { ApprovalRulesService } from './services/approval-rules.service'; @@ -58,6 +62,7 @@ import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; +import { YardDistancesService } from './services/yard-distances.service'; import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; @@ -80,6 +85,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceType, WeightLimitRule, Yard, + YardDistance, YardFacility, ShippingLine, Rate, @@ -100,6 +106,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesController, WeightLimitRulesController, YardsController, + YardDistancesController, ShippingLinesController, RatesController, ApprovalRulesController, @@ -117,6 +124,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. { provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository }, YardsRepository, { provide: YARDS_REPOSITORY, useExisting: YardsRepository }, + YardDistancesRepository, + { provide: YARD_DISTANCES_REPOSITORY, useExisting: YardDistancesRepository }, ShippingLinesRepository, { provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository }, RatesRepository, @@ -131,6 +140,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesService, WeightLimitRulesService, YardsService, + YardDistancesService, YardFacilitiesService, ShippingLinesService, RatesService, @@ -146,6 +156,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. WeightLimitRulesService, PriorityConfigsService, YardsService, + YardDistancesService, YardFacilitiesService, ShippingLinesService, RatesService, @@ -155,6 +166,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. SERVICE_TYPES_REPOSITORY, SHIPPING_LINES_REPOSITORY, YARDS_REPOSITORY, + YARD_DISTANCES_REPOSITORY, ], }) export class RuleEngineModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts new file mode 100644 index 000000000..a41e593e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts @@ -0,0 +1,119 @@ +import { PaginatedResponse } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; +import { + IYardDistancesRepository, + YARD_DISTANCES_REPOSITORY, +} from '../interfaces/yard-distances.repository.interface'; +import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; + +/** + * Flat row shape for the backoffice config table: the yard relations stay for + * API consumers, plus label fields the generic rule-engine grid can render. + */ +export type YardDistanceRow = YardDistance & { + fromYardLabel: string; + toYardLabel: string; +}; + +const yardDisplay = (yard?: { label?: string; code?: string } | null): string => + yard?.label ?? yard?.code ?? '—'; + +const toRow = (entity: YardDistance): YardDistanceRow => + Object.assign(entity, { + fromYardLabel: yardDisplay(entity.fromYard), + toYardLabel: yardDisplay(entity.toYard), + }); + +@Injectable() +export class YardDistancesService { + constructor( + @Inject(YARD_DISTANCES_REPOSITORY) + private readonly repository: IYardDistancesRepository, + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, + ) {} + + async findAll(query: ListYardDistancesQueryDto): Promise> { + const page = await this.repository.findPaged(query); + return { ...page, items: page.items.map(toRow) }; + } + + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Yard distance ${id} not found`); + return toRow(entity); + } + + async create(dto: CreateYardDistanceDto): Promise { + await this.assertValidPair(dto.fromYardId, dto.toYardId); + + const created = await this.repository.create({ + fromYardId: dto.fromYardId, + toYardId: dto.toYardId, + distanceKm: dto.distanceKm.toFixed(2), + }); + return toRow(created); + } + + async update(id: string, dto: UpdateYardDistanceDto): Promise { + const existing = await this.findById(id); + + const fromYardId = dto.fromYardId ?? existing.fromYardId; + const toYardId = dto.toYardId ?? existing.toYardId; + if (fromYardId !== existing.fromYardId || toYardId !== existing.toYardId) { + await this.assertValidPair(fromYardId, toYardId, id); + } + + const updated = await this.repository.update(id, { + fromYardId, + toYardId, + ...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}), + }); + if (!updated) throw new NotFoundException(`Yard distance ${id} not found`); + return toRow(updated); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + /** + * Both yards must exist and differ, and the pair must not already be + * configured in either direction — distances are symmetric, so an A→B row + * already covers B→A. + */ + private async assertValidPair( + fromYardId: string, + toYardId: string, + ignoreId?: string, + ): Promise { + if (fromYardId === toYardId) { + throw new BadRequestException('From and to yards must be different'); + } + + const [fromYard, toYard] = await Promise.all([ + this.yardsRepository.findById(fromYardId), + this.yardsRepository.findById(toYardId), + ]); + if (!fromYard) throw new BadRequestException(`Yard ${fromYardId} does not exist`); + if (!toYard) throw new BadRequestException(`Yard ${toYardId} does not exist`); + + const existing = await this.repository.findBetween(fromYardId, toYardId); + if (existing && existing.id !== ignoreId) { + throw new ConflictException( + `A distance between ${fromYard.label} and ${toYard.label} is already configured`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index fb140158c..db18d6804 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -68,6 +69,9 @@ export class BookingJourneyService { } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); + // Export cargo must be in the warehouse with a GRN before it can be loaded, + // however it arrived and whatever it is allocated to. + await assertExportReceivedWithGrn(this.dataSource, booking); const now = new Date(); await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 2f7ca0776..93d8bba56 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2632,6 +2632,11 @@ export class TrainSchedulingService { // dispatch pre-check keeps reporting these bookings as unloaded). const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); if (wagonAssignedIds.size) { + // Export cargo must be received at the warehouse with a GRN before it can + // be confirmed loaded — an allocation is not proof the goods are in hand. + if (this.isExportSchedule(schedule)) { + await this.assertExportBookingsReceived([...wagonAssignedIds]); + } await this.trainScheduleBookingsRepository.updateLoadingStatusMany( scheduleId, [...wagonAssignedIds], @@ -2909,6 +2914,38 @@ export class TrainSchedulingService { return direction === 'EXPORT'; } + /** + * Every export booking being confirmed loaded must already be received at the + * warehouse with a GRN. An allocation puts a booking on a wagon on paper; this + * is the check that the cargo is physically in the yard before we call it loaded. + */ + private async assertExportBookingsReceived(bookingIds: string[]): Promise { + if (!bookingIds.length) return; + const rows: Array<{ reference: string | null }> = await this.dataSource.query( + `SELECT b.reference + FROM freight.bookings b + WHERE b.id = ANY($1) + AND b.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = b.id + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + )`, + [bookingIds], + ); + if (rows.length) { + const refs = rows.map((r) => r.reference ?? '(unknown)').join(', '); + throw new BadRequestException( + `These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`, + ); + } + } + private buildImportLoadListHtml(loadList: Awaited>): string { const esc = (value: unknown) => String(value ?? '-') diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 3658f4f37..662b83534 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -29,6 +29,9 @@ import { const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; +/** Locomotive statuses that block a train from reactivating. */ +const UNFIT_FOR_REACTIVATION = new Set(['MAINTENANCE', 'OUT_OF_SERVICE', 'UNAVAILABLE']); + /** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */ export interface ActiveScheduleRef { id: string; @@ -596,11 +599,30 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */ + /** + * Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled + * again. Blocked if any coupled locomotive is unfit for service — a + * deactivated train can sit parked for a while and its locomotives may have + * since been sent to maintenance independently; reactivating must not wave + * a down locomotive back onto the schedule board. + */ async activate(id: string) { const train = await this.dataSource.getRepository(Train).findOne({ where: { id } }); if (!train) throw new NotFoundException(`Train ${id} not found`); if (train.status === Freight.TrainStatus.Deactivated) { + const links = await this.dataSource + .getRepository(TrainLocomotive) + .find({ where: { trainId: id }, relations: { locomotive: true } }); + const unfit = links + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)) + .filter((loco) => UNFIT_FOR_REACTIVATION.has(loco.status)); + if (unfit.length) { + const names = unfit.map((l) => `${l.code} (${l.status})`).join(', '); + throw new ConflictException( + `Train cannot be reactivated: ${names} ${unfit.length > 1 ? 'are' : 'is'} not fit for service. Detach and replace before reactivating.`, + ); + } await this.dataSource .getRepository(Train) .update(id, { status: Freight.TrainStatus.Available }); diff --git a/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts b/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts new file mode 100644 index 000000000..38268af11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts @@ -0,0 +1,55 @@ +import { BadRequestException } from '@nestjs/common'; + +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * Export cargo is received into the warehouse to wait for its train, and only a + * paid booking may be received — otherwise storage and a GRN would start against + * cargo the customer has not settled. Import is never blocked: it arrives OFF a + * train and its receive is the unload. + * + * The guard touches only the DataSource, so the instance is built off the + * prototype rather than stubbing all 20-odd collaborators. + */ +type Guard = ( + bookingId: string | null | undefined, + direction: string | null, +) => Promise; + +function makeGuard(paymentStatus: string | null) { + const query = jest.fn().mockResolvedValue([{ paymentStatus }]); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + const guard = ( + service as unknown as { assertExportBookingPaid: Guard } + ).assertExportBookingPaid.bind(service); + return { guard, query }; +} + +describe('receive() — export paid gate', () => { + it('rejects an unpaid export booking', async () => { + const { guard } = makeGuard('PENDING'); + + await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows a paid export booking', async () => { + const { guard } = makeGuard('PAID'); + + await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined(); + }); + + it('never blocks import, paid or not', async () => { + const { guard, query } = makeGuard('PENDING'); + + await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + }); + + it('ignores a receive with no booking attached', async () => { + const { guard, query } = makeGuard('PENDING'); + + await expect(guard(null, 'EXPORT')).resolves.toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/unload-booking-grn.spec.ts b/apps/edr-freight-api/src/modules/warehouses/unload-booking-grn.spec.ts new file mode 100644 index 000000000..0835ac862 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/unload-booking-grn.spec.ts @@ -0,0 +1,86 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import type { UnloadBookingDto } from './dto/unload-booking.dto'; + +/** + * A GRN is the receipt for cargo entering the warehouse, so unloadBooking must + * issue one for every direction — import as well as export. It used to mint only + * for export, leaving import cargo received with no GRN. + */ +function makeService(opts: { + tradeDirection: string | null; + existing?: { id: string; grnNumber: string | null }; +}) { + const created: Record[] = []; + const updated: Array<{ id: string; patch: Record }> = []; + + const inventoryRepository = { + findAll: jest.fn().mockResolvedValue(opts.existing ? [opts.existing] : []), + update: jest.fn((id: string, patch: Record) => { + updated.push({ id, patch }); + return Promise.resolve(); + }), + create: jest.fn((row: Record) => { + created.push(row); + return Promise.resolve({ id: 'new-inv', ...row }); + }), + }; + + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.inventoryRepository = inventoryRepository; + service.dataSource = { + query: jest.fn().mockResolvedValue([{ tradeDirection: opts.tradeDirection }]), + }; + // Location comes straight from the dto in these cases, so pickDefaultLocation + // is never reached; findById just echoes what was written. + service.findById = jest.fn((id: string) => + Promise.resolve(updated.find((u) => u.id === id)?.patch ?? created[0] ?? { id }), + ); + + const dto: UnloadBookingDto = { + warehouseId: 'w1', + yardId: 'y1', + zoneId: 'z1', + } as UnloadBookingDto; + + return { service: service as unknown as WarehouseInventoryService, dto, created, updated }; +} + +describe('unloadBooking — GRN issuance', () => { + it('issues an IMPORT GRN when unloading a fresh import booking', async () => { + const { service, dto, created } = makeService({ tradeDirection: 'IMPORT' }); + + await service.unloadBooking('b-import', dto); + + expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/); + }); + + it('still issues an EXPORT GRN', async () => { + const { service, dto, created } = makeService({ tradeDirection: 'EXPORT' }); + + await service.unloadBooking('b-export', dto); + + expect(created[0].grnNumber).toMatch(/^GRN-EXPORT-/); + }); + + it('mints a GRN for an existing import row that has none', async () => { + const { service, dto, updated } = makeService({ + tradeDirection: 'IMPORT', + existing: { id: 'inv-1', grnNumber: null }, + }); + + await service.unloadBooking('b-import', dto); + + expect(updated[0].patch.grnNumber).toMatch(/^GRN-IMPORT-/); + }); + + it('does not reissue when the row already has a GRN', async () => { + const { service, dto, updated } = makeService({ + tradeDirection: 'IMPORT', + existing: { id: 'inv-1', grnNumber: 'GRN-IMPORT-EXISTING' }, + }); + + await service.unloadBooking('b-import', dto); + + expect(updated[0].patch).not.toHaveProperty('grnNumber'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 5fe25416f..96122d9bd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -417,12 +417,16 @@ export class WarehouseInventoryService { * * Covers both haulage paths because the gate does: a customer's own truck and * an EDR last-mile truck arrive at the same barrier and need the same paper. - * "On site" means arrived and not yet departed. + * Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see + * what is coming as well as what is here — an assigned truck only stamps + * `arrived_at` when it reaches the warehouse. A truck drops off the list once + * it departs. */ async trucksOnSite(): Promise< Array<{ source: 'CUSTOMER' | 'EDR'; assignmentId: string; + status: 'INBOUND' | 'ON_SITE'; plateNumber: string | null; driverName: string | null; truckType: string | null; @@ -436,6 +440,7 @@ export class WarehouseInventoryService { return this.dataSource.query( `SELECT 'CUSTOMER' AS "source", a.id AS "assignmentId", + CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status", a.plate_number AS "plateNumber", a.driver_name AS "driverName", a.truck_type AS "truckType", @@ -450,13 +455,13 @@ export class WarehouseInventoryService { JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id WHERE a.deleted_at IS NULL - AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL UNION ALL SELECT 'EDR' AS "source", va.id AS "assignmentId", + CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status", COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber", NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName", v.vehicle_type AS "truckType", @@ -474,10 +479,11 @@ export class WarehouseInventoryService { LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id LEFT JOIN freight.companies company ON company.id = b.company_id WHERE va.deleted_at IS NULL - AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL - ORDER BY "arrivedAt" ASC`, + -- On-site trucks first, each group oldest-arrival first; inbound trucks + -- (null arrival) sort to the end. + ORDER BY "arrivedAt" ASC NULLS LAST`, ); } @@ -1138,14 +1144,15 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); - // EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto - // a train without one. Import GRN handling is left untouched. + // A GRN is the receipt for cargo entering the warehouse, so every booking + // gets one on unload — import as well as export. The direction only decides + // the GRN prefix, not whether one is issued. const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( `SELECT trade_direction AS "tradeDirection" FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); - const isExport = bookingRow?.tradeDirection === 'EXPORT'; + const grnDirection = bookingRow?.tradeDirection ?? 'WH'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1166,10 +1173,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, - // Export only, and keep an already-issued GRN rather than reissuing. - ...(isExport && !existing[0].grnNumber - ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } - : {}), + // Keep an already-issued GRN rather than reissuing; mint one otherwise. + ...(existing[0].grnNumber + ? {} + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1184,9 +1191,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - ...(isExport - ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } - : {}), + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -2551,6 +2556,7 @@ export class WarehouseInventoryService { async receive(dto: ReceiveWarehouseInventoryDto): Promise { const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; + await this.assertExportBookingPaid(dto.bookingId, bookingDirection); const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); @@ -5649,6 +5655,31 @@ export class WarehouseInventoryService { ); } + /** + * Export cargo is received into the warehouse to wait for its train, and it is + * received only once the booking is paid — receiving an unpaid export booking + * would start storage and mint a GRN against cargo the customer has not settled. + * + * Export only: import cargo arrives OFF a train and its receive is the unload, + * so gating that on payment would strand cargo already at the yard. + */ + private async assertExportBookingPaid( + bookingId: string | null | undefined, + direction: string | null, + ): Promise { + if (!bookingId || direction !== 'EXPORT') return; + const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query( + `SELECT payment_status AS "paymentStatus" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') { + throw new BadRequestException( + 'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.', + ); + } + } + private assertCapacity( label: string, node: LocationNode, diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index d65b2e2d8..03908b62b 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -568,6 +568,20 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ }, ]; +// ── Intercity documents ───────────────────────────────────────────────────── +// One shared set for DOMESTIC (intercity) shipments, reviewed by Operations. +// ONE_TIME contracts collect it at contract level after both signatures; +// GENERAL contracts collect it per booking right after the booking is created. +// Fields start empty and are configured in the backoffice file-settings editor. +const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "intercity_documents", + label: "Intercity documents", + entity: "booking", + fields: [], + }, +]; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -619,6 +633,11 @@ export class FileUploadSettingsSeeder { description: "Documents uploaded against a driver profile (license, ID, contracts, etc.).", })), + ...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.", + })), ]; // Insert setting rows only — no FileUploadField rows. Fields start empty diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d3b5bbb00..396ac95db 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -18,6 +18,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'priority-configs', 'rates', 'approval-rules', + 'yard-distances', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record { const originalRequest = error.config as RetriableRequest | undefined; - // Report the failure to PostHog. Hooked here rather than inside - // `emitApiError`, which stays silent on suppressed paths (warehouse / - // mile / onboarding) — those failures still need reporting. + // Report the failure to PostHog, including on suppressErrorModal paths — + // those opt out of the user-facing toast, not of reporting. // 401s are skipped: an expired session is refreshed below, not a defect. if (!error.response || error.response.status !== 401) { captureApiError(error); @@ -108,16 +110,25 @@ api.interceptors.response.use( originalRequest.url?.includes("/auth/mfa-verify") || originalRequest.url?.includes("/auth/refresh-token") ) { - // Surface the server's actual error message in the global error modal - // (401s are handled by the session-refresh flow, so skip them). A request - // may opt out via `suppressErrorModal` when it handles the failure itself. - if ( - error.response && - error.response.status !== 401 && - !originalRequest?.suppressErrorModal - ) { - // const payload = extractApiErrorPayload(error); - // if (payload) emitApiError(payload); + // Surface the server's actual error message in a global toast — never + // the error modal (401s are handled by the session-refresh flow, so skip + // them). A request may opt out via `suppressErrorModal` when it handles + // the failure itself. + if (error.response && error.response.status !== 401) { + const payload = extractApiErrorPayload(error); + // Normalize the error's own `message` to the SERVER's actual message so + // every downstream `toast.error(err.message)` handler shows the real + // cause instead of "Request failed with status code NNN". Applies even + // on suppressErrorModal paths — only the toast is opted out. + if (payload?.messages.length) { + const message = payload.messages.join("\n"); + (error as { message?: string }).message = message; + // Keyed by message so a retried request replaces its toast instead + // of stacking duplicates. + if (!originalRequest?.suppressErrorModal) { + toast.error(message, { id: message }); + } + } } return Promise.reject(error); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 97573410f..dcdfe6e39 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react"; +import { Check, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -8,6 +8,7 @@ import { Button, Box, Modal, + Select, Textarea, } from "@mantine/core"; import type { Freight } from "@edr/types"; @@ -35,6 +36,10 @@ export function ContractApprovalStepsCard({ const [rejectStepRow, setRejectStepRow] = useState(null); const [rejectReason, setRejectReason] = useState(""); + // Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an + // earlier APPROVED step to send the chain back to. First approver has no + // choice — customer only. + const [rejectTarget, setRejectTarget] = useState("CUSTOMER"); const steps = useMemo( () => @@ -46,6 +51,11 @@ export function ContractApprovalStepsCard({ const nextPending = steps.find((s) => s.status === "PENDING"); const summary = formatContractApprovalProgress(contract.status, steps); + // The card also renders read-only trails (e.g. a REJECTED contract) — only + // offer approve/reject while the backend accepts step actions. + const actionable = + contract.status === "PENDING_APPROVAL" || + contract.status === "APPROVED_PENDING_SIGNATURE"; // Approvers review a live preview of the document; there is no PDF to // generate first — the final approval is what produces it. @@ -70,6 +80,7 @@ export function ContractApprovalStepsCard({ const openReject = (step: Freight.IContractApprovalStep) => { setRejectStepRow(step); setRejectReason(""); + setRejectTarget("CUSTOMER"); setRejectOpen(true); }; @@ -77,14 +88,34 @@ export function ContractApprovalStepsCard({ setRejectOpen(false); setRejectStepRow(null); setRejectReason(""); + setRejectTarget("CUSTOMER"); }; const trimmedReason = rejectReason.trim(); + // Earlier stages this rejection can be returned to — only stages that have + // already approved. Empty for the first approver, whose only target is the + // customer. + const returnableSteps = rejectStepRow + ? steps.filter( + (s) => + s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED", + ) + : []; + + const sendBack = rejectTarget !== "CUSTOMER"; + const targetStep = sendBack + ? returnableSteps.find((s) => s.id === rejectTarget) + : undefined; + const runReject = () => { if (!rejectStepRow || !trimmedReason) return; mutations.rejectStep.mutate( - { stepId: rejectStepRow.id, reason: trimmedReason }, + { + stepId: rejectStepRow.id, + reason: trimmedReason, + returnToStepId: sendBack ? rejectTarget : undefined, + }, { onSuccess: () => closeReject() }, ); }; @@ -134,7 +165,7 @@ export function ContractApprovalStepsCard({ - - Rejecting the{" "} - - {rejectStepRow?.requiredRole} - {" "} - step rejects contract{" "} - - {contract.reference} - {" "} - outright. The customer must create a new contract — this cannot be - undone. - + {returnableSteps.length > 0 && ( +