diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index bca74475e..66909a037 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -14,6 +14,7 @@ "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", "seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts", + "seed:trucks": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-trucks.ts", "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 72a5952d7..5a9ae9ef9 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -39,6 +39,7 @@ import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; +import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; @@ -59,6 +60,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; // import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; @@ -159,6 +161,7 @@ import { LoggerMiddleware } from "./logger.middleware"; BillingModule, NotificationsModule, NotificationInboxModule, + SupportChatModule, FileUploadSettingsModule, DropdownSettingsModule, ContractTemplatesModule, @@ -196,6 +199,7 @@ import { LoggerMiddleware } from "./logger.middleware"; EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, @@ -221,6 +225,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, @@ -258,6 +263,10 @@ export class AppModule implements OnApplicationBootstrap { // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, + // Dire Dawa). Idempotent; creates no yards. + await this.yardFacilitiesSeeder.run(); + // Dropdown settings are not seeded on boot; run them with // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts new file mode 100644 index 000000000..5cae30302 --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -0,0 +1,13 @@ +/** + * Goods Received Note number: `GRN---`. + * + * Shared so a GRN raised at a load/unload facility is indistinguishable from one + * raised in a warehouse — the two live in different tables + * (facility_handling_events vs warehouse_inventory), and a second generator would + * eventually let their formats drift apart. + */ +export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; +} diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index 12ba30e11..14c0385ee 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { FreightPermissionGuard } from './freight-permission.guard'; import { FREIGHT_PERMS, + type RuleEngineApprovableSlug, type RuleEngineResourceSlug, } from '../seed/freight-permissions.registry'; @@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) => applyDecorators( UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), ); + +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Super admins pass any freight permission check, so + * approvals work before the permission is granted to a director role. + */ +export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), + ); diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -0,0 +1,28 @@ +/** + * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings + * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`. + * + * A booking reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only ever + * written by the demo seeders, so both sources are unioned: real allocations work + * and the seeded scenarios keep working. + * + * Shared so the warehouse loading queue and the train dispatch guard agree on + * exactly which bookings are on a train — if they drift, a train can be + * dispatched leaving cargo the warehouse still thinks it should load. + */ +export const SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts index 6aacab16c..92bef9dd3 100644 --- a/apps/edr-freight-api/src/contracts/contract-article.util.ts +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -13,6 +13,9 @@ export interface RenderedClause { /** A dynamic article ready for the Handlebars template. */ export interface RenderedArticle { number: number; + /** Stable article id from the template (e.g. "pricing") — lets the layout + * inject the live rate schedule table under the pricing article. */ + id: string; title: string; /** Set (instead of clauses) when the body is a single plain paragraph. */ paragraph?: string; diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index a298b8dcb..363008660 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -18,6 +18,7 @@ import { ContractDynamicTemplateView, ContractViewModel, } from './contract-view-model.builder'; +import { RateSchedule } from './contract-rate-schedule.builder'; /** * Signature row for the contract PDF. Mirrors the booking builder's @@ -135,6 +136,7 @@ export class ContractDocumentViewModelBuilder { } const pricing = this.buildPricing(contract); + const rateSchedule = this.buildRateSchedule(pricing); const signatures = await this.loadSignatures(contractId); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); @@ -177,6 +179,7 @@ export class ContractDocumentViewModelBuilder { }, schedule: this.buildSchedule(contract), pricing: pricing as unknown as ContractViewModel['pricing'], + rateSchedule, // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking // view-model's narrower CUSTOMER|STAFF role union. signatures: signatures as unknown as ContractViewModel['signatures'], @@ -230,6 +233,40 @@ export class ContractDocumentViewModelBuilder { }; } + /** + * A rate schedule for the contract PDF, sourced from the contract's own frozen + * unit rates (its agreed lane prices) rather than the global rate config — a + * signed contract must show the prices it was signed on. Rendered as freight + * lanes labelled with the contract's primary origin → destination route. + */ + private buildRateSchedule(pricing: ContractUnitRateSchedule): RateSchedule { + const route = `${pricing.originLabel} → ${pricing.destinationLabel}`; + const freightLanes = pricing.unitRates.map((line) => ({ + route, + cargo: line.label, + currency: line.currency, + amount: this.formatAmount(line.unitPrice), + unit: line.unit.startsWith('per ') ? line.unit : `per ${line.unit}`, + })); + + return { + freightLanes, + additionalServices: [], + surcharges: [], + isEmpty: freightLanes.length === 0, + currencyLabel: pricing.currency, + }; + } + + private formatAmount(value: number | string): string { + const num = Number(value); + if (!Number.isFinite(num)) return String(value); + return num.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + } + private buildSchedule(contract: Contract): ContractViewModel['schedule'] { const firstRoute = this.firstRoute(contract); const cargoScope = (contract.cargoScope ?? [])[0]; diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts index 49fb3416a..765c41142 100644 --- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -133,6 +133,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { originLabel: 'Nagad', destinationLabel: 'Galaan Multipurpose Port', } as unknown as ContractViewModel['pricing'], + rateSchedule: { + freightLanes: [ + { route: 'Nagad → Galaan Multipurpose Port', cargo: 'Wheat', currency: 'USD', amount: '100', unit: 'per wagon' }, + ], + additionalServices: [ + { route: 'First-mile pickup by truck', cargo: '—', currency: 'USD', amount: '50', unit: 'per wagon' }, + ], + surcharges: [], + isEmpty: false, + currencyLabel: 'USD', + }, signatures: [], canSignCustomer: false, canSignStaff: false, @@ -151,11 +162,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance', order: 1, }, + { + id: 'pricing', + title: 'Contract Price and Payment Terms', + body: 'Rates are set out in the Rate Schedule below.\nPayments 100% in advance.', + order: 2, + }, { id: 'duration', title: 'Duration', body: 'Valid until August 31, {{contractYear}}.', - order: 2, + order: 3, }, ], }, @@ -175,6 +192,16 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { expect(html).toContain('#1b9e7a'); }); + it('renders the live rate schedule lane under the pricing article', () => { + const html = renderer.render(dynamicView()); + expect(html).toContain('Rate Schedule'); + // Base freight lane pulled from the rate config + expect(html).toContain('Nagad → Galaan Multipurpose Port'); + expect(html).toContain('USD 100 per wagon'); + // Additional-service group + expect(html).toContain('First-mile pickup by truck'); + }); + it('keeps the generic layout when no dynamic template is attached', () => { const view = dynamicView(); delete view.dynamicTemplate; diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts new file mode 100644 index 000000000..a7b007617 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -0,0 +1,98 @@ +import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; + +/** Minimal Rate factory for the builder unit tests. */ +function rate(partial: Partial): Rate { + return { + trigger: 'ALWAYS', + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + ...partial, + } as Rate; +} + +describe('ContractRateScheduleBuilder', () => { + const LIVE: Rate[] = [ + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + originYard: { label: 'Negad' } as never, + destinationYard: { label: 'Mojo Dry Port' } as never, + containerType: { label: '40ft GP' } as never, + }), + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import + rateType: 'CONTAINER_EXPORT', + rateValue: 819, + originYard: { label: 'GMP' } as never, + destinationYard: { label: 'SGTD' } as never, + }), + rate({ + appliesTo: 'BULK', // wrong freight — filtered out for a container contract + tradeDirection: 'IMPORT', + rateType: 'BULK_IMPORT', + rateUnit: 'PER_WAGON', + rateValue: 100, + }), + rate({ + appliesTo: 'FIRST_MILE', + trigger: 'ALWAYS', + tradeDirection: null, + rateUnit: 'PER_CONTAINER', + rateValue: 50, + }), + rate({ + appliesTo: 'OTHER', + trigger: 'CUSTOMS_CLEARANCE', + tradeDirection: null, + rateType: 'CUSTOMS_CLEARANCE', + rateUnit: 'FLAT', + rateValue: 120, + }), + ]; + + const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) }; + return new ContractRateScheduleBuilder(service as never).build(dir, freight); + }; + + it('shows only import container lanes for an import container contract', async () => { + const s = await build('IMP', 'CON'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ + route: 'Negad → Mojo Dry Port', + cargo: '40ft GP', + currency: 'USD', + amount: '200', + unit: 'per container', + }); + }); + + it('always lists route-agnostic services and surcharges', async () => { + const s = await build('IMP', 'CON'); + expect(s.additionalServices).toHaveLength(1); + expect(s.additionalServices[0].route).toBe('First-mile pickup by truck'); + expect(s.surcharges).toHaveLength(1); + expect(s.surcharges[0].route).toBe('Customs clearance service'); + }); + + it('excludes container lanes from a bulk contract', async () => { + const s = await build('IMP', 'BULK'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' }); + }); + + it('flags an empty schedule when nothing priced matches', async () => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) }; + const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON'); + expect(s.isEmpty).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts new file mode 100644 index 000000000..af9b9428c --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -0,0 +1,226 @@ +import { Injectable } from '@nestjs/common'; + +import { RatesService } from '../modules/rule-engine/services/rates.service'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; +import { + ContractDirection, + ContractFreight, +} from './contract-template.types'; + +/** One priced line in the contract's rate schedule. */ +export interface RateScheduleRow { + /** "Negad → Mojo Dry Port" for base freight, service name otherwise. */ + route: string; + /** "40ft GP", "Wheat", or "—" when the rate is not scoped to a type. */ + cargo: string; + currency: string; + /** Pre-formatted amount, e.g. "200" (grouped, no trailing zeros). */ + amount: string; + /** Human unit, e.g. "per container", "per wagon", "per ton". */ + unit: string; +} + +/** + * The origin → destination rate schedule shown in a generated contract's + * pricing article. Grouped so the reader sees rail freight lanes first, then + * pickup/delivery legs, then trigger-based surcharges and demurrage. + */ +export interface RateSchedule { + /** Base rail freight lanes matching this contract's direction + freight. */ + freightLanes: RateScheduleRow[]; + /** First-mile / last-mile truck legs (route-agnostic). */ + additionalServices: RateScheduleRow[]; + /** Hazard, reefer, overweight, demurrage, customs, etc. */ + surcharges: RateScheduleRow[]; + /** True when every group is empty — the template falls back to prose. */ + isEmpty: boolean; + /** Currencies present across the schedule, e.g. "USD" or "USD, ETB". */ + currencyLabel: string; +} + +const UNIT_LABELS: Record = { + PER_WAGON: 'per wagon', + PER_TON: 'per ton', + PER_CONTAINER: 'per container', + PER_KM: 'per km', + PER_INVOICE: 'per invoice', + FLAT: 'flat', +}; + +const SERVICE_ROUTE_LABELS: Partial> = { + FIRST_MILE: 'First-mile pickup by truck', + LAST_MILE: 'Last-mile delivery by truck', +}; + +/** Friendly wording for the trigger-based charges shown in the surcharge group. */ +const TRIGGER_ROUTE_LABELS: Partial> = { + HAZARDOUS: 'Hazardous cargo surcharge', + OVERWEIGHT: 'Overweight surcharge', + REEFER: 'Reefer (refrigerated) surcharge', + WITH_RETURN: 'Empty-container return service', + SHIPPING_LINE: 'Shipping line handling', + CONSOLIDATION: 'Container consolidation (extra document)', + LASHING: 'Cargo lashing and securing', + CANCELLATION: 'Booking cancellation fee', + DEMURRAGE: 'Demurrage / wagon detention', + PIL_EXTRA_FEE: 'PIL shipping line extra fee', + CUSTOMS_CLEARANCE: 'Customs clearance service', +}; + +@Injectable() +export class ContractRateScheduleBuilder { + constructor(private readonly ratesService: RatesService) {} + + /** + * Build the rate schedule for a contract of the given direction + freight. + * Base-freight lanes are filtered to the matching trade direction / freight + * kind so an import container contract shows import container lanes only; + * additional services and surcharges are route-agnostic and always shown. + */ + async build( + direction: ContractDirection, + freight: ContractFreight, + ): Promise { + const rates = await this.ratesService.findLiveRatesDetailed(); + + const freightLanes: RateScheduleRow[] = []; + const additionalServices: RateScheduleRow[] = []; + const surcharges: RateScheduleRow[] = []; + + for (const rate of rates) { + if (this.isBaseFreight(rate)) { + if (this.baseFreightMatches(rate, direction, freight)) { + freightLanes.push(this.laneRow(rate)); + } + continue; + } + + if (rate.appliesTo === 'FIRST_MILE' || rate.appliesTo === 'LAST_MILE') { + additionalServices.push(this.serviceRow(rate)); + continue; + } + + // Everything left is a trigger-based charge (surcharge / demurrage / customs). + surcharges.push(this.surchargeRow(rate)); + } + + const currencyLabel = this.currencyLabel([ + ...freightLanes, + ...additionalServices, + ...surcharges, + ]); + + return { + freightLanes, + additionalServices, + surcharges, + isEmpty: + freightLanes.length === 0 && + additionalServices.length === 0 && + surcharges.length === 0, + currencyLabel, + }; + } + + private isBaseFreight(rate: Rate): boolean { + return ( + rate.trigger === 'ALWAYS' && + (rate.appliesTo === 'BULK' || + rate.appliesTo === 'CONTAINER' || + rate.appliesTo === 'INTERCITY') + ); + } + + private baseFreightMatches( + rate: Rate, + direction: ContractDirection, + freight: ContractFreight, + ): boolean { + // Domestic contracts price off intercity rates; the freight kind is carried + // in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER). + if (direction === 'DOM') { + if (rate.appliesTo !== 'INTERCITY') return false; + return freight === 'BULK' + ? rate.rateType === 'INTERCITY_BULK' + : rate.rateType === 'INTERCITY_CONTAINER'; + } + + // Import / export price off BULK or CONTAINER rates matching the direction. + const wantAppliesTo = freight === 'BULK' ? 'BULK' : 'CONTAINER'; + if (rate.appliesTo !== wantAppliesTo) return false; + const wantDirection = direction === 'IMP' ? 'IMPORT' : 'EXPORT'; + return rate.tradeDirection === wantDirection; + } + + private laneRow(rate: Rate): RateScheduleRow { + const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; + const destination = + rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + return { + route: `${origin} → ${destination}`, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + private serviceRow(rate: Rate): RateScheduleRow { + return { + route: SERVICE_ROUTE_LABELS[rate.appliesTo] ?? rate.appliesTo, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + private surchargeRow(rate: Rate): RateScheduleRow { + return { + route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger), + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + /** The type a rate is scoped to (container/cargo), or a dash when unscoped. */ + private cargoLabel(rate: Rate): string { + return ( + rate.containerType?.label ?? + rate.containerType?.code ?? + rate.cargoType?.cargoTypeName ?? + '—' + ); + } + + private unitLabel(unit: Rate['rateUnit']): string { + return UNIT_LABELS[unit] ?? unit.toLowerCase().replace(/_/g, ' '); + } + + /** Group thousands and drop the DB's trailing zeros: "200.0000" → "200". */ + private formatAmount(value: number | string): string { + const num = Number(value); + if (!Number.isFinite(num)) return String(value); + return num.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + } + + private currencyLabel(rows: RateScheduleRow[]): string { + const seen: string[] = []; + for (const row of rows) { + if (!seen.includes(row.currency)) seen.push(row.currency); + } + return seen.join(', ') || 'USD'; + } + + private titleCase(value: string): string { + return value + .toLowerCase() + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts index a66a516c0..b0fc4c8ef 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -58,6 +58,15 @@ describe('ContractRendererService', () => { destinationLabel: 'Modjo', containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }], }, + rateSchedule: { + freightLanes: [ + { route: 'SGTD → Modjo', cargo: '40ft GP', currency: 'USD', amount: '200', unit: 'per container' }, + ], + additionalServices: [], + surcharges: [], + isEmpty: false, + currencyLabel: 'USD', + }, signatures: [], canSignCustomer: true, canSignStaff: false, diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts index 7b3301c87..b1b02c62f 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -57,6 +57,7 @@ export class ContractRendererService implements OnModuleInit { .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((article, index) => ({ number: index + 1, + id: article.id, title: interpolateTemplateText(article.title, view), ...parseArticleBody(interpolateTemplateText(article.body, view)), })); diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index d90b8e709..8b8b92f09 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -7,6 +7,7 @@ import { ContractSignerRole, } from '../modules/bookings/entities/booking-contract-signature.entity'; import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; +import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; @@ -74,6 +75,12 @@ export interface ContractViewModel { lastMileDeliveryAddress: string; }; pricing: PricingSchedule; + /** + * The live origin → destination rate schedule (base freight lanes + services + * + surcharges) matching this contract's direction and freight kind. Drives + * the pricing article's rate table so the contract mirrors the rate config. + */ + rateSchedule: RateSchedule; signatures: ContractSignatureView[]; canSignCustomer: boolean; canSignStaff: boolean; @@ -89,6 +96,7 @@ export class ContractViewModelBuilder { private readonly bookingsRepository: BookingsRepository, private readonly templateResolver: ContractTemplateResolver, private readonly pricingBuilder: ContractPricingScheduleBuilder, + private readonly rateScheduleBuilder: ContractRateScheduleBuilder, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -101,6 +109,10 @@ export class ContractViewModelBuilder { booking.contractTemplateKey ?? this.templateResolver.resolve(booking); const template = getTemplateMeta(templateKey); const pricing = await this.pricingBuilder.build(booking); + const rateSchedule = await this.rateScheduleBuilder.build( + template.direction, + template.freight, + ); const signatures = await this.loadSignatures(bookingId); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); @@ -143,6 +155,7 @@ export class ContractViewModelBuilder { }, schedule: this.buildSchedule(booking), pricing, + rateSchedule, signatures, canSignCustomer: booking.status === 'CONTRACT_READY' && !hasCustomer, diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs index 64319612a..eec54fa79 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -25,25 +25,9 @@

Equipment return: {{pricing.equipmentReturn}}

{{/if}} - {{#if pricing.unitRates}} -

Unit Rate Schedule

-

- The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting - totals are determined per shipment at booking time; no total contract value is fixed at this stage. -

- - - - - - {{#each pricing.unitRates}} - - - - - {{/each}} - -
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
+ {{#unless rateSchedule.isEmpty}} +

Rate Schedule

+ {{> rate_schedule}} {{else}}

Charges

@@ -76,7 +60,7 @@
- {{/if}} + {{/unless}}

Terms of payment

Unless otherwise agreed in writing, the Client shall settle the contract value in diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs index bec620655..4bdc9a24a 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs @@ -20,5 +20,9 @@ {{/each}} {{/if}} + {{#if (eq id "pricing")}} +

Rate Schedule

+ {{> rate_schedule}} + {{/if}} {{/each}} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs new file mode 100644 index 000000000..f0cb0fa7d --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs @@ -0,0 +1,55 @@ +{{#if rateSchedule.isEmpty}} +

+ No published rate schedule is currently on file for this corridor. Applicable charges will be quoted + by the Service Provider per shipment in accordance with the prevailing EDR tariff. +

+{{else}} +

+ The charges below are the current published railway tariff for this contract's trade direction and + freight type, expressed as unit prices per origin → destination lane. Quantities and the resulting + totals are determined per shipment at booking time. +

+ + + + + + + + + + {{#if rateSchedule.freightLanes.length}} + + {{#each rateSchedule.freightLanes}} + + + + + + {{/each}} + {{/if}} + + {{#if rateSchedule.additionalServices.length}} + + {{#each rateSchedule.additionalServices}} + + + + + + {{/each}} + {{/if}} + + {{#if rateSchedule.surcharges.length}} + + {{#each rateSchedule.surcharges}} + + + + + + {{/each}} + {{/if}} + +
Route / ServiceCargo / EquipmentUnit price
Railway Freight — Origin → Destination
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
Additional Services
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
Surcharges, Demurrage & Fees
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
+{{/if}} diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 4ba35ea3b..0e06d9f7b 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -134,26 +134,8 @@ - {{#if pricing.unitRates.length}} -

Agreed Unit Rates

-

- The rates below are the frozen unit prices applicable to this contract. Quantities and resulting - totals are determined per shipment at booking time. -

- - - - - - {{#each pricing.unitRates}} - - - - - {{/each}} - -
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
- {{/if}} +

Published Rate Schedule

+ {{> rate_schedule}} {{!-- ────────────────────────── Signatures ───────────────────────────── --}} diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts index 717a48217..a651b8f60 100644 --- a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts +++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts @@ -44,13 +44,13 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter // train_set_wagons null their link, wagon_movements cascade. await queryRunner.query(`DELETE FROM freight.wagons;`); - // Wagon.wagonNumber declares `unique: true`, but some environments never got - // the constraint. Repair it here — the table is empty at this point, so the - // index build cannot fail on pre-existing duplicates. - await queryRunner.query(` - CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key - ON freight.wagons (wagon_number); - `); + // Deliberately does NOT create a unique index on wagon_number. It once did, + // to satisfy an ON CONFLICT clause that no longer exists (the DELETE above + // makes collisions impossible). Recreating the plain index here would undo + // WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL + // unique index so soft-deleted wagons stop reserving their number — this + // seeder is run directly by scripts/seed-edr-wagons.ts, which would + // otherwise resurrect the plain index on an already-migrated database. for (const row of FLEET) { if (row.end - row.start + 1 !== row.count) { diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts new file mode 100644 index 000000000..7050c1c40 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form. + * + * Nullable with no default: a wagon is not on a run until an operator says so. + * Mirrors the width of trains.export_train_number / trains.import_train_number + * (varchar 20) so the two stay comparable. + */ +export class AddWagonTrainNumbers2270000000000 implements MigrationInterface { + name = 'AddWagonTrainNumbers2270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS export_train_number varchar(20), + ADD COLUMN IF NOT EXISTS import_train_number varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts new file mode 100644 index 000000000..dae700883 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts @@ -0,0 +1,206 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Assign EDR export/import run numbers to the wagon fleet. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with NULL run numbers — so this must stay later in timestamp order. + * + * Source data below is the operator-supplied roster, kept verbatim rather than + * pre-resolved so its quirks stay visible: + * - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50). + * - Four wagons are claimed by two runs each. A wagon holds a single run, so + * FIRST-LISTED WINS, which is why four runs land one short of their listed + * count: + * ER0484 8301 over 8401 + * ER0451 8401 over 8701 + * ER0887 8701 over 9001 + * ER0936 8801 over 8901 + * + * Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs. + */ + +/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */ +const RUN_WAGONS: Record = { + '8001': [ + 'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901', + 'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840', + 'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694', + 'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868', + 'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826', + 'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825', + 'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782', + 'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519', + 'ER0479', 'ER0440', + ], + '8101': [ + 'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459', + 'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768', + 'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937', + 'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590', + 'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435', + 'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633', + 'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520', + 'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880', + 'ER0422', 'ER0852', + ], + '8201': [ + 'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618', + 'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625', + 'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231', + 'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464', + 'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733', + 'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588', + 'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928', + 'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236', + 'ER0933', 'ER0456', + ], + '8301': [ + 'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780', + 'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818', + 'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485', + 'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762', + 'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528', + 'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232', + 'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622', + 'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513', + ], + '8401': [ + 'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758', + 'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434', + 'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740', + 'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787', + 'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530', + 'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563', + 'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442', + 'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614', + 'ER0561', 'ER0393', + ], + '8501': [ + 'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748', + 'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433', + 'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508', + 'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572', + 'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814', + 'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418', + 'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702', + 'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483', + 'ER0824', 'ER0640', 'ER0714', + ], + '8601': [ + 'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808', + 'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922', + 'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496', + 'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667', + 'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711', + 'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487', + 'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257', + ], + '8701': [ + 'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665', + 'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582', + 'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680', + 'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900', + 'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726', + 'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705', + 'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655', + 'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861', + 'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315', + 'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693', + 'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518', + 'ER0887', + ], + '8801': [ + 'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476', + 'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501', + 'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601', + 'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896', + 'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895', + 'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610', + 'ER0275', 'ER0333', 'ER0344', 'ER0469', + ], + '8901': [ + 'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441', + 'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453', + 'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866', + 'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908', + 'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478', + 'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497', + 'ER0643', 'ER0638', 'ER0468', 'ER0597', + ], + '9001': [ + 'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672', + 'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912', + 'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399', + 'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865', + 'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574', + 'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699', + 'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259', + 'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927', + 'ER0810', 'ER0681', 'ER0887', + ], +}; + +/** + * Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather + * than computed as export+1 so a run that ever breaks the convention stays + * correct. Run numbers are always 4 digits (8401, never 84001). + */ +const IMPORT_RUN: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +export class SeedWagonRunNumbers2280000000000 implements MigrationInterface { + name = 'SeedWagonRunNumbers2280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Idempotent: clear the roster's runs first so a re-run cannot leave a + // wagon on a run it was since moved off of. + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + + const claimed = new Set(); + + for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) { + const importRun = IMPORT_RUN[exportRun]; + if (!importRun) throw new Error(`import_run_missing:${exportRun}`); + + // First-listed wins — skip any wagon an earlier run already claimed. + const fresh = wagons.filter((w) => !claimed.has(w)); + fresh.forEach((w) => claimed.add(w)); + if (!fresh.length) continue; + + await queryRunner.query( + ` + UPDATE freight.wagons + SET export_train_number = $1, + import_train_number = $2, + updated_at = now() + WHERE wagon_number = ANY($3::text[]); + `, + [exportRun, importRun, fresh], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts new file mode 100644 index 000000000..ed66c37d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * container_types.wagons_per_unit is no longer stored: the wagon fraction is + * derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per + * wagon; see rule-engine/container-type.util.ts). The stored value duplicated + * that rule and could silently drift from it. + */ +export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface { + name = 'DropContainerWagonsPerUnit2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2); + `); + // Backfill from the same size rule the code now derives from. + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts b/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts new file mode 100644 index 000000000..7e12efdbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Stand the whole wagon fleet in Doraleh. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with a NULL yard — so this must stay later in timestamp order. + * + * A wagon with no yard cannot be coupled to a train (the train builder only + * offers AVAILABLE wagons standing in the train's own yard), which left the + * seeded fleet unusable. Doraleh is the Djibouti-side port yard the import runs + * originate from. + * + * The yard is created when absent: environments disagree about which yards + * exist, so this cannot assume one is there. + */ +const YARD_CODE = 'DORALEH'; + +export class SeedWagonYardDoraleh2290000000000 implements MigrationInterface { + name = 'SeedWagonYardDoraleh2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Ensure the yard exists and is usable. Deliberately does NOT overwrite an + // existing label/country — a deployment that already calls this yard + // something else keeps its own naming. + await queryRunner.query( + ` + INSERT INTO freight.yards (code, label, country, is_active, display_order) + VALUES ($1, 'Doraleh', 'Djibouti', true, 12) + ON CONFLICT (code) DO UPDATE SET + is_active = true, + deleted_at = NULL, + updated_at = now(); + `, + [YARD_CODE], + ); + + const [yard] = await queryRunner.query( + `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`, + [YARD_CODE], + ); + + if (!yard?.id) { + throw new Error(`yard_missing:${YARD_CODE}`); + } + + // Whole fleet — a wagon already coupled to a built train follows the train, + // so leave those where they stand. + await queryRunner.query( + ` + UPDATE freight.wagons + SET current_yard_id = $1::uuid, + updated_at = now() + WHERE train_id IS NULL; + `, + [yard.id], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Back to the state SeedEdrWagonFleetErNumbering leaves them in. + await queryRunner.query(` + UPDATE freight.wagons + SET current_yard_id = NULL + WHERE train_id IS NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts new file mode 100644 index 000000000..620eebc14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts @@ -0,0 +1,85 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its + * destination yard, but only some yards have the equipment to do it. EDR's + * load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad — + * and the set grows, so it must be data, not a constant. + * + * `yards.has_facility` marks a yard as a load/unload point; `yard_facilities` + * holds what that facility can do. Only a facility with `has_warehouse` (Indode + * today) stores cargo, and therefore accrues storage/demurrage — the rest just + * move it on and off the train. + * + * `facility_handling_events` records each load/unload and carries its GRN. + * warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so + * a facility with no warehouse could never have a row. `inventory_id` links to the + * storage record when the facility does have a warehouse. + */ +export class YardFacilities2290000000000 implements MigrationInterface { + name = 'YardFacilities2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yards + ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_facilities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE, + has_warehouse boolean NOT NULL DEFAULT false, + equipment_notes text NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + // One facility record per yard. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard" + ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.facility_handling_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id), + yard_id uuid NOT NULL REFERENCES freight.yards(id), + train_schedule_id uuid NULL REFERENCES freight.train_schedules(id), + event_type varchar(10) NOT NULL, + grn_number varchar(60) NULL, + quantity numeric(14, 3) NULL, + weight_tons numeric(14, 3) NULL, + inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id), + performed_by varchar(120) NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_booking" + ON freight.facility_handling_events (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard" + ON freight.facility_handling_events (yard_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn" + ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`); + await queryRunner.query(` + ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts new file mode 100644 index 000000000..a3e36f0b9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for edits to LIVE rates. A LIVE rate is what pricing + * charges, so it is never edited in place: the edit is filed here as PENDING + * and the live row keeps its value until an approver applies it. + * + * `payload` holds the changed fields only; `previous_values` snapshots what + * they were at submit time so the approver sees a real before→after diff. + */ +export class CreateRateChangeRequests2300000000000 implements MigrationInterface { + name = 'CreateRateChangeRequests2300000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.rate_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rate_id uuid NOT NULL REFERENCES freight.rates (id), + payload jsonb NOT NULL, + previous_values jsonb NOT NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rcr_status + ON freight.rate_change_requests (status) + `); + // At most one pending edit per rate — two racing requests would both pass + // validation and the second would silently overwrite the first on approval. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate + ON freight.rate_change_requests (rate_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts new file mode 100644 index 000000000..f4628db36 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Repair for environments missing the GPS tracking tables. + * + * AddGpsTracking2000000000000 creates freight.gps_devices / gps_positions, but + * some databases have it RECORDED in public.migrations without the tables ever + * landing. TypeORM never re-runs a recorded migration, so those environments + * stay broken through any number of restarts — the GT06 listener accepts tracker + * packets on its TCP port regardless of schema state and fails per packet with + * `relation "freight.gps_devices" does not exist`, dropping position fixes. + * + * This re-issues the same DDL under a new name so it is applied afresh. Every + * statement is IF NOT EXISTS, so it is a no-op where the tables already exist + * and safe on every environment. + * + * Kept byte-identical to the original DDL on purpose: this must converge on the + * schema the entities expect, not a variant of it. + */ +export class RepairGpsTrackingTables2300000000000 implements MigrationInterface { + name = "RepairGpsTrackingTables2300000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei varchar(20) NOT NULL UNIQUE, + name varchar, + vehicle_id uuid REFERENCES freight.vehicles(id), + status varchar(16) NOT NULL DEFAULT 'REGISTERED', + last_seen_at timestamptz, + last_lat numeric(10,6), + last_lng numeric(10,6), + last_speed numeric(6,2), + last_course int, + last_fix_at timestamptz, + voltage_level int, + gsm_level int, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE" + ON freight.gps_devices (vehicle_id) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL, + imei varchar(20) NOT NULL, + vehicle_id uuid, + lat numeric(10,6) NOT NULL, + lng numeric(10,6) NOT NULL, + speed numeric(6,2) NOT NULL DEFAULT 0, + course int NOT NULL DEFAULT 0, + satellites int NOT NULL DEFAULT 0, + positioned boolean NOT NULL DEFAULT false, + gps_time timestamptz NOT NULL, + alarm int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME" + ON freight.gps_positions (device_id, gps_time) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME" + ON freight.gps_positions (vehicle_id, gps_time) + `); + } + + public async down(): Promise { + // No-op: dropping the tables would discard tracker history on environments + // where this migration was the one that created them. AddGpsTracking owns + // the teardown. + } +} diff --git a/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts new file mode 100644 index 000000000..17db8a4e1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Customer-support chat. A `support_conversations` row is the single ongoing + * thread with a company; `support_messages` are its text messages. There is no + * lifecycle column — a thread is opened by whichever side speaks first and + * stays open. Enum-like columns are varchar (no PG enum churn). + * + * The unique index on `company_id` is load-bearing, not just an optimization: + * the get-or-create path depends on it to settle concurrent first-messages. + * It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block + * a fresh one. + */ +export class CreateSupportChat2310000000000 implements MigrationInterface { + name = "CreateSupportChat2310000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_conversations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL, + company_name varchar(200), + created_by_user_id uuid, + last_message_at timestamptz, + last_message_preview varchar(280), + last_message_author_role varchar(12), + customer_last_read_at timestamptz, + agent_last_read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY" + ON freight.support_conversations (company_id) + WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG" + ON freight.support_conversations (last_message_at) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id uuid NOT NULL, + author_user_id uuid NOT NULL, + author_role varchar(12) NOT NULL, + author_name varchar(200), + body text 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_SUPPORT_MSG_CONV_CREATED" + ON freight.support_messages (conversation_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS freight.support_conversations`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts new file mode 100644 index 000000000..8e693cf2a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts @@ -0,0 +1,140 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope base rail freight to a route (origin yard → destination yard). + * + * Until now a base-freight rate was keyed by direction + container/bulk scope + * only, so "container import" cost the same whether the box was railed to Dire + * Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which + * is what the business actually sells: `container import, Djibouti → Dire Dawa, + * 500 USD`. + * + * Existing base-freight rates predate the yard pair and cannot be backfilled — + * there is no way to know which route each was meant for. They are retired + * (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot + * and rate_change_requests hold FKs to them (RESTRICT) and those rows are price + * history. Retiring drops them out of pricing and the admin UI just the same; + * the yard-scoped replacements must be re-entered. + * + * Surcharges, first-mile and last-mile rates are untouched: they are not + * route-scoped and keep NULL yards. + */ +export class AddRateYardScope2320000000000 implements MigrationInterface { + name = 'AddRateYardScope2320000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. Yard columns + FKs ────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL, + ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL; + `); + + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_origin_yard_id" + FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_destination_yard_id" + FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id); + END IF; + END $$; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`, + ); + + // ── 2. Retire route-less base freight ────────────────────────────────── + // Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE + // RESTRICT and those snapshots are what past bookings were charged. + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND "trigger" = 'ALWAYS' + AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'); + `); + + // ── 3. Route is part of a rate's identity ────────────────────────────── + // Two rates may now share rateType + scope + unit as long as they price + // different legs, so the yard pair joins the uniqueness tuple. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + // ── 4. Base freight must carry a route; nothing else may ─────────────── + // Retired rows are exempt — they are the route-less rates step 2 just + // superseded, and they must stay readable for snapshot history. + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // The retired rates are not un-superseded: which route each belonged to was + // never recorded, so reviving them would restore rates that price the wrong + // legs. Down only reverses the schema. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`); + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`, + ); + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + DROP COLUMN IF EXISTS destination_yard_id, + DROP COLUMN IF EXISTS origin_yard_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts b/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts new file mode 100644 index 000000000..b435e55ea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a global "booking close offset" — how long BEFORE departure a schedule's + * booking window shuts — configurable separately for import and export. + * + * When an offset is set, the window's close instant is `departure − offset` + * (e.g. departure 17:00 with a 3-hour import offset closes at 14:00; departure + * Jul-10 16:00 with a 1-day export offset closes Jul-9 16:00). It caps the whole + * booking lifecycle: the first window close, every reopen cycle, and the export + * FCFS close all land at/at-or-before this cutoff instead of at departure. + * + * NULL / 0 preserves the previous behaviour exactly (import closes at + * open+duration clamped to departure; export closes at departure), so existing + * installs are unaffected until an offset is entered. + * + * `*_close_offset_minutes` on the global-rules singleton is the live config; the + * matching `rule_*_close_offset_minutes` snapshot on each schedule freezes it at + * creation so the batch board keeps drawing the window the customer was shown + * even after a later global-rules edit. Both are nullable with no backfill — + * absent means "no offset", the safe default. + */ +export class AddBookingCloseOffset2330000000000 implements MigrationInterface { + name = "AddBookingCloseOffset2330000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS import_close_offset_minutes integer, + ADD COLUMN IF NOT EXISTS export_close_offset_minutes integer; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_import_close_offset_minutes integer, + ADD COLUMN IF NOT EXISTS rule_export_close_offset_minutes integer; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_import_close_offset_minutes, + DROP COLUMN IF EXISTS rule_export_close_offset_minutes; + `); + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_close_offset_minutes, + DROP COLUMN IF EXISTS export_close_offset_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts b/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts new file mode 100644 index 000000000..6585cc842 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add `has_lashing` to cargo types. + * + * When true, every booking of that cargo type incurs the flat LASHING + * surcharge (a rate with trigger = 'LASHING'). Defaults to false so existing + * cargo ships without the fee until the flag is turned on. + */ +export class AddCargoTypeHasLashing2340000000000 implements MigrationInterface { + name = "AddCargoTypeHasLashing2340000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_lashing boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP COLUMN IF EXISTS has_lashing; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts b/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts new file mode 100644 index 000000000..ad389e929 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add an opt-in "reverse wagon order" flag to a train schedule. + * + * When true, the built wagon plan is flipped at build time so the physically-last + * wagon sits at position 1. Only the order (sequence_no) changes — composition and + * booking allocations travel with their slot. The flag is frozen on the schedule + * at creation and re-applied every time the wagon plan is rebuilt, so the stored + * train order and the schedule order always match. + * + * Defaults to false; existing schedules keep their as-built order. + */ +export class AddReverseWagonOrder2340000000000 implements MigrationInterface { + name = "AddReverseWagonOrder2340000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS reverse_wagon_order boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS reverse_wagon_order; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..95e007c8a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the "pricing" article of each seeded contract template so it points + * at the live Rate Schedule instead of hardcoded price figures (USD 400/wagon, + * USD 919/40ft, …). The original CreateContractTemplates migration seeded the + * old prose with ON CONFLICT DO NOTHING, so those figures are frozen in the DB + * rows and would otherwise contradict the rate-config-driven schedule table now + * rendered under the pricing article. + * + * Only the article whose id = 'pricing' is touched, and only when its body + * still matches the originally-seeded prose — so any admin edit to the pricing + * article is left untouched. Idempotent: re-running is a no-op once refreshed. + */ +export class RefreshContractPricingArticles2350000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((article) => article.id === 'pricing'); + if (!pricing) continue; + + // jsonb_set the title + body of the element whose id = 'pricing', matched + // by array index. Guarded so admin-edited bodies are never overwritten. + await queryRunner.query( + ` + UPDATE freight.contract_templates ct + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN elem || jsonb_build_object('title', $2::text, 'body', $3::text) + ELSE elem + END + ) + FROM jsonb_array_elements(ct.articles) elem + ) + WHERE ct.code = $1 + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(ct.articles) e + WHERE e->>'id' = 'pricing' + AND e->>'body' LIKE ANY (ARRAY[ + '%USD 59.4 per metric ton%', + '%USD 696 (six hundred ninety-six) per wagon%', + '%USD 400 (four hundred) per wagon%', + '%From SGTD to Dire Dawa dry port, the rate is USD 919%', + '%Railway transportation charges from GMP to SGTD: USD 819%', + '%prevailing EDR domestic container tariff, as set out in the commercial schedule%' + ]) + ); + `, + [seed.code, pricing.title, pricing.body], + ); + } + } + + public async down(): Promise { + // No-op: the refreshed pricing prose is the correct forward state; reverting + // to hardcoded figures would reintroduce the rate-schedule contradiction. + } +} diff --git a/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..566d0308e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the `pricing` article body of the six seeded contract templates to + * the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per + * wagon") are now rendered from the LIVE rate config instead of frozen prose, + * so any template whose pricing article still carries a hardcoded price token + * is rewritten to the current seed text. + * + * The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original + * prose (which always quoted a currency + figure) and matches neither an + * already-migrated body nor a hand-edited one that adopted the schedule + * wording — so admin edits are preserved. Idempotent: after the rewrite the + * price token is gone, so a re-run is a no-op. Fresh databases seed the new + * text directly (CreateContractTemplates imports the same seed), making this + * a targeted backfill for databases seeded before the seed changed. + */ +const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]'; + +export class RefreshContractPricingArticles2360000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((a) => a.id === 'pricing'); + if (!pricing) continue; + + // Rewrite only the article whose id = 'pricing', in place, and only when + // its body still quotes a hardcoded currency figure. jsonb_agg keeps the + // rest of the article (id/title/order) and every other article intact. + await queryRunner.query( + ` + UPDATE freight.contract_templates AS t + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true) + ELSE elem + END + ORDER BY ord + ) + FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord) + ), + updated_at = now() + WHERE t.code = $1 + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(t.articles) AS x + WHERE x->>'id' = 'pricing' + AND x->>'body' ~ $3 + ); + `, + [seed.code, pricing.body, HARDCODED_PRICE_TOKEN], + ); + } + } + + /** + * Irreversible in practice — the original per-lane figures are not restored. + * A no-op down keeps the migration reversible-by-contract without + * resurrecting stale hardcoded prices. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts new file mode 100644 index 000000000..1971f6166 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container handling opt-in: each physical container can now be marked + * hazardous / reefer / with-return individually, next to its VGM. The hazardous + * and reefer flags already existed on the unit row; only the return leg was + * missing, so a booking of 20 containers with 10 returning empty can bill the + * WITH_RETURN surcharge on 10 instead of all 20. + * + * Backfill: existing rows keep false. The line-level counts + * (booking_container.return_quantity etc.) stay authoritative for bookings made + * before this change — the rule engine falls back to them when no unit is flagged. + */ +export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface { + name = 'AddContainerUnitReturnFlag2370000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.controller.ts b/apps/edr-freight-api/src/modules/auth/account.controller.ts new file mode 100644 index 000000000..d7d7f15c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { AccountService } from "./account.service"; +import { + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; + +/** + * The caller's own account record. Everything here is scoped to the JWT's user + * id — there is no `:id` parameter to tamper with, so these routes need no + * permission key beyond being authenticated. + */ +@ApiTags("auth") +@Controller("me") +@ApiBearerAuth() +@UseGuards(JwtGuard) +export class AccountController { + constructor(private readonly accountService: AccountService) {} + + @Post("contact/otp") + @ApiOperation({ + summary: "Send a verification code to a new email/phone before changing it", + description: + "The code goes to the NEW value supplied here, proving the caller controls " + + "it. Returns the target masked — an unverified caller never gets it back in full.", + }) + sendContactOtp( + @CurrentUser() user: TCurrentUser, + @Body() dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + return this.accountService.sendContactOtp(user.id, dto); + } + + @Patch("contact") + @ApiOperation({ + summary: "Change the account's email or phone, gated by a verification code", + description: + "Verifies the code and writes the new value in one call, so the API never " + + "has to take a client's word that verification happened.", + }) + updateContact( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + return this.accountService.updateContact(user.id, dto); + } + + @Patch("name") + @ApiOperation({ summary: "Change the account's display name" }) + updateName( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + return this.accountService.updateName(user.id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.service.ts b/apps/edr-freight-api/src/modules/auth/account.service.ts new file mode 100644 index 000000000..b7413a649 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.service.ts @@ -0,0 +1,226 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, EntityManager, Repository } from "typeorm"; +import { isValidPhoneNumber } from "libphonenumber-js"; + +import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum"; +import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type"; +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { + ContactChannel, + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; +import { maskOtpTarget } from "./mask-target.util"; + +/** How long a contact-change code stays valid before it must be re-requested. */ +const CONTACT_OTP_TTL_MS = 10 * 60 * 1000; + +/** Postgres unique-violation SQLSTATE. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** + * Self-serve management of the caller's own IAM user record. + * + * IAM ships `PATCH /api/auth/update-profile`, but it takes email + username + + * phone + name all at once (every field `@IsNotEmpty`) and performs no + * verification — it will move an account's phone to any number the caller + * types. These routes exist so a contact change is *proven*: the code goes to + * the NEW address and the write only lands once it comes back. + */ +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Send a code to the address the caller wants to move TO. Sending to the new + * value (rather than the one on file) is the whole point — it proves control + * of the destination before anything is written. + */ + async sendContactOtp( + userId: string, + dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + const target = this.targetFor(dto.channel, value); + await this.otpService.sendOtp(target); + + return { sentTo: maskOtpTarget(target) }; + } + + /** + * Verify the code, then write the new contact value. The verify and the write + * are one call: the API never has to trust that a client "already verified" + * — unlike the signup flow, where the OTP is client-orchestrated and + * `POST /api/otp/verify` is a separate public route the client may simply skip. + */ + async updateContact( + userId: string, + dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + await this.otpService.verifyOtpForAction( + this.targetFor(dto.channel, value), + dto.otp, + CONTACT_OTP_TTL_MS, + ); + + const isEmail = dto.channel === ContactChannel.Email; + const userPatch = isEmail + ? { email: value } + : { + phoneNumber: value, + // The number just passed an OTP, which is exactly what IAM's own + // phone-verification flag means. Set it here so the freight app stops + // needing its own parallel "verified phone" bookkeeping. + isPhoneNumberVerified: true, + verifiedBy: EUserVerifiedBy.PHONE_NUMBER, + }; + const sessionPatch: Partial = isEmail + ? { email: value } + : { phoneNumber: value, isPhoneNumberVerified: true }; + + try { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, userPatch); + await this.refreshSessions(manager, userId, sessionPatch); + }); + } catch (error) { + throw this.asConflict(error, dto.channel); + } + + this.logger.log(`Account ${dto.channel} updated for user ${userId}`); + return { success: true, value }; + } + + /** Rename the account. No OTP — a name change proves nothing and grants nothing. */ + async updateName( + userId: string, + dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + const en = dto.name.en?.trim(); + const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) }; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, { name }); + // IAM mirrors the name onto the employee row. Portal customers are + // `individual` users with no employee row at all, so this is a no-op for + // them — hence an unconditional update() rather than a lookup-then-write. + await manager.getRepository(Employee).update({ userId }, { name }); + await this.refreshSessions(manager, userId, { name }); + }); + + return { success: true }; + } + + /** + * `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only + * when a session is created at login. Without patching it here, a saved change + * stays invisible to /me (and to anything reading the token's claims) until the + * user logs out and back in, which reads as "my edit didn't save". + */ + private async refreshSessions( + manager: EntityManager, + userId: string, + patch: Partial, + ): Promise { + const repo = manager.getRepository(Session); + const sessions = await repo.find({ where: { userId } }); + + await Promise.all( + sessions.map((session) => + repo.update( + { id: session.id }, + { userInfo: { ...session.userInfo, ...patch } }, + ), + ), + ); + } + + /** Canonicalise for the channel and reject anything malformed up front. */ + private normalize(channel: ContactChannel, value: string): string { + const raw = value.trim(); + + if (channel === ContactChannel.Email) { + const email = raw.toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new BadRequestException("A valid email address is required"); + } + return email; + } + + if (!isValidPhoneNumber(raw)) { + throw new BadRequestException( + "A valid international phone number is required (E.164, e.g. +251911223344)", + ); + } + // Store the same canonical form the OTP is keyed by, so the code sent here + // is findable on verify regardless of how the number was typed. + return normalizeE164(raw) as string; + } + + private targetFor(channel: ContactChannel, value: string): OtpTarget { + return channel === ContactChannel.Email ? { email: value } : { phone: value }; + } + + /** + * `iam.users.email` and `.phone_number` are each independently UNIQUE, so a + * collision would otherwise surface as a raw 500 at write time. This is a + * courtesy check, not the guard — it races, so {@link asConflict} still has to + * catch the violation. + */ + private async assertNotTaken( + channel: ContactChannel, + value: string, + userId: string, + ): Promise { + const existing = await this.userRepository.findOne({ + where: + channel === ContactChannel.Email + ? { email: value } + : { phoneNumber: value }, + select: { id: true }, + }); + + if (existing && existing.id !== userId) { + throw this.takenError(channel); + } + } + + private asConflict(error: unknown, channel: ContactChannel): Error { + const code = (error as { code?: string } | null)?.code; + if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel); + return error as Error; + } + + private takenError(channel: ContactChannel): ConflictException { + return new ConflictException( + channel === ContactChannel.Email + ? "That email address is already registered to another account" + : "That phone number is already registered to another account", + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts new file mode 100644 index 000000000..363e39072 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +/** The contact channel being changed on the caller's own account. */ +export enum ContactChannel { + Email = "email", + Phone = "phone", +} + +export class SendContactOtpDto { + @ApiProperty({ enum: ContactChannel }) + @IsEnum(ContactChannel) + channel!: ContactChannel; + + @ApiProperty({ + description: + "The NEW email or phone to verify. The code is sent here, not to the " + + "address currently on the account — that is what proves the caller " + + "controls the number/inbox they are moving to.", + example: "+251911223344", + }) + @IsString() + @IsNotEmpty() + value!: string; +} + +export class UpdateContactDto extends SendContactOtpDto { + @ApiProperty({ description: "The 6-digit code sent to the new value" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class AccountNameDto { + @ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" }) + @IsString() + @IsNotEmpty() + am!: string; + + @ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" }) + @IsOptional() + @IsString() + en?: string; +} + +export class UpdateAccountNameDto { + @ApiProperty({ type: AccountNameDto }) + @IsObject() + @ValidateNested() + @Type(() => AccountNameDto) + name!: AccountNameDto; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index 42dc723d5..b357c2cfb 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user import { OtpService, OtpTarget } from "../otp/otp.service"; import { ResetChannel } from "./dto/forgot-password.dto"; +import { maskOtpTarget } from "./mask-target.util"; /** * How long the reset ticket minted for `PATCH /api/auth/set-password` stays @@ -158,12 +159,6 @@ export class ForgotPasswordService { /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ maskTarget(target: OtpTarget): string { - if (target.email) { - const [local, domain] = target.email.split("@"); - const head = local.slice(0, 1); - return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; - } - const phone = target.phone ?? ""; - return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + return maskOtpTarget(target); } } diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 6415cf4c1..1a375d86f 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; import { ExternalProfile } from '../companies/entities/external-profile.entity'; import { OtpModule } from '../otp/otp.module'; +import { AccountController } from './account.controller'; +import { AccountService } from './account.service'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; import { CustomerResetController } from './customer-reset.controller'; @@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service'; @Module({ imports: [ - TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + TypeOrmModule.forFeature([ + User, + UserVerification, + ExternalProfile, + Session, + Employee, + ]), OtpModule, ], controllers: [ FreightMeController, + AccountController, CheckAvailabilityController, ForgotPasswordController, CustomerResetController, ], providers: [ FreightMeService, + AccountService, CheckAvailabilityService, ForgotPasswordService, CustomerResetService, diff --git a/apps/edr-freight-api/src/modules/auth/mask-target.util.ts b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts new file mode 100644 index 000000000..213a14656 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts @@ -0,0 +1,16 @@ +import { OtpTarget } from "../otp/otp.service"; + +/** + * Mask an OTP target for echoing back to the caller: `+251911234567` -> + * `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to + * a caller who has not yet proven possession of the channel. + */ +export function maskOtpTarget(target: OtpTarget): string { + if (target.email) { + const [local, domain] = target.email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; + } + const phone = target.phone ?? ""; + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 0f60876d4..2bdc6ee16 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1016,7 +1016,7 @@ export class BillingService { // in the domain via `${source}.invoice.paid`. Neither billing nor the payment // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, - orderRef: invoice.invoiceNumber.replace("-", "_"), + orderRef: invoice.invoiceNumber.replace(/-/g, "_"), amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, @@ -1032,10 +1032,8 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); - // DEMO: manually fire the gateway `payment.succeeded` callback here, without - // waiting for real gateway settlement. Runs AFTER the paymentId link above so - // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: - // remove — real settlement flips this via the `${source}.invoice.paid` handler. + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); + // billing must not simulate it. Kept commented for local demos only. if (!result.immediateSuccess) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index b0a3ad766..3d8222d63 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -16,6 +16,7 @@ import { InvoiceLineInput, } from "../billing/billing.service"; import { Invoice } from "../billing/entities/invoice.entity"; +import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service"; import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; @@ -120,17 +121,27 @@ export class BookingInvoiceService { } /** - * Expire the booking's currently-open prepaid invoice when the booking is + * Expire the booking's currently-open invoices (freight PREPAID and the + * per-shipment clearance fee) when the booking is * cancelled or rejected — the counterpart to the pay-window-expiry path * (which also calls {@link BillingService.expirePayable}). Stops a terminated * booking from leaving a payable invoice open. No-op when the booking has no * open invoice (never invoiced, already paid/cancelled/expired). Pass a * caller `manager` to enlist in its transaction. */ - expireOpenInvoices( + async expireOpenInvoices( bookingId: string, manager?: EntityManager, ): Promise { + // The per-shipment clearance fee (GENERAL contracts) bills this same booking + // id under its own source/type — retire it alongside the freight invoice, or + // a cancelled shipment keeps a payable clearance invoice open. + await this.billing.expirePayable( + Freight.InvoiceSource.Clearance, + bookingId, + CLEARANCE_BOOKING_INVOICE_TYPE, + manager, + ); return this.billing.expirePayable( Freight.InvoiceSource.Booking, bookingId, 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 a4546e301..1cef9528c 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 @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(b: Booking): string { @@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index db6b70eae..3f4f64186 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity'; const MOCK_CBE_RATE = 130; +// Base freight is configured per leg, so every rate and every booking names the +// route it runs. MOJO → DIRE is the corridor these rates are priced for. +const MOJO = 'yard-mojo'; +const DIRE = 'yard-dire-dawa'; +const LEBU = 'yard-lebu'; + describe('BookingPricingService — domestic corridor', () => { const intercityBulkUsd: Rate = { id: 'rate-intercity-bulk-usd', @@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => { rateUnit: 'PER_TON', status: 'LIVE', containerTypeId: null, + originYardId: MOJO, + destinationYardId: DIRE, } as Rate; const intercityContainerUsd: Rate = { @@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => { rateUnit: 'PER_CONTAINER', status: 'LIVE', containerTypeId: null, + originYardId: MOJO, + destinationYardId: DIRE, } as Rate; let service: BookingPricingService; @@ -56,6 +66,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'ETB', cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -81,6 +93,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'USD', cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -106,6 +120,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'ETB', cargoTotalWeightVgm: 50, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -126,4 +142,59 @@ describe('BookingPricingService — domestic corridor', () => { const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!; expect(line.currency).toBe('ETB'); }); + + // Rates are quoted per leg, so one configured for MOJO → DIRE must not price a + // shipment that runs LEBU → DIRE. Charging the wrong corridor's price because + // nobody configured this one yet is worse than billing no base freight. + it('does not price bulk off a rate configured for a different leg', async () => { + const booking = { + id: 'b-3', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 120, + originYardId: LEBU, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + expect(result.lineItems).toHaveLength(0); + }); + + it('does not price containers off a rate configured for a different leg', async () => { + const booking = { + id: 'b-4', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 50, + originYardId: LEBU, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [{ containerTypeId: 'ct-20', quantity: 3 }], + }); + + expect(result.lineItems).toHaveLength(0); + }); }); 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 091232611..cbb630794 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 @@ -10,11 +10,9 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; -import { - containersPerWagon, - wagonRemainder, -} from './consolidation.service'; +import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -307,8 +305,12 @@ export class BookingPricingService { vgmPerUnitTons: vgm, totalVgmTons: qty * vgm, isReefer: ct.isReefer, + // Per-container opt-ins — PER_CONTAINER surcharges bill these. + hazardousQuantity: Number(bc.hazardousQuantity ?? 0), + reeferQuantity: Number(bc.reeferQuantity ?? 0), + returnQuantity: Number(bc.returnQuantity ?? 0), }, - perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), + perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, }; }), @@ -477,7 +479,14 @@ export class BookingPricingService { const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { - const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); + const rate = this.pickRate( + liveRates, + rateType, + container.containerTypeId, + 'USD', + booking.originYardId, + booking.destinationYardId, + ); if (!rate) continue; usedRatesMap.set(rate.id, rate); @@ -517,8 +526,15 @@ export class BookingPricingService { } if (lines.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. const fallback = liveRates.find( - (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE', + (r) => + r.rateType === rateType && + r.currency === 'USD' && + r.status === 'LIVE' && + r.originYardId === booking.originYardId && + r.destinationYardId === booking.destinationYardId, ); if (fallback) { usedRatesMap.set(fallback.id, fallback); @@ -713,20 +729,32 @@ export class BookingPricingService { } } + /** + * Base freight is quoted per leg, so a rate only applies to a booking running + * the exact origin → destination it was configured for. There is deliberately + * no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment + * because nobody configured Mojo yet is worse than surfacing no line at all. + * Within the leg, a rate scoped to the container type wins over one that + * covers every type. + */ private pickRate( rates: Rate[], rateType: string, containerTypeId: string, currency: string, + originYardId: string, + destinationYardId: string, ): Rate | undefined { + const onLeg = rates.filter( + (r) => + r.rateType === rateType && + r.currency === currency && + r.originYardId === originYardId && + r.destinationYardId === destinationYardId, + ); return ( - rates.find( - (r) => - r.rateType === rateType && - r.currency === currency && - r.containerTypeId === containerTypeId, - ) ?? - rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) + onLeg.find((r) => r.containerTypeId === containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId) ); } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 6e96dc6f8..a978fd22b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -110,7 +110,6 @@ export function groupContainersBySize( name: ct.label?.trim() ? ct.label : ct.code, code: ct.code, is_reefer: ct.isReefer ?? false, - wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), }), ), })); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 9201e4fa9..cbbb999ef 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { BookingTransitionService } from './booking-transition.service'; /** @@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => { ); }); }); + +/** + * Export over-book gate at the customer's requestOperation step: export never + * splits, so the free-space check runs the moment the customer commits to a + * shipment day. When no single export train that day can carry the whole + * booking, `pickExportSchedule` throws and the request is refused BEFORE the + * booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated + * here (they are batched + splittable later). + */ +describe('BookingTransitionService — requestOperation export space gate', () => { + function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) { + const booking = { + id: 'b-1', + reference: 'BKG-1', + status: 'CLEARANCE_READY', + tradeDirection, + originYardId: 'o-1', + destinationYardId: 'd-1', + totalAmount: 1000, + contractId: null, + serviceType: { code: 'RAIL_CONTAINER' }, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + checkDayCompatibilityForBooking: jest + .fn() + .mockResolvedValue({ hasDeparture: true, hasCompatible: true }), + }; + const bookingBatchService = { + // Over-book → the export gate rejects; otherwise it returns a schedule id. + pickExportSchedule: overbook + ? jest.fn().mockRejectedValue(new ConflictException('Not enough train space')) + : jest.fn().mockResolvedValue('sched-1'), + }; + const notifier = { operationRequestedToStaff: jest.fn() }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + bookingBatchService as never, + bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + notifier as never, + ); + return { service, bookingsRepository, bookingBatchService }; + } + + it('rejects an over-booked export request and does NOT advance the booking', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'EXPORT', + true, + ); + await expect( + service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'), + ).rejects.toBeInstanceOf(ConflictException); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('lets an export request through when a train fits the whole booking', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'EXPORT', + false, + ); + await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + }); + + it('never runs the export gate for an import request', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'IMPORT', + true, // would reject IF called — proves it is not called + ); + await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); + expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled(); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + }); +}); 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 2aed86027..aacc68012 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 @@ -42,6 +42,7 @@ export class BookingTransitionService { private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, + @Inject(forwardRef(() => BookingContractService)) private readonly contractService: BookingContractService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @@ -1036,6 +1037,40 @@ export class BookingTransitionService { ); } + // Export is FCFS and never splits — a booking must ride one train whole. So + // the free-space check belongs HERE, the moment the customer commits to a + // shipment day, not later at staff operation-accept. Blocking now stops the + // customer booking more wagons than any single export train that day can + // still carry; `exportSpaceReport` throws a 409 whose message carries the + // largest bookable leftover ("reduce to N wagons or pick another day"). + // Import/domestic bookings are batched + splittable, so they are NOT gated + // here — they get an advisory count below and the batch engine sizes them. + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + const isExportTrain = + booking.tradeDirection === "EXPORT" && + !isRoadService(booking.serviceType); + if (isExportTrain) { + // With export split ON the booking no longer has to ride ONE train whole: + // the largest fitting part is offered and the leftover rebooks on the next + // train. So the day is only unbookable when NO export train that day has + // any room at all — reject on the day total, not on a single-train fit. + // With the flag off this stays the strict whole-booking gate. + if (process.env.FREIGHT_EXPORT_SPLIT === "true") { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + scheduledBooking, + eatDay(date), + "EXPORT", + ); + if (!fitting.length) { + throw new ConflictException( + "No export train on this day has space left — pick another shipment day.", + ); + } + } else { + await this.bookingBatchService.pickExportSchedule(scheduledBooking); + } + } + await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, @@ -1045,6 +1080,47 @@ export class BookingTransitionService { return fresh; } + /** + * Advisory availability for a shipment day the customer is considering — a + * planning hint for the day picker, computed but never enforced. For EXPORT it + * mirrors the real request-time gate: `fits` is whether a single open train + * that day can carry the WHOLE booking (export never splits), and `freeWagons` + * is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the + * TOTAL room across the day's trains for the booking's wagon type (the batch + * engine may still split or defer a remainder), and `fits` is whether that + * total covers the booking. `trainsForDay` is false when no departure carries + * the leg — the day is unbookable regardless of space. + */ + async dayAvailabilityForBooking( + bookingId: string, + scheduledDate: string, + ): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> { + const booking = await this.bookingsService.findById(bookingId); + const date = new Date(scheduledDate); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException("A valid schedule date is required"); + } + const day = eatDay(date); + const isExportTrain = + booking.tradeDirection === "EXPORT" && + !isRoadService(booking.serviceType); + + if (isExportTrain) { + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + const report = + await this.bookingBatchService.exportSpaceReport(scheduledBooking); + return { + fits: report.scheduleId != null, + freeWagons: report.bestAvailable?.wagons ?? 0, + trainsForDay: report.trainsForDay && report.corridorMatched, + }; + } + + const { freeWagons, need, trainsForDay } = + await this.bookingBatchService.dayImportAvailability(booking, day); + return { fits: freeWagons >= need, freeWagons, trainsForDay }; + } + /** * Operations team reviews a pending operation request (capacity, documents, * route). Two outcomes: diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 53554b2e3..56e788b93 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -370,6 +370,31 @@ export class BookingsController { return this.bookingsService.availableDaysForBooking(id); } + @Get(':id/day-availability') + @ApiOperation({ + summary: + 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + + 'Export: whole-booking fit + largest single-train leftover. ' + + 'Import/domestic: total room across the day for the booking\'s wagon type.', + }) + async dayAvailability( + @Param('id', ParseUUIDPipe) id: string, + @Query('date') date: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.transitionService.dayAvailabilityForBooking(id, date); + } + @Get(':id/mile-summary') @ApiOperation({ summary: 'First/last-mile operational summary for a booking (customer-safe)', diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index e7806c13c..15c5e751d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -47,6 +47,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; @@ -106,6 +107,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, + ContractRateScheduleBuilder, ContractRendererService, ContractPdfService, CustomerTruckAssignmentsRepository, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 72211925f..c93ebd9c7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -149,7 +150,7 @@ export class BookingsRepository extends BaseRepository { for (const item of containers) { const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt); const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); // A per-line breakdown can never exceed the line's own quantity. @@ -179,7 +180,10 @@ export class BookingsRepository extends BaseRepository { async calculateWagonCount(bookingId: string): Promise { const result = await this.dataSource .createQueryBuilder() - .select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total') + .select( + 'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))', + 'total', + ) .from(BookingContainer, 'bc') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .where('bc.booking_id = :bookingId', { bookingId }) @@ -1283,6 +1287,23 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** Same as {@link findAllBySchedule} but for a page of schedules at once — + * one query instead of one per schedule (batch monitoring board). */ + findAllBySchedules(scheduleIds: string[]): Promise { + if (!scheduleIds.length) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds }) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository @@ -1338,6 +1359,10 @@ export class BookingsRepository extends BaseRepository { if (!bookingIds.length) return Promise.resolve([]); return this.bookingRepo(manager).find({ where: { id: In(bookingIds) }, + // Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins + // multiply rows badly in a single join (hot path for every allocation + // preview / assignment validation). + relationLoadStrategy: 'query', relations: { company: true, originYard: true, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 588e4f1ed..d9dd53f94 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, @@ -438,7 +439,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, - wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), + wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), }; }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index e16b97997..9a87054e2 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { Booking } from './entities/booking.entity'; @@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult { messages: string[]; } -/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ -export function containersPerWagon(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - export function wagonRemainder(quantity: number, perWagon: number): number { const r = quantity % perWagon; return r; @@ -73,7 +67,7 @@ export class ConsolidationService { const slots: ConsolidationSlot[] = []; for (const [containerTypeId, quantity] of quantityByType) { const ct = await this.containerTypesService.findById(containerTypeId); - const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const perWagon = containersPerWagonForSize(ct.sizeFt); const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index c930d7aa1..a793cc558 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto { @ApiProperty() is_reefer!: boolean; - - @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) - wagons_per_unit!: number; } export class BookingReferenceContainerSizeGroupDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index 619013280..217ffe5f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** This container ships back empty after unloading (equipment return). */ + @Column({ name: 'is_return', type: 'boolean', default: false }) + isReturn!: boolean; + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; 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 15ca85c73..6a0365854 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { + /** + * A company still being filled in by its owner in the portal wizard: it was + * self-registered (so it has an external profile) and nobody has submitted + * onboarding yet. The row exists from the wizard's first click, carrying a + * placeholder name + TIN, so it must not be offered up for review. + * Staff-created companies have no external profiles and are never drafts. + */ + private static readonly DRAFT_SQL = `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, kind, status } = query; + const { + page = 1, + pageSize = 20, + search, + type, + kind, + status, + onboardingCompleted, + } = query; const qb = this.repository .createQueryBuilder('company') .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + // External profiles carry onboardingCompleted, which the backoffice list + // uses to flag customers still mid-onboarding (not yet reviewable). + .leftJoinAndSelect('company.profiles', 'profiles') .where('company.deleted_at IS NULL'); if (type) { @@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (onboardingCompleted !== undefined) { + qb.andWhere( + onboardingCompleted + ? `NOT ${CompaniesRepository.DRAFT_SQL}` + : CompaniesRepository.DRAFT_SQL, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository { } async getStats(): Promise { - const rows: { status: string; count: string }[] = await this.repository - .createQueryBuilder('company') - .select('company.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('company.deleted_at IS NULL') - .groupBy('company.status') - .getRawMany(); + // Drafts are counted separately rather than under `pending`: they carry + // status=pending from creation, which would otherwise inflate the review + // queue's KPI with customers who haven't submitted anything yet. + const rows: { status: string; is_draft: boolean; count: string }[] = + await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .addGroupBy(CompaniesRepository.DRAFT_SQL) + .getRawMany(); - const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const map = new Map(); + let onboarding = 0; + let total = 0; + for (const row of rows) { + const count = parseInt(row.count, 10); + total += count; + if (row.is_draft) onboarding += count; + else map.set(row.status, (map.get(row.status) ?? 0) + count); + } return { total, active: map.get('active') ?? 0, pending: map.get('pending') ?? 0, + onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, }; 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 1027de955..04b8790cd 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -372,6 +372,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + // External profiles carry the onboarding flag the backoffice gates + // approval decisions on (see ResponseCompanyDto.onboardingCompleted). + company.profiles = await this.profilesRepo.findByCompanyId(id); return company; } @@ -962,6 +965,28 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // A self-registered company is only reviewable once its owner submits the + // onboarding wizard (markOnboardingComplete) — until then its profiles are + // half-filled drafts and approving one would mint a reference against an + // application that doesn't exist yet. Staff-created companies have no + // external profiles and are exempt. + // + // Only the review decision itself is gated (a profile still awaiting one: + // Pending, or Rejected and awaiting re-approval). Profiles already in + // service stay managable so staff can suspend/blacklist them — including to + // undo an approval granted before this guard existed. + const awaitingReview = + existing.status === ProfileStatus.Pending || + existing.status === ProfileStatus.Rejected; + if (awaitingReview) { + const owners = await this.profilesRepo.findByCompanyId(existing.companyId); + if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { + throw new BadRequestException( + "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", + ); + } + } + // 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 }; 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 167526988..43d4e9905 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 @@ -1,4 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; import { NotificationAudience, NotificationPriority, @@ -8,6 +10,7 @@ import { import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -28,11 +31,13 @@ export class CompanyNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** Send SMS + email to the company contact; log-only on failure. */ private async notifyContact(company: Company, message: string): Promise { - const phone = company.contactPersonPhone ?? company.phone ?? null; + const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id); const email = company.email ?? company.generalManagerEmail ?? null; if (phone) { 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 a6b8b3b6e..c054b3531 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 @@ -1,7 +1,10 @@ export class CompanyStatsResponseDto { total!: number; active!: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending!: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding!: number; suspended!: number; blacklisted!: 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 4dbb932cb..adaa12479 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 @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; @@ -37,4 +37,14 @@ export class ListCompaniesQueryDto { @IsOptional() @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + + @ApiPropertyOptional({ + description: + "Filter by onboarding submission. `true` = reviewable applications; " + + "`false` = drafts still in the portal wizard. Omit for both.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + onboardingCompleted?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 0c783cbcf..a05812558 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -62,6 +62,13 @@ export class ResponseCompanyDto { attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; + /** + * Whether the owning portal user has submitted the onboarding wizard. + * Approval decisions are blocked while this is false. Staff-created + * companies (no external profiles) count as completed. Undefined when the + * external profiles weren't loaded. + */ + onboardingCompleted?: boolean; createdAt: Date; updatedAt: Date; @@ -84,6 +91,10 @@ export class ResponseCompanyDto { this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), ); + this.onboardingCompleted = company.profiles + ? company.profiles.length === 0 || + company.profiles.some((p) => p.onboardingCompleted) + : undefined; this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index d2aea9bc7..d2755fece 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { RateSchedule } from "../../contracts/contract-rate-schedule.builder"; import { getTemplateMeta } from "../../contracts/contract-template.registry"; import { ContractDynamicTemplateView, @@ -177,17 +178,9 @@ export class ContractTemplatesService { const isBulk = code.endsWith("_BULK"); const now = new Date(); - const unitRates = isBulk - ? [ - { label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" }, - { label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" }, - { label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" }, - ] - : [ - { label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" }, - { label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" }, - { label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" }, - ]; + // Representative rate schedule so the admin preview shows the live-rate + // table shape. Real contracts populate this from freight.rates (LIVE). + const rateSchedule = this.mockRateSchedule(code, isBulk); return { bookingId: "00000000-0000-0000-0000-000000000000", @@ -239,13 +232,16 @@ export class ContractTemplatesService { lastMileDeliveryAddress: "—", }, pricing: { - displayMode: "UNIT_RATES", - unitRates, + lineItems: [], + surcharges: [], + totalAmount: 0, currency: "USD", equipmentReturn: isBulk ? "—" : "With empty return", originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", destinationLabel: "Galaan Multipurpose Port (GMP)", + containerLines: [], } as unknown as ContractViewModel["pricing"], + rateSchedule, signatures: [], canSignCustomer: false, canSignStaff: false, @@ -256,6 +252,43 @@ export class ContractTemplatesService { }; } + /** Static, representative rate schedule for the admin preview only. */ + private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { + const dir = code.startsWith("IMPORT") + ? "import" + : code.startsWith("EXPORT") + ? "export" + : "domestic"; + const lane = + dir === "export" + ? "Galaan Multipurpose Port → SGTD" + : dir === "domestic" + ? "Mojo Dry Port → Dire Dawa" + : "Negad → Mojo Dry Port"; + + const freightLanes = isBulk + ? [ + { route: lane, cargo: "Wheat", currency: "USD", amount: "100", unit: "per wagon" }, + ] + : [ + { route: lane, cargo: "40ft GP", currency: "USD", amount: "200", unit: "per container" }, + { route: lane, cargo: "20ft GP", currency: "USD", amount: "180", unit: "per container" }, + ]; + + return { + freightLanes, + additionalServices: [ + { route: "First-mile pickup by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, + { route: "Last-mile delivery by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, + ], + surcharges: [ + { route: "Customs clearance service", cargo: "—", currency: "USD", amount: "120", unit: "flat" }, + ], + isEmpty: false, + currencyLabel: "USD", + }; + } + private assertCode(code: string): ContractTemplateCode { const upper = code?.toUpperCase() as ContractTemplateCode; if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts index 43ab9d22a..8de7aed87 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts @@ -161,6 +161,21 @@ export class ClearanceFeeService { return invoice; } + /** + * Retire (idempotently) the unpaid contract-level fee invoice when the + * contract reaches a terminal state — a dead contract must not leave a + * payable clearance invoice open for the customer to settle. No-op when the + * fee was already paid or never invoiced (mirrors the booking cancel path, + * {@link BillingService.expirePayable}). + */ + async expireForContract(contractId: string): Promise { + return this.billing.expirePayable( + Freight.InvoiceSource.Clearance, + contractId, + CLEARANCE_CONTRACT_INVOICE_TYPE, + ); + } + /** * Settlement branch point for `clearance`-source invoices: unlock the * document-upload step the fee was gating. Idempotent — a replayed event on 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 a0c2b42a4..fd1f530b8 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 @@ -25,6 +25,8 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -38,7 +40,10 @@ import { ContractsRepository } from './contracts.repository'; import { ClearanceFeeService } from './clearance-fee.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; -import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CreateBookingContainerLineDto, + CreateBookingUnderContractDto, +} from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; @@ -53,6 +58,18 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * Outstanding split remainder of a contract: what was booked in the first split + * booking's pre-split snapshot MINUS everything currently booked. Container + * contracts report per size; bulk reports one tonnage figure. `null` when the + * contract has no live split chain. Consumed by the remainder-placement engine + * to size the auto-created remainder booking. + */ +export type SplitOutstanding = { + bySize: Map; + bulk: { total: number; outstanding: number } | null; +}; + /** * The single create path for shipment bookings under a contract. * @@ -269,8 +286,8 @@ export class ContractBookingService { tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, @@ -579,11 +596,40 @@ export class ContractBookingService { if (!booking || booking.contractId !== contract.id) { throw new NotFoundException(`Booking ${bookingId} not found on this contract`); } - if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) { + if ( + !['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes( + booking.status, + ) + ) { throw new BadRequestException( 'Clearance must be finalized before the booking can be completed.', ); } + // An unpaid booking that expired at train dispatch keeps its finished + // per-booking clearance — GL rebooks it onto a new shipment day instead of + // forcing the customer through a new shipment request + clearance fee. + if (booking.status === 'EXPIRED') { + // Only a booking that completed once (it has a price, so its clearance + // finished and cargo is persisted) can be rebooked after expiry. + if (!(Number(booking.totalAmount) > 0)) { + throw new BadRequestException( + 'Only a previously completed booking can be rebooked after it expires.', + ); + } + // Expiry released the booking's contract-capacity hold; if the payload + // re-states the cargo, make sure the released share is still free. + if (dto.containers?.length || dto.bulkLines?.length) { + await this.assertWithinQuantityCap(contract, dto); + } + // Drop the departed train's link and fall into the day-only resubmit + // path below — same machinery as OPERATION_CHANGES_REQUESTED. + await this.bookingsRepository.update(booking.id, { + status: 'OPERATION_CHANGES_REQUESTED', + trainScheduleId: null, + } as never); + booking.status = 'OPERATION_CHANGES_REQUESTED'; + booking.trainScheduleId = null; + } // Path B: only GL Ethiopia completes a customs instance — the customer // never enters shipment data on a customs contract. if (contract.customsClearingEnabled) { @@ -965,9 +1011,11 @@ export class ContractBookingService { * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the * contract has no live split booking. */ - private async splitOutstanding( - contract: Contract, - ): Promise<{ bySize: Map; bulk: { total: number; outstanding: number } | null } | null> { + /** + * Public: the remainder-placement engine reads this to size the auto-created + * remainder booking. Returns `null` when there is no live split chain. + */ + async splitOutstanding(contract: Contract): Promise { const first = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') @@ -1014,6 +1062,25 @@ export class ContractBookingService { const probe = await this.buildExportProbe(contract, route, dto, yards); const report = await this.bookingBatchService.exportSpaceReport(probe); if (report.scheduleId) return; + + // With export split ON a booking no longer has to ride ONE train whole: the + // largest fitting part is offered and the leftover is rebooked on the next + // train. Rejecting on the single-train fit here would block exactly the + // bookings the split exists to serve — including the auto-created remainder, + // which by definition did not fit the train it was split off. Fall back to + // the day total: unbookable only when NO export train that day has room. + if (process.env.FREIGHT_EXPORT_SPLIT === 'true') { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + probe, + eatDay(new Date(dto.scheduledDate)), + 'EXPORT', + ); + if (fitting.length > 0) return; + throw new BadRequestException( + 'No export train on this day has space left — pick another shipment day.', + ); + } + throw new BadRequestException( report.fullMessage ?? 'Not enough train space for this day.', ); @@ -1053,7 +1120,7 @@ export class ContractBookingService { bc.quantity = line.quantity; bc.containerTypeId = ct.id; bc.containerType = ct; - bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)); + bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)); bc.totalVgmTons = (line.units ?? []).reduce( (sum, u) => sum + Number(u.vgmTons ?? 0), 0, @@ -1416,6 +1483,53 @@ export class ContractBookingService { ); } + /** + * Per-line handling counts. Each physical container carries its own hazardous + * / reefer / return switch (entered next to its VGM), so the count is however + * many units opted in. Forms that predate per-unit switches send line-level + * counts and no unit flags — those are honoured as-is. + */ + private handlingCounts(line: CreateBookingContainerLineDto): { + hazardousQuantity: number; + reeferQuantity: number; + returnQuantity: number; + } { + const units = line.units ?? []; + const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn); + if (!flagged) { + return { + hazardousQuantity: Number(line.hazardousQuantity ?? 0), + reeferQuantity: Number(line.reeferQuantity ?? 0), + returnQuantity: Number(line.returnQuantity ?? 0), + }; + } + return { + hazardousQuantity: units.filter((u) => u.isHazardous).length, + reeferQuantity: units.filter((u) => u.isReefer).length, + returnQuantity: units.filter((u) => u.isReturn).length, + }; + } + + /** + * Booking-level hazardous / reefer flags. The CONTRACT gates the service; the + * per-container opt-ins decide whether THIS shipment actually uses it. A + * container contract that allows hazardous but a booking where nobody ticked + * the switch is not a hazardous booking, and must not fire the surcharge. + * Bulk keeps the contract flag — it has its own bulk*Quantity fields. + */ + private resolveShipmentHandlingFlag( + contract: Contract, + dto: CreateBookingUnderContractDto, + field: 'hazardousQuantity' | 'reeferQuantity', + ): boolean { + const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer; + if (!gated) return false; + if (contract.freightType !== 'CONTAINER') return true; + const lines = dto.containers ?? []; + if (!lines.length) return Boolean(gated); + return lines.some((l) => this.handlingCounts(l)[field] > 0); + } + /** * Resolve the booking's equipment return from the per-line return quantities * (container freight). The CONTRACT gates the service — like hazardous: @@ -1435,7 +1549,7 @@ export class ContractBookingService { const lines = dto.containers ?? []; for (const line of lines) { - const qty = Number(line.returnQuantity ?? 0); + const qty = this.handlingCounts(line).returnQuantity; if (qty === 0) continue; if (contract.equipmentReturn !== 'WITH_RETURN') { throw new BadRequestException( @@ -1451,7 +1565,7 @@ export class ContractBookingService { } if (contract.equipmentReturn === 'WITH_RETURN') { - const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0); + const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0); return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN'; } return legacy; @@ -1489,9 +1603,10 @@ export class ContractBookingService { ); } + const counts = this.handlingCounts(line); const containerType = await this.resolveContainerTypeForSize( line.containerSize, - contract.isReefer || (line.reeferQuantity ?? 0) > 0, + contract.isReefer || counts.reeferQuantity > 0, ); const vgmPerUnit = line.units.length @@ -1505,15 +1620,13 @@ export class ContractBookingService { containerTypeId: containerType.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: counts.hazardousQuantity, + reeferQuantity: counts.reeferQuantity, returnQuantity: - contract.equipmentReturn === 'WITH_RETURN' - ? (line.returnQuantity ?? 0) - : 0, + contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, - wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), isOverweight: false, overweightExcessTons: null, } as Partial), @@ -1529,6 +1642,8 @@ export class ContractBookingService { vgmTons: unit.vgmTons, isHazardous: unit.isHazardous ?? false, isReefer: unit.isReefer ?? false, + isReturn: + contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false), sortOrder: sortOrder++, }), ); @@ -1629,12 +1744,14 @@ export class ContractBookingService { paymentCurrency: contract.paymentCurrency, serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, shippingLineId: null, contractRouteId: route?.id ?? null, + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, @@ -1643,15 +1760,15 @@ export class ContractBookingService { containerTypeId: ct.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: this.handlingCounts(line).hazardousQuantity, + reeferQuantity: this.handlingCounts(line).reeferQuantity, returnQuantity: contract.equipmentReturn === 'WITH_RETURN' - ? (line.returnQuantity ?? 0) + ? this.handlingCounts(line).returnQuantity : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, - wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), }) as Booking; 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 575767a87..e35bd2bf5 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 @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -24,6 +27,8 @@ export class ContractNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(c: Contract): string { @@ -37,7 +42,9 @@ export class ContractNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(c)}`); - const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const phone = c.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) + : null; const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; if (phone) { 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 60db03ff1..bba044c85 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 @@ -4,6 +4,8 @@ import { Injectable, Logger, } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; import { insertWithGeneratedReference } from '@edr/api-common'; @@ -102,8 +104,41 @@ export class ContractTransitionService { private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, private readonly clearanceFeeService: ClearanceFeeService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} + /** + * The phone the signing OTP is sent to and verified against: the signer's own + * IAM account number. + * + * H12(b): resolved server-side from the authenticated user id, never from the + * request body — a caller-supplied number would let an attacker point the code + * at their own phone. Ownership is already gated separately by + * {@link ContractsService.assertCustomerCanAccessContract}, so this binds the + * signature to the *person* signing rather than to a company landline that may + * be shared, stale, or imported from eTrade. + */ + private async resolveSignerPhone(signerUserId?: string): Promise { + if (!signerUserId) { + // Unreachable in practice (the ownership gate rejects a missing user + // first), but never fall back to another number if it ever changes. + throw new BadRequestException('Authentication required to sign'); + } + const rows: Array<{ phone_number: string | null }> = + await this.dataSource.query( + `SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`, + [signerUserId], + ); + const phone = rows[0]?.phone_number?.trim(); + if (!phone) { + throw new BadRequestException( + 'Your account has no registered phone number. Add one in Settings → Account before signing.', + ); + } + return phone; + } + /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ async submit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -419,6 +454,10 @@ export class ContractTransitionService { actorId, 'STAFF', ); + // Stop the open-invoice leak: a rejected contract must not leave a payable + // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). + await this.clearanceFeeService.expireForContract(contractId); + await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); @@ -457,6 +496,10 @@ export class ContractTransitionService { 'STAFF', ); + // Stop the open-invoice leak: a rejected contract must not leave a payable + // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). + await this.clearanceFeeService.expireForContract(contractId); + await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); @@ -796,10 +839,10 @@ export class ContractTransitionService { } /** - * Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone — - * the same number {@link sign} verifies against. The client never picks the - * number (that is the H12(b) trust property): it only asks us to send, and we - * resolve the phone from the contract. Returns a masked hint so the UI can + * Send the sudo-mode signing OTP to the SIGNER's own registered phone — the + * same number {@link sign} verifies against. The client never picks the number + * (that is the H12(b) trust property): it only asks us to send, and we resolve + * the phone from the authenticated user id. Returns a masked hint so the UI can * say where the code went without exposing the full number. */ async sendSigningOtp( @@ -815,14 +858,9 @@ export class ContractTransitionService { ); assertContractStatus(contract, ['CONTRACT_READY']); - const companyPhone = contract.company?.phone?.trim(); - if (!companyPhone) { - throw new BadRequestException( - 'The contract company has no registered phone on file to send the signing OTP to', - ); - } - await this.otpService.sendOtp({ phone: companyPhone }); - return { sentTo: maskPhone(companyPhone) }; + const signerPhone = await this.resolveSignerPhone(options.signerUserId); + await this.otpService.sendOtp({ phone: signerPhone }); + return { sentTo: maskPhone(signerPhone) }; } /** Customer signs the ready contract → SIGNED_CUSTOMER. */ @@ -848,20 +886,18 @@ export class ContractTransitionService { throw new BadRequestException('Customer has already signed this contract'); } // Sudo-mode gate: a fresh, single-use OTP must be verified before the - // signature is applied. H12(b): verify against the CONTRACT COMPANY's - // registered phone — never the caller-supplied dto.otpPhone, which an - // attacker could point at their own phone to sign someone else's - // contract. The OTP is issued to the company's registered number. - const companyPhone = contract.company?.phone?.trim(); - if (!companyPhone) { - throw new BadRequestException( - 'The contract company has no registered phone on file to verify the signing OTP against', - ); - } + // signature is applied. H12(b): verify against the SIGNER's own registered + // phone, resolved server-side from the authenticated user id — never a + // caller-supplied number, which an attacker could point at their own + // phone. Ownership is already asserted above, so this proves the specific + // person holding the account is present, not merely that someone reached a + // shared company line. Must resolve identically to sendSigningOtp, or send + // and verify would target different numbers. + const signerPhone = await this.resolveSignerPhone(options.signerUserId); if (!dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp); + await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 256b11b63..3b5a30ca0 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -50,6 +50,15 @@ export class CreateContainerUnitDto { @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'This container ships back empty (equipment return).', + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReturn?: boolean; } export class CreateBookingContainerLineDto { diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index f0676b629..5ddffad6c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -28,17 +28,13 @@ export class SignContractDto { consentText?: string; // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code - // SMS'd to the signer's phone, verified server-side before the signature is - // applied. `otpPhone` is the number the code was sent to (the signed-in - // customer's registered phone). + // SMS'd to the signer's registered phone, verified server-side before the + // signature is applied. The number itself is deliberately NOT part of this + // DTO — the server resolves it from the authenticated user id, so a caller + // cannot redirect the challenge to a phone they control. @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) @IsOptional() @IsString() @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) otp?: string; - - @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) - @IsOptional() - @IsString() - otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index aa1c956e0..f155a27be 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -256,6 +256,11 @@ export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Terminal contracts stay on the list — the clearance hub is GL's history of + // everything that passed through, not just the live work queue. + 'EXPIRED', + 'CANCELLED', + 'REJECTED', ] as const; /** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ @@ -275,12 +280,22 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ 'OPERATION_REQUEST_PENDING', 'OPERATION_CHANGES_REQUESTED', 'ROAD_DISPATCH_PENDING', + // Payment phase — the booking is selected/awaiting the customer's payment. + 'SELECTED_FOR_BATCH', + 'PNR_GENERATED', + 'AWAITING_PAYMENT', + 'PAYMENT_VERIFICATION_IN_PROGRESS', 'IN_TRANSIT', 'ARRIVED', 'PAID', 'COMPLETED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Terminal bookings stay on the list — EXPIRED especially: GL rebooks it + // from here, and the hub doubles as clearance history. + 'EXPIRED', + 'CANCELLED', + 'REJECTED', ] as const; /** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index d30ebe3a9..e9f3af958 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -33,6 +33,7 @@ import { WsAuthService } from "./ws-auth.service"; WsAuthService, NotificationInboxService, ], - exports: [NotificationInboxService], + // WsAuthService is reused by the support-chat gateway for handshake auth. + exports: [NotificationInboxService, WsAuthService], }) export class NotificationInboxModule {} diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 9d7f32c3d..9f121e18a 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -1,6 +1,10 @@ import { DataSource } from 'typeorm'; import { NotificationsService } from './notifications.service'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from './resolve-company-phone.util'; /** * Best-effort SMS + email fan-out to a company's contacts. Looks up the @@ -15,9 +19,10 @@ export async function sendCompanyChannels( ): Promise { const [contact]: Array<{ phone: string | null; email: string | null }> = await dataSource.query( - `SELECT COALESCE(phone, etrade_phone) AS phone, email - FROM freight.companies - WHERE id = $1 AND deleted_at IS NULL`, + `SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email + FROM freight.companies co + ${primaryContactUserJoin('co')} + WHERE co.id = $1 AND co.deleted_at IS NULL`, [companyId], ); if (contact?.phone) { diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts new file mode 100644 index 000000000..511f3cf8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts @@ -0,0 +1,60 @@ +import { DataSource, EntityManager } from "typeorm"; + +/** + * Where a customer-facing SMS actually goes. + * + * The person who signs up, logs in, and receives OTPs is an IAM user, and + * `iam.users.phone_number` is the number they control and can change themselves + * (see the account settings flow). A company's own `phone` is business contact + * data — often a landline, a shared desk, or a stale eTrade import — so it is + * the fallback, not the source. + * + * `companies.contact_person_phone` is deliberately NOT consulted: the live write + * path stores that value in the `attributes` jsonb and has never populated the + * column, so every reader of it was silently falling through to `phone` anyway. + */ + +/** + * LEFT JOIN a company alias to its primary contact's IAM user, exposing + * `pc.phone_number`. + * + * LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a + * company having two `is_primary_contact` rows, and a plain join would then + * duplicate the company row — which in a fan-out query means sending the same + * customer the same SMS twice. + * + * `alias` is always a code-controlled literal, never caller input. + */ +export function primaryContactUserJoin(alias: string): string { + return ` + LEFT JOIN LATERAL ( + SELECT u.phone_number + FROM freight.external_profiles ep + JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true + WHERE ep.company_id = ${alias}.id + AND ep.is_primary_contact = true + AND ep.deleted_at IS NULL + ORDER BY ep.created_at + LIMIT 1 + ) pc ON true`; +} + +/** SQL expression for the company's SMS number, given the joined `pc` alias. */ +export function companyNotifyPhoneExpr(alias: string): string { + return `COALESCE(pc.phone_number, ${alias}.phone)`; +} + +/** The SMS number for one company, or null when neither source has one. */ +export async function resolveCompanyNotifyPhone( + db: DataSource | EntityManager, + companyId: string, +): Promise { + const rows: Array<{ phone: string | null }> = await db.query( + `SELECT ${companyNotifyPhoneExpr("co")} AS phone + FROM freight.companies co + ${primaryContactUserJoin("co")} + WHERE co.id = $1 AND co.deleted_at IS NULL`, + [companyId], + ); + return rows[0]?.phone ?? null; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 5b8a3ddca..d96bcf492 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -190,12 +190,16 @@ export class PaymentService { */ async initiate(input: InitiateIntentInput): Promise { try { + + + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, referenceType: PaymentReferenceType.SHIPMENT, referenceId: input.referenceId, orderRef: input.orderRef, - amountMinor: input.amountMinor, + // amountMinor: input.amountMinor, + amountMinor:1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, diff --git a/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts new file mode 100644 index 000000000..ed64c1edb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts @@ -0,0 +1,15 @@ +/** + * Wagon fraction one container occupies, derived from its size: 40ft = 1 wagon, + * 20ft = 0.5 (two per wagon). Unknown size reads as a whole wagon so counts + * never under-book. + */ +export function wagonsPerUnitForSize(sizeFt?: number | null): number { + const size = Number(sizeFt); + if (!Number.isFinite(size) || size <= 0) return 1; + return size >= 40 ? 1 : 0.5; +} + +/** Containers that fit on one wagon for a given container size (inverse of the wagon fraction). */ +export function containersPerWagonForSize(sizeFt?: number | null): number { + return Math.max(1, Math.round(1 / wagonsPerUnitForSize(sizeFt))); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 6424f013e..0b3334431 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -29,8 +29,7 @@ export class PriorityConfigsController { @Get('next-range') @RuleEngineView('priority-configs') @ApiOperation({ - summary: - "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling", + summary: 'Where the next contiguous range for a type (and currency) must start', }) nextRange( @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts new file mode 100644 index 000000000..9972ab06a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { isSuperAdmin } from '../../../common/freight-permission.util'; +import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { RateChangeStatus } from '../entities/rate-change-request.entity'; +import { RateChangeRequestsService } from '../services/rate-change-requests.service'; + +/** + * Edits to LIVE rates. Staff with `manage` propose (submit); only holders of + * `approve` decide. Until a change is approved the live rate keeps its current + * value, so pricing never moves on an unapproved edit. + */ +@ApiTags('rate-change-requests') +@Controller('rate-change-requests') +@ApiBearerAuth() +export class RateChangeRequestsController { + constructor(private readonly service: RateChangeRequestsService) {} + + @Post() + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Propose a change to a LIVE rate' }) + submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) { + return this.service.submit(dto, user?.id); + } + + @Get() + @RuleEngineView('rates') + @ApiOperation({ summary: 'List rate change requests, optionally by status' }) + list(@Query('status') status?: RateChangeStatus) { + return this.service.list(status); + } + + @Post(':id/approve') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Approve a rate change and put it into effect' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user)); + } + + @Post(':id/reject') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Reject a rate change — the rate keeps its current value' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id, dto.decisionNote); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index c2c034c18..6f7882c76 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -37,6 +37,14 @@ export class CreateCargoTypeDto { @IsBoolean() requiresDirectorApproval?: boolean; + @ApiPropertyOptional({ + default: false, + description: 'When true, bookings of this cargo type incur the flat LASHING surcharge.', + }) + @IsOptional() + @IsBoolean() + hasLashing?: boolean; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index a01ba4b5b..379997e6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -14,12 +13,6 @@ export class CreateContainerTypeDto { @Max(40) sizeFt!: number; - @ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' }) - @IsNumber() - @Min(0.01) - @Transform(({ value }) => Number(value)) - wagonsPerUnit!: number; - @ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 9a4cd642b..41971bbf1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -9,6 +9,7 @@ import { const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; const CURRENCIES = ['USD'] as const; +export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; export class CreateRateDto { @ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' }) @@ -37,6 +38,31 @@ export class CreateRateDto { @IsIn([...TRADE_DIRECTIONS]) tradeDirection?: string; + @ApiPropertyOptional({ + enum: INTERCITY_KINDS, + description: + 'Whether an intercity rate covers containers or bulk. Required when appliesTo = INTERCITY; ignored otherwise. Not stored — it selects the INTERCITY_CONTAINER / INTERCITY_BULK rate type.', + }) + @IsOptional() + @IsIn([...INTERCITY_KINDS]) + intercityKind?: string; + + @ApiPropertyOptional({ + description: + 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', + }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ + description: + 'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', + }) + @IsOptional() + @IsUUID() + destinationYardId?: string; + @ApiPropertyOptional({ enum: CURRENCIES }) @IsOptional() @IsIn([...CURRENCIES]) @@ -48,9 +74,14 @@ export class CreateRateDto { @Transform(({ value }) => Number(value)) rateValue!: number; - @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) + @ApiPropertyOptional({ + enum: RATE_UNITS, + description: + 'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.', + }) + @IsOptional() @IsIn([...RATE_UNITS]) - rateUnit!: string; + rateUnit?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 53583e3e8..295e9e72b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -17,6 +17,15 @@ export class CreateYardDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'This yard can load/unload cargo. Intercity bookings may only be loaded at their origin and unloaded at their destination when it is a facility.', + }) + @IsOptional() + @IsBoolean() + hasFacility?: boolean; + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) @IsOptional() @IsInt() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts new file mode 100644 index 000000000..6c5dc3fbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; + +import { UpdateRateDto } from './update-rate.dto'; + +export class SubmitRateChangeDto { + @ApiProperty({ description: 'The LIVE rate to reprice' }) + @IsUUID() + rateId!: string; + + @ApiProperty({ + description: + 'Proposed field changes. The live rate keeps its current values until this is approved.', + type: UpdateRateDto, + }) + @ValidateNested() + @Type(() => UpdateRateDto) + update!: UpdateRateDto; +} + +export class DecideRateChangeDto { + @ApiPropertyOptional({ description: 'Optional note shown to the requester' }) + @IsOptional() + @IsString() + @MaxLength(1000) + decisionNote?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 7396595d1..037bf08be 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -53,6 +53,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; + /** + * When true, any booking of this cargo type incurs the flat LASHING surcharge + * (the LASHING-trigger rate). Set on commodities that need EDR-provided + * lashing/securing; leave false for cargo that ships without it. + */ + @Column({ name: 'has_lashing', type: 'boolean', default: false }) + hasLashing!: boolean; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index 2347426ca..642f6942e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -16,9 +16,6 @@ export class ContainerType extends BaseEntity { @Column({ name: 'size_ft', type: 'smallint', nullable: true }) sizeFt!: number; - @Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true }) - wagonsPerUnit!: number; - @Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true }) isReefer!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts new file mode 100644 index 000000000..00a1c0ba3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Rate } from './rate.entity'; + +export type RateChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +/** + * One proposed edit to a LIVE rate, awaiting approval. + * + * A LIVE rate is what pricing actually charges, so it is never mutated in + * place: the edit is filed here and the live row keeps its old value until an + * approver applies it. `payload` holds only the changed fields (an + * UpdateRateDto patch), `rateId` the rate being repriced. + * + * DRAFT rates are not covered — nothing prices off a draft, so those still + * edit directly and reach LIVE through the existing submit/approve flow. + */ +@Entity({ schema: 'freight', name: 'rate_change_requests' }) +@Index(['status']) +export class RateChangeRequest extends BaseEntity { + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; + + @ManyToOne(() => Rate, { nullable: false }) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate | null; + + /** Proposed field changes — an UpdateRateDto patch, changed keys only. */ + @Column({ name: 'payload', type: 'jsonb' }) + payload!: Record; + + /** + * The rate's values at submit time, for the approver's before→after diff. + * Snapshotted because the live row can move on between submit and decision. + */ + @Column({ name: 'previous_values', type: 'jsonb' }) + previousValues!: Record; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' }) + status!: RateChangeStatus; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true }) + decidedByUserId?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + @Column({ name: 'decision_note', type: 'text', nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts new file mode 100644 index 000000000..480b2c484 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -0,0 +1,28 @@ +import { deriveRateType } from './rate-type.util'; + +describe('deriveRateType — surcharge triggers', () => { + // Every surcharge trigger must land on its own rateType. A trigger with no + // mapping falls through to the base-freight branch and is silently stored as + // CANCELLATION_FEE, which both mislabels the booking's rate snapshot and + // hides the rate from contract pricing (which looks rateTypes up by name). + it.each([ + ['HAZARDOUS', 'HAZARD_SURCHARGE'], + ['REEFER', 'REEFER_SURCHARGE'], + ['WITH_RETURN', 'RETURN_SURCHARGE'], + ['OVERWEIGHT', 'OVERWEIGHT_PER_TON'], + ['SHIPPING_LINE', 'DOUBLE_HANDLING'], + ['CONSOLIDATION', 'LASHING'], + ['CANCELLATION', 'CANCELLATION_FEE'], + ['DEMURRAGE', 'DEMURRAGE'], + ['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'], + ['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'], + ] as const)('maps trigger %s to %s', (trigger, expected) => { + expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected); + }); + + it('does not fall back to CANCELLATION_FEE for the empty-return service', () => { + expect(deriveRateType({ appliesTo: 'OTHER', trigger: 'WITH_RETURN' })).not.toBe( + 'CANCELLATION_FEE', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 5ecc006c1..a5b5bfc30 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -25,11 +25,18 @@ export function deriveRateType(input: { return 'HAZARD_SURCHARGE'; case 'REEFER': return 'REEFER_SURCHARGE'; + // Empty-container return service. Contract pricing looks this rateType up + // by name, so without the mapping a WITH_RETURN rate fell through to the + // base-freight branch and was stored as CANCELLATION_FEE — invisible to + // the contract, and mislabelled on the booking's snapshot. + case 'WITH_RETURN': + return 'RETURN_SURCHARGE'; case 'OVERWEIGHT': return 'OVERWEIGHT_PER_TON'; case 'SHIPPING_LINE': return 'DOUBLE_HANDLING'; case 'CONSOLIDATION': + case 'LASHING': return 'LASHING'; case 'CANCELLATION': return 'CANCELLATION_FEE'; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 9bbd7728d..78e2eb724 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -36,6 +36,9 @@ export function allowedRateUnits(input: { case 'CUSTOMS_CLEARANCE': // Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL). return ['FLAT']; + case 'LASHING': + // Flat cargo-securing fee, billed once per booking. + return ['FLAT']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index 83c225ea1..cd2a6e14b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { CargoType } from './cargo-type.entity'; import { ContainerType } from './container-type.entity'; +import { Yard } from './yard.entity'; export const RATE_TYPES = [ 'CONTAINER_IMPORT', @@ -77,6 +78,9 @@ export const RATE_TRIGGERS = [ 'WITH_RETURN', 'SHIPPING_LINE', 'CONSOLIDATION', + // Cargo securing / lashing. Fires when the booking's cargo type has + // hasLashing = true. Flat fee, billed once per booking. + 'LASHING', 'CANCELLATION', 'DEMURRAGE', 'PIL_EXTRA_FEE', @@ -91,6 +95,8 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Index(['status']) @Index(['containerTypeId']) @Index(['trigger']) +@Index(['originYardId']) +@Index(['destinationYardId']) export class Rate extends BaseEntity { @Column({ name: 'rate_type', type: 'varchar', length: 50 }) rateType!: RateType; @@ -118,6 +124,26 @@ export class Rate extends BaseEntity { @Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true }) tradeDirection?: string | null; + /** + * The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per + * route — "container import, Djibouti → Dire Dawa" — so both yards are + * required for BULK/CONTAINER/INTERCITY and NULL for everything else. The + * `CK_rates_yard_scope` DB constraint enforces both halves of that. + */ + @Column({ name: 'origin_yard_id', type: 'uuid', nullable: true }) + originYardId?: string | null; + + @ManyToOne(() => Yard, { nullable: true, eager: false }) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard | null; + + @Column({ name: 'destination_yard_id', type: 'uuid', nullable: true }) + destinationYardId?: string | null; + + @ManyToOne(() => Yard, { nullable: true, eager: false }) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard | null; + @Column({ name: 'currency', type: 'varchar', length: 5 }) currency!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts new file mode 100644 index 000000000..ba5a0d671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * What a yard's load/unload facility can do. One record per yard flagged + * `has_facility`. + * + * `hasWarehouse` is the line that matters: a facility with a warehouse (Indode + * today) stores cargo and therefore accrues storage/demurrage through the normal + * warehouse flow; the rest only move cargo on and off the train, so they record + * the handling event and its GRN and nothing else. + */ +@Entity({ schema: 'freight', name: 'yard_facilities' }) +@Index(['yardId']) +export class YardFacility extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** Cargo can be stored here — enables the warehouse flow (storage, demurrage). */ + @Column({ name: 'has_warehouse', type: 'boolean', default: false }) + hasWarehouse!: boolean; + + @Column({ name: 'equipment_notes', type: 'text', nullable: true }) + equipmentNotes?: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts index 3f7f1ae97..808a102e5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -22,6 +22,14 @@ export class Yard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** + * This yard has the equipment to load/unload cargo. Intercity bookings can only + * be loaded at their origin and unloaded at their destination where this is + * true. What the facility can do lives on the YardFacility record. + */ + @Column({ name: 'has_facility', type: 'boolean', default: false }) + hasFacility!: boolean; + @Column({ name: 'display_order', type: 'int', default: 1 }) displayOrder!: number; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index be0962942..76203edef 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -6,12 +6,20 @@ import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { findById(id: string): Promise; findLiveRates(): Promise; + /** + * LIVE rates with the yard / container / cargo relations eagerly joined, so + * lanes can be rendered with human labels (contract rate schedule). Ordered + * for a stable, readable schedule table. + */ + findLiveRatesDetailed(): Promise; findByPattern(pattern: { rateType: string; rateUnit: string; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 85bfc783f..48a948784 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -25,6 +25,22 @@ export class RatesRepository implements IRatesRepository { .getMany(); } + findLiveRatesDetailed(): Promise { + return this.repo + .createQueryBuilder('rate') + .leftJoinAndSelect('rate.originYard', 'originYard') + .leftJoinAndSelect('rate.destinationYard', 'destinationYard') + .leftJoinAndSelect('rate.containerType', 'containerType') + .leftJoinAndSelect('rate.cargoType', 'cargoType') + .where('rate.status = :status', { status: 'LIVE' }) + .orderBy('rate.appliesTo', 'ASC') + .addOrderBy('rate.tradeDirection', 'ASC') + .addOrderBy('originYard.label', 'ASC') + .addOrderBy('destinationYard.label', 'ASC') + .addOrderBy('rate.rateValue', 'ASC') + .getMany(); + } + /** * Find a non-superseded rate matching an identity pattern — the same tuple the * `UQ_rates_pattern` unique index enforces. Used to reject duplicates before @@ -37,6 +53,8 @@ export class RatesRepository implements IRatesRepository { containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; }): Promise { const qb = this.repo .createQueryBuilder('rate') @@ -59,6 +77,18 @@ export class RatesRepository implements IRatesRepository { } else { qb.andWhere('rate.trade_direction IS NULL'); } + if (pattern.originYardId) { + qb.andWhere('rate.origin_yard_id = :originYardId', { originYardId: pattern.originYardId }); + } else { + qb.andWhere('rate.origin_yard_id IS NULL'); + } + if (pattern.destinationYardId) { + qb.andWhere('rate.destination_yard_id = :destinationYardId', { + destinationYardId: pattern.destinationYardId, + }); + } else { + qb.andWhere('rate.destination_yard_id IS NULL'); + } return qb.getOne(); } @@ -75,6 +105,10 @@ export class RatesRepository implements IRatesRepository { findPaged(query: ListRatesQueryDto): Promise> { const qb = this.repo .createQueryBuilder('rate') + // The admin table shows the leg a base-freight rate prices — without the + // yards joined the route columns have only ids to render. + .leftJoinAndSelect('rate.originYard', 'originYard') + .leftJoinAndSelect('rate.destinationYard', 'destinationYard') .orderBy('rate.createdAt', query.sortOrder ?? 'DESC'); if (query.status) { 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 7edcf0bbf..3a342ef62 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 @@ -6,6 +6,7 @@ import { CargoTypesController } from './controllers/cargo-types.controller'; import { ContainerTypesController } from './controllers/container-types.controller'; import { PriorityConfigsController } from './controllers/priority-configs.controller'; import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller'; +import { RateChangeRequestsController } from './controllers/rate-change-requests.controller'; import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; @@ -17,11 +18,13 @@ import { CargoType } from './entities/cargo-type.entity'; import { ContainerType } from './entities/container-type.entity'; import { PriorityConfig } from './entities/priority-config.entity'; import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity'; +import { RateChangeRequest } from './entities/rate-change-request.entity'; import { Rate } from './entities/rate.entity'; 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 { YardFacility } from './entities/yard-facility.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -49,11 +52,13 @@ import { CargoTypesService } from './services/cargo-types.service'; import { ContainerTypesService } from './services/container-types.service'; import { PriorityConfigsService } from './services/priority-configs.service'; import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service'; +import { RateChangeRequestsService } from './services/rate-change-requests.service'; import { RatesService } from './services/rates.service'; 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 { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; @@ -72,9 +77,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerType, PriorityConfig, PriorityRuleChangeRequest, + RateChangeRequest, ServiceType, WeightLimitRule, Yard, + YardFacility, ShippingLine, Rate, ApprovalRule, @@ -91,6 +98,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesController, PriorityConfigsController, PriorityRuleChangeRequestsController, + RateChangeRequestsController, ServiceTypesController, WeightLimitRulesController, YardsController, @@ -121,9 +129,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesService, PriorityConfigsService, PriorityRuleChangeRequestsService, + RateChangeRequestsService, ServiceTypesService, WeightLimitRulesService, YardsService, + YardFacilitiesService, ShippingLinesService, RatesService, ApprovalRulesService, @@ -138,6 +148,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. WeightLimitRulesService, PriorityConfigsService, YardsService, + YardFacilitiesService, ShippingLinesService, RatesService, ApprovalRulesService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index bc1a8095d..d1715d9fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -42,6 +42,14 @@ export interface BookingContainerEvalInput { isReefer?: boolean; isOverweight?: boolean; overweightExcessTons?: number | null; + /** + * How many individual containers on this line opted into each handling + * service. PER_CONTAINER surcharges bill these counts, not the line + * quantity — 20 containers with 10 hazardous bill hazard on 10. + */ + hazardousQuantity?: number; + reeferQuantity?: number; + returnQuantity?: number; } export interface BookingEvaluationInput { @@ -61,6 +69,12 @@ export interface BookingEvaluationInput { isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; + /** + * Booking's cargo type needs EDR-provided lashing/securing (cargoType + * hasLashing = true). Fires the flat LASHING surcharge. Resolved by the + * engine from cargoTypeId when omitted. + */ + hasLashing?: boolean; totalWagons: number; /** * Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale @@ -132,12 +146,22 @@ export class RuleEngineService { requiresDirectorApproval = true; } + // Lashing is a cargo-type property: a booking incurs the flat LASHING + // surcharge when its cargo type has hasLashing = true. Resolve it here so + // matchesTrigger can fire the LASHING rate. Falls back to an explicit + // input flag when no cargo type is set (e.g. container bookings). + let hasLashing = input.hasLashing === true; if (input.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); if (!cargoType) { hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`); - } else if (cargoType.requiresDirectorApproval) { - requiresDirectorApproval = true; + } else { + if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + if (cargoType.hasLashing) { + hasLashing = true; + } } } @@ -237,6 +261,7 @@ export class RuleEngineService { hasOverweight, shippingLineMapped, allowConsolidation: input.allowConsolidation ?? false, + hasLashing, }); if (!triggered) continue; @@ -253,6 +278,27 @@ export class RuleEngineService { (sum, r) => sum + (r.overweightExcessTons ?? 0), 0, ); + /** + * Containers that opted into this trigger's handling service, summed + * across lines. null when the trigger isn't per-container handling (or + * no line carries a count) so the caller falls back to the full count. + */ + const optedInCount = (trigger: string | null): number | null => { + const field = + trigger === 'HAZARDOUS' + ? 'hazardousQuantity' + : trigger === 'REEFER' + ? 'reeferQuantity' + : trigger === 'WITH_RETURN' + ? 'returnQuantity' + : null; + if (!field) return null; + const total = input.containers.reduce( + (sum, c) => sum + Number(c[field] ?? 0), + 0, + ); + return total > 0 ? total : null; + }; let triggerValue: number | null = null; let calculatedAmount: number; @@ -268,7 +314,11 @@ export class RuleEngineService { calculatedAmount = triggerValue * rateValue; break; case 'PER_CONTAINER': - triggerValue = containerCount; + // Handling surcharges bill only the containers that opted in, not the + // whole line — 20 containers with 10 hazardous bill hazard on 10. + // Legacy bookings carry no per-container counts (all 0) while their + // booking-level flag is set, so fall back to the full count there. + triggerValue = optedInCount(rate.trigger) ?? containerCount; calculatedAmount = triggerValue * rateValue; break; case 'PER_WAGON': @@ -466,6 +516,7 @@ export class RuleEngineService { hasOverweight: boolean; shippingLineMapped: boolean; allowConsolidation: boolean; + hasLashing: boolean; }, ): boolean { // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. @@ -484,6 +535,8 @@ export class RuleEngineService { return truthy(state.shippingLineMapped); case 'CONSOLIDATION': return truthy(state.allowConsolidation); + case 'LASHING': + return truthy(state.hasLashing); // CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied // explicitly elsewhere (not auto-triggered by a booking's cargo flags). default: diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 42ce389e1..4c40cae4b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -48,7 +48,6 @@ export class ContainerTypesService { code, label: dto.label, sizeFt: dto.sizeFt, - wagonsPerUnit: dto.wagonsPerUnit, isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts index 53cb7ed83..0b0ef1b0d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts @@ -5,9 +5,8 @@ import { PriorityConfigsService } from './priority-configs.service'; /** * Contiguous-range rules for priority configs: per type (per currency for - * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range - * must start at the lowest uncovered wagon count. Caps: WAGON 50, - * CURRENCY 35, CUSTOMS 15. + * CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range + * must start at the lowest uncovered wagon count. There is no upper ceiling. */ describe('PriorityConfigsService range validation', () => { const rule = ( @@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => { ).rejects.toThrow(/overlaps existing rule/); }); - it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => { + it('imposes no upper ceiling on any type', async () => { await expect( - attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), - ).rejects.toThrow(/may not exceed 50/); + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }), + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CURRENCY', currency: 'USD', minWagonCount: 1, - maxWagonCount: 36, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 35/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CUSTOMS', minWagonCount: 1, - maxWagonCount: 16, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 15/); + ).resolves.toBeUndefined(); }); - it('rejects any new rule once the chain covers the full range', async () => { + it('keeps extending the chain past the old caps', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 50)]), { minWagonCount: 51, - maxWagonCount: 51, + maxWagonCount: 120, }), - ).rejects.toThrow(/may not exceed 50/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { type: 'CUSTOMS', - minWagonCount: 1, - maxWagonCount: 1, + minWagonCount: 16, + maxWagonCount: 99, }), - ).rejects.toThrow(/already cover the full 1–15 range/); + ).resolves.toBeUndefined(); + }); + + it('still rejects a min greater than the max', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 9, maxWagonCount: 4 }), + ).rejects.toThrow(BadRequestException); }); it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { @@ -214,16 +219,13 @@ describe('PriorityConfigsService range validation', () => { it('reports the next-range prefill for the form', async () => { const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); - await expect(svc.nextRange('WAGON')).resolves.toEqual({ - nextMin: 6, - maxCap: 50, - }); + await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 }); + // Past the old CUSTOMS cap of 15 the chain simply continues. await expect( serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), - ).resolves.toEqual({ nextMin: null, maxCap: 15 }); + ).resolves.toEqual({ nextMin: 16 }); await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ nextMin: 1, - maxCap: 35, }); }); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index ff3711e42..274ce687f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -10,28 +10,19 @@ import { } from '../interfaces/priority-configs.repository.interface'; import { DisplayOrderService } from './display-order.service'; -/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */ -export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = { - WAGON: 50, - CURRENCY: 35, - CUSTOMS: 15, -}; - /** * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range - * must start. Null when the chain is already complete up to the type's cap. + * must start. The chain is unbounded above, so there is always a next start. */ function nextRangeStart( rules: Pick[], -): number | null { - const cap = rules.length ? RANGE_CAPS[rules[0].type] : null; +): number { const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); let next = 1; for (const r of sorted) { if (r.minWagonCount > next) break; // gap before this rule — fill it next = Math.max(next, r.maxWagonCount + 1); } - if (cap != null && next > cap) return null; return next; } @@ -102,8 +93,8 @@ export class PriorityConfigsService { * - ranges never overlap — a booking matches at most one rule per type; * - ranges are contiguous from 1: a new range must START at the lowest * wagon count not yet covered (after 1–5 the next is 6–…; deleting a - * middle rule opens a gap and the next create must fill it first); - * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15. + * middle rule opens a gap and the next create must fill it first). + * There is no upper ceiling — max wagon count is unbounded. * Ranges are inclusive on both ends. */ async assertNoRangeCollision(input: { @@ -118,14 +109,6 @@ export class PriorityConfigsService { 'Min wagon count cannot be greater than max wagon count', ); } - const cap = RANGE_CAPS[input.type]; - if (input.maxWagonCount > cap) { - throw new BadRequestException( - `${input.type} ranges may not exceed ${cap} — ` + - `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`, - ); - } - const siblings = ( await this.repository.findAll({ where: { type: input.type } }) ).filter( @@ -142,12 +125,6 @@ export class PriorityConfigsService { const currentStart = input.excludeId ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null : null; - if (expectedStart == null && currentStart == null) { - throw new BadRequestException( - `${input.type} rules already cover the full 1–${cap} range — ` + - 'delete or shrink an existing rule first.', - ); - } if ( input.minWagonCount !== expectedStart && input.minWagonCount !== currentStart @@ -174,21 +151,21 @@ export class PriorityConfigsService { } /** - * Where the next range for a type/currency must start, and the type's - * ceiling — feeds the create form so the min field is auto-filled and - * locked. `nextMin` is null when the chain already covers 1..cap. + * Where the next range for a type/currency must start — feeds the create + * form so the min field is auto-filled and locked. Always a number: the + * chain has no ceiling, so another range always fits. */ async nextRange( type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', currency?: string | null, - ): Promise<{ nextMin: number | null; maxCap: number }> { + ): Promise<{ nextMin: number }> { const siblings = ( await this.repository.findAll({ where: { type } }) ).filter( (s) => type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), ); - return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; + return { nextMin: nextRangeStart(siblings) }; } async remove(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts new file mode 100644 index 000000000..6c3adce66 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -0,0 +1,213 @@ +import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; + +import { RateChangeRequest } from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RateChangeRequestsService } from './rate-change-requests.service'; + +/** + * The guarantee under test: editing a LIVE rate never moves the live value. + * A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only + * approval applies it, and only then through RatesService (so every rate rule + * is re-checked against the state at approval time). + */ +describe('RateChangeRequestsService', () => { + const liveRate = (overrides: Partial = {}): Rate => + ({ + id: 'rate-1', + status: 'LIVE', + rateType: 'OCEAN_FREIGHT', + appliesTo: 'CONTAINER', + trigger: 'ALWAYS', + currency: 'USD', + // Postgres numeric comes back as a string — the no-op check must cope. + rateValue: '100.0000' as unknown as number, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + tradeDirection: null, + proposedByStaffId: 'staff-1', + ...overrides, + }) as unknown as Rate; + + const build = (opts: { + rate?: Rate; + pending?: RateChangeRequest | null; + applyThrows?: Error; + } = {}) => { + const rate = opts.rate ?? liveRate(); + const saved: RateChangeRequest[] = []; + + const repo = { + findOne: jest.fn(async ({ where }: { where: Record }) => { + if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null; + return saved.find((r) => r.id === where.id) ?? opts.pending ?? null; + }), + create: jest.fn((data: Partial) => ({ id: 'req-1', ...data })), + save: jest.fn(async (entity: RateChangeRequest) => { + saved.push(entity); + return entity; + }), + find: jest.fn(async () => saved), + }; + + const rates = { + findById: jest.fn(async () => rate), + assertUpdateValid: jest.fn(async () => undefined), + applyApprovedUpdate: jest.fn(async () => { + if (opts.applyThrows) throw opts.applyThrows; + return rate; + }), + }; + + const inbox = { notify: jest.fn(async () => undefined) }; + + const service = new RateChangeRequestsService( + repo as never, + rates as never, + inbox as never, + ); + // `pending` is the very object approve/reject mutate — assert on it, not a copy. + return { service, repo, rates, inbox, pending: opts.pending }; + }; + + describe('submit', () => { + it('files a pending request instead of touching the live rate', async () => { + const { service, rates } = build(); + + const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }); + + expect(request.status).toBe('PENDING'); + expect(request.payload).toEqual({ rateValue: 200 }); + // The old value is snapshotted for the approver's diff... + expect(request.previousValues).toEqual({ rateValue: '100.0000' }); + // ...and nothing wrote to the rate itself. + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('keeps only the fields that actually changed', async () => { + const { service } = build(); + + // A form posts every field back; only rateValue differs from the live rate. + const request = await service.submit({ + rateId: 'rate-1', + update: { + rateValue: 200, + currency: 'USD', + rateUnit: 'PER_CONTAINER', + appliesTo: 'CONTAINER', + }, + }); + + expect(request.payload).toEqual({ rateValue: 200 }); + }); + + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { + const { service } = build(); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }), + ).rejects.toThrow(/Nothing changed/); + }); + + it('refuses a rate that is not LIVE — those edit directly', async () => { + const { service } = build({ rate: liveRate({ status: 'DRAFT' }) }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(BadRequestException); + }); + + it('refuses a second pending change for the same rate', async () => { + const { service } = build({ + pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest, + }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(ConflictException); + }); + + it('validates up front so the requester hears about a bad patch, not the approver', async () => { + const { service, rates } = build(); + rates.assertUpdateValid.mockRejectedValueOnce( + new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), + ); + await expect( + service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }), + ).rejects.toThrow(/not valid for this rate/); + }); + }); + + describe('approve', () => { + const pendingRequest = (): RateChangeRequest => + ({ + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + }) as unknown as RateChangeRequest; + + it('applies the change through RatesService and marks it approved', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + + const decided = await service.approve('req-1', 'approver-1', 'Agreed'); + + expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 }); + expect(decided.status).toBe('APPROVED'); + expect(decided.decidedByUserId).toBe('approver-1'); + expect(decided.decisionNote).toBe('Agreed'); + }); + + it('blocks the requester from approving their own change', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('lets a super admin self-approve', async () => { + const { service } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({ + status: 'APPROVED', + }); + }); + + it('stays PENDING when applying now fails — never marks a change that did not land', async () => { + const { service, pending, repo } = build({ + pending: pendingRequest(), + applyThrows: new ConflictException('A rate for this exact combination already exists.'), + }); + + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/); + // Apply runs first, so a failure leaves the request untouched and re-decidable. + expect(pending!.status).toBe('PENDING'); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('refuses to decide an already-decided request', async () => { + const { service } = build({ + pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest, + }); + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException); + }); + }); + + describe('reject', () => { + it('never touches the rate — it simply keeps its current value', async () => { + const { service, rates } = build({ + pending: { + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + } as unknown as RateChangeRequest, + }); + + const decided = await service.reject('req-1', 'approver-1', 'Too steep'); + + expect(decided.status).toBe('REJECTED'); + expect(decided.decisionNote).toBe('Too steep'); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts new file mode 100644 index 000000000..357c67f95 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -0,0 +1,241 @@ +import { NotificationAudience, NotificationType } from '@edr/types'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service'; +import { SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { + RateChangeRequest, + RateChangeStatus, +} from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RatesService } from './rates.service'; + +/** Backoffice page where both the queue and the rates live. */ +const RATES_LINK = '/dashboard/rules/rates'; + +/** Fields a change request may carry — anything else in the patch is ignored. */ +const DIFFABLE_FIELDS = [ + 'rateValue', + 'currency', + 'rateUnit', + 'appliesTo', + 'trigger', + 'tradeDirection', + 'containerTypeId', + 'cargoTypeId', +] as const; + +/** + * Approval workflow for edits to LIVE rates. + * + * A LIVE rate is what pricing charges right now, so it is never edited in + * place. The edit is filed here as a PENDING request and the live row keeps + * its old value — a rate at 100 USD keeps quoting 100 while a change to 200 + * waits. Approval replays the edit through RatesService, so every rule + * (unit validity, pattern uniqueness) is re-checked against whatever is true + * at approval time, not at submit time. + */ +@Injectable() +export class RateChangeRequestsService { + private readonly logger = new Logger(RateChangeRequestsService.name); + + constructor( + @InjectRepository(RateChangeRequest) + private readonly repo: Repository, + private readonly rates: RatesService, + private readonly inbox: NotificationInboxService, + ) {} + + /** + * File an edit against a LIVE rate. Validated up front so the requester + * hears about a bad unit or a pattern clash immediately rather than the + * approver hitting it days later. + */ + async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise { + const rate = await this.rates.findById(dto.rateId); + if (rate.status !== 'LIVE') { + throw new BadRequestException( + `Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`, + ); + } + + const payload = this.changedFieldsOnly(rate, dto.update); + if (Object.keys(payload).length === 0) { + throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); + } + + // One pending edit per rate: two racing requests would both validate, then + // the second would silently overwrite the first on approval. + const inFlight = await this.repo.findOne({ + where: { rateId: dto.rateId, status: 'PENDING' }, + }); + if (inFlight) { + throw new ConflictException( + 'This rate already has a change awaiting approval. Have it approved or rejected first.', + ); + } + + await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + + const request = await this.repo.save( + this.repo.create({ + rateId: dto.rateId, + payload, + previousValues: this.snapshot(rate, payload), + status: 'PENDING', + requestedByUserId: userId ?? null, + }), + ); + + this.notifyTeam( + 'Rate change submitted', + `A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`, + request, + ); + return request; + } + + async list(status?: RateChangeStatus): Promise { + return this.repo.find({ + where: status ? { status } : {}, + relations: { rate: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Approve and apply. The live mutation runs FIRST — if it now fails (someone + * created a clashing rate since submit), the request stays PENDING and the + * approver sees the real error instead of a request marked approved that + * never landed. + */ + async approve( + id: string, + userId?: string | null, + decisionNote?: string, + canSelfApprove = false, + ): Promise { + const request = await this.findPending(id); + + // Separation of duties: the requester cannot approve their own repricing — + // except super admins, who have full backoffice authority. + if (!canSelfApprove && userId && userId === request.requestedByUserId) { + throw new ForbiddenException('You cannot approve a rate change you submitted'); + } + + await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto); + + request.status = 'APPROVED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change approved', + `The rate change was approved and is now live.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** Reject — the live rate is never touched, so it simply keeps its value. */ + async reject( + id: string, + userId?: string | null, + decisionNote?: string, + ): Promise { + const request = await this.findPending(id); + request.status = 'REJECTED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change rejected', + `The rate change was rejected — the rate keeps its current value.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** + * Keep only fields the requester actually changed. A form posts every field + * back, so without this the diff would list untouched values as changes. + */ + private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + const patch: Record = {}; + for (const field of DIFFABLE_FIELDS) { + const proposed = (update as Record)[field]; + if (proposed === undefined) continue; + if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; + patch[field] = proposed; + } + return patch; + } + + /** The live values the patch would overwrite — the "before" side of the diff. */ + private snapshot(rate: Rate, payload: Record): Record { + const before: Record = {}; + for (const field of Object.keys(payload)) { + before[field] = (rate as unknown as Record)[field] ?? null; + } + return before; + } + + /** + * rateValue arrives as a string from Postgres `numeric` but as a number from + * the form, so 100 and "100.0000" must compare equal or every submit would + * look like a change. + */ + private sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + const numA = Number(a); + const numB = Number(b); + if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') { + return numA === numB; + } + return String(a) === String(b); + } + + private async findPending(id: string): Promise { + const request = await this.repo.findOne({ where: { id }, relations: { rate: true } }); + if (!request) throw new NotFoundException(`Rate change request ${id} not found`); + if (request.status !== 'PENDING') { + throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`); + } + return request; + } + + /** Fire-and-forget — a notification failure never blocks the workflow. */ + private notifyTeam(title: string, body: string, request: RateChangeRequest): void { + void this.inbox + .notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: RATES_LINK, + data: { rateChangeRequestId: request.id, rateId: request.rateId }, + }) + .catch((err) => + this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 3cd17cf6e..488865f38 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -6,7 +6,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { PaginatedResponse } from '@edr/types'; +import { PaginatedResponse, YardCountry } from '@edr/types'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; @@ -14,12 +14,24 @@ import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; +import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; + +/** Categories priced per rail leg — they carry an origin → destination yard pair. */ +const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY']; + +/** The yard pair a rate scopes to, already validated against its direction. */ +interface YardScope { + originYardId: string | null; + destinationYardId: string | null; +} @Injectable() export class RatesService { constructor( @Inject(RATES_REPOSITORY) private readonly repository: IRatesRepository, + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, ) {} /** List rates — standard paginated envelope with server-side search. */ @@ -32,6 +44,14 @@ export class RatesService { return this.repository.findLiveRates(); } + /** + * LIVE rates with yard / container / cargo relations joined — used to render + * the origin → destination rate schedule inside generated contracts. + */ + async findLiveRatesDetailed(): Promise { + return this.repository.findLiveRatesDetailed(); + } + /** Get a rate by ID. */ async findById(id: string): Promise { const entity = await this.repository.findById(id); @@ -48,20 +68,156 @@ export class RatesService { private resolveRateUnit( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], - requestedUnit: Rate['rateUnit'], + requestedUnit: Rate['rateUnit'] | undefined, ): Rate['rateUnit'] { - // Overweight is per-ton, full stop. + // Overweight is per-ton, full stop — the admin form hides the unit field + // for it and omits rateUnit from the payload entirely. if (trigger === 'OVERWEIGHT') return 'PER_TON'; - if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { - const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + const allowed = allowedRateUnits({ appliesTo, trigger }); + if (!requestedUnit) { throw new BadRequestException( - `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, + ); + } + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, ); } return requestedUnit; } + /** Base rail freight is priced per leg; surcharges and truck legs are not. */ + private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { + return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo); + } + + /** + * Which country each end of the leg must sit in, given what the rate is for. + * The railway only sells three shapes: import lands at the Djibouti ports and + * rails inland, export is the reverse, and intercity stays inside Ethiopia. + */ + private expectedYardCountries( + appliesTo: Rate['appliesTo'], + tradeDirection: string | null, + ): { origin: YardCountry; destination: YardCountry } { + if (appliesTo === 'INTERCITY') { + return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA }; + } + return tradeDirection === 'EXPORT' + ? { origin: YardCountry.ETHIOPIA, destination: YardCountry.DJIBOUTI } + : { origin: YardCountry.DJIBOUTI, destination: YardCountry.ETHIOPIA }; + } + + /** + * Validate and normalise the leg a rate prices. + * + * Base freight must name both yards and they must match the direction, so a + * "container import" rate cannot be quoted Ethiopia → Ethiopia. Everything + * else (surcharges, first/last mile) is route-agnostic and has its yards + * cleared, mirroring how container/cargo scope is cleared for surcharges. + */ + private async resolveYardScope(input: { + appliesTo: Rate['appliesTo']; + trigger: Rate['trigger']; + tradeDirection: string | null; + originYardId?: string | null; + destinationYardId?: string | null; + }): Promise { + const { appliesTo, trigger, tradeDirection } = input; + if (!this.isBaseFreight(appliesTo, trigger)) { + return { originYardId: null, destinationYardId: null }; + } + + const originYardId = input.originYardId ?? null; + const destinationYardId = input.destinationYardId ?? null; + if (!originYardId || !destinationYardId) { + throw new BadRequestException( + 'Base freight rates are priced per leg — pick both an origin and a destination yard.', + ); + } + if (originYardId === destinationYardId) { + throw new BadRequestException('Origin and destination yard must be different.'); + } + + const [origin, destination] = await Promise.all([ + this.yardsRepository.findById(originYardId), + this.yardsRepository.findById(destinationYardId), + ]); + if (!origin) throw new BadRequestException(`Origin yard ${originYardId} not found`); + if (!destination) { + throw new BadRequestException(`Destination yard ${destinationYardId} not found`); + } + + const expected = this.expectedYardCountries(appliesTo, tradeDirection); + if (origin.country !== expected.origin || destination.country !== expected.destination) { + const shape = + appliesTo === 'INTERCITY' ? 'Intercity' : `${tradeDirection ?? 'Import'} freight`; + throw new BadRequestException( + `${shape} runs ${expected.origin} → ${expected.destination}, but ${origin.label} is in ` + + `${origin.country} and ${destination.label} is in ${destination.country}.`, + ); + } + + return { originYardId, destinationYardId }; + } + + /** + * Guard the scope fields a base-freight category needs before we derive its + * rateType: import/export must say which, and intercity must say whether it + * carries containers or bulk (the two price differently and an unstated kind + * would silently file the rate as one of them). + */ + private assertScopeCoherent(input: { + appliesTo: Rate['appliesTo']; + trigger: Rate['trigger']; + tradeDirection: string | null; + intercityKind: string | null; + containerTypeId: string | null; + cargoTypeId: string | null; + }): void { + const { appliesTo, trigger, tradeDirection, intercityKind } = input; + const { containerTypeId, cargoTypeId } = input; + if (!this.isBaseFreight(appliesTo, trigger)) return; + + if (appliesTo === 'INTERCITY') { + if (intercityKind !== 'CONTAINER' && intercityKind !== 'BULK') { + throw new BadRequestException( + 'An intercity rate must say whether it covers containers or bulk.', + ); + } + // The scope field has to agree with the kind, or the rate would advertise + // one cargo kind and narrow by the other. + if (intercityKind === 'CONTAINER' && cargoTypeId) { + throw new BadRequestException( + 'An intercity container rate cannot be scoped to a bulk cargo type.', + ); + } + if (intercityKind === 'BULK' && containerTypeId) { + throw new BadRequestException( + 'An intercity bulk rate cannot be scoped to a container type.', + ); + } + return; + } + + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { + throw new BadRequestException( + `${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`, + ); + } + } + + /** + * Whether a rate covers bulk cargo — the flag `deriveRateType` splits + * INTERCITY_BULK from INTERCITY_CONTAINER on. Intercity states its kind + * explicitly; for BULK/CONTAINER the category already says it. + */ + private resolvesToBulk(appliesTo: Rate['appliesTo'], intercityKind: string | null): boolean { + return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK'; + } + /** * Reject a second rate with the same identity pattern (rateType + scope). With * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would @@ -73,12 +229,14 @@ export class RatesService { containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; + originYardId: string | null; + destinationYardId: string | null; ignoreId?: string; }): Promise { const existing = await this.repository.findByPattern(pattern); if (existing && existing.id !== pattern.ignoreId) { throw new ConflictException( - 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.', + 'A rate for this exact combination already exists on this route. Edit or delete the existing rate instead of creating a duplicate.', ); } } @@ -92,17 +250,49 @@ export class RatesService { const isSurcharge = trigger !== 'ALWAYS'; const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); - const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + // Intercity never leaves Ethiopia, so it has no trade direction to store — + // its yard pair already says where it runs. + const tradeDirection = + isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null); + + const intercityKind = dto.intercityKind ?? null; + this.assertScopeCoherent({ + appliesTo, + trigger, + tradeDirection, + intercityKind, + containerTypeId, + cargoTypeId, + }); + const { originYardId, destinationYardId } = await this.resolveYardScope({ + appliesTo, + trigger, + tradeDirection, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, + }); const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, - isBulk: Boolean(cargoTypeId), + isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); - const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + const rateUnit = this.resolveRateUnit( + appliesTo, + trigger, + dto.rateUnit as Rate['rateUnit'] | undefined, + ); - await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection }); + await this.assertNoDuplicatePattern({ + rateType, + rateUnit, + containerTypeId, + cargoTypeId, + tradeDirection, + originYardId, + destinationYardId, + }); return this.repository.create({ appliesTo, @@ -111,6 +301,8 @@ export class RatesService { containerTypeId, cargoTypeId, tradeDirection, + originYardId, + destinationYardId, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, rateUnit, @@ -119,12 +311,62 @@ export class RatesService { }); } - /** Update a DRAFT rate. */ + /** + * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit + * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`. + */ async update(id: string, dto: UpdateRateDto): Promise { const existing = await this.findById(id); if (existing.status !== 'DRAFT') { - throw new BadRequestException('Only DRAFT rates can be updated'); + throw new BadRequestException( + existing.status === 'LIVE' + ? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.' + : 'Only DRAFT rates can be updated', + ); } + return this.applyUpdate(existing, dto); + } + + /** + * Apply an approved change request to a LIVE rate. Same validation as a + * DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching + * here has already been through approval. Only ever called by + * RateChangeRequestsService.approve. + */ + async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise { + const existing = await this.findById(id); + if (existing.status !== 'LIVE') { + throw new BadRequestException( + `Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`, + ); + } + return this.applyUpdate(existing, dto); + } + + /** + * Validate a proposed patch against a rate without writing anything — lets a + * change request be refused at submit time instead of surprising the + * approver. Throws exactly what applying it would throw. + */ + async assertUpdateValid(id: string, dto: UpdateRateDto): Promise { + await this.buildUpdate(await this.findById(id), dto); + } + + private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { + const updates = await this.buildUpdate(existing, dto); + const updated = await this.repository.update(existing.id, updates); + if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`); + return updated; + } + + /** + * The shared edit body: re-derives rateType, re-validates the unit against + * the (possibly changed) shape, and guards pattern uniqueness. Status is + * never touched — an approved edit to a LIVE rate stays LIVE. Pure apart + * from the uniqueness read, so it doubles as the dry-run validator. + */ + private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise> { + const id = existing.id; const updates: Partial = {}; const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo; @@ -144,21 +386,52 @@ export class RatesService { : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; - const tradeDirection = isSurcharge - ? null - : dto.tradeDirection !== undefined - ? dto.tradeDirection - : existing.tradeDirection; + const tradeDirection = + isSurcharge || appliesTo === 'INTERCITY' + ? null + : dto.tradeDirection !== undefined + ? dto.tradeDirection + : existing.tradeDirection; updates.containerTypeId = containerTypeId ?? null; updates.cargoTypeId = cargoTypeId ?? null; updates.tradeDirection = tradeDirection ?? null; + + // A patch that leaves the cargo kind unsaid keeps the one the rate already + // has — read back off its rateType, the only place it is recorded. + const intercityKind = + dto.intercityKind ?? (existing.rateType === 'INTERCITY_BULK' ? 'BULK' : 'CONTAINER'); + + this.assertScopeCoherent({ + appliesTo, + trigger, + tradeDirection: updates.tradeDirection, + intercityKind, + containerTypeId: updates.containerTypeId, + cargoTypeId: updates.cargoTypeId, + }); + // Re-validate the leg: changing direction can invalidate a yard pair that + // was legal under the old one (an import route is not an export route). + const yardScope = await this.resolveYardScope({ + appliesTo, + trigger, + tradeDirection: updates.tradeDirection, + originYardId: + dto.originYardId !== undefined ? dto.originYardId : existing.originYardId, + destinationYardId: + dto.destinationYardId !== undefined + ? dto.destinationYardId + : existing.destinationYardId, + }); + updates.originYardId = yardScope.originYardId; + updates.destinationYardId = yardScope.destinationYardId; + // Keep the derived rateType in sync with whatever changed. const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, - isBulk: Boolean(cargoTypeId), + isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); updates.rateType = rateType; @@ -174,14 +447,14 @@ export class RatesService { containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, + originYardId: updates.originYardId, + destinationYardId: updates.destinationYardId, ignoreId: id, }); updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - const updated = await this.repository.update(id, updates); - if (!updated) throw new NotFoundException(`Rate ${id} not found`); - return updated; + return updates; } /** Submit a DRAFT rate for CEO approval. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts new file mode 100644 index 000000000..f32b0129a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** A yard's load/unload capability, resolved for the handling flows. */ +export interface YardFacilityInfo { + yardId: string; + yardCode: string | null; + yardLabel: string | null; + /** The yard can load/unload cargo at all. */ + hasFacility: boolean; + /** The facility stores cargo — enables the warehouse flow (storage, demurrage). */ + hasWarehouse: boolean; +} + +/** + * Which yards can handle cargo, and how. + * + * A yard is a load/unload point when `yards.has_facility` is set; the matching + * `yard_facilities` record says whether it also stores cargo. Facilities without a + * warehouse move cargo on and off the train and nothing more — no storage, no + * demurrage. This is the single resolver the journey and handling flows use, so + * they can't drift on what a facility is. + */ +@Injectable() +export class YardFacilitiesService { + constructor(private readonly dataSource: DataSource) {} + + /** Resolve a yard's handling capability. Null when the yard doesn't exist. */ + async facilityForYard(yardId: string): Promise { + const [row]: Array<{ + yardId: string; + yardCode: string | null; + yardLabel: string | null; + hasFacility: boolean; + hasWarehouse: boolean | null; + }> = await this.dataSource.query( + `SELECT y.id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + y.has_facility AS "hasFacility", + f.has_warehouse AS "hasWarehouse" + FROM freight.yards y + LEFT JOIN freight.yard_facilities f + ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true + WHERE y.id = $1 AND y.deleted_at IS NULL`, + [yardId], + ); + if (!row) return null; + return { + yardId: row.yardId, + yardCode: row.yardCode, + yardLabel: row.yardLabel, + hasFacility: Boolean(row.hasFacility), + // No facility record means no warehouse, whatever the flag says. + hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse), + }; + } + + /** Every yard that can load/unload, for pickers and the intercity queues. */ + async listFacilityYards(): Promise { + const rows: Array<{ + yardId: string; + yardCode: string | null; + yardLabel: string | null; + hasFacility: boolean; + hasWarehouse: boolean | null; + }> = await this.dataSource.query( + `SELECT y.id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + y.has_facility AS "hasFacility", + f.has_warehouse AS "hasWarehouse" + FROM freight.yards y + LEFT JOIN freight.yard_facilities f + ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true + WHERE y.deleted_at IS NULL + AND y.is_active = true + AND y.has_facility = true + ORDER BY y.display_order ASC, y.label ASC`, + ); + return rows.map((r) => ({ + yardId: r.yardId, + yardCode: r.yardCode, + yardLabel: r.yardLabel, + hasFacility: true, + hasWarehouse: Boolean(r.hasWarehouse), + })); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 47b1f05bc..69eab608a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -45,6 +45,7 @@ export class YardsService { label: dto.label, country: dto.country, isActive: dto.isActive ?? true, + hasFacility: dto.hasFacility ?? false, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts new file mode 100644 index 000000000..a4ebab160 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts @@ -0,0 +1,34 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +export class ListConversationsQueryDto { + @ApiPropertyOptional({ description: "Search company name." }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ + description: "Keep only threads with unread messages.", + default: false, + }) + @IsOptional() + @Transform(({ value }) => value === true || value === "true") + @IsBoolean() + unreadOnly?: boolean; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts new file mode 100644 index 000000000..89178ef63 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts @@ -0,0 +1,11 @@ +import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class SendMessageDto implements ISendSupportMessageDto { + @ApiProperty({ description: "Message text." }) + @IsString() + @MinLength(1) + @MaxLength(4000) + body!: string; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts new file mode 100644 index 000000000..736ff13aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts @@ -0,0 +1,10 @@ +import { StartSupportConversationDto as IStartSupportConversationDto } from "@edr/types"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsUUID } from "class-validator"; + +/** Agent opens the thread with a company before sending the first message. */ +export class StartConversationDto implements IStartSupportConversationDto { + @ApiProperty({ description: "Customer company to chat with." }) + @IsUUID() + companyId!: string; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts b/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts new file mode 100644 index 000000000..4e74364d4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from "@edr/api-common"; +import { SupportAuthorRole } from "@edr/types"; +import { Column, Entity, Index } from "typeorm"; + +/** + * The single support thread for a customer **company**. Any portal user of that + * company sees and continues it; backoffice agents work a shared inbox. Either + * side may open it — whoever sends the first message — and it has no lifecycle: + * no status, no resolve, no close. + * + * The unique index on `company_id` is what enforces one-thread-per-company; the + * get-or-create path relies on it to settle races. Last-message fields are + * denormalized so the inbox list can sort and preview without joining + * `support_messages`. Read cursors are per-side (shared across a company's + * users) — unread = messages from the other role newer than the side's cursor. + */ +@Entity({ schema: "freight", name: "support_conversations" }) +@Index("IDX_SUPPORT_CONV_COMPANY", ["companyId"], { + unique: true, + where: "deleted_at IS NULL", +}) +@Index("IDX_SUPPORT_CONV_LASTMSG", ["lastMessageAt"]) +export class SupportConversation extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + /** Denormalized company name for the agent inbox (resolved at creation). */ + @Column({ name: "company_name", type: "varchar", length: 200, nullable: true }) + companyName?: string | null; + + /** Null when an agent opened the thread — no customer created it. */ + @Column({ name: "created_by_user_id", type: "uuid", nullable: true }) + createdByUserId?: string | null; + + @Column({ name: "last_message_at", type: "timestamptz", nullable: true }) + lastMessageAt?: Date | null; + + @Column({ name: "last_message_preview", type: "varchar", length: 280, nullable: true }) + lastMessagePreview?: string | null; + + @Column({ + name: "last_message_author_role", + type: "varchar", + length: 12, + nullable: true, + }) + lastMessageAuthorRole?: SupportAuthorRole | null; + + @Column({ name: "customer_last_read_at", type: "timestamptz", nullable: true }) + customerLastReadAt?: Date | null; + + @Column({ name: "agent_last_read_at", type: "timestamptz", nullable: true }) + agentLastReadAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts b/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts new file mode 100644 index 000000000..443a1694f --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts @@ -0,0 +1,24 @@ +import { BaseEntity } from "@edr/api-common"; +import { SupportAuthorRole } from "@edr/types"; +import { Column, Entity, Index } from "typeorm"; + +/** A single text message inside a {@link SupportConversation}. */ +@Entity({ schema: "freight", name: "support_messages" }) +@Index("IDX_SUPPORT_MSG_CONV_CREATED", ["conversationId", "createdAt"]) +export class SupportMessage extends BaseEntity { + @Column({ name: "conversation_id", type: "uuid" }) + conversationId!: string; + + @Column({ name: "author_user_id", type: "uuid" }) + authorUserId!: string; + + @Column({ name: "author_role", type: "varchar", length: 12 }) + authorRole!: SupportAuthorRole; + + /** Display name captured at send time (best-effort). */ + @Column({ name: "author_name", type: "varchar", length: 200, nullable: true }) + authorName?: string | null; + + @Column({ name: "body", type: "text" }) + body!: string; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts new file mode 100644 index 000000000..efeb918be --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts @@ -0,0 +1,76 @@ +import { CurrentUser } from "@edr/api-common"; +import { SupportAuthorRole } from "@edr/types"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { + AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto"; +import { SendMessageDto } from "./dto/send-message.dto"; +import { StartConversationDto } from "./dto/start-conversation.dto"; +import { SupportChatService } from "./support-chat.service"; + +/** Backoffice (agent) support-chat endpoints. Shared inbox over all companies. */ +@ApiTags("support-chat-agent") +@Controller("support/agent") +export class SupportChatAgentController { + constructor(private readonly service: SupportChatService) {} + + @Get("conversations") + @ApiOperation({ summary: "List all support threads (shared inbox)" }) + list(@Query() query: ListConversationsQueryDto) { + return this.service.listForAgents(query); + } + + @Post("conversations") + @ApiOperation({ + summary: "Start chatting with a company (returns the thread if one exists)", + }) + start(@Body() body: StartConversationDto) { + return this.service.startWithCompany(body.companyId); + } + + @Get("conversations/:id/messages") + @ApiOperation({ summary: "List messages in a thread" }) + messages(@Param("id", ParseUUIDPipe) id: string) { + return this.service.getMessages(id); + } + + @Post("conversations/:id/messages") + @ApiOperation({ summary: "Reply as an agent" }) + send( + @CurrentUser() user: AuthUserPayload, + @Param("id", ParseUUIDPipe) id: string, + @Body() body: SendMessageDto, + ) { + return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body); + } + + @Post("conversations/:id/read") + @ApiOperation({ summary: "Mark a thread read (agent side)" }) + read( + @CurrentUser() user: AuthUserPayload, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.service.markAgentRead(id, resolveAuthUserId(user)); + } + + @Get("unread-count") + @ApiOperation({ summary: "Count unread threads (agent side)" }) + unread(@CurrentUser() user: AuthUserPayload) { + return this.service.unreadCount( + SupportAuthorRole.AGENT, + resolveAuthUserId(user), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts new file mode 100644 index 000000000..8d20ee91f --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts @@ -0,0 +1,59 @@ +import { CurrentUser } from "@edr/api-common"; +import { SupportAuthorRole } from "@edr/types"; +import { Body, Controller, Get, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { + AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { SendMessageDto } from "./dto/send-message.dto"; +import { SupportChatService } from "./support-chat.service"; + +/** + * Portal (customer) support-chat endpoints. The caller's company has exactly one + * thread, so these are addressed as a singleton — no conversation id on the wire, + * and nothing for a portal user to pick between. + */ +@ApiTags("support-chat") +@Controller("support") +export class SupportChatController { + constructor(private readonly service: SupportChatService) {} + + @Get("conversation") + @ApiOperation({ + summary: "My company's support thread (null until someone speaks)", + }) + conversation(@CurrentUser() user: AuthUserPayload) { + return this.service.getCustomerConversation(resolveAuthUserId(user)); + } + + @Get("conversation/messages") + @ApiOperation({ summary: "Messages in my company's support thread" }) + messages(@CurrentUser() user: AuthUserPayload) { + return this.service.getCustomerMessages(resolveAuthUserId(user)); + } + + @Post("conversation/messages") + @ApiOperation({ + summary: "Send a message as the customer, opening the thread if needed", + }) + send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) { + return this.service.sendAsCustomer(resolveAuthUserId(user), body.body); + } + + @Post("conversation/read") + @ApiOperation({ summary: "Mark my company's thread read (customer side)" }) + read(@CurrentUser() user: AuthUserPayload) { + return this.service.markCustomerRead(resolveAuthUserId(user)); + } + + @Get("unread-count") + @ApiOperation({ summary: "Count my unread support messages" }) + unread(@CurrentUser() user: AuthUserPayload) { + return this.service.unreadCount( + SupportAuthorRole.CUSTOMER, + resolveAuthUserId(user), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts new file mode 100644 index 000000000..e2e2875bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts @@ -0,0 +1,122 @@ +import { + SUPPORT_CHAT_WS_EVENTS, + SUPPORT_CHAT_WS_NAMESPACE, + SupportConversationDto, + SupportMessageDto, +} from "@edr/types"; +import { Logger } from "@nestjs/common"; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from "@nestjs/websockets"; +import { Server, Socket } from "socket.io"; + +import { BackofficeService } from "../backoffice/backoffice.service"; +import { ExternalProfileRepository } from "../companies/external-profile.repository"; +import { WsAuthService } from "../notification-inbox/ws-auth.service"; + +/** + * Server → client push for support chat. Clients only *listen* (no + * `@SubscribeMessage`); the handshake is authenticated in `handleConnection` + * (reusing the notification module's {@link WsAuthService}). Each socket joins a + * room based on its side: + * - backoffice staff → the shared `backoffice` room (see every conversation). + * - portal users → their `company:` room (their tickets only). + * + * A message is emitted to *both* the company room and the backoffice room so the + * customer thread, the sender's echo, and every other agent's inbox update live. + */ +@WebSocketGateway({ + namespace: SUPPORT_CHAT_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class SupportChatGateway implements OnGatewayConnection { + private readonly logger = new Logger(SupportChatGateway.name); + + private static readonly BACKOFFICE_ROOM = "backoffice"; + + @WebSocketServer() + private readonly server!: Server; + + constructor( + private readonly wsAuth: WsAuthService, + private readonly backoffice: BackofficeService, + private readonly externalProfiles: ExternalProfileRepository, + ) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + if (!userId) { + this.logger.debug(`Rejected support-chat handshake ${socket.id}`); + socket.disconnect(true); + return; + } + socket.data.userId = userId; + + try { + const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds(); + if (staffIds.includes(userId)) { + await socket.join(SupportChatGateway.BACKOFFICE_ROOM); + socket.data.side = "AGENT"; + return; + } + } catch (err) { + this.logger.warn(`Staff lookup failed: ${(err as Error).message}`); + } + + const profile = await this.externalProfiles.findByUserId(userId); + if (profile?.companyId) { + await socket.join(this.companyRoom(profile.companyId)); + socket.data.side = "CUSTOMER"; + socket.data.companyId = profile.companyId; + } + } + + /** Push a new message + updated conversation to the company and backoffice rooms. */ + emitMessage( + companyId: string, + conversation: SupportConversationDto, + message: SupportMessageDto, + ): void { + const payload = { conversation, message }; + for (const room of this.targetRooms(companyId)) { + const to = this.server.to(room); + to.emit(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, payload); + to.emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation); + } + } + + /** Push a conversation metadata change (e.g. status) to both rooms. */ + emitConversationUpdated( + companyId: string, + conversation: SupportConversationDto, + ): void { + for (const room of this.targetRooms(companyId)) { + this.server + .to(room) + .emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation); + } + } + + private targetRooms(companyId: string): string[] { + return [this.companyRoom(companyId), SupportChatGateway.BACKOFFICE_ROOM]; + } + + private companyRoom(companyId: string): string { + return `company:${companyId}`; + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === "string") return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith("Bearer ")) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts new file mode 100644 index 000000000..34accab44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts @@ -0,0 +1,35 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { BackofficeModule } from "../backoffice/backoffice.module"; +import { CompaniesModule } from "../companies/companies.module"; +import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; +import { SupportConversation } from "./entities/support-conversation.entity"; +import { SupportMessage } from "./entities/support-message.entity"; +import { SupportChatAgentController } from "./support-chat-agent.controller"; +import { SupportChatController } from "./support-chat.controller"; +import { SupportChatGateway } from "./support-chat.gateway"; +import { SupportChatService } from "./support-chat.service"; +import { SupportConversationRepository } from "./support-conversation.repository"; +import { SupportMessageRepository } from "./support-message.repository"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SupportConversation, SupportMessage]), + // ExternalProfileRepository — company lookup + ownership checks. + // CompaniesService — resolve the company an agent opens a thread with. + CompaniesModule, + // BackofficeService.getAllCurrentEmployeeUserIds — staff room membership. + BackofficeModule, + // WsAuthService — reused handshake authentication for the gateway. + NotificationInboxModule, + ], + controllers: [SupportChatController, SupportChatAgentController], + providers: [ + SupportConversationRepository, + SupportMessageRepository, + SupportChatGateway, + SupportChatService, + ], +}) +export class SupportChatModule {} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts new file mode 100644 index 000000000..70de1ce11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts @@ -0,0 +1,367 @@ +import { + SendSupportMessageResult, + SupportAuthorRole, + SupportConversationDto, + SupportConversationListResult, + SupportMessageDto, +} from "@edr/types"; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { QueryFailedError } from "typeorm"; + +import { CompaniesService } from "../companies/companies.service"; +import { ExternalProfileRepository } from "../companies/external-profile.repository"; +import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto"; +import { SupportConversation } from "./entities/support-conversation.entity"; +import { SupportMessage } from "./entities/support-message.entity"; +import { SupportChatGateway } from "./support-chat.gateway"; +import { SupportConversationRepository } from "./support-conversation.repository"; +import { SupportMessageRepository } from "./support-message.repository"; + +interface CustomerContext { + companyId: string; + companyName?: string | null; + authorName?: string | null; +} + +/** Postgres unique_violation — the one-thread-per-company index fired. */ +const PG_UNIQUE_VIOLATION = "23505"; + +@Injectable() +export class SupportChatService { + constructor( + private readonly conversations: SupportConversationRepository, + private readonly messages: SupportMessageRepository, + private readonly gateway: SupportChatGateway, + private readonly externalProfiles: ExternalProfileRepository, + private readonly companies: CompaniesService, + ) {} + + // ---- customer (portal) ------------------------------------------------- + + /** + * The caller's company thread, or null if nobody has spoken yet. Deliberately + * does *not* create: opening the widget shouldn't push an empty thread into + * the agent inbox. Creation happens on the first message. + */ + async getCustomerConversation( + userId: string, + ): Promise { + const ctx = await this.resolveCustomer(userId); + const conversation = await this.conversations.findByCompanyId(ctx.companyId); + if (!conversation) return null; + const unread = await this.messages.unreadCountsByConversation( + [conversation.id], + SupportAuthorRole.CUSTOMER, + ); + return this.toConversationDto( + conversation, + unread.get(conversation.id) ?? 0, + ); + } + + async getCustomerMessages(userId: string): Promise { + const ctx = await this.resolveCustomer(userId); + const conversation = await this.conversations.findByCompanyId(ctx.companyId); + if (!conversation) return []; + return this.listMessages(conversation.id); + } + + /** Send as the customer, opening the thread if this is the first message. */ + async sendAsCustomer( + userId: string, + body: string, + ): Promise { + const ctx = await this.resolveCustomer(userId); + const conversation = await this.getOrCreate( + ctx.companyId, + ctx.companyName, + userId, + ); + const { conversation: updated, message } = await this.appendMessage( + conversation, + userId, + SupportAuthorRole.CUSTOMER, + body, + ctx.authorName, + ); + return { + conversation: this.toConversationDto(updated, 0), + message: this.toMessageDto(message), + }; + } + + async markCustomerRead(userId: string): Promise<{ unreadCount: number }> { + const ctx = await this.resolveCustomer(userId); + const conversation = await this.conversations.findByCompanyId(ctx.companyId); + if (conversation) { + await this.conversations.update(conversation.id, { + customerLastReadAt: new Date(), + }); + } + return this.unreadCount(SupportAuthorRole.CUSTOMER, userId); + } + + // ---- agent (backoffice) ------------------------------------------------ + + async listForAgents( + query: ListConversationsQueryDto, + ): Promise { + const [rows, count] = await this.conversations.listAll( + SupportAuthorRole.AGENT, + query, + ); + return this.buildListResult(rows, count, SupportAuthorRole.AGENT); + } + + /** + * Open (or reuse) the thread with a company so an agent can start chatting. + * Idempotent — clicking a company that already has a thread just returns it. + */ + async startWithCompany(companyId: string): Promise { + const company = await this.companies.findCompanyById(companyId); + const existing = await this.conversations.findByCompanyId(companyId); + const conversation = + existing ?? (await this.getOrCreate(companyId, company.name, null)); + + const unread = await this.messages.unreadCountsByConversation( + [conversation.id], + SupportAuthorRole.AGENT, + ); + const dto = this.toConversationDto( + conversation, + unread.get(conversation.id) ?? 0, + ); + if (!existing) { + // Surface the new thread in every agent's inbox right away. + this.gateway.emitConversationUpdated(conversation.companyId, dto); + } + return dto; + } + + async sendAsAgent( + conversationId: string, + userId: string, + body: string, + ): Promise { + const conversation = await this.requireConversation(conversationId); + const { message } = await this.appendMessage( + conversation, + userId, + SupportAuthorRole.AGENT, + body, + ); + return this.toMessageDto(message); + } + + async markAgentRead( + conversationId: string, + userId: string, + ): Promise<{ unreadCount: number }> { + await this.requireConversation(conversationId); + await this.conversations.update(conversationId, { + agentLastReadAt: new Date(), + }); + return this.unreadCount(SupportAuthorRole.AGENT, userId); + } + + // ---- shared ------------------------------------------------------------ + + /** + * A thread's messages. Pass `asCustomerUserId` to enforce that the caller's + * company owns it (portal route); omit for agents, who see every thread. + */ + async getMessages( + conversationId: string, + asCustomerUserId?: string, + ): Promise { + const conversation = await this.requireConversation(conversationId); + if (asCustomerUserId) { + await this.assertCustomerOwns(conversation, asCustomerUserId); + } + return this.listMessages(conversationId); + } + + async unreadCount( + side: SupportAuthorRole, + userId: string, + ): Promise<{ unreadCount: number }> { + if (side === SupportAuthorRole.CUSTOMER) { + const ctx = await this.resolveCustomer(userId); + return { + unreadCount: await this.messages.countUnreadConversations( + side, + ctx.companyId, + ), + }; + } + return { unreadCount: await this.messages.countUnreadConversations(side) }; + } + + // ---- internals --------------------------------------------------------- + + /** + * Fetch the company's thread or open it. Two first-messages can race here, so + * we let the unique index arbitrate and re-read the winner rather than + * locking — the loser's insert is the only wasted work. + */ + private async getOrCreate( + companyId: string, + companyName: string | null | undefined, + createdByUserId: string | null, + ): Promise { + const existing = await this.conversations.findByCompanyId(companyId); + if (existing) return existing; + + try { + return await this.conversations.create({ + companyId, + companyName: companyName ?? null, + createdByUserId, + }); + } catch (error) { + if ( + error instanceof QueryFailedError && + (error as QueryFailedError & { code?: string }).code === + PG_UNIQUE_VIOLATION + ) { + const winner = await this.conversations.findByCompanyId(companyId); + if (winner) return winner; + } + throw error; + } + } + + private async listMessages( + conversationId: string, + ): Promise { + const rows = await this.messages.listByConversation(conversationId); + return rows.map((m) => this.toMessageDto(m)); + } + + /** Persist a message, bump the conversation's denormalized fields, emit live. */ + private async appendMessage( + conversation: SupportConversation, + userId: string, + role: SupportAuthorRole, + body: string, + authorName?: string | null, + ): Promise<{ conversation: SupportConversation; message: SupportMessage }> { + const message = await this.messages.create({ + conversationId: conversation.id, + authorUserId: userId, + authorRole: role, + authorName: authorName ?? null, + body, + }); + + conversation.lastMessageAt = message.createdAt; + conversation.lastMessagePreview = body.slice(0, 280); + conversation.lastMessageAuthorRole = role; + await this.conversations.update(conversation.id, { + lastMessageAt: conversation.lastMessageAt, + lastMessagePreview: conversation.lastMessagePreview, + lastMessageAuthorRole: role, + }); + + const dto = this.toConversationDto(conversation, 0); + this.gateway.emitMessage( + conversation.companyId, + dto, + this.toMessageDto(message), + ); + return { conversation, message }; + } + + private async buildListResult( + rows: SupportConversation[], + count: number, + side: SupportAuthorRole, + companyId?: string, + ): Promise { + const unreadMap = await this.messages.unreadCountsByConversation( + rows.map((r) => r.id), + side, + ); + const items = rows.map((r) => + this.toConversationDto(r, unreadMap.get(r.id) ?? 0), + ); + const unreadCount = await this.messages.countUnreadConversations( + side, + companyId, + ); + return { items, count, unreadCount }; + } + + private async resolveCustomer(userId: string): Promise { + const profile = await this.externalProfiles.findByUserId(userId); + if (!profile?.companyId) { + throw new ForbiddenException( + "No company profile is linked to this account.", + ); + } + const name = [profile.firstName, profile.lastName] + .filter(Boolean) + .join(" ") + .trim(); + return { + companyId: profile.companyId, + companyName: profile.company?.name ?? null, + authorName: name || null, + }; + } + + private async assertCustomerOwns( + conversation: SupportConversation, + userId: string, + ): Promise { + const ctx = await this.resolveCustomer(userId); + if (conversation.companyId !== ctx.companyId) { + throw new ForbiddenException("This conversation belongs to another company."); + } + return ctx; + } + + private async requireConversation(id: string): Promise { + const conversation = await this.conversations.findById(id); + if (!conversation) { + throw new NotFoundException("Conversation not found."); + } + return conversation; + } + + private toConversationDto( + c: SupportConversation, + unreadCount: number, + ): SupportConversationDto { + return { + id: c.id, + companyId: c.companyId, + companyName: c.companyName ?? null, + createdByUserId: c.createdByUserId ?? null, + lastMessageAt: c.lastMessageAt + ? new Date(c.lastMessageAt).toISOString() + : null, + lastMessagePreview: c.lastMessagePreview ?? null, + lastMessageAuthorRole: c.lastMessageAuthorRole ?? null, + unreadCount, + createdAt: new Date(c.createdAt).toISOString(), + updatedAt: new Date(c.updatedAt).toISOString(), + }; + } + + private toMessageDto(m: SupportMessage): SupportMessageDto { + return { + id: m.id, + conversationId: m.conversationId, + authorUserId: m.authorUserId, + authorRole: m.authorRole, + authorName: m.authorName ?? null, + body: m.body, + createdAt: new Date(m.createdAt).toISOString(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts b/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts new file mode 100644 index 000000000..10bdfcb4a --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts @@ -0,0 +1,75 @@ +import { BaseRepository } from "@edr/api-common"; +import { SupportAuthorRole } from "@edr/types"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { SupportConversation } from "./entities/support-conversation.entity"; + +export interface ListConversationsOptions { + search?: string; + /** Keep only threads with at least one message the side hasn't read. */ + unreadOnly?: boolean; + page?: number; + limit?: number; +} + +@Injectable() +export class SupportConversationRepository extends BaseRepository { + constructor( + @InjectRepository(SupportConversation) + repo: Repository, + ) { + super(repo); + } + + /** The company's thread, or null if neither side has spoken yet. */ + async findByCompanyId(companyId: string): Promise { + return this.repository.findOne({ where: { companyId } }); + } + + /** Every thread (backoffice shared inbox), most-recently-active first. */ + async listAll( + side: SupportAuthorRole, + opts: ListConversationsOptions = {}, + ): Promise<[SupportConversation[], number]> { + const page = opts.page && opts.page > 0 ? opts.page : 1; + const limit = opts.limit && opts.limit > 0 ? opts.limit : 20; + const qb = this.repository + .createQueryBuilder("c") + .orderBy("c.last_message_at", "DESC", "NULLS LAST") + .addOrderBy("c.created_at", "DESC") + .skip((page - 1) * limit) + .take(limit); + + if (opts.search?.trim()) { + qb.andWhere("c.company_name ILIKE :term", { + term: `%${opts.search.trim()}%`, + }); + } + if (opts.unreadOnly) { + // Same rule as SupportMessageRepository.baseUnreadQuery: a message from + // the other role, newer than this side's cursor. `cursorCol` is chosen + // from a closed set below — never caller input. + const otherRole = + side === SupportAuthorRole.CUSTOMER + ? SupportAuthorRole.AGENT + : SupportAuthorRole.CUSTOMER; + const cursorCol = + side === SupportAuthorRole.CUSTOMER + ? "c.customer_last_read_at" + : "c.agent_last_read_at"; + qb.andWhere( + `EXISTS ( + SELECT 1 FROM freight.support_messages m + WHERE m.conversation_id = c.id + AND m.deleted_at IS NULL + AND m.author_role = :otherRole + AND (${cursorCol} IS NULL OR m.created_at > ${cursorCol}) + )`, + { otherRole }, + ); + } + return qb.getManyAndCount(); + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts b/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts new file mode 100644 index 000000000..827035320 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts @@ -0,0 +1,82 @@ +import { BaseRepository } from "@edr/api-common"; +import { SupportAuthorRole } from "@edr/types"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { SupportConversation } from "./entities/support-conversation.entity"; +import { SupportMessage } from "./entities/support-message.entity"; + +@Injectable() +export class SupportMessageRepository extends BaseRepository { + constructor( + @InjectRepository(SupportMessage) + repo: Repository, + ) { + super(repo); + } + + /** All messages of a conversation, oldest first. */ + async listByConversation(conversationId: string): Promise { + return this.repository.find({ + where: { conversationId }, + order: { createdAt: "ASC" }, + }); + } + + /** + * Unread message counts per conversation *for one side*: messages authored by + * the other role that are newer than the side's read cursor. Returns a map of + * conversationId → count (conversations with 0 unread are absent). + */ + async unreadCountsByConversation( + conversationIds: string[], + mySide: SupportAuthorRole, + ): Promise> { + if (conversationIds.length === 0) return new Map(); + const rows = await this.baseUnreadQuery(mySide) + .select("m.conversation_id", "conversationId") + .addSelect("COUNT(*)", "count") + .andWhere("m.conversation_id IN (:...ids)", { ids: conversationIds }) + .groupBy("m.conversation_id") + .getRawMany<{ conversationId: string; count: string }>(); + return new Map(rows.map((r) => [r.conversationId, Number(r.count)])); + } + + /** Number of distinct conversations with at least one unread message for the side. */ + async countUnreadConversations( + mySide: SupportAuthorRole, + companyId?: string, + ): Promise { + const qb = this.baseUnreadQuery(mySide).select( + "COUNT(DISTINCT m.conversation_id)", + "count", + ); + if (companyId) { + qb.andWhere("c.company_id = :companyId", { companyId }); + } + const row = await qb.getRawOne<{ count: string }>(); + return Number(row?.count ?? 0); + } + + /** + * Base query for "unread for `mySide`": join the conversation, keep only + * messages from the opposite role that are newer than the side's read cursor. + */ + private baseUnreadQuery(mySide: SupportAuthorRole) { + const otherRole = + mySide === SupportAuthorRole.CUSTOMER + ? SupportAuthorRole.AGENT + : SupportAuthorRole.CUSTOMER; + const cursorCol = + mySide === SupportAuthorRole.CUSTOMER + ? "c.customer_last_read_at" + : "c.agent_last_read_at"; + return this.repository + .createQueryBuilder("m") + .innerJoin(SupportConversation, "c", "c.id = m.conversation_id") + .where("m.deleted_at IS NULL") + .andWhere("m.author_role = :otherRole", { otherRole }) + .andWhere(`(${cursorCol} IS NULL OR m.created_at > ${cursorCol})`); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index cf87622ab..2b9968f04 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -73,6 +73,16 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) direction?: string | null; + /** + * Reverse the wagon ORDER on this train: when true, the built wagon plan is + * flipped at build so the physically-last wagon sits at position 1. Only the + * order (sequenceNo) changes — composition and allocations travel with their + * slot. Frozen at create; every (re)assignment rebuilds under this flag so the + * stored train order and the schedule order always match. Default false. + */ + @Column({ name: 'reverse_wagon_order', type: 'boolean', default: false }) + reverseWagonOrder!: boolean; + @Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true }) actualDepartureAt?: Date | null; @@ -149,6 +159,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true }) ruleExportBookingLeadHours?: number | null; + /** Frozen import booking-close offset (minutes before departure). NULL = none. */ + @Column({ name: 'rule_import_close_offset_minutes', type: 'int', nullable: true }) + ruleImportCloseOffsetMinutes?: number | null; + + /** Frozen export booking-close offset (minutes before departure). NULL = none. */ + @Column({ name: 'rule_export_close_offset_minutes', type: 'int', nullable: true }) + ruleExportCloseOffsetMinutes?: number | null; + // Frozen wagon plan captured once when the schedule leaves the editable // DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a // non-editable schedule read THIS instead of the live wagon↔slot joins, so the diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 8e40c0384..08b906e84 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -21,6 +21,10 @@ export class TrainSchedulesRepository extends BaseRepository { findByIdWithFullGraph(id: string, manager?: EntityManager): Promise { return this.repo(manager).findOne({ where: { id }, + // One SELECT per relation instead of a single monster join — the nested + // wagon×allocation×booking×container branches multiply rows catastrophically + // when joined (measured ~925ms vs ~84ms on a 21-wagon schedule). + relationLoadStrategy: 'query', relations: { // Yards carry the route's display name; without them formatRouteLabel // degrades to the literal "Origin → Destination". Milestones (with diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index e913af6b7..51669459c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -6,6 +6,8 @@ import { listConfigBookingWindows, groupBookingsIntoBoardWindows, computeImportWindowTimes, + computeExportWindowTimes, + bookingCloseCutoff, type BoardWindowConfig, } from './batch-window.util'; @@ -360,3 +362,101 @@ describe('computeImportWindowTimes — immediate open inside the window day', () expect(t.windowOpensAt.getTime()).toBe(now.getTime()); }); }); + +// Booking-close offset: a configured offset pulls the window close earlier than +// departure by that many minutes, separately for import and export. +describe('bookingCloseCutoff — departure − offset', () => { + const departure = new Date('2026-07-10T13:00:00.000Z'); // 16:00 EAT Jul 10 + + it('returns departure unchanged when no offset is set', () => { + expect(bookingCloseCutoff(departure, 'IMPORT', {}).toISOString()).toBe( + departure.toISOString(), + ); + expect( + bookingCloseCutoff(departure, 'EXPORT', { + importCloseOffsetMinutes: 180, + }).toISOString(), + ).toBe(departure.toISOString()); + }); + + it('a non-positive offset is treated as no offset', () => { + expect( + bookingCloseCutoff(departure, 'IMPORT', { + importCloseOffsetMinutes: 0, + }).toISOString(), + ).toBe(departure.toISOString()); + expect( + bookingCloseCutoff(departure, 'IMPORT', { + importCloseOffsetMinutes: -5, + }).toISOString(), + ).toBe(departure.toISOString()); + }); + + it('import 3-hour offset: 16:00 EAT departure → cutoff 13:00 EAT (14:00 → 3h before)', () => { + // Departure 16:00 EAT (13:00 UTC), 3h offset → 13:00 EAT = 10:00 UTC. + const cutoff = bookingCloseCutoff(departure, 'IMPORT', { + importCloseOffsetMinutes: 180, + }); + expect(cutoff.toISOString()).toBe('2026-07-10T10:00:00.000Z'); + }); + + it('export 1-day offset: Jul-10 16:00 EAT departure → cutoff Jul-9 16:00 EAT', () => { + const cutoff = bookingCloseCutoff(departure, 'EXPORT', { + exportCloseOffsetMinutes: 1440, + }); + // Jul 9 16:00 EAT = Jul 9 13:00 UTC. + expect(cutoff.toISOString()).toBe('2026-07-09T13:00:00.000Z'); + }); + + it('import and export offsets are independent', () => { + const cfg = { + importCloseOffsetMinutes: 180, + exportCloseOffsetMinutes: 1440, + }; + expect(bookingCloseCutoff(departure, 'IMPORT', cfg).toISOString()).toBe( + '2026-07-10T10:00:00.000Z', + ); + expect(bookingCloseCutoff(departure, 'EXPORT', cfg).toISOString()).toBe( + '2026-07-09T13:00:00.000Z', + ); + // DOMESTIC uses the import offset. + expect(bookingCloseCutoff(departure, 'DOMESTIC', cfg).toISOString()).toBe( + '2026-07-10T10:00:00.000Z', + ); + }); +}); + +describe('window-time computation honours the close offset', () => { + it('export closes at departure − offset, not departure', () => { + // Departs Jul 10 16:00 EAT (13:00 UTC), lead 24h, 24-hour desk, 1-day offset. + const departure = new Date('2026-07-10T13:00:00.000Z'); + const { windowClosesAt } = computeExportWindowTimes(departure, { + exportBookingLeadHours: 48, + windowOpenHour: 8, + windowCloseHour: 8, // 24-hour desk + exportCloseOffsetMinutes: 1440, + }); + // Jul 9 16:00 EAT = Jul 9 13:00 UTC. + expect(windowClosesAt.toISOString()).toBe('2026-07-09T13:00:00.000Z'); + }); + + it('import close is capped at the cutoff (departure − offset)', () => { + // Round-the-clock desk, opens 05 Jul 12:00 EAT, 24h duration would run to + // 06 Jul 12:00; departure 06 Jul 08:00 EAT (05:00 UTC) with a 2-hour offset → + // cutoff 06 Jul 06:00 EAT = 03:00 UTC. + const departure = new Date('2026-07-06T05:00:00.000Z'); + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowClosesAt } = computeImportWindowTimes( + departure, + { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowCloseHour: 8, // 24-hour desk (no office-hour cap) + windowDurationHours: 24, + importCloseOffsetMinutes: 120, + }, + now, + ); + expect(windowClosesAt.toISOString()).toBe('2026-07-06T03:00:00.000Z'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index a1dfe61f4..aa0540544 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -266,6 +266,29 @@ export function clampCloseToOfficeHours( return closesAt; } +/** + * The instant a schedule stops accepting bookings. By default that is departure, + * but a configured close offset (import/export, minutes) pulls it earlier: + * `departure − offset`. This is the single bound every window close, reopen + * cycle and export FCFS close is capped at — swap it in wherever the logic used + * to cap at departure. A non-positive/absent offset yields departure unchanged. + */ +export function bookingCloseCutoff( + departure: Date, + direction: string | null | undefined, + cfg: { + importCloseOffsetMinutes?: number | null; + exportCloseOffsetMinutes?: number | null; + }, +): Date { + const offsetMinutes = + direction === 'EXPORT' + ? cfg.exportCloseOffsetMinutes + : cfg.importCloseOffsetMinutes; + if (offsetMinutes == null || !(offsetMinutes > 0)) return departure; + return new Date(departure.getTime() - offsetMinutes * 60_000); +} + export interface InitialWindowTimes { windowOpensAt: Date; windowClosesAt: Date; @@ -296,9 +319,13 @@ export function computeImportWindowTimes( windowOpenHour: number; windowCloseHour: number; windowDurationHours: number; + importCloseOffsetMinutes?: number | null; }, now: Date, ): InitialWindowTimes { + // The window opens off the REAL departure (open day = departure − leadDays), + // but shuts at the configured cutoff (departure − closeOffset, or departure). + const cutoff = bookingCloseCutoff(departure, 'IMPORT', cfg); const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour); @@ -324,8 +351,8 @@ export function computeImportWindowTimes( windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }); - if (closesAt.getTime() > departure.getTime()) { - closesAt = departure; + if (closesAt.getTime() > cutoff.getTime()) { + closesAt = cutoff; } return { windowOpensAt: opensAt, windowClosesAt: closesAt }; } @@ -344,8 +371,12 @@ export function computeExportWindowTimes( exportBookingLeadHours: number; windowOpenHour: number; windowCloseHour: number; + exportCloseOffsetMinutes?: number | null; }, ): InitialWindowTimes { + // Opens off the real departure (lead hours), shuts at the cutoff + // (departure − closeOffset, or departure when no offset is set). + const cutoff = bookingCloseCutoff(departure, 'EXPORT', cfg); const rawOpen = new Date( departure.getTime() - cfg.exportBookingLeadHours * 3_600_000, ); @@ -353,10 +384,12 @@ export function computeExportWindowTimes( windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }); - if (opensAt.getTime() > departure.getTime()) { - opensAt = departure; + // Open can't outlive the cutoff (a huge offset would otherwise leave a + // negative-length window); clamp to a zero-length window at the cutoff. + if (opensAt.getTime() > cutoff.getTime()) { + opensAt = cutoff; } - return { windowOpensAt: opensAt, windowClosesAt: departure }; + return { windowOpensAt: opensAt, windowClosesAt: cutoff }; } /** @@ -457,6 +490,10 @@ export interface BoardWindowConfig { */ reopenGapMinutes: number; exportBookingLeadHours: number; + /** Minutes before departure the import window shuts; NULL/0 ⇒ close at departure. */ + importCloseOffsetMinutes?: number | null; + /** Minutes before departure the export window shuts; NULL/0 ⇒ close at departure. */ + exportCloseOffsetMinutes?: number | null; } const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { @@ -507,10 +544,15 @@ export function listConfigBookingWindows( cfg: BoardWindowConfig, anchorOpensAt?: Date | null, ): BoardWindow[] { + // Bookings shut at the cutoff (departure − closeOffset), not departure. The + // window opens still key off the real departure below; only closes are capped + // here, so the board draws the exact windows the engine runs. + const cutoff = bookingCloseCutoff(departure, direction, cfg); + if (direction === 'EXPORT') { const start = anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt; - return [boardWindowFromInterval(start, departure)]; + return [boardWindowFromInterval(start, cutoff)]; } const windows: BoardWindow[] = []; @@ -527,29 +569,29 @@ export function listConfigBookingWindows( let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour); // The loop terminates naturally: every cycle advances opensAt by at least // (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would - // reach departure. maxCycles is a derived runaway backstop sized to the real - // span (first open → departure) over the smallest possible advance, so a + // reach the cutoff. maxCycles is a derived runaway backstop sized to the real + // span (first open → cutoff) over the smallest possible advance, so a // legitimate config is never silently truncated — only a pathological // zero-length one would hit it. - const spanMs = departure.getTime() - opensAt.getTime(); + const spanMs = cutoff.getTime() - opensAt.getTime(); const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000); const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2; for (let cycle = 0; cycle < maxCycles; cycle += 1) { - if (opensAt.getTime() >= departure.getTime()) break; + if (opensAt.getTime() >= cutoff.getTime()) break; let closesAt = new Date(opensAt.getTime() + durationMs); closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours); - if (closesAt.getTime() > departure.getTime()) closesAt = departure; + if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff; windows.push(boardWindowFromInterval(opensAt, closesAt)); const earliestNextOpen = new Date(closesAt.getTime() + reopenMs); - opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure); + opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, cutoff); if (opensAt == null) break; } - // Degenerate config (no window before departure) — surface a single window - // clamped to departure so the board still renders something meaningful. + // Degenerate config (no window before the cutoff) — surface a single window + // clamped to the cutoff so the board still renders something meaningful. if (windows.length === 0) { - windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure)); + windows.push(boardWindowFromInterval(new Date(cutoff.getTime() - durationMs), cutoff)); } return windows; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts index cb2e63223..3df73b26a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts @@ -137,4 +137,109 @@ describe('BookingBatchService — exportSpaceReport (whole-booking, single train 'No export train is accepting bookings for this day', ); }); + + describe('dayImportAvailability (advisory, summed across the day)', () => { + const DAY_STR = '2026-07-20'; + + const importSchedule = (id: string, over: Record = {}) => ({ + id, + status: 'SCHEDULED', + direction: 'IMPORT', + scheduledDepartureDate: DAY, + bookingWindowStatus: 'OPEN', + windowPhase: 'OPEN', // still OPEN — the advisory ignores the fill phase + ...over, + }); + + // A bulk booking small enough to fit; freeWagons is what matters, not `fits`. + const importBooking = (cargoTons: number) => + ({ + id: 'bk-imp', + freightType: 'BULK', + tradeDirection: 'IMPORT', + originYardId: 'yard-a', + destinationYardId: 'yard-b', + cargoTotalWeightVgm: cargoTons, + bookingContainers: [], + }) as unknown as Booking; + + it('sums free wagons across every import train on the day', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + importSchedule('train-2'), + ]); + + const one = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + // Re-run with a single train to prove two trains sum to double one train. + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + ]); + const solo = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + expect(solo.freeWagons).toBeGreaterThan(0); + expect(one.freeWagons).toBe(solo.freeWagons * 2); + expect(one.trainsForDay).toBe(true); + }); + + it('ignores EXPORT trains — they are not part of the import pool', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + { ...importSchedule('train-2'), direction: 'EXPORT' }, + ]); + + const both = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + ]); + const solo = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + expect(both.freeWagons).toBe(solo.freeWagons); + }); + + it('ignores FULL trains', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1', { bookingWindowStatus: 'FULL' }), + ]); + + const report = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + expect(report.freeWagons).toBe(0); + expect(report.trainsForDay).toBe(false); + }); + + it('nets out capacity already held by reserved bookings', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + ]); + const empty = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + bookingsRepository.findReservedForSchedule.mockResolvedValue([ + heavyReserved, + ]); + const withHold = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + expect(withHold.freeWagons).toBeLessThan(empty.freeWagons); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 8c3c92193..fc97f772b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 210, bookingContainers: [ - { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + { quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(3); @@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 40, bookingContainers: [ - { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + { quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(2); @@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => { { quantity: 2, wagonsRequired: 2, - containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, + containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, }, ], }; @@ -950,3 +950,132 @@ describe('BookingBatchService — wagonsFor', () => { }); }); }); + +describe('BookingBatchService — built-train wagon capacity', () => { + // A schedule created from a built train is capped by its PHYSICAL consist: + // wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the + // old weight/length math would call every one of these trains FULL, so any + // assertion below that says "not full" proves those axes are ignored. + const scheduleId = 'schedule-built'; + + const reservedBooking = (id: string, leg?: { origin: string; dest: string }) => + ({ + id, + freightType: 'BULK', + cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload + bookingContainers: [], + originYardId: leg?.origin ?? 'yard-a', + destinationYardId: leg?.dest ?? 'yard-b', + }) as unknown as Booking; + + const buildService = (opts: { + physicalWagons: number; + reserved: Booking[]; + maxWagons?: number; + routeStops?: string[]; + }) => { + const schedule = { + id: scheduleId, + maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose + bookingWindowStatus: 'OPEN', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + routeId: opts.routeStops ? 'route-1' : null, + scheduleBookings: [], + trainSet: { + locomotive: { + maxPullWeightTons: 1, + maxTrainLengthMeters: 1, + overageToleranceTons: 0, + overageToleranceMeters: 0, + }, + train: { id: 'train-built-1' }, + }, + }; + const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) }; + const milestoneRepo = { + find: jest + .fn() + .mockResolvedValue( + (opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })), + ), + }; + const genericRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(undefined), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => { + if (entity?.name === 'Wagon') return wagonRepo; + if (entity?.name === 'RouteMilestone') return milestoneRepo; + return genericRepo; + }), + transaction: jest.fn(), + }; + const service = new BookingBatchService( + dataSource as never, + { + findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved), + } as never, + { + findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule), + findById: jest.fn().mockResolvedValue(schedule), + } as never, + null as never, + null as never, + null as never, + null as never, + null as never, + { emitPhase: jest.fn() } as never, + null as never, + ); + return { service, wagonRepo }; + }; + + it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => { + const { service } = buildService({ + physicalWagons: 2, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + maxWagons: 44, // stale: the old slot cap would say 42 slots remain + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + + it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => { + const { service } = buildService({ + physicalWagons: 3, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + // 1T pull cap would have been exhausted long ago under the old math. + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => { + // Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor + // left the pass-through edges reading "free" in the per-edge budget, so the + // full train's window cycled OPEN forever and the day pool never expired. + // A wagon is committed for the whole trip — leg-free edges are not capacity. + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'], + reserved: [ + reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }), + reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }), + ], + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { + const { service } = buildService({ + physicalWagons: 1, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({ + maxWagons: 1, + allocatedWagons: 2, + remainingSlots: 0, + overAllocatedBy: 1, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index ef39ab138..4a836060e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -33,7 +33,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; -import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { eatDay } from './batch-window.util'; import { BATCH_BOARD_STATUSES, BatchBoardQueryDto, @@ -68,8 +68,10 @@ import { wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; +import { RemainderPlacementService } from './remainder-placement.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, @@ -170,23 +172,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { consolidationPartnerRef: string | null; } -export interface BatchWindowGroup { - key: string; - label: string; - /** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */ - date: string; - /** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */ - dateLabel: string; - start: string; - end: string; - counts: { - allocated: number; - selectedForBatch: number; - ready: number; - waiting: number; - expired: number; - pendingContract: number; - }; +export interface BatchBoardCounts { + allocated: number; + selectedForBatch: number; + ready: number; + waiting: number; + expired: number; + pendingContract: number; +} + +/** A booking bucket on the detail board (in-window vs pending-contract). */ +export interface BatchBoardBucket { + counts: BatchBoardCounts; bookings: BatchBoardBookingDetail[]; } @@ -213,8 +210,9 @@ export interface BatchBoardScheduleDetail { locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; - windows: BatchWindowGroup[]; - pendingContract: BatchWindowGroup; + /** Bookings inside the schedule's booking window (fully-executed contracts). */ + bookings: BatchBoardBookingDetail[]; + pendingContract: BatchBoardBucket; allocationViolations: string[]; } @@ -305,6 +303,9 @@ export class BookingBatchService implements OnModuleInit { private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, + // forwardRef: TrainSchedulingService injects this service back (window + // refresh after adjust-consist), so the classes load in a cycle. + @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, private readonly bookingWindowGateway: BookingWindowGateway, @@ -313,9 +314,29 @@ export class BookingBatchService implements OnModuleInit { @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, + @Optional() + @Inject(forwardRef(() => RemainderPlacementService)) + private readonly remainderPlacement?: RemainderPlacementService, ) {} + /** + * Auto-place a paid booking's split remainder onto the next fitting train. + * Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true. + */ + private get autoRemainderEnabled(): boolean { + return process.env.FREIGHT_AUTO_REMAINDER === "true"; + } + + /** + * Let EXPORT bookings split (offer the largest fitting part, leftover rebooks + * on the next train). Separate flag from auto-remainder: export touches the + * FCFS money path, so partial-offer can be enabled independently. + */ + private get exportSplitEnabled(): boolean { + return process.env.FREIGHT_EXPORT_SPLIT === "true"; + } + /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { const groups = await this.openRouteDayGroups(); @@ -492,6 +513,34 @@ export class BookingBatchService implements OnModuleInit { // to the offered part before it boards (remainder returns to the contract cap). if (this.splitService) { await this.splitService.applySplit(bookingId); + + // The split only happens on payment (here) — so auto-placing the remainder + // also only happens once the customer has accepted+paid. Re-read to see if + // applySplit actually reduced this booking (an open offer existed); if so, + // auto-create + place the remainder booking on the next fitting train. + // applySplit committed its own transaction before returning, so this reads + // the reduced lines. Best-effort: a placement failure never blocks the + // paid booking from boarding — the remainder falls back to manual rebook. + if (this.autoRemainderEnabled && this.remainderPlacement) { + const split = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + // Export remainders only auto-place when export split is on — otherwise + // an export booking never splits in the first place. + const directionOn = + split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled; + if (split?.isSplit && directionOn) { + await this.remainderPlacement + .placeRemainder(split) + .catch((err) => + this.logger.error( + `Auto-place remainder failed for ${split.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + } } const linked = @@ -727,6 +776,186 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Trains that can carry a booking's leg on a given day, earliest departure + * first, each with the largest number of wagons it could still admit for the + * booking's wagon type. Direction-filtered: EXPORT bookings see export trains, + * IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL + * allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a + * non-primary allowed type still counts. The remainder placer uses this to + * pick the next fitting train; the `free` wagon count is the best across the + * allowed types (a train fits under whichever allowed type gives most room). + */ + async fittingTrainsForDay( + booking: Booking, + day: string, + direction: "IMPORT" | "EXPORT", + ): Promise> { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.bookingWindowStatus !== "FULL" && + (direction === "EXPORT" + ? s.direction === "EXPORT" + : s.direction !== "EXPORT"), + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + + const wagonDims = await this.loadWagonDims(); + const dimsOptions = this.dimsForAllowed(booking, wagonDims); + const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = []; + + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + const room = budget.remainingFor(leg); + // Best usable wagons across the allowed types — a train fits under + // whichever configured wagon type gives it the most room. + let freeWagons = 0; + for (const dims of dimsOptions) { + const w = this.bookableWithin(room, dims).wagons; + if (w > freeWagons) freeWagons = w; + } + if (freeWagons > 0) { + out.push({ + scheduleId: schedule.id, + departure: schedule.scheduledDepartureDate!, + freeWagons, + }); + } + } + return out; + } + + /** + * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, + * summed across every train on the booking's corridor that day. Unlike the + * export gate this does NOT block and does NOT first-fit a single train: + * import is batched and splittable, so the honest number a customer can plan + * against is the TOTAL room across the day's trains for the booking's wagon + * type, in that type's own wagon units. + * + * It deliberately skips the `isFillable` window-phase gate. A customer picks a + * shipment day while its window is still OPEN (or pre-window) — the batch fill + * only makes those trains fillable after the window closes — so gating on the + * fill phase here would report 0 for exactly the days customers are choosing. + * We therefore count any non-FULL train that carries the leg, netting out the + * capacity already consumed by allocated + live-reserved bookings + * (`remainingBudget`). The count is an upper bound: the batch engine may still + * split the booking across trains or defer a remainder to a later window. + */ + async dayImportAvailability( + booking: Booking, + day: string, + ): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor.filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.bookingWindowStatus !== 'FULL' && + s.direction !== 'EXPORT', + ); + + const wagonDims = await this.loadWagonDims(); + const dims = this.dimsFor(booking, wagonDims); + const need = this.wagonsFor(booking, wagonDims); + let freeWagons = 0; + let trainsForDay = false; + + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + trainsForDay = true; + freeWagons += this.bookableWithin(budget.remainingFor(leg), dims).wagons; + } + + return { freeWagons, need, trainsForDay }; + } + + /** + * Export split: no single train carries the whole booking, so offer the + * largest fitting part on the export train with the most room for its leg. + * Returns true when an offer was opened (the caller must NOT then reserve — + * the offer already opened its own pay window), false when the booking fits + * whole somewhere (normal FCFS path) or no meaningful partial exists. + * + * Only the offer is written here: the booking is reduced to the offered part + * on payment (applySplit), and the leftover is auto-placed afterwards. So an + * unpaid export booking stays whole and the customer may still cancel it. + */ + private async tryExportPartialOffer(booking: Booking): Promise { + if (!this.splitService) return false; + const report = await this.exportSpaceReport(booking); + // A train fits it whole — nothing to split, take the normal path. + if (report.scheduleId) return false; + if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false; + + if (!booking.scheduledDate) return false; + const day = eatDay(new Date(booking.scheduledDate)); + const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT"); + if (!fitting.length) return false; + // Most room first — the largest single part ships now, the smallest leftover + // is what has to find another train. + const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0]; + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + target.scheduleId, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) return false; + const wagonDims = await this.loadWagonDims(); + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) return false; + + const offered = await this.tryPartialOffer( + booking, + schedule.id, + budget.remainingFor(leg), + report.need, + ); + if (!offered) return false; + this.logger.log( + `[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` + + `${schedule.id} — leftover rebooks on the next train once paid.`, + ); + this.notifyBoardChanged(schedule.id, "batch_fill"); + return true; + } + /** * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. * A consolidated booking reserves as a pair only once BOTH partners are ready @@ -738,6 +967,15 @@ export class BookingBatchService implements OnModuleInit { async acceptExportBooking(booking: Booking): Promise { const partnerId = booking.consolidationPartnerId ?? null; if (!partnerId) { + // Export split: when no single train carries the whole booking, offer the + // largest fitting part instead of failing the accept. The customer pays + // that part; on payment applySplit reduces this booking to it and the + // leftover is auto-placed as its own booking on the next train. Pairs are + // excluded (handled below) — a shared wagon is never split. + if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) { + const offered = await this.tryExportPartialOffer(booking); + if (offered) return; + } const scheduleId = await this.pickExportSchedule(booking); await this.reserveOnExport([booking], scheduleId); return; @@ -951,11 +1189,32 @@ export class BookingBatchService implements OnModuleInit { const wagonDims = await this.loadWagonDims(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); + // One links query + one bookings query for the whole page (was 2 per card). + const scheduleIds = schedules.map((s) => s.id); + const [allLinks, allBookings] = await Promise.all([ + scheduleIds.length + ? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } }) + : Promise.resolve([]), + this.bookingsRepository.findAllBySchedules(scheduleIds), + ]); + const linkedIdsBySchedule = new Map>(); + for (const l of allLinks) { + let set = linkedIdsBySchedule.get(l.trainScheduleId); + if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set())); + set.add(l.bookingId); + } + const bookingsBySchedule = new Map(); + for (const b of allBookings) { + if (!b.trainScheduleId) continue; + let list = bookingsBySchedule.get(b.trainScheduleId); + if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = [])); + list.push(b); + } + const board: BatchBoardSchedule[] = []; for (const s of schedules) { - const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); - const linkedIds = new Set(links.map((l) => l.bookingId)); - const bookings = await this.bookingsRepository.findAllBySchedule(s.id); + const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set(); + const bookings = bookingsBySchedule.get(s.id) ?? []; const items: BatchBoardBooking[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); @@ -984,7 +1243,8 @@ export class BookingBatchService implements OnModuleInit { return { items: board, meta: buildPaginationMeta(total, page, pageSize) }; } - /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ + /** Schedule-level batch board: the schedule's own booking window plus its + * bookings split into in-window (contract executed) vs pending-contract. */ async getBatchBoardDetail( scheduleId: string, ): Promise { @@ -1002,17 +1262,24 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); - const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); - const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); - const linkedIds = new Set(links.map((l) => l.bookingId)); + // The full graph already carries the schedule↔booking links — no separate + // link query needed. + const linkedIds = new Set( + (s.scheduleBookings ?? []).map((l) => l.bookingId), + ); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); let allocationPreview: Awaited< ReturnType >; try { + // Reuse the graph loaded above — the preview otherwise re-loads the same + // heavy schedule graph a second time per request. allocationPreview = - await this.trainSchedulingService.previewAllocationForSchedule(s.id); + await this.trainSchedulingService.previewAllocationForSchedule( + s.id, + s, + ); } catch { allocationPreview = { assignedBookingIds: [], @@ -1082,62 +1349,22 @@ export class BookingBatchService implements OnModuleInit { const loco = s.trainSet?.locomotive ?? null; - // Display windows are the REAL booking-window cycles this schedule was FROZEN - // with at creation (import: opens at its stored window time, lasts its rule's - // duration, reopens per its rule's delay; export: single FCFS lead window) — - // NOT the live global config. A later global-rules edit only re-derives - // not-yet-open schedules (restampPendingWindows), so an already-open schedule - // must keep drawing from its own snapshot, anchored on its stored open time. - // Legacy rows with no snapshot fall back to the live config. - const liveCfg = await this.trainSchedulingService.getWindowConfig(); - const num = (v: unknown, fallback: number) => { - const n = v == null ? NaN : Number(v); - return Number.isFinite(n) ? n : fallback; - }; - const windowCfg = { - windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), - windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour), - windowDurationHours: num( - s.ruleWindowDurationHours, - liveCfg.windowDurationHours, - ), - // Frozen doc-review + payment sum; legacy rows fall back to the live sum. - reopenGapMinutes: num( - s.ruleReopenDelayMinutes, - liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes, - ), - importWindowLeadDays: num( - s.ruleImportWindowLeadDays, - liveCfg.importWindowLeadDays, - ), - exportBookingLeadHours: num( - s.ruleExportBookingLeadHours, - liveCfg.exportBookingLeadHours, - ), - }; - const departureDate = s.scheduledDepartureDate ?? new Date(); - const windowBuckets = groupBookingsIntoBoardWindows( - items, - (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - s.direction ?? null, - departureDate, - windowCfg, - undefined, - s.windowOpensAt ?? null, - ); - - const emptyCounts = () => ({ - allocated: 0, - selectedForBatch: 0, - ready: 0, - waiting: 0, - expired: 0, - pendingContract: 0, - }); - - const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { - const counts = emptyCounts(); - for (const b of bookingsInWindow) { + // The board renders ONE booking window — the schedule's own frozen window + // (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings + // split into two buckets: contract executed (in the window) vs pending + // contract. The old per-cycle window projection was dropped — the UI never + // showed it, and reconstructing every cycle cost a config load + grouping + // pass per request. + const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => { + const counts: BatchBoardCounts = { + allocated: 0, + selectedForBatch: 0, + ready: 0, + waiting: 0, + expired: 0, + pendingContract: 0, + }; + for (const b of bucket) { if (b.state === "ALLOCATED") counts.allocated += 1; else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; else if (b.state === "READY") counts.ready += 1; @@ -1148,26 +1375,8 @@ export class BookingBatchService implements OnModuleInit { return counts; }; - const windows: BatchWindowGroup[] = []; - for (const [key, bucket] of windowBuckets) { - if (key === "pending-contract" || !bucket.window) continue; - const w = bucket.window; - windows.push({ - key: w.key, - label: w.label, - date: w.date, - dateLabel: w.dateLabel, - start: w.start.toISOString(), - end: w.end.toISOString(), - counts: countFor(bucket.items), - bookings: bucket.items, - }); - } - windows.sort( - (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), - ); - - const pendingBookings = windowBuckets.get("pending-contract")?.items ?? []; + const windowBookings = items.filter((i) => i.fullyExecutedAt); + const pendingBookings = items.filter((i) => !i.fullyExecutedAt); return { scheduleId: s.id, @@ -1217,14 +1426,8 @@ export class BookingBatchService implements OnModuleInit { .length, expired: items.filter((i) => i.state === "EXPIRED").length, }, - windows, + bookings: windowBookings, pendingContract: { - key: "pending-contract", - label: "Pending contract", - date: "", - dateLabel: "", - start: "", - end: "", counts: countFor(pendingBookings), bookings: pendingBookings, }, @@ -1779,15 +1982,23 @@ export class BookingBatchService implements OnModuleInit { } /** - * A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be - * offered a partial (split-on-payment). Consolidated pairs never split (both-or- - * neither shared wagon) and government bookings never split (they preempt). + * A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a + * partial (split-on-payment). Consolidated pairs never split (both-or-neither + * shared wagon) and government bookings never split (they preempt). + * + * IMPORT is always eligible. EXPORT is eligible only when export split is + * enabled: export historically rides one train whole, so splitting it changes + * the FCFS money path — each split part still rides ONE train whole, and the + * leftover becomes its own booking on the next train. */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { + const directionOk = + booking.tradeDirection === "IMPORT" || + (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled); return ( !isPair && !booking.isGovernment && - booking.tradeDirection === "IMPORT" && + directionOk && (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && this.splitService != null ); @@ -2250,7 +2461,13 @@ export class BookingBatchService implements OnModuleInit { if (!schedule || !locomotive) return null; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); - const budget = await this.remainingBudget(schedule, limits, wagonDims); + // Built trains: collapse to a single train-wide pool so the freed capacity of + // a booking that alights mid-corridor is NOT re-offered on the pass-through + // leg (see remainingBudget). Keeps intercity accept consistent with the + // train-wide isTrainFull / committedWagons finalize signal. + const budget = await this.remainingBudget(schedule, limits, wagonDims, { + collapseForBuiltTrain: true, + }); return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } @@ -2915,7 +3132,7 @@ export class BookingBatchService implements OnModuleInit { ? Math.ceil(booking.wagonsRequired) : 0; - // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback + // TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. const byLength = containerWagonsForLines(booking.bookingContainers ?? []); @@ -2993,17 +3210,20 @@ export class BookingBatchService implements OnModuleInit { } /** - * Keep schedule.max_wagons aligned with the train's boarding limit: the - * locomotive's length-derived slot count. The physical wagons currently in - * the train set do NOT cap this — bookings are admitted on length/weight - * alone and yard staff attach the wagons manually before departure. + * Keep schedule.max_wagons aligned with the train's boarding limit. A built + * train's limit is its physical consist — the wagon count staff marshalled + * (and may change via adjust-consist). Only schedules WITHOUT a built train + * fall back to the locomotive's length-derived slot count, where bookings + * are admitted on length/weight alone and yard staff attach the wagons + * manually before departure. */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, ): Promise { - const limits = await this.capacityLimits(locomotive); - const maxWagons = limits.base.wagons; + const physicalWagons = await this.builtTrainWagonCount(schedule); + const maxWagons = + physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons; if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) @@ -3041,7 +3261,14 @@ export class BookingBatchService implements OnModuleInit { * (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has * no wagon type configured yet. */ + /** Wagon types are near-static reference data — a short TTL cache spares one + * table scan per board/detail request without letting edits go stale long. */ + private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null; + private async loadWagonDims(): Promise { + if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) { + return this.wagonDimsCache.value; + } const types = await this.dataSource.getRepository(WagonType).find(); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), @@ -3055,7 +3282,7 @@ export class BookingBatchService implements OnModuleInit { // must fall back rather than yield an infinite wagon count. const payload = (value: number | undefined, fallback: number): number => value && value > 0 ? value : fallback; - return { + const value: WagonDims = { container: { lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, @@ -3068,6 +3295,8 @@ export class BookingBatchService implements OnModuleInit { }, byWagonTypeId, }; + this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 }; + return value; } /** @@ -3098,6 +3327,40 @@ export class BookingBatchService implements OnModuleInit { }; } + /** + * EVERY wagon-type dimension a booking may ride — its cargo/container type's + * full allowed (many-to-many) wagon-type list, not just the first like + * {@link dimsFor}. The remainder placer needs the whole set so a train that + * stocks a non-primary allowed type still counts as fitting: a container type + * mapped to both NW5 and (say) NW7 must be measured against whichever a given + * train actually has free. Deduped by wagon-type id; falls back to the single + * representative dims when no allowed type is configured. + */ + private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] { + const fallback = + booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; + const ids = + booking.freightType === "BULK" + ? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id) + : (booking.bookingContainers ?? []) + .flatMap((line) => line.containerType?.wagonTypes ?? []) + .map((wt) => wt.id); + const seen = new Set(); + const dims: PerWagonDims[] = []; + for (const id of ids) { + if (!id || seen.has(id)) continue; + seen.add(id); + const d = wagonDims.byWagonTypeId.get(id); + if (d) { + dims.push({ + ...d, + capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + }); + } + } + return dims.length ? dims : [fallback]; + } + /** * Ordered stop yards of the schedule's route (origin → milestones → * destination); the legacy two-stop pseudo-route when milestones are absent. @@ -3122,17 +3385,47 @@ export class BookingBatchService implements OnModuleInit { * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. * - * The wagon axis is the locomotive's length-derived slot count only — the - * physical wagons currently marshalled in the train set do NOT cap it. - * Bookings are admitted on length/weight capacity and yard staff attach - * the missing wagons manually before wagon assignment. + * Two capacity regimes, decided by the schedule's train: + * - Built train (Train Builder consist with physical wagons): the consist IS + * the capacity. Wagon slots = physical wagon count; weight and length are + * NOT re-checked here — the builder and adjust-consist already enforced the + * locomotive's pull/length limits when the consist was assembled. + * - No built train (legacy schedules): the locomotive's length-derived slot + * count plus its weight/length budgets, as before — yard staff attach the + * missing wagons manually before wagon assignment. */ private async remainingBudget( schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, + opts?: { collapseForBuiltTrain?: boolean }, ): Promise { - const stops = await this.stopsForSchedule(schedule); + const physicalWagons = await this.builtTrainWagonCount(schedule); + if (physicalWagons != null) { + limits = { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; + } + // A built train's wagons are coupled for the WHOLE trip, and the allocator + // commits each booking to a wagon for the entire route — it never reloads a + // wagon at a mid-corridor alight yard. So a built train has no leg concept: + // its capacity is one train-wide pool, exactly as isTrainFull / + // committedWagons already count it. When a caller opts in, collapse the + // corridor to a single whole-route edge so every booking (full-route OR + // mid-corridor) draws from that one pool — a train full of import-to-DireDawa + // then correctly shows NO room for a DireDawa->Addis intercity booking on the + // leg it merely passes through, instead of over-promising the freed slots. + // Locomotive-derived schedules keep the leg-aware multi-edge corridor: their + // abstract slot/weight/length budget genuinely frees past an alight yard. + const stops = + physicalWagons != null && opts?.collapseForBuiltTrain + ? [schedule.originStationId, schedule.destinationStationId] + : await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) @@ -3149,6 +3442,23 @@ export class BookingBatchService implements OnModuleInit { return budget; } + /** + * Physical wagons marshalled in the schedule's built train, or null when the + * schedule has no built train (or the consist is still empty) and the legacy + * locomotive-derived capacity must apply. This count is what caps a built + * train's bookings: 50 wagons coupled → 50 wagon slots, no more. + */ + private async builtTrainWagonCount( + schedule: TrainSchedule, + ): Promise { + const trainId = schedule.trainSet?.train?.id; + if (!trainId) return null; + const count = await this.dataSource + .getRepository(Wagon) + .count({ where: { trainId } }); + return count > 0 ? count : null; + } + /** * Wagon slots still boardable somewhere on the corridor (most-open edge). * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide @@ -3219,11 +3529,14 @@ export class BookingBatchService implements OnModuleInit { } /** - * FULL on ANY capacity axis: out of wagon slots, or out of pull weight / - * train length for even one more loaded wagon. The old slot-only check let - * a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of - * 3500+90T, slots bind at 44) cycle its booking window forever instead of - * finalizing — 7 phantom slots kept it "not full" while nothing could board. + * Built train: FULL when every physical wagon slot is taken — the consist is + * the capacity, weight/length were settled at build time. + * No built train: FULL on ANY capacity axis — out of wagon slots, or out of + * pull weight / train length for even one more loaded wagon. The old + * slot-only check let a weight-bound train (PW2: weight binds at 37 wagons = + * 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever + * instead of finalizing — 7 phantom slots kept it "not full" while nothing + * could board. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3232,8 +3545,60 @@ export class BookingBatchService implements OnModuleInit { return this.isTrainFull(schedule); } + /** + * Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the + * schedule's slot capacity, how many slots allocated + reserved bookings + * already hold on the busiest edge, how many are still free on the most-open + * edge, and by how many slots the consist has been trimmed BELOW what is + * already committed (0 when nothing is over-allocated). + */ + async scheduleWagonUsage(scheduleId: string): Promise<{ + maxWagons: number; + allocatedWagons: number; + remainingSlots: number; + overAllocatedBy: number; + } | null> { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return null; + const capacity = + (await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0; + const wagonDims = await this.loadWagonDims(); + const budget = await this.remainingBudget( + schedule, + { + base: { + wagons: capacity, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }, + wagonDims, + ); + const tightest = budget.remainingFor(budget.fullLeg()).wagons; + return { + maxWagons: capacity, + allocatedWagons: capacity - tightest, + remainingSlots: Math.max(0, budget.maxRemaining().wagons), + overAllocatedBy: Math.max(0, -tightest), + }; + } + /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { + // Built train: the physical consist is the only capacity axis, and a wagon + // is committed to its booking for the WHOLE trip — wagon allocation has no + // leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for + // the Doraleh→Negad edge it merely passes through. Count commitments + // train-wide, not per corridor edge: the per-edge budget read "free slots" + // on pass-through legs of a sold-out consist, so the window of a full train + // cycled OPEN forever instead of concluding DONE (and the day pool's + // leftover bookings were never expired). + const physicalWagons = await this.builtTrainWagonCount(schedule); + if (physicalWagons != null) { + return (await this.committedWagons(schedule)) >= physicalWagons; + } if ((await this.remainingWagons(schedule)) <= 0) return true; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against @@ -3243,6 +3608,29 @@ export class BookingBatchService implements OnModuleInit { return budget.isExhausted(this.minPerWagonNeed(wagonDims)); } + /** + * Wagons the schedule's allocated + reserved bookings occupy train-wide, + * regardless of which corridor leg each rides. Deduped by booking id — a + * booking mid-settle can momentarily be both linked and reserved. + */ + private async committedWagons(schedule: TrainSchedule): Promise { + const wagonDims = await this.loadWagonDims(); + const allocated = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b)); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); + const byId = new Map( + [...allocated, ...reserved].map((b) => [b.id, b] as const), + ); + let total = 0; + for (const booking of byId.values()) { + total += this.wagonsFor(booking, wagonDims); + } + return total; + } + /** * Smallest gross weight / shortest length one more wagon could add: the * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, 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 426bd37be..6f366d060 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 @@ -9,6 +9,8 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, EntityManager, In } from 'typeorm'; import { Freight } from '@edr/types'; +import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; +import { FacilityHandlingService } from './facility-handling.service'; import { Booking } from '../bookings/entities/booking.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -43,6 +45,8 @@ export class BookingJourneyService { constructor( @InjectDataSource() private readonly dataSource: DataSource, + private readonly yardFacilities: YardFacilitiesService, + private readonly facilityHandling: FacilityHandlingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -63,6 +67,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); const now = new Date(); await this.dataSource.transaction(async (manager) => { @@ -72,6 +77,16 @@ export class BookingJourneyService { loadedByUserId: userId ?? null, } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + // The facility handed the cargo over — raise its GRN. No-ops for yards + // without a facility (import/export terminals), which keep their own flow. + await this.facilityHandling.recordHandling(manager, { + booking, + yardId: booking.originYardId, + trainScheduleId: scheduleId, + eventType: 'LOAD', + performedBy: userId ?? null, + occurredAt: now, + }); }); // Customer tracking: cargo is on the train — loading milestones plus the @@ -99,6 +114,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination'); // Intercity has no clearance/delivery tail — unloading completes it. Import/ // export continue into clearance, keyed on the booking's own arrival. @@ -112,6 +128,17 @@ export class BookingJourneyService { } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED'); await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null); + // The facility took the cargo off the train — raise its GRN. Where the + // facility also stores cargo (Indode), the event links the storage record + // that storage/demurrage accrue against. + await this.facilityHandling.recordHandling(manager, { + booking, + yardId: booking.destinationYardId, + trainScheduleId: scheduleId, + eventType: 'UNLOAD', + performedBy: userId ?? null, + occurredAt: now, + }); }); // Customer tracking: THIS booking arrived (train may still be rolling). @@ -306,6 +333,33 @@ export class BookingJourneyService { }); } + /** + * INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the + * booking's own yards, so those yards need the equipment to do it — a train + * stopping somewhere is not the same as somewhere being able to load it. + * + * Import/export are untouched: their cargo is handled at the route's terminal + * ports, not at an arbitrary mid-corridor yard, and gating them here would + * block existing traffic. + * + * Lives here rather than in the controller so the checkpoint-driven + * autoUnloadAtYard path cannot route around it. + */ + private async assertYardCanHandleCargo( + booking: Booking, + yardId: string, + side: 'origin' | 'destination', + ): Promise { + if (booking.tradeDirection !== 'DOMESTIC') return; + const facility = await this.yardFacilities.facilityForYard(yardId); + if (!facility?.hasFacility) { + throw new BadRequestException( + `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` + + `${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`, + ); + } + } + /** * The train is "at" a yard when the latest recorded checkpoint is that yard, * or — for a booking boarding at the train's own origin — when the train has diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index f3d7697f6..e17445b4d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationPriority, @@ -9,6 +11,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -20,6 +23,8 @@ export class BookingNotifierService { private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, private readonly trainSchedules: TrainSchedulesRepository, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** @@ -60,7 +65,9 @@ export class BookingNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { @@ -143,10 +150,18 @@ export class BookingNotifierService { ): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const leftover = totalWagons - offeredWagons; + // With auto-placement on, the leftover is booked FOR the customer on another + // train (its own invoice) — telling them to rebook it themselves would be + // wrong. Without it, the leftover returns to the contract to rebook. + const leftoverCopy = + process.env.FREIGHT_AUTO_REMAINDER === 'true' + ? `The remaining ${leftover} will be booked for you on another train, with its own invoice. ` + : `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `; const msg = `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` + - `The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` + + leftoverCopy + `If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); // HIGH: a split is a change to what the customer ordered AND a live payment @@ -157,6 +172,23 @@ export class BookingNotifierService { }); } + /** + * The wagons that did not fit the train the customer just paid for have been + * auto-booked as their own booking (`remainder`) — they ride another train and + * are billed separately. Sent instead of leaving the customer to rebook. + */ + remainderPlaced(remainder: Booking, parentReference: string): void { + const msg = + `The wagons left over from booking ${parentReference} have been booked as ` + + `${remainder.reference ?? remainder.id} on another train. ` + + `It carries its own invoice — pay it to secure that slot.`; + void this.notifyContact(remainder, msg, 'REMAINDER BOOKED'); + this.inApp(remainder, 'Leftover wagons booked', msg, { + type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, + }); + } + secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { void (async () => { const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts index 7128bd705..617d83561 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -19,6 +19,17 @@ export interface BookingWindowConfig { /** Max staff document-review time after the window closes. */ docReviewMinutes: number; paymentWindowMinutes: number; + /** + * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set + * (> 0), the effective booking cutoff is `departure − this`, capping the first + * window close and every reopen cycle. NULL/0 ⇒ no offset (close at departure). + */ + importCloseOffsetMinutes?: number | null; + /** + * Minutes before departure the EXPORT FCFS booking window shuts. When set (> 0), + * export closes at `departure − this` instead of at departure. NULL/0 ⇒ none. + */ + exportCloseOffsetMinutes?: number | null; } /** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f728afe6a..02c5fb993 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -13,11 +13,16 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from '../notifications/resolve-company-phone.util'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; import { + bookingCloseCutoff, clampCloseToOfficeHours, eatDay, nextCycleOpensAt, @@ -411,6 +416,15 @@ export class BookingWindowService implements OnModuleInit { // window and the cycle stays in PAYMENT; check live reservations on THIS // schedule because the day-level fill may have reserved onto a sibling. // Waiting bookings that fit no train stay pooled and the window reopens. + // Booking shuts at the configured cutoff (departure − closeOffset), not + // departure — every phase-end below is bounded by it, mirroring the initial + // window computation. + const cutoff = bookingCloseCutoff( + schedule.scheduledDepartureDate, + schedule.direction, + cfg, + ); + const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id); if ( promoted > 0 && @@ -419,8 +433,8 @@ export class BookingWindowService implements OnModuleInit { let paymentPhaseEndsAt = new Date( now.getTime() + cfg.paymentWindowMinutes * 60_000, ); - if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) { - paymentPhaseEndsAt = schedule.scheduledDepartureDate; + if (paymentPhaseEndsAt > cutoff) { + paymentPhaseEndsAt = cutoff; } await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); this.logger.log( @@ -437,11 +451,7 @@ export class BookingWindowService implements OnModuleInit { windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }; - const nextOpensAt = nextCycleOpensAt( - now, - officeHours, - schedule.scheduledDepartureDate, - ); + const nextOpensAt = nextCycleOpensAt(now, officeHours, cutoff); if (nextOpensAt == null) { await this.setPhase(schedule, { windowPhase: 'DONE' }); this.logger.log( @@ -460,8 +470,8 @@ export class BookingWindowService implements OnModuleInit { // Office hours end a running window early: never let the duration outlive // the desk close (open 16:00, 3h, desk 8–17 → closes 17:00). nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours); - if (nextClosesAt > schedule.scheduledDepartureDate) { - nextClosesAt = schedule.scheduledDepartureDate; + if (nextClosesAt > cutoff) { + nextClosesAt = cutoff; } // Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt, // whether that is later today or next morning after the office-hours break. @@ -527,7 +537,7 @@ export class BookingWindowService implements OnModuleInit { }> = await this.dataSource.query( `SELECT DISTINCT c.company_id, - COALESCE(co.contact_person_phone, co.phone) AS phone, + ${companyNotifyPhoneExpr('co')} AS phone, COALESCE(co.email, co.general_manager_email) AS email FROM freight.contract_routes cr JOIN freight.contracts c @@ -535,6 +545,7 @@ export class BookingWindowService implements OnModuleInit { AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') AND c.deleted_at IS NULL JOIN freight.companies co ON co.id = c.company_id + ${primaryContactUserJoin('co')} WHERE cr.origin_yard_id = $1 AND cr.destination_yard_id = $2 AND cr.deleted_at IS NULL`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 5b3e93ba7..8ad256a16 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, + IsBoolean, IsDateString, IsInt, IsNumber, @@ -61,4 +62,15 @@ export class CreateContainerTrainScheduleDto { @IsInt() @Min(1) maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ + description: + 'Reverse the wagon order on this train: the physically-last wagon becomes ' + + 'position 1. Frozen on the schedule; applied every time the wagon plan is ' + + 'rebuilt so the stored train order and the schedule order stay in sync.', + default: false, + }) + @IsOptional() + @IsBoolean() + reverseWagonOrder?: boolean; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts index 56cd9592b..6cf6a22fc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, + IsBoolean, IsDateString, IsInt, IsNumber, @@ -58,4 +59,15 @@ export class PreviewTrainScheduleDto { @IsInt() @Min(1) maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ + description: + 'Reverse the wagon order on the train: the physically-last wagon becomes ' + + 'position 1. The composition and allocations are unchanged — only the order ' + + 'flips, applied at build so the stored train and schedule stay in sync.', + default: false, + }) + @IsOptional() + @IsBoolean() + reverseWagonOrder?: boolean; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 2948b874d..b10736f0f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -3,20 +3,6 @@ import { Type } from 'class-transformer'; import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { - @ApiPropertyOptional({ example: 760 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainLengthMeters?: number; - - @ApiPropertyOptional({ example: 3500 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainWeightTons?: number; - @ApiPropertyOptional({ example: 53 }) @IsOptional() @Type(() => Number) @@ -24,20 +10,6 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(1) maxWagonsPerTrain?: number; - @ApiPropertyOptional({ example: 30 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0.001) - max20ftContainerWeightTons?: number; - - @ApiPropertyOptional({ example: 10 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0) - max20ftPairWeightDiffTons?: number; - @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) @IsOptional() @Type(() => Number) @@ -95,4 +67,31 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsInt() @Min(1) paymentWindowMinutes?: number; + + // Booking-close offsets: minutes before departure the window shuts. The UI + // enters days/hours/minutes and converts to minutes. 0 or null clears the + // offset (close at departure). Nullable so it can be explicitly cleared. + @ApiPropertyOptional({ + example: 180, + nullable: true, + description: + 'Minutes before departure the IMPORT booking window closes; 0/null = close at departure', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importCloseOffsetMinutes?: number | null; + + @ApiPropertyOptional({ + example: 1440, + nullable: true, + description: + 'Minutes before departure the EXPORT booking window closes; 0/null = close at departure', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + exportCloseOffsetMinutes?: number | null; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts new file mode 100644 index 000000000..cd8807b8a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts @@ -0,0 +1,56 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const FACILITY_HANDLING_EVENT_TYPES = ['LOAD', 'UNLOAD'] as const; +export type FacilityHandlingEventType = (typeof FACILITY_HANDLING_EVENT_TYPES)[number]; + +/** + * Cargo loaded onto or unloaded off a train at a yard's facility, and the GRN + * raised for it. + * + * This exists because warehouse_inventory can't do the job: its + * warehouse/yard/zone are NOT NULL, so a facility that only has equipment and no + * warehouse (Sebeta, Modjo, Adama, Dire Dawa) could never have a row there — + * yet it still hands cargo over and still needs a GRN. + * + * `inventoryId` links to the warehouse record when the facility does store cargo + * (Indode), which is what makes storage and demurrage accrue there and nowhere + * else. + */ +@Entity({ schema: 'freight', name: 'facility_handling_events' }) +@Index(['bookingId']) +@Index(['yardId']) +export class FacilityHandlingEvent extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + /** The facility yard where the cargo was handled. */ + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + /** The train the cargo came off / went onto. */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @Column({ name: 'event_type', type: 'varchar', length: 10 }) + eventType!: FacilityHandlingEventType; + + @Column({ name: 'grn_number', type: 'varchar', length: 60, nullable: true }) + grnNumber?: string | null; + + @Column({ name: 'quantity', type: 'numeric', precision: 14, scale: 3, nullable: true }) + quantity?: number | null; + + @Column({ name: 'weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + weightTons?: number | null; + + /** Set only when the facility stores cargo (has_warehouse) — the storage record. */ + @Column({ name: 'inventory_id', type: 'uuid', nullable: true }) + inventoryId?: string | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) + occurredAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 729063599..caa3ce24f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -79,4 +79,21 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) paymentWindowMinutes!: number; + + /** + * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set, + * the window's close (first cycle and every reopen) is capped at + * `departure − this`, instead of the default open+duration/departure cap. + * NULL or 0 = no offset (previous behaviour). + */ + @Column({ name: 'import_close_offset_minutes', type: 'int', nullable: true }) + importCloseOffsetMinutes?: number | null; + + /** + * Minutes before departure the EXPORT FCFS booking window shuts. When set, the + * export window closes at `departure − this` instead of at departure. NULL or + * 0 = no offset (export closes at departure, previous behaviour). + */ + @Column({ name: 'export_close_offset_minutes', type: 'int', nullable: true }) + exportCloseOffsetMinutes?: number | null; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts new file mode 100644 index 000000000..c0e0b7c5f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +import { generateGrnNumber } from '../../common/grn.util'; +import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { + FacilityHandlingEvent, + FacilityHandlingEventType, +} from './entities/facility-handling-event.entity'; + +/** + * Records cargo being loaded/unloaded at a yard's facility, and raises its GRN. + * + * Every facility raises a GRN — the goods changed hands, whether or not anyone + * stores them. What differs is what happens next: a facility with a warehouse + * (Indode) keeps the cargo, so it goes through the normal warehouse flow and + * accrues storage/demurrage; the rest only move it between train and truck, so + * the event and its GRN are the whole record. + * + * Best-effort by design: a failure here must not undo a load/unload that + * physically happened. + */ +@Injectable() +export class FacilityHandlingService { + private readonly logger = new Logger(FacilityHandlingService.name); + + constructor(private readonly yardFacilities: YardFacilitiesService) {} + + /** + * Write the handling event and mint its GRN. Returns the GRN, or null when the + * yard has no facility (nothing to record) or the write failed. + */ + async recordHandling( + manager: EntityManager, + input: { + booking: Booking; + yardId: string; + trainScheduleId?: string | null; + eventType: FacilityHandlingEventType; + performedBy?: string | null; + occurredAt?: Date; + }, + ): Promise { + const { booking, yardId, eventType } = input; + try { + const facility = await this.yardFacilities.facilityForYard(yardId); + if (!facility?.hasFacility) return null; + + const occurredAt = input.occurredAt ?? new Date(); + const grnNumber = generateGrnNumber( + booking.tradeDirection ?? 'DOMESTIC', + booking.id, + occurredAt, + ); + + // Link the storage record when this facility keeps cargo — that link is + // what ties an Indode handover to its storage/demurrage. + let inventoryId: string | null = null; + if (facility.hasWarehouse) { + const [inv]: Array<{ id: string }> = await manager.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + [booking.id], + ); + inventoryId = inv?.id ?? null; + } + + const repo = manager.getRepository(FacilityHandlingEvent); + await repo.save( + repo.create({ + bookingId: booking.id, + yardId, + trainScheduleId: input.trainScheduleId ?? null, + eventType, + grnNumber, + weightTons: Number(booking.cargoTotalWeightVgm) || null, + inventoryId, + performedBy: input.performedBy ?? null, + occurredAt, + }), + ); + + this.logger.log( + `GRN ${grnNumber} raised on ${eventType} at ${facility.yardCode ?? yardId} for booking ${booking.reference ?? booking.id}`, + ); + return grnNumber; + } catch (err) { + // The cargo moved regardless — never fail the journey over the paperwork. + this.logger.error( + `Facility ${eventType} record failed for booking ${booking.id} at yard ${yardId}: ${String(err)}`, + ); + return null; + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 9cca4d213..4d408689b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -56,7 +56,7 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n } // TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1 - // wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored + // wagon). Derived from containerType.sizeFt; falls back to the line's stored // fraction. Ceiling per line would over-count split 20ft lines. return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index d2f1dd7b1..af0f410a6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -40,9 +40,72 @@ export class IntercityService { * remaining capacity along all three axes (wagons, weight, length) and each * booking's need, so staff can pick what fits. */ + /** + * Every intercity booking and where it is in its ride-along, across all trains. + * + * The per-schedule candidate list answers "what can THIS train carry"; this + * answers "what is happening to intercity cargo" — which is what a yard + * operator needs when the work is spread over whichever trains happen to pass. + * + * Carries each end's facility state, because a booking whose origin or + * destination has no facility can never be loaded or unloaded there and the + * operator should see that before the train arrives, not when the load is + * refused. + */ + async listBookings() { + return this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "reference", + b.status AS "status", + b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "weightTons", + b.loaded_at AS "loadedAt", + b.arrived_at AS "arrivedAt", + company.name AS "customer", + b.train_schedule_id AS "trainScheduleId", + ts.train_number AS "trainNumber", + ts.status AS "scheduleStatus", + oy.id AS "originYardId", + COALESCE(oy.label, oy.code) AS "origin", + oy.has_facility AS "originHasFacility", + dy.id AS "destinationYardId", + COALESCE(dy.label, dy.code) AS "destination", + dy.has_facility AS "destinationHasFacility", + -- Where the train actually is, so the operator knows if the cargo + -- can be worked right now. + cp.yard_id AS "trainAtYardId", + -- Most recent GRN raised for this booking at a facility. + fh.grn_number AS "grnNumber" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.train_schedules ts + ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT c.yard_id + FROM freight.train_checkpoint_events c + WHERE c.train_schedule_id = b.train_schedule_id + ORDER BY c.occurred_at DESC, c.created_at DESC + LIMIT 1 + ) cp ON true + LEFT JOIN LATERAL ( + SELECT e.grn_number + FROM freight.facility_handling_events e + WHERE e.booking_id = b.id AND e.deleted_at IS NULL + ORDER BY e.occurred_at DESC + LIMIT 1 + ) fh ON true + WHERE b.deleted_at IS NULL + AND b.trade_direction = 'DOMESTIC' + ORDER BY b.created_at DESC`, + ); + } + async listCandidates(scheduleId: string) { const schedule = await this.getSchedule(scheduleId); - const milestoneSeq = await this.routeMilestoneSequence(schedule); + const milestones = await this.routeMilestones(schedule); + const milestoneSeq = this.milestoneSequenceOf(schedule, milestones); const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); const waiting = milestoneSeq @@ -50,29 +113,45 @@ export class IntercityService { : []; const accepted = await this.findAcceptedIntercityBookings(scheduleId); + // Mid-corridor intercity matching needs a real stop list (>= 2 route + // milestones). Without one the fallback is a 2-stop origin->destination + // pseudo-route that only matches bookings on the train's exact corridor — + // surface that so an empty candidate list isn't misread as "nobody waiting". + const warning = + milestoneSeq == null + ? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.' + : schedule.routeId && milestones.length < 2 + ? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear." + : null; + return { scheduleId, routeId: schedule.routeId ?? null, + warning, // Segment-based: "remaining" is the most-open edge; each candidate's // `fits` is judged against ITS OWN leg, so a booking on a free leg fits // even when the train is full elsewhere. remaining: capacity?.budget.maxRemaining() ?? null, candidates: waiting.map((booking) => { const need = capacity?.needFor(booking) ?? null; - const leg = capacity?.budget.legOf( + // legForYards, not legOf: on a built train the budget is a single + // whole-route edge (see intercityCapacity), so a mid-corridor booking + // must draw from that one pool via the whole-route fallback. On a + // locomotive-derived schedule it still resolves to the booking's own leg. + const leg = capacity?.budget.legForYards( booking.originYardId, booking.destinationYardId, ); return { - ...this.mapBooking(booking), + ...this.mapBooking(booking, need), need, fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), - accepted: accepted.map((booking) => ({ - ...this.mapBooking(booking), - need: capacity?.needFor(booking) ?? null, - })), + accepted: accepted.map((booking) => { + const need = capacity?.needFor(booking) ?? null; + return { ...this.mapBooking(booking, need), need }; + }), }; } @@ -124,14 +203,17 @@ export class IntercityService { continue; } const need = capacity.needFor(booking); - const leg = budget.legOf(booking.originYardId, booking.destinationYardId); - // Segment-based: only the booking's own leg must have room, so an - // intercity booking still boards a train that is full on other legs. - if (!leg || !budget.fits(need, leg)) { + // legForYards, not legOf: a built train's budget is a single whole-route + // pool (mid-corridor wagons are committed for the whole trip and never + // reloaded), so the booking draws from that pool via the whole-route + // fallback; a locomotive-derived schedule still gets the booking's own + // leg, so it can still board a train that is full only on other legs. + const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); + if (!budget.fits(need, leg)) { rejected.push({ bookingId, reason: - 'Does not fit the remaining wagon/weight/length capacity on its leg', + 'Does not fit the remaining wagon/weight/length capacity for this train', }); continue; } @@ -182,16 +264,21 @@ export class IntercityService { * so an intercity booking exactly matching the train's own corridor still * qualifies. */ - private async routeMilestoneSequence( + private async routeMilestones( schedule: TrainSchedule, - ): Promise | null> { - if (schedule.routeId) { - const milestones = await this.dataSource - .getRepository(RouteMilestone) - .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); - if (milestones.length >= 2) { - return new Map(milestones.map((m) => [m.yardId, m.sequenceNo])); - } + ): Promise { + if (!schedule.routeId) return []; + return this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + } + + private milestoneSequenceOf( + schedule: TrainSchedule, + milestones: RouteMilestone[], + ): Map | null { + if (milestones.length >= 2) { + return new Map(milestones.map((m) => [m.yardId, m.sequenceNo])); } if (schedule.originStationId && schedule.destinationStationId) { return new Map([ @@ -202,6 +289,15 @@ export class IntercityService { return null; } + private async routeMilestoneSequence( + schedule: TrainSchedule, + ): Promise | null> { + return this.milestoneSequenceOf( + schedule, + await this.routeMilestones(schedule), + ); + } + /** Waiting = ready intercity bookings not yet on any train, corridor on this route. */ private async findWaitingIntercityBookings( milestoneSeq: Map, @@ -294,7 +390,12 @@ export class IntercityService { return { schedule, booking }; } - private mapBooking(booking: Booking) { + /** + * `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is + * spent in. Prefer it, so the row's weight sits on the same axis as the + * remaining-capacity figure shown beside it; cargo VGM is the fallback. + */ + private mapBooking(booking: Booking, need?: { weightTons: number } | null) { return { id: booking.id, reference: booking.reference, @@ -310,7 +411,7 @@ export class IntercityService { booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', - weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0), paymentDeadline: booking.paymentDeadline?.toISOString() ?? null, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts new file mode 100644 index 000000000..55cacaf03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts @@ -0,0 +1,211 @@ +import { RemainderPlacementService } from './remainder-placement.service'; + +/** + * The remainder placer reconstructs the outstanding split remainder as a new + * booking. The delicate parts under test: bulk sizes from the outstanding tons; + * container recovers real numbers from the SOFT-DELETED units (never fabricates) + * and throws on a shortfall; and nothing is placed when there's no outstanding + * or no fitting train. + */ +describe('RemainderPlacementService', () => { + const DAY = '2026-07-20'; + + function make(opts: { + freightType: 'CONTAINER' | 'BULK'; + contractKind?: 'ONE_TIME' | 'GENERAL'; + outstanding: unknown; + createThrows?: Error; + deferredUnits?: Array<{ + containerNumber: string; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + }>; + fittingTrains?: Array<{ scheduleId: string }>; + }) { + const contract = { + id: 'c-1', + freightType: opts.freightType, + contractKind: opts.contractKind ?? 'ONE_TIME', + }; + const contractsRepository = { + findByIdWithRelations: jest.fn().mockResolvedValue(contract), + }; + const createUnderContract = opts.createThrows + ? jest.fn().mockRejectedValue(opts.createThrows) + : jest + .fn() + .mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] }); + const contractBookingService = { + splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding), + createUnderContract, + }; + const bookingBatchService = { + fittingTrainsForDay: jest + .fn() + .mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]), + }; + // getRepository is only hit on the container path (recoverDeferredUnits). + const lineRepo = { + find: jest.fn().mockResolvedValue([{ id: 'line-1' }]), + }; + const unitRepo = { + find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => { + const n = entity?.name ?? ''; + if (n.includes('Unit')) return unitRepo; + return lineRepo; + }), + }; + const notifier = { remainderPlaced: jest.fn() }; + const service = new RemainderPlacementService( + dataSource as never, + contractsRepository as never, + contractBookingService as never, + bookingBatchService as never, + notifier as never, + ); + return { + service, + createUnderContract, + contractBookingService, + bookingBatchService, + notifier, + }; + } + + const splitBooking = { + id: 'bk-1', + reference: 'BKG-1', + contractId: 'c-1', + scheduledDate: new Date('2026-07-20T06:00:00Z'), + createdByUserId: 'u-1', + } as never; + + it('sizes a BULK remainder from the outstanding tons', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBe('rem-1'); + const dto = createUnderContract.mock.calls[0][1]; + expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]); + expect(dto.scheduledDate).toBe(DAY); + }); + + it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => { + const deferredUnits = [ + { containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true }, + { containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true }, + ]; + const { service, createUnderContract } = make({ + freightType: 'CONTAINER', + outstanding: { + bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]), + bulk: null, + }, + deferredUnits, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBe('rem-1'); + const dto = createUnderContract.mock.calls[0][1]; + expect(dto.containers).toHaveLength(1); + const line = dto.containers[0]; + expect(line.containerSize).toBe('40ft'); + expect(line.quantity).toBe(2); + expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([ + 'ABCD1234567', + 'ABCD7654321', + ]); + expect(line.reeferQuantity).toBe(1); + expect(line.hazardousQuantity).toBe(1); + }); + + it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => { + const { service, createUnderContract } = make({ + freightType: 'CONTAINER', + outstanding: { + bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]), + bulk: null, + }, + deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3 + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); + + it('is a no-op when there is no outstanding remainder', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); + + it('tells the customer the leftover wagons were booked on another train', async () => { + const { service, notifier } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + await service.placeRemainder(splitBooking); + expect(notifier.remainderPlaced).toHaveBeenCalledWith( + expect.objectContaining({ id: 'rem-1' }), + 'BKG-1', + ); + }); + + it('never double-books the leftover when two payments land together', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + // Both callers enter before either create commits. + await Promise.all([ + service.placeRemainder(splitBooking), + service.placeRemainder(splitBooking), + ]); + expect(createUnderContract).toHaveBeenCalledTimes(1); + }); + + // splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's + // snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and + // either drops a real remainder or double-draws the cap, so we must not place. + it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => { + const { service, createUnderContract, contractBookingService } = make({ + freightType: 'BULK', + contractKind: 'GENERAL', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled(); + }); + + // The paid booking has already boarded — a create-gate rejection (e.g. the + // export whole-train gate) must leave the remainder rebookable, not escape. + it('swallows a create rejection and leaves the remainder for manual rebook', async () => { + const { service } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + createThrows: new Error('Not enough train space for this day.'), + }); + await expect(service.placeRemainder(splitBooking)).resolves.toBeNull(); + }); + + it('is a no-op when the contract has no split chain', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: null, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts new file mode 100644 index 000000000..85b6d0878 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts @@ -0,0 +1,342 @@ +import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common'; +import { DataSource, IsNull, Not } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { Contract } from '../contracts/entities/contract.entity'; +import { + ContractBookingService, + SplitOutstanding, +} from '../contracts/contract-booking.service'; +import { ContractsRepository } from '../contracts/contracts.repository'; +import { + CreateBookingUnderContractDto, + CreateContainerUnitDto, +} from '../contracts/dto/create-booking-under-contract.dto'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingBatchService } from './booking-batch.service'; +import { BookingNotifierService } from './booking-notifier.service'; +import { eatDay } from './batch-window.util'; + +/** + * Auto-creates and places the OUTSTANDING split remainder of a contract as a new + * booking, so the customer doesn't have to manually rebook the wagons that did + * not fit the train they just paid for. + * + * Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only + * once the customer has actually accepted+paid the offered part. Before payment + * nothing is split: the booking stays whole and the customer may still edit or + * cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}. + * + * IMPORT/DOMESTIC: the remainder booking is created with the next fitting + * shipment day set and then follows the normal windowed batch flow (train + * assigned at window close, paid in its own window). It is NOT force-reserved on + * a specific train — import is not FCFS. + * + * Container reconstruction is HYBRID: the remainder's quantities come from the + * split snapshot (`splitOutstanding`), but the actual container numbers / VGM / + * seals are read back from the units `applySplit` SOFT-DELETED off the parent + * (they survive as valid ISO records). We never `restore()` those rows — the new + * booking gets fresh rows — so the contract cap is never double-counted. + */ +@Injectable() +export class RemainderPlacementService { + private readonly logger = new Logger(RemainderPlacementService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly contractsRepository: ContractsRepository, + @Inject(forwardRef(() => ContractBookingService)) + private readonly contractBookingService: ContractBookingService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Create + place the outstanding split remainder of the contract that owns + * `splitBooking`. No-op when there is no live remainder or no fitting day. + * Returns the created remainder booking id, or null when nothing was placed + * (residual falls back to the customer's manual rebook, as today). + */ + async placeRemainder(splitBooking: Booking): Promise { + if (!splitBooking.contractId) return null; + // Two payment webhooks for the same contract landing together would both see + // the remainder as unbooked (the placing create has not committed yet) and + // each create one — double-booking the leftover. Serialize per contract: the + // second caller returns immediately and the first one's create is what the + // (now smaller) outstanding reflects. + if (this.inFlight.has(splitBooking.contractId)) { + this.logger.debug( + `Remainder placement already running for contract ${splitBooking.contractId} — skipped.`, + ); + return null; + } + this.inFlight.add(splitBooking.contractId); + try { + return await this.placeRemainderInner(splitBooking); + } finally { + this.inFlight.delete(splitBooking.contractId); + } + } + + /** Contracts with a placement in flight — see {@link placeRemainder}. */ + private readonly inFlight = new Set(); + + private async placeRemainderInner( + splitBooking: Booking, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations( + splitBooking.contractId!, + ); + if (!contract) return null; + + // ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total + // from a SINGLE booking's pre-split snapshot, which is only coherent when + // the contract has exactly one live chain — that is the ONE_TIME invariant + // (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL + // contract with other live bookings the subtraction mixes scopes: it either + // clamps to 0 and silently drops a real remainder, or sizes one that then + // draws the quantity cap a second time. GENERAL remainders keep the existing + // manual-rebook behaviour until the remainder can be derived from the + // offer's own dropped lines rather than from the contract-wide ledger. + if (contract.contractKind !== 'ONE_TIME') { + this.logger.debug( + `Contract ${contract.id} is ${contract.contractKind} — remainder left ` + + `for manual rebook (auto-placement is ONE_TIME only).`, + ); + return null; + } + + const outstanding = await this.contractBookingService.splitOutstanding( + contract, + ); + if (!outstanding || !this.hasOutstanding(contract, outstanding)) { + return null; + } + + // The next fitting day: the earliest day on/after the split booking's own day + // that still has an import train with room for this cargo type. We reuse the + // split booking as the capacity probe — it carries the leg + cargo relations. + const day = await this.nextFittingDay(splitBooking); + if (!day) { + this.logger.warn( + `No train with room for the remainder of contract ${contract.id} ` + + `(booking ${splitBooking.reference}) — left for manual rebook.`, + ); + return null; + } + + let dto: CreateBookingUnderContractDto; + try { + dto = await this.buildRemainderDto( + contract, + outstanding, + splitBooking.id, + day, + ); + } catch (err) { + // A reconstruction shortfall (fewer recoverable units than outstanding) + // must NOT fabricate container numbers — fail loudly, leave manual rebook. + this.logger.error( + `Could not reconstruct the remainder of contract ${contract.id}: ` + + `${err instanceof Error ? err.message : String(err)} — left for manual rebook.`, + ); + return null; + } + + // Any create-gate rejection (no train space, cap, container clash) must not + // escape: the customer's paid booking has already boarded, and a thrown + // error here would only be logged upstream while the remainder vanished + // silently. Fall back to leaving it rebookable, which is the pre-feature + // behaviour, and say so in the log. + let created: Awaited< + ReturnType + >; + try { + created = await this.contractBookingService.createUnderContract( + contract.id, + dto, + { id: splitBooking.createdByUserId ?? undefined }, + // System actor: a permission-bag carrying the contract create-booking key + // so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B + // contracts; harmless for customer (Path A) contracts. + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + ); + } catch (err) { + this.logger.error( + `Could not create the remainder booking for contract ${contract.id} ` + + `(from ${splitBooking.reference}): ${ + err instanceof Error ? err.message : String(err) + } — left for manual rebook.`, + ); + return null; + } + // EXPORT is FCFS — there is no window to wait for, so the remainder is + // reserved on the next export train right away (its own pay window opens). + // If it does not fit one train whole either, the export accept offers it a + // partial and the chain repeats on ITS payment: each pass leaves a strictly + // smaller remainder, so it terminates at the day's train count. + // IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next + // fitting day and rides the normal windowed batch flow. + if (splitBooking.tradeDirection === 'EXPORT') { + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ + where: { id: created.booking.id }, + relations: { + company: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + if (fresh) { + await this.bookingBatchService + .acceptExportBooking(fresh) + .catch((err) => + // No export train took it — it stays created and rebookable, which + // is the same place a customer-driven rebook would leave it. + this.logger.warn( + `Export remainder ${fresh.reference} created but not reserved: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + } + + this.notifier.remainderPlaced( + created.booking, + splitBooking.reference ?? splitBooking.id, + ); + this.logger.log( + `Auto-placed split remainder of contract ${contract.id} as booking ` + + `${created.booking.reference} on ${day}.`, + ); + return created.booking.id; + } + + private hasOutstanding( + contract: Contract, + outstanding: SplitOutstanding, + ): boolean { + if (contract.freightType === 'CONTAINER') { + return [...outstanding.bySize.values()].some((s) => s.outstanding > 0); + } + return (outstanding.bulk?.outstanding ?? 0) > 0.001; + } + + /** + * The shipment day to create the remainder on — the split booking's own day. + * + * EXPORT is FCFS and must actually board a train that day, so a day with NO + * export train having room is rejected (null → left for manual rebook on a day + * the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is + * assigned by the batch engine at window close, not now, and the window may + * still free up — forcing a different day here would override the customer's + * binding shipment day. + */ + private async nextFittingDay(booking: Booking): Promise { + if (!booking.scheduledDate) return null; + const day = eatDay(new Date(booking.scheduledDate)); + if (booking.tradeDirection !== 'EXPORT') return day; + + const fitting = await this.bookingBatchService.fittingTrainsForDay( + booking, + day, + 'EXPORT', + ); + return fitting.length > 0 ? day : null; + } + + /** + * Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the + * outstanding tonnage directly. Container reads the deferred (soft-deleted) + * units of the split booking back into real unit records. + */ + private async buildRemainderDto( + contract: Contract, + outstanding: SplitOutstanding, + splitBookingId: string, + day: string, + ): Promise { + const dto: CreateBookingUnderContractDto = { scheduledDate: day }; + + if (contract.freightType !== 'CONTAINER') { + const tons = outstanding.bulk?.outstanding ?? 0; + dto.bulkLines = [{ cargoWeightTons: tons }]; + return dto; + } + + // Container: recover the deferred units per size from the split booking's + // soft-deleted rows and reshape into DTO units. + const containers: NonNullable = []; + for (const [size, { outstanding: need }] of outstanding.bySize) { + if (need <= 0) continue; + const units = await this.recoverDeferredUnits(splitBookingId, size, need); + if (units.length < need) { + throw new Error( + `size ${size}: recovered ${units.length} deferred container(s) but ` + + `${need} are outstanding`, + ); + } + const line: NonNullable[number] = { + containerSize: size, + quantity: need, + units, + }; + line.hazardousQuantity = units.filter((u) => u.isHazardous).length; + line.reeferQuantity = units.filter((u) => u.isReefer).length; + containers.push(line); + } + dto.containers = containers; + return dto; + } + + /** + * The `need` deferred container units of a given size for the split booking, + * read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the + * LIFO trim in applySplit so the same physical containers deferred are the + * ones rebooked). Returns them as DTO units; does NOT restore the rows. + */ + private async recoverDeferredUnits( + splitBookingId: string, + containerSize: string, + need: number, + ): Promise { + // The line ids of this booking for this size (live + soft-deleted): units + // key on bookingContainerId, so gather every line of the size first. + const lines = await this.dataSource + .getRepository(BookingContainer) + .find({ + where: { bookingId: splitBookingId, containerSize }, + withDeleted: true, + select: { id: true }, + }); + const lineIds = lines.map((l) => l.id); + if (!lineIds.length) return []; + + // Only the DELETED units are the deferred ones (live units stayed on the + // paid part). Oldest-first to match the deferred set. + const deferred = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: lineIds.map((bookingContainerId) => ({ + bookingContainerId, + deletedAt: Not(IsNull()), + })), + withDeleted: true, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + take: need, + }); + + return deferred.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? undefined, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index bbb19dbee..3e142f778 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -139,7 +139,8 @@ export class TrainSchedulingController { @Get("batch-board/:scheduleId") @TrainSchedulingView() @ApiOperation({ - summary: "Batch board detail for one schedule with EAT 3h windows", + summary: + "Batch board detail for one schedule: its booking window, in-window bookings and pending-contract bucket", }) getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) { return this.bookingBatchService.getBatchBoardDetail(scheduleId); @@ -459,6 +460,16 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("intercity/bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Every intercity booking with its ride-along state, both yards' facility status, and where its train is", + }) + listIntercityBookings() { + return this.intercityService.listBookings(); + } + @Get("schedules/:id/intercity-candidates") @TrainSchedulingView() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 1e1eb1695..35503d432 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -7,6 +7,8 @@ import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { FacilityHandlingService } from './facility-handling.service'; +import { FacilityHandlingEvent } from './entities/facility-handling-event.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Route } from '../routes/entities/route.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; @@ -32,6 +34,7 @@ import { IntercityService } from './intercity.service'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingJourneyService } from './booking-journey.service'; import { BookingSplitService } from './booking-split.service'; +import { RemainderPlacementService } from './remainder-placement.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -41,6 +44,7 @@ import { ContractsModule } from '../contracts/contracts.module'; @Module({ imports: [ TypeOrmModule.forFeature([ + FacilityHandlingEvent, Locomotive, WagonType, TrainSet, @@ -79,8 +83,10 @@ import { ContractsModule } from '../contracts/contracts.module'; WsAuthService, BookingWindowService, BookingSplitService, + RemainderPlacementService, IntercityService, BookingJourneyService, + FacilityHandlingService, ], exports: [ TrainSchedulingService, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index c4f2e7d6d..c0ff19b25 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -234,13 +234,17 @@ describe('TrainSchedulingService', () => { destinationStationId: 'yard-destination', }); + // Availability rows now report what the BOUNDED plan actually uses per + // type (never more than stock, so no shortfall on the rows themselves); + // the shortage is carried by the deferred bookings' own shortage rows. expect(result.fleetAvailability?.length).toBeGreaterThan(0); - expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0); + expect( + result.fleetAvailability?.every((row) => row.needed <= row.available), + ).toBe(true); expect(result.deferredBookings?.length).toBeGreaterThan(0); + expect(result.deferredBookings?.[0]?.reason).toContain('short'); expect(result.summary.wagonsNeeded).toBeLessThan(30); - expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe( - true, - ); + expect(result.warnings.some((w) => w.includes('deferred'))).toBe(true); }); it('computes slot-based preview for Group A', async () => { 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 b92a7ea90..1edd27792 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 @@ -12,6 +12,8 @@ import { BadRequestException, ConflictException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, @@ -19,6 +21,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { DataSource, EntityManager, @@ -95,6 +98,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingBatchService } from './booking-batch.service'; import { computeFleetAvailability, summarizeFleetWarnings, @@ -105,12 +109,13 @@ import { type FleetAvailabilityRow, } from './fleet-plan.util'; import { + applyWagonOrderReversal, planWagonsWithStock, - unboundedStock, type AllowedWagonTypeMap, type WagonStock, } from './wagon-plan-flex.util'; import { + containerWagonsForLines, expandBookingContainerUnits, getContainerSlotSequenceNos, roundTons, @@ -130,8 +135,10 @@ import { WagonTypeDimensions, } from './train-capacity.util'; import { + DEFAULT_BULK_WAGON_CAPACITY_TONS, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_BULK_WAGON_TARE_TONS, + DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; @@ -176,6 +183,8 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) { ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes, ruleImportWindowLeadDays: cfg.importWindowLeadDays, ruleExportBookingLeadHours: cfg.exportBookingLeadHours, + ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null, + ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null, }; } @@ -200,6 +209,8 @@ export function effectiveWindowConfig( ruleReopenDelayMinutes?: number | null; ruleImportWindowLeadDays?: number | null; ruleExportBookingLeadHours?: number | null; + ruleImportCloseOffsetMinutes?: number | null; + ruleExportCloseOffsetMinutes?: number | null; }, liveCfg: BookingWindowConfig, ): BookingWindowConfig { @@ -216,6 +227,18 @@ export function effectiveWindowConfig( : liveCfg.windowDurationHours, docReviewMinutes: liveCfg.docReviewMinutes, paymentWindowMinutes: liveCfg.paymentWindowMinutes, + // The close offset is frozen per-schedule: a snapshot value of null means + // "created with no offset" and must NOT inherit a later live offset (that + // would retro-shrink an open train's window). Only a truly legacy row that + // predates the snapshot column (value undefined) falls back to live config. + importCloseOffsetMinutes: + schedule.ruleImportCloseOffsetMinutes !== undefined + ? schedule.ruleImportCloseOffsetMinutes + : liveCfg.importCloseOffsetMinutes, + exportCloseOffsetMinutes: + schedule.ruleExportCloseOffsetMinutes !== undefined + ? schedule.ruleExportCloseOffsetMinutes + : liveCfg.exportCloseOffsetMinutes, }; } @@ -244,6 +267,8 @@ export interface CompositionUnassignedBookingRow { freightType: string | null; priorityScore: number; cargoTotalWeightVgm: number; + /** GROSS: cargo VGM + tare of every wagon the booking occupies. */ + grossWeightTons: number; status: string | null; schedulingStatus: string | null; wagonsRequired: number; @@ -317,6 +342,11 @@ export class TrainSchedulingService { private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, + // forwardRef: BookingBatchService injects this service back; @Optional so + // existing specs that construct the service without it keep working. + @Optional() + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService?: BookingBatchService, ) {} /** @@ -449,6 +479,38 @@ export class TrainSchedulingService { return qb.getMany(); } + /** + * A built train makes at most ONE departure per route per EAT day. Returns + * the non-cancelled schedule already holding this train on this route for + * `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay + * legal — siblings must be different trains. + */ + private async findTrainRouteDayConflict( + trainId: string, + routeId: string, + departure: Date, + excludeScheduleId?: string, + ): Promise { + const day = eatDay(departure); + const dayStart = eatDayToUtc(day, 0); + const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0); + const qb = this.dataSource + .getRepository(TrainSchedule) + .createQueryBuilder('s') + .innerJoin('s.trainSet', 'ts') + .where('ts.trainId = :trainId', { trainId }) + .andWhere('s.routeId = :routeId', { routeId }) + .andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart }) + .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart }) + .andWhere('s.status != :cancelledStatus', { + cancelledStatus: TrainScheduleStatusEnum.Cancelled, + }); + if (excludeScheduleId) { + qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId }); + } + return qb.getOne(); + } + /** * The window timeline a brand-new schedule must adopt to join its route+day * group. Returns the canonical open/close times + rule snapshot copied from an @@ -579,7 +641,11 @@ export class TrainSchedulingService { trainScheduleId: query.trainScheduleId, day, }); - return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; + const tareDims = await this.loadWagonTareDims(); + return { + count: bookings.length, + items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)), + }; } async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { @@ -591,7 +657,24 @@ export class TrainSchedulingService { } async getTrainSchedulingGlobalRules() { - return this.loadGlobalRulesRow(); + return this.toPublicGlobalRules(await this.loadGlobalRulesRow()); + } + + /** + * Train length/weight and 20ft weight caps are engine-internal (wagon + * planning still reads them off the row); they are no longer exposed or + * editable through the global-rules endpoints. + */ + private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) { + if (!row) return row; + const { + maxTrainLengthMeters: _len, + maxTrainWeightTons: _wt, + max20ftContainerWeightTons: _cw, + max20ftPairWeightDiffTons: _pd, + ...pub + } = row; + return pub; } async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { @@ -599,15 +682,7 @@ export class TrainSchedulingService { if (!row) { throw new NotFoundException('Train scheduling global rules not configured'); } - if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; - if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; - if (dto.max20ftContainerWeightTons != null) { - row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; - } - if (dto.max20ftPairWeightDiffTons != null) { - row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; - } if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; @@ -615,6 +690,11 @@ export class TrainSchedulingService { if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; + // Store 0 as null so "no offset" is a single canonical value. + if (dto.importCloseOffsetMinutes !== undefined) + row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null; + if (dto.exportCloseOffsetMinutes !== undefined) + row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null; // The booking desk supports three shapes: a same-day range // (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an @@ -631,7 +711,9 @@ export class TrainSchedulingService { dto.windowDurationHours != null || dto.docReviewMinutes != null || dto.paymentWindowMinutes != null || - dto.exportBookingLeadHours != null; + dto.exportBookingLeadHours != null || + dto.importCloseOffsetMinutes !== undefined || + dto.exportCloseOffsetMinutes !== undefined; const saved = await this.dataSource .getRepository(TrainSchedulingGlobalRules) @@ -645,7 +727,7 @@ export class TrainSchedulingService { await this.restampPendingWindows(); } - return saved; + return this.toPublicGlobalRules(saved); } /** @@ -703,6 +785,17 @@ export class TrainSchedulingService { // override changes them, so the derived snapshot delay stays consistent. docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + // A per-schedule override isn't a close-offset control, so inherit the + // offset already frozen on the schedule (null = none), or the live one for + // legacy rows — the override must not silently drop the global offset. + importCloseOffsetMinutes: + schedule.ruleImportCloseOffsetMinutes !== undefined + ? schedule.ruleImportCloseOffsetMinutes + : liveCfg.importCloseOffsetMinutes, + exportCloseOffsetMinutes: + schedule.ruleExportCloseOffsetMinutes !== undefined + ? schedule.ruleExportCloseOffsetMinutes + : liveCfg.exportCloseOffsetMinutes, }; // Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid @@ -821,6 +914,28 @@ export class TrainSchedulingService { ); } + // Moving onto a day where this same built train already runs this route + // would double-book the physical train — blocked for planning moves. + if (schedule.trainSetId && schedule.routeId) { + const trainSet = await this.dataSource + .getRepository(TrainSet) + .findOne({ where: { id: schedule.trainSetId } }); + if (trainSet?.trainId) { + const conflict = await this.findTrainRouteDayConflict( + trainSet.trainId, + schedule.routeId, + departure, + id, + ); + if (conflict) { + throw new ConflictException( + `This train is already scheduled on this route for that day ` + + `(${conflict.reference ?? conflict.id}) — one departure per route per day`, + ); + } + } + } + // Re-derive the window from the schedule's own rule snapshot (falling back to // the live config where a legacy row has no snapshot) against the new date. const merged = effectiveWindowConfig(schedule, windowCfg); @@ -854,10 +969,37 @@ export class TrainSchedulingService { scheduledDepartureDate: departure, ...windowFields, }); + + // Only customers whose bookings already HOLD wagons on this train are told + // about the move (SMS + email + portal inbox). Linked-but-unallocated + // bookings are skipped — nothing of theirs is riding this departure yet. + let notifiedCount = 0; + if (schedule.trainSetId) { + const allocations = await this.dataSource + .getRepository(WagonBookingAllocation) + .createQueryBuilder('a') + .innerJoin('a.trainSetWagon', 'slot') + .where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId }) + .getMany(); + const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))]; + if (allocatedBookingIds.length) { + const allocatedBookings = await this.dataSource.getRepository(Booking).find({ + where: { id: In(allocatedBookingIds) }, + relations: { company: true }, + }); + for (const booking of allocatedBookings) { + if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue; + this.bookingNotifier.rescheduled(booking, departure); + notifiedCount += 1; + } + } + } + this.logger.log( `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + `(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` + - `${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`, + `${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` + + `${notifiedCount} allocated customer booking(s) notified`, ); void this.emitWindowState(id); @@ -1036,6 +1178,13 @@ export class TrainSchedulingService { const n = v == null ? NaN : Number(v); return Number.isFinite(n) ? n : fallback; }; + // Offsets are optional: a missing/unset value means "no offset", not a + // numeric default — keep it null so bookingCloseCutoff falls back to + // departure. Zero and negatives are treated as "no offset" too. + const offset = (v: unknown): number | null => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) && n > 0 ? n : null; + }; return { importWindowLeadDays: num(row?.importWindowLeadDays, 3), exportBookingLeadHours: num(row?.exportBookingLeadHours, 24), @@ -1044,6 +1193,8 @@ export class TrainSchedulingService { windowDurationHours: num(row?.windowDurationHours, 3), docReviewMinutes: num(row?.docReviewMinutes, 30), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes), + exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes), }; } @@ -1152,6 +1303,17 @@ export class TrainSchedulingService { `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, ); } + const conflict = await this.findTrainRouteDayConflict( + builtTrain.id, + route.id, + new Date(dto.scheduleDate), + ); + if (conflict) { + throw new ConflictException( + `Train ${builtTrain.code} is already scheduled on this route for that day ` + + `(${conflict.reference ?? conflict.id}) — one departure per route per day`, + ); + } } else { locomotiveIds = [...new Set(dto.locomotiveIds ?? [])]; if (locomotiveIds.length < 2) { @@ -1304,6 +1466,7 @@ export class TrainSchedulingService { direction, trainNumber: pairTrainNumber ?? undefined, maxWagons, + reverseWagonOrder: dto.reverseWagonOrder ?? false, ...windowFields, }), ); @@ -1382,6 +1545,10 @@ export class TrainSchedulingService { maxTrainWeightTons: dto.maxTrainWeightTons, maxTrainLengthMeters: dto.maxTrainLengthMeters, maxWagonsPerTrain: dto.maxWagonsPerTrain, + // The reverse-order choice is a property of the SCHEDULE, frozen when it was + // created — every (re)assignment rebuilds the plan under the same flag so the + // stored train order stays consistent no matter how bookings are added. + reverseWagonOrder: schedule.reverseWagonOrder ?? false, }; const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); @@ -1458,6 +1625,31 @@ export class TrainSchedulingService { }); } + // Every REQUESTED booking must have made the plan. Silently dropping a + // deferred one let the workspace "Add from pool" report success while the + // booking never boarded (e.g. it needs a PW2 wagon and the train only has + // NW5 free) — the caller saw HTTP 200 and a green toast over a no-op. + // A stock shortage is a physical impossibility, so forceAssign cannot + // override it either. + const plannedIds = new Set(validation.bookings.map((b) => b.id)); + const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id)); + if (droppedRequested.length) { + const reasonById = new Map( + validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]), + ); + const details = droppedRequested.map( + (id) => + reasonById.get(id) ?? + `${id}: does not fit the train's wagon stock or capacity`, + ); + throw new BadRequestException({ + message: `Cannot allocate — ${details.join('; ')}`, + violations: details, + warnings: validation.warnings, + deferredBookings: validation.deferredBookings, + }); + } + const { bookings, wagonPlan, warnings, deferredBookings } = validation; const totalWeightTons = validation.summary.totalWeightTons; const totalLengthMeters = validation.summary.totalLengthMeters; @@ -1581,6 +1773,7 @@ export class TrainSchedulingService { scheduleId, schedule.originStationId, savedWagons, + schedule.reverseWagonOrder ?? false, ); }); @@ -1799,13 +1992,15 @@ export class TrainSchedulingService { } const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const tareDims = await this.loadWagonTareDims(); const items = bookings .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') .map((b) => ({ id: b.id, reference: b.reference ?? null, customer: b.company?.name ?? null, - weightTons: b.cargoTotalWeightVgm, + // GROSS: cargo + tare of the wagons the booking occupies. + weightTons: this.grossBookingWeightTons(b, tareDims), loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, })); return { count: items.length, items }; @@ -2029,6 +2224,58 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + /** + * EXPORT ONLY. An export train must not leave carrying nothing while its cargo + * sits in the shed: the goods are received into the origin warehouse, GRN'd and + * loaded onto the wagons allocated to the booking, so anything still in the + * warehouse at dispatch is being left behind. Blocks dispatch when an allocated + * booking has warehouse inventory that never made it onto a wagon (received / + * stored / ready but not LOADED) — either load it from the Load-to-Train queue, + * or drop the booking's wagon allocation so it rides a later train. + * + * Import/domestic are untouched: their cargo isn't loaded out of an origin + * warehouse, so warehouse inventory says nothing about what's aboard. + * + * Bookings with no warehouse inventory at all are NOT blocked — allocating a + * wagon before the goods arrive is normal planning; they simply aren't aboard. + */ + private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> = + await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + if (!route) return; + const direction = deriveTradeDirection( + { country: route.originCountry }, + { country: route.destinationCountry }, + ); + if (direction !== 'EXPORT') return; + + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( + `WITH ${SCHEDULE_BOOKINGS_CTE} + SELECT DISTINCT b.reference AS "reference", inv.status AS "status" + FROM sched_bookings sb + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = $1 + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, + [scheduleId], + ); + if (rows.length) { + const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', '); + throw new BadRequestException( + `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` + + `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`, + ); + } + } + async dispatchSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2038,6 +2285,8 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // Export only: don't leave received cargo behind in the warehouse. + await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); @@ -3596,13 +3845,6 @@ export class TrainSchedulingService { const allowed = await this.loadAllowedWagonTypes(bookings); const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId); - // Pure demand (unbounded stock) drives the availability report rows. - const demandPlan = planWagonsWithStock({ - bookings, - allowed, - stock: unboundedStock(allowed), - }).plan; - const originYardId = dto.originStationId; let stock: WagonStock; if (builtTrainId) { @@ -3639,10 +3881,24 @@ export class TrainSchedulingService { violations.push(...planned.configIssues); const fittingBookings = planned.fitting; const deferredBookings: DeferredBookingRow[] = planned.deferred; - const wagonPlan = planned.plan; + // Opt-in wagon-order reversal: flip the built plan's order (physically-last + // wagon → position 1) BEFORE legs are stamped and the plan is persisted, so + // the stored train order, allocations and snapshot all carry the reversed + // order together. No-op unless the schedule set the flag. + const wagonPlan = applyWagonOrderReversal( + planned.plan, + (dto as { reverseWagonOrder?: boolean }).reverseWagonOrder, + ); + // Availability rows come from the BOUNDED plan — the one that actually + // mixes wagon types against real stock. The old unbounded "pure demand" + // plan had infinite stock of every allowed type, so its tie-break parked a + // booking's ENTIRE need on one arbitrary type and produced false "Fleet + // shortage: need 30 PW2" warnings for bookings the real plan fits fine by + // mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through + // the deferred bookings' own shortage rows. const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability( - demandPlan, + planned.plan, stock.remainingByTypeId, stock.codesByTypeId, ); @@ -3705,11 +3961,18 @@ export class TrainSchedulingService { } const totalWeightTons = totalAssignedWeight(fittingBookings); + // Every weight limit below (global max, loco pull) is a GROSS axis, so the + // figure spent against it must be gross too — cargo alone under-reports the + // train by the full consist tare and disagrees with the assign path. + const totalTareTons = roundTons( + wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0), + ); + const grossWeightTons = roundTons(totalWeightTons + totalTareTons); const totalLengthMeters = roundTons( wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), ); - if (totalWeightTons > trainLimits.maxWeightTons) { - const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; + if (grossWeightTons > trainLimits.maxWeightTons) { + const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`; if (!violations.includes(message) && !warnings.includes(message)) { pushLimit([message]); } @@ -3736,7 +3999,7 @@ export class TrainSchedulingService { if ( setLimits && (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < - totalWeightTons || + grossWeightTons || setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < totalLengthMeters) ) { @@ -3757,7 +4020,7 @@ export class TrainSchedulingService { !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= - totalWeightTons && + grossWeightTons && Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= totalLengthMeters, ) @@ -3779,6 +4042,9 @@ export class TrainSchedulingService { summary: { totalBookings: fittingBookings.length, totalWeightTons, + /** GROSS: cargo + the tare of every wagon in the plan. */ + grossWeightTons, + totalTareTons, // Human-readable wagon type(s) of the plan — mixed consists list all. wagonType: plannedTypeCodes.join('/') || 'NONE', wagonsNeeded: wagonPlan.length, @@ -4110,6 +4376,7 @@ export class TrainSchedulingService { scheduleId: string, originYardId: string, slots: TrainSetWagon[], + reverseWagonOrder = false, ) { const wagons = await manager.getRepository(Wagon).find(); const wagonTypes = await manager.getRepository(WagonType).find(); @@ -4155,6 +4422,7 @@ export class TrainSchedulingService { assignedPhysicalIds, builtTrainId, pinnedToScheduleIds, + reverseWagonOrder, ); if (!physical) continue; @@ -4248,6 +4516,7 @@ export class TrainSchedulingService { assignedPhysicalIds: Set, builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), + reverseWagonOrder = false, ): Wagon | undefined { const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; @@ -4268,12 +4537,26 @@ export class TrainSchedulingService { // wherever they currently sit (they travel with the train), never a loose // yard wagon. if (builtTrainId) { - return wagons.find( - (w) => - w.trainId === builtTrainId && - w.wagonTypeId === slot.wagonTypeId && - !assignedPhysicalIds.has(w.id), - ); + // Pin in the train's as-built coupling order (wagon.sequenceNumber) so the + // consist views draw the schedule exactly like the train builder; a schedule + // created with reverseWagonOrder pins back-to-front (physically-last wagon + // takes slot #1). Unsequenced wagons sort after every sequenced one. + const candidates = wagons + .filter( + (w) => + w.trainId === builtTrainId && + w.wagonTypeId === slot.wagonTypeId && + !assignedPhysicalIds.has(w.id), + ) + .sort((a, b) => { + if (a.sequenceNumber == null || b.sequenceNumber == null) { + return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0); + } + return reverseWagonOrder + ? b.sequenceNumber - a.sequenceNumber + : a.sequenceNumber - b.sequenceNumber; + }); + return candidates[0]; } // Prefer a wagon already waiting at the slot's board yard (no empty haul); // fall back to one riding from the train's origin. @@ -4733,7 +5016,10 @@ export class TrainSchedulingService { ); } - private mapEligibleBooking(booking: Booking) { + private mapEligibleBooking( + booking: Booking, + tareDims: Awaited>, + ) { return { id: booking.id, reference: booking.reference, @@ -4747,7 +5033,8 @@ export class TrainSchedulingService { .join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'), quantity: booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0, - weightTons: roundTons(booking.cargoTotalWeightVgm), + // GROSS: cargo + tare of the wagons the booking occupies. + weightTons: this.grossBookingWeightTons(booking, tareDims), origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', @@ -5054,6 +5341,11 @@ export class TrainSchedulingService { wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), ); + // Wagon-slot picture for the dialog: the consist IS the schedule's booking + // capacity, so trimming/coupling wagons moves the FULL line live. + const wagonUsage = + (await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null; + const mapWagon = (wagon: Wagon) => ({ id: wagon.id, wagonNumber: wagon.wagonNumber, @@ -5092,6 +5384,12 @@ export class TrainSchedulingService { grossTons: roundTons(cargoTons + consistTareTons), consistLengthMeters, }, + scheduleCapacity: wagonUsage + ? { + ...wagonUsage, + bookingWindowStatus: schedule.bookingWindowStatus ?? null, + } + : null, wagons: wagons.map((wagon) => ({ ...mapWagon(wagon), loaded: loadedWagonIds.has(wagon.id), @@ -5284,7 +5582,37 @@ export class TrainSchedulingService { ); }); - return this.getScheduleConsist(scheduleId); + // The consist IS the schedule's booking capacity, so an edit moves the + // FULL line: freeing slots on a FULL schedule reopens its window, taking + // the last slot closes it. Staff may shrink below what is already + // committed — allowed, but reported back as a warning (never silently). + const warnings: string[] = []; + const wasFull = schedule.bookingWindowStatus === 'FULL'; + const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId); + if (usage) { + const nowFull = usage.remainingSlots <= 0; + if (usage.overAllocatedBy > 0) { + warnings.push( + `The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` + + `${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` + + 'Couple more wagons or free bookings before departure.', + ); + } + if (wasFull && !nowFull) { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + warnings.push( + `This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` + + 'so it is no longer FULL and can take bookings again.', + ); + } else if (!wasFull && nowFull) { + await this.bookingBatchService?.setWindow(scheduleId, 'FULL'); + warnings.push( + 'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.', + ); + } + } + + return { ...(await this.getScheduleConsist(scheduleId)), warnings }; } /** @@ -5997,6 +6325,97 @@ export class TrainSchedulingService { } } + /** + * Per-wagon tare/payload for every wagon type, keyed by id, with the batch + * engine's representative fallbacks for bookings whose cargo/container type + * has no wagon type configured. Loaded once per request before mapping. + */ + /** Wagon types are near-static reference data — a short TTL cache spares one + * table scan per detail/board request without letting edits go stale long. */ + private wagonTareDimsCache: { + value: Awaited>; + expiresAt: number; + } | null = null; + + private async loadWagonTareDims(): Promise<{ + byWagonTypeId: Map; + bulk: { tareWeightTons: number; capacityTons: number }; + container: { tareWeightTons: number; capacityTons: number }; + }> { + if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) { + return this.wagonTareDimsCache.value; + } + const types = await this.dataSource.getRepository(WagonType).find(); + const byWagonTypeId = new Map( + types.map((t) => [ + t.id, + { + tareWeightTons: Number(t.tareWeightTons) || 0, + capacityTons: Number(t.capacityTons) || 0, + }, + ]), + ); + const value = { + byWagonTypeId, + bulk: { + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS, + }, + container: { + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, + }, + }; + this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 }; + return value; + } + + /** + * Booking weight as the train actually hauls it: cargo VGM plus the tare of + * every wagon the booking occupies — the same gross axis the batch engine + * spends against the locomotive's pull limit. Wagon count mirrors the batch + * engine's sizing (stored wagonsRequired, TEU geometry for containers, + * tons ÷ payload for bulk — whichever is largest). + */ + private grossBookingWeightTons( + booking: Pick< + Booking, + | 'freightType' + | 'cargoTotalWeightVgm' + | 'wagonsRequired' + | 'bookingContainers' + | 'cargoType' + >, + tareDims: Awaited>, + ): number { + const cargo = Number(booking.cargoTotalWeightVgm ?? 0); + const fallback = + booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container; + // Same first-configured-type resolution the batch engine's dimsFor uses. + const wagonTypeId = + booking.freightType === 'BULK' + ? booking.cargoType?.wagonTypes?.[0]?.id + : (booking.bookingContainers ?? []) + .flatMap((line) => line.containerType?.wagonTypes ?? []) + .map((wagonType) => wagonType.id) + .find((id): id is string => Boolean(id)); + const typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined; + const dims = { + tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons, + capacityTons: typed?.capacityTons || fallback.capacityTons, + }; + + const stored = + booking.wagonsRequired && booking.wagonsRequired > 0 + ? Math.ceil(booking.wagonsRequired) + : 0; + const byLength = containerWagonsForLines(booking.bookingContainers ?? []); + const byWeight = + cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; + const wagons = Math.max(1, stored, byLength, byWeight); + return roundTons(cargo + wagons * dims.tareWeightTons); + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { @@ -6010,41 +6429,9 @@ export class TrainSchedulingService { // (loadedOnTrainAt on the operation). Other directions have no departure // loading gate, so the workspace shows the confirm button as already done. const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule); - let loadingConfirmed = !requiresLoadingConfirmation; - if (requiresLoadingConfirmation) { - const op = await this.dataSource - .getRepository(ImportDjiboutiOperation) - .findOne({ where: { trainScheduleId: schedule.id } }); - loadingConfirmed = Boolean(op?.loadedOnTrainAt); - } - - const windowCfg = await this.getWindowConfig(); - - const [containerItems, bulkLoads] = await Promise.all([ - allocationIds.length - ? this.wagonAllocationContainerItemsRepository.findAll({ - where: { wagonBookingAllocationId: In(allocationIds) }, - relations: { containerType: true, bookingContainer: true }, - }) - : [], - allocationIds.length - ? this.wagonAllocationBulkLoadsRepository.findAll({ - where: { wagonBookingAllocationId: In(allocationIds) }, - relations: { cargoType: true }, - }) - : [], - ]); - - const containerItemsByAllocation = new Map(); - for (const item of containerItems) { - const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? []; - list.push(item); - containerItemsByAllocation.set(item.wagonBookingAllocationId, list); - } - const bulkLoadsByAllocation = new Map( - bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), - ); + // Snapshot state decides below whether the live consist may be drawn at + // all, so it is derived before the consist wagons are fetched. // Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released // and re-pinned onto later trains — the live wagon↔slot joins no longer // describe THIS train. If a frozen snapshot was captured at the transition, @@ -6059,6 +6446,55 @@ export class TrainSchedulingService { (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), ); + // All independent lookups fired at once — they used to run one after + // another, stacking round-trips onto every detail request. + // tareDims: booking weights are reported GROSS (cargo + wagon tare) — the + // number the locomotive actually hauls against its pull limit. + const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] = + await Promise.all([ + this.loadWagonTareDims(), + requiresLoadingConfirmation + ? this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: schedule.id } }) + : null, + this.getWindowConfig(), + allocationIds.length + ? this.wagonAllocationContainerItemsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { containerType: true, bookingContainer: true }, + }) + : [], + allocationIds.length + ? this.wagonAllocationBulkLoadsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { cargoType: true }, + }) + : [], + schedule.trainSet?.trainId && !isWagonAllocationFrozen + ? this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + // Mirror the pinning direction: a reverse-order schedule draws the + // whole consist back-to-front, empties included. + order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' }, + }) + : [], + ]); + const loadingConfirmed = requiresLoadingConfirmation + ? Boolean(importOp?.loadedOnTrainAt) + : true; + + const containerItemsByAllocation = new Map(); + for (const item of containerItems) { + const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? []; + list.push(item); + containerItemsByAllocation.set(item.wagonBookingAllocationId, list); + } + const bulkLoadsByAllocation = new Map( + bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), + ); + // The trainSet slots below are the PLANNED wagons (one per allocation). A // schedule tied to a built train hauls EVERY coupled wagon — empty ones // included (the pull-limit check already counts their tare) — so append the @@ -6081,41 +6517,32 @@ export class TrainSchedulingService { 0, ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), ); - const emptyConsistWagons = - schedule.trainSet?.trainId && !isWagonAllocationFrozen - ? ( - await this.dataSource.getRepository(Wagon).find({ - where: { trainId: schedule.trainSet.trainId }, - relations: { wagonType: true }, - order: { sequenceNumber: 'ASC' }, - }) - ) - .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) - .map((wagon, index) => ({ - // Physical wagon id — there is no TrainSetWagon slot behind this - // row, so remove/edit affordances must stay disabled (consistOnly). - id: wagon.id, - sequenceNo: maxSlotSequenceNo + index + 1, - capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), - lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), - assignedWeightTons: 0, - tareWeightTons: wagon.wagonType - ? roundTons(Number(wagon.wagonType.tareWeightTons)) - : null, - status: 'EMPTY', - physicalWagonId: wagon.id, - physicalWagonNumber: wagon.wagonNumber ?? null, - wagonType: wagon.wagonType - ? { - id: wagon.wagonType.id, - code: wagon.wagonType.code, - name: wagon.wagonType.name, - } - : null, - allocations: [], - consistOnly: true, - })) - : []; + const emptyConsistWagons = rawConsistWagons + .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) + .map((wagon, index) => ({ + // Physical wagon id — there is no TrainSetWagon slot behind this + // row, so remove/edit affordances must stay disabled (consistOnly). + id: wagon.id, + sequenceNo: maxSlotSequenceNo + index + 1, + capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), + lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), + assignedWeightTons: 0, + tareWeightTons: wagon.wagonType + ? roundTons(Number(wagon.wagonType.tareWeightTons)) + : null, + status: 'EMPTY', + physicalWagonId: wagon.id, + physicalWagonNumber: wagon.wagonNumber ?? null, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + } + : null, + allocations: [], + consistOnly: true, + })); return { id: schedule.id, @@ -6125,6 +6552,7 @@ export class TrainSchedulingService { trainNumber: schedule.trainNumber ?? null, maxWagons: schedule.maxWagons ?? null, direction: schedule.direction ?? null, + reverseWagonOrder: schedule.reverseWagonOrder ?? false, requiresLoadingConfirmation, loadingConfirmed, // Booking-window phase + phase deadlines drive the countdown timers in the @@ -6293,7 +6721,9 @@ export class TrainSchedulingService { id: sb.booking?.id ?? sb.bookingId, reference: sb.booking?.reference ?? null, customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null, - weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)), + weightTons: sb.booking + ? this.grossBookingWeightTons(sb.booking, tareDims) + : 0, status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, @@ -6423,8 +6853,13 @@ export class TrainSchedulingService { /** Preview wagon allocation issues per linked booking without mutating the schedule. */ async previewAllocationForSchedule( scheduleId: string, + // Callers that already hold the full schedule graph (batch board detail) + // pass it in so the preview doesn't re-load the same heavy graph. + preloadedSchedule?: TrainSchedule, ): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + const schedule = + preloadedSchedule ?? + (await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } @@ -6471,7 +6906,10 @@ export class TrainSchedulingService { ); if (!eligible.length) return empty; - const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id); + const wagonAssignedIds = await this.getWagonAssignedBookingIds( + schedule.id, + schedule, + ); const previewDto = { bookingIds: eligible.map((b) => b.id), scheduleDate: schedule.scheduledDepartureDate.toISOString(), @@ -6746,6 +7184,15 @@ export class TrainSchedulingService { shortfall: 0, })); + // Gross weight needs the scheduling graph (containers, cargo type, wagon + // types) that the trimmed select above deliberately skips. + const tareDims = await this.loadWagonTareDims(); + const fullById = new Map( + (await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map( + (b) => [b.id, b], + ), + ); + const bookings = await Promise.all( unassigned.map(async (b) => { const assignability = await this.previewUnassignedBookingAssignability( @@ -6760,6 +7207,11 @@ export class TrainSchedulingService { freightType: b.freightType ?? null, priorityScore: b.priorityScore ?? 0, cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0), + // GROSS: cargo + tare of the wagons the booking occupies. + grossWeightTons: this.grossBookingWeightTons( + (fullById.get(b.id) ?? b) as Booking, + tareDims, + ), status: b.status ?? null, schedulingStatus: b.schedulingStatus ?? null, ...assignability, @@ -6988,8 +7440,15 @@ export class TrainSchedulingService { return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId); } - private async getWagonAssignedBookingIds(scheduleId: string): Promise> { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + private async getWagonAssignedBookingIds( + scheduleId: string, + // Pass when the caller already holds the schedule with trainSet.wagons — + // only wagon ids are read here, the old full-graph reload was pure waste. + preloadedSchedule?: TrainSchedule, + ): Promise> { + const schedule = + preloadedSchedule ?? + (await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)); const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id); if (!wagonIds.length) return new Set(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 157666f55..5870d3785 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -1,6 +1,10 @@ import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { planWagonsWithStock } from './wagon-plan-flex.util'; +import { + applyWagonOrderReversal, + planWagonsWithStock, +} from './wagon-plan-flex.util'; +import type { WagonPlanSlot } from './wagon-plan.util'; const nw6: WagonType = { id: 'wt-nw6', @@ -77,7 +81,6 @@ describe('planWagonsWithStock — shortage detail', () => { fortyFooter.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40, - wagonsPerUnit: 1, } as never; const result = planWagonsWithStock({ bookings: [fortyFooter], @@ -131,3 +134,56 @@ describe('planWagonsWithStock — shortage detail', () => { expect(result.deferred[0]?.shortage).toBeNull(); }); }); + +describe('applyWagonOrderReversal', () => { + const slot = ( + seq: number, + wagonTypeId: string, + bookingId: string, + ): WagonPlanSlot => + ({ + sequenceNo: seq, + wagonTypeId, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 25, + allocations: [{ bookingId }], + }) as unknown as WagonPlanSlot; + + const plan: WagonPlanSlot[] = [ + slot(1, 'wt-a', 'BKG-A'), + slot(2, 'wt-b', 'BKG-B'), + slot(3, 'wt-c', 'BKG-C'), + ]; + + it('returns the plan unchanged when the flag is false/absent', () => { + expect(applyWagonOrderReversal(plan, false)).toBe(plan); + expect(applyWagonOrderReversal(plan, undefined)).toBe(plan); + expect(applyWagonOrderReversal(plan, null)).toBe(plan); + }); + + it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => { + const reversed = applyWagonOrderReversal(plan, true); + // Physically-last wagon (was seq 3, wt-c) is now position 1. + expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']); + expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + }); + + it('keeps each booking with its own wagon — only the position changes', () => { + const reversed = applyWagonOrderReversal(plan, true); + // The booking that was in the last wagon now sits at sequenceNo 1. + expect(reversed[0].sequenceNo).toBe(1); + expect( + (reversed[0].allocations as { bookingId: string }[])[0].bookingId, + ).toBe('BKG-C'); + expect( + (reversed[2].allocations as { bookingId: string }[])[0].bookingId, + ).toBe('BKG-A'); + }); + + it('does not mutate the input plan', () => { + applyWagonOrderReversal(plan, true); + expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index ad4c29aa1..6a3c1c49f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -340,6 +340,31 @@ export function planWagonsWithStock(params: { }; } +/** + * Reverse the wagon ORDER of a built plan when a schedule opts in. + * + * The plan comes out of planWagonsWithStock ordered by booking scheduling order + * (first slot opened = sequenceNo 1). When `reverse` is set, the physically-last + * wagon becomes wagon #1: the slot objects — and the bookings already allocated + * into each — travel WITH their slot, so only the position numbers flip. The + * physical composition, which booking is in which wagon, and every per-slot + * field are untouched; sequenceNo is renumbered 1..N over the reversed array. + * + * This single flip is the whole feature: persistTrainSetWagons writes these + * sequenceNos, the snapshot re-sorts by them, and the board/allocation views all + * read them — so the stored train order and the schedule order stay identical, + * just reversed. A false/absent flag returns the plan unchanged. + */ +export function applyWagonOrderReversal( + plan: WagonPlanSlot[], + reverse: boolean | null | undefined, +): WagonPlanSlot[] { + if (!reverse) return plan; + return [...plan] + .reverse() + .map((slot, index) => ({ ...slot, sequenceNo: index + 1 })); +} + /** Unbounded stock — used to compute pure demand for availability reporting. */ export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock { const remainingByTypeId = new Map(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index 19e19dca3..c3d48f286 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -106,7 +106,7 @@ describe('wagon-plan.util', () => { }); it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { - // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons + // 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); expect(sumWagonsRequired(booking)).toBe(3); const plan = buildContainerWagonPlan([booking], nw5); @@ -227,7 +227,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ quantity, wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, - containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, + containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, }); it('20×20ft = 10 wagons (not 20)', () => { @@ -266,7 +266,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => expect(containerWagonsForLines([line(21, 1)])).toBe(21); }); - it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => { + it('falls back to line wagonsRequired when containerType/sizeFt missing', () => { // No containerType relation loaded → use the stored (0.5-aware) fraction. expect( containerWagonsForLines([ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 78cd8bf54..bb5ce890c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -1,6 +1,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; +import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { consistViolations } from './train-capacity.util'; @@ -61,7 +62,6 @@ export type ContainerUnitRow = { label: string; grossWeightTons: number; sizeFt?: number; - wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; containerNumber?: string | null; @@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; } -export function containersPerWagonFromType(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - type ContainerLine = { quantity?: number | null; wagonsRequired?: number | null; - containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; + containerType?: { sizeFt?: number | null } | null; }; /** - * RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit - * (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so - * the BOOKING total is ceiled once — ceiling per line over-counts a booking that - * splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). + * RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived + * fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept + * fractional so the BOOKING total is ceiled once — ceiling per line over-counts a + * booking that splits its 20ft units across several lines (3×20 + 3×20 = 3 + * wagons, not 4). */ function lineWagonsRaw(line: ContainerLine): number { const qty = Number(line.quantity ?? 0); if (qty <= 0) return 0; - const wpu = Number(line.containerType?.wagonsPerUnit); - if (Number.isFinite(wpu) && wpu > 0) { - return qty * wpu; + const sizeFt = Number(line.containerType?.sizeFt); + if (Number.isFinite(sizeFt) && sizeFt > 0) { + return qty * wagonsPerUnitForSize(sizeFt); } - // No wagonsPerUnit on the type: fall back to the line's stored fraction, else - // treat the whole line as one wagon. + // No size on the type: fall back to the line's stored fraction, else treat + // the whole line as one wagon. const stored = Number(line.wagonsRequired); return Number.isFinite(stored) && stored > 0 ? stored : 1; } @@ -250,8 +245,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const qty = Number(line.quantity ?? 0); const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); - const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); - const perWagon = containersPerWagonFromType(wagonsPerUnit); + const perWagon = containersPerWagonForSize(sizeFt); const teuSlots = teuSlotsForSizeFt(sizeFt); // The REAL per-container numbers/weights entered at booking time. Unit i of // the line maps to units[i] (sortOrder order); the line-level number is only @@ -271,7 +265,6 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, - wagonsPerUnit, containersPerWagon: perWagon, teuSlots, containerNumber: diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index d1939c9f5..408b13be5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -20,6 +20,16 @@ export class CreateWagonDto { // Tare weight and payload capacity are not accepted here: they belong to the // wagon type and are resolved through wagonTypeId. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). */ + @IsOptional() + @IsString() + exportTrainNumber?: string; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). */ + @IsOptional() + @IsString() + importTrainNumber?: string; + @IsOptional() @IsEnum(WagonStatus) status?: WagonStatus; diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 23b517785..c7eecfc02 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -29,6 +29,13 @@ export class ListWagonsQueryDto { @IsUUID() trainId?: string; + @ApiPropertyOptional({ + description: 'Filter by run number — matches export OR import run (e.g. 8001).', + }) + @IsOptional() + @IsString() + trainNumber?: string; + @ApiPropertyOptional({ default: 'wagonNumber' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index b66fc3f9d..7bfb59e1d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -43,6 +43,14 @@ export class Wagon extends BaseEntity { // Tare weight and payload capacity are properties of the wagon TYPE — read them // through `wagonType`, never off the individual wagon. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). Null until set. */ + @Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true }) + exportTrainNumber!: string | null; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). Null until set. */ + @Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true }) + importTrainNumber!: string | null; + @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; diff --git a/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts b/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts new file mode 100644 index 000000000..85bb5e88a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts @@ -0,0 +1,41 @@ +/** + * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The + * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run. + * + * Run numbers are always 4 digits (8401, never 84001). Pairs are listed out + * rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks + * the convention stays correct here. + * + * SeedWagonRunNumbers2280000000000 carries its own frozen copy on purpose: a + * migration must keep doing what it did when it was applied, whereas this list + * is live config for the update script. Add or retire runs HERE. + */ +export const TRAIN_RUN_PAIRS: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +/** Even IMPORT run -> its odd EXPORT run. Derived so the two cannot drift. */ +export const EXPORT_BY_IMPORT: Record = Object.fromEntries( + Object.entries(TRAIN_RUN_PAIRS).map(([exportRun, importRun]) => [importRun, exportRun]), +); + +/** + * Normalise any run number to its EXPORT run. Accepts either half of a pair, so + * a sheet listing "8002" and one listing "8001" both resolve to the same train. + * Returns null when the number belongs to no known run. + */ +export const toExportRun = (run: string): string | null => { + const value = run.trim(); + if (TRAIN_RUN_PAIRS[value]) return value; + return EXPORT_BY_IMPORT[value] ?? null; +}; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 8c5dd83c8..188bf1783 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -6,7 +6,7 @@ import { ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm'; +import { Repository, DataSource, In } from 'typeorm'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; @@ -38,26 +38,47 @@ export class WagonsService { if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; if (dto.currentYardId === undefined) wagon.currentYardId = null; + if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; + if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; return this.wagonRepo.save(wagon); } async findAll(query: ListWagonsQueryDto = {}): Promise { - const where: FindOptionsWhere[] | FindOptionsWhere = []; const search = query.search?.trim(); const trainId = query.trainId?.trim(); const wagonTypeId = query.wagonTypeId?.trim(); - const filters: FindOptionsWhere = { - ...(query.status ? { status: query.status } : {}), - ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}), - ...(trainId ? { trainId } : {}), - ...(wagonTypeId ? { wagonTypeId } : {}), - }; + const trainNumber = query.trainNumber?.trim(); + // QueryBuilder (not find) because both search and the trainNumber filter span + // two columns each (export/import run) — an OR that FindOptions cannot express + // without cross-producting into conflicting branches. Soft-deleted rows are + // still excluded automatically (BaseEntity's @DeleteDateColumn). + const qb = this.wagonRepo + .createQueryBuilder('w') + .leftJoinAndSelect('w.currentYard', 'currentYard') + .leftJoinAndSelect('w.wagonType', 'wagonType'); + + if (query.status) qb.andWhere('w.status = :status', { status: query.status }); + if (query.currentYardId) + qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId }); + if (trainId) qb.andWhere('w.trainId = :trainId', { trainId }); + if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId }); + + // Filter by run: the odd export run identifies the pair, so match either + // column — a wagon carries export on one, import on the other. + if (trainNumber) { + qb.andWhere( + '(w.exportTrainNumber = :trainNumber OR w.importTrainNumber = :trainNumber)', + { trainNumber }, + ); + } + + // Search matches the wagon number or either run number. if (search) { - where.push({ - wagonNumber: ILike(`%${search}%`), - ...filters, - }); + qb.andWhere( + '(w.wagonNumber ILIKE :search OR w.exportTrainNumber ILIKE :search OR w.importTrainNumber ILIKE :search)', + { search: `%${search}%` }, + ); } // Spec columns (tare, payload) are no longer sortable here — they live on the @@ -73,14 +94,14 @@ export class WagonsService { ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + qb.orderBy(`w.${sortBy}`, sortOrder); - return this.wagonRepo.find({ - where: search ? where : filters, - relations: { currentYard: true, wagonType: true }, - order: { [sortBy]: sortOrder } as FindOptionsOrder, - skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, - take: query.limit ? Number(query.limit) : undefined, - }); + if (query.page && query.limit) { + qb.skip((Number(query.page) - 1) * Number(query.limit)); + } + if (query.limit) qb.take(Number(query.limit)); + + return qb.getMany(); } async findById(id: string): Promise { 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 85311f049..1deedd14d 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 @@ -3,6 +3,8 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { generateGrnNumber } from '../../common/grn.util'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; @@ -13,6 +15,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from '../notifications/resolve-company-phone.util'; import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -1027,6 +1033,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads + // onto a train without one. Import GRN handling is left untouched. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1036,6 +1044,9 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), + ...(booking.tradeDirection === 'EXPORT' + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1056,6 +1067,14 @@ 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. + 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'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1076,6 +1095,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) } + : {}), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1090,6 +1113,9 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, + ...(isExport + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -1145,7 +1171,7 @@ export class WarehouseInventoryService { b.company_id AS "customerId", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", @@ -1183,6 +1209,7 @@ export class WarehouseInventoryService { b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id @@ -1288,7 +1315,7 @@ export class WarehouseInventoryService { b.cargo_total_weight_vgm AS "weight", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", bc.container_numbers AS "containerNumber", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", @@ -1317,6 +1344,7 @@ export class WarehouseInventoryService { OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id @@ -1536,11 +1564,19 @@ export class WarehouseInventoryService { // their already-allocated wagons. Reuses the single-item load() machinery. /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ + /** + * Export flow this queue serves: booked -> paid -> received at the warehouse + * (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the + * booking. Which bookings ride a train comes from the shared CTE. + */ + private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; + async loadableTrains(): Promise { const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( - `SELECT ts.id AS "scheduleId", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT ts.id AS "scheduleId", ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", @@ -1548,15 +1584,15 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount", + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status = 'LOADED') AS "loadedCount" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id @@ -1564,11 +1600,11 @@ export class WarehouseInventoryService { WHERE ts.deleted_at IS NULL AND ts.status = ANY($1) AND EXISTS ( - SELECT 1 FROM freight.train_schedule_bookings tsb2 + SELECT 1 FROM sched_bookings sb2 JOIN freight.warehouse_inventory inv2 - ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL - WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL - AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL + WHERE sb2.schedule_id = ts.id + AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, [['DRAFT', 'SCHEDULED']], @@ -1593,22 +1629,28 @@ export class WarehouseInventoryService { */ async trainLoadableItems(scheduleId: string): Promise { const rows: Array> = await this.dataSource.query( - `SELECT inv.id AS "id", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT inv.id AS "id", inv.booking_id AS "bookingId", b.reference AS "bookingReference", company.name AS "customerName", ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", + -- receive() stamps the GRN onto the row and mirrors it into the + -- note; prefer the column and fall back for legacy/seeded rows. + COALESCE( + inv.grn_number, + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) AS "grnNumber", inv.inspection_status AS "inspectionStatus", inv.status AS "status", wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", wl.sequence_no AS "sequenceNo" - FROM freight.train_schedule_bookings tsb - JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id - JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + FROM sched_bookings sb + JOIN freight.train_schedules ts ON ts.id = sb.schedule_id + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id @@ -1625,15 +1667,19 @@ export class WarehouseInventoryService { ORDER BY tsw.sequence_no ASC NULLS LAST LIMIT 1 ) wl ON true - WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + WHERE sb.schedule_id = $1 + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], ); return rows.map((r) => ({ ...r, - loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId), + // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. + // The row only exists once the goods were received, so requiring a GRN and + // an allocated wagon completes the chain. + loadable: + r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), })); } @@ -1686,6 +1732,9 @@ export class WarehouseInventoryService { if (!item) { skip('Not assigned to this train'); continue; } if (item.status === 'LOADED') { skip('Already loaded'); continue; } if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; } + // Export: the GRN is raised when the goods arrive at the warehouse, and + // nothing rides a train without one. + if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { @@ -1877,7 +1926,8 @@ export class WarehouseInventoryService { // 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries). const [schedule] = await this.dataSource.query( - `SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry" + `SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId", + oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -1908,14 +1958,20 @@ export class WarehouseInventoryService { tradeDirection: string | null; cargoTypeCode: string | null; }[] = await this.dataSource.query( + // Only bookings whose destination IS this train's final yard unload into + // this (final-destination) warehouse. A mid-corridor import that alighted + // at an intermediate yard was already unloaded there by the checkpoint + // auto-unload; without this filter it would be mis-located into the final + // yard's inventory too. `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", cgt.code AS "cargoTypeCode" FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id - WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`, - [scheduleId], + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + AND b.destination_yard_id = $2`, + [scheduleId, schedule.destinationStationId], ); const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null; @@ -4946,7 +5002,7 @@ export class WarehouseInventoryService { `SELECT b.reference AS "reference", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", b.cargo_total_weight_vgm AS "weight", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription", bc.container_numbers AS "containerNumber", @@ -4963,6 +5019,7 @@ export class WarehouseInventoryService { v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, @@ -5024,10 +5081,9 @@ export class WarehouseInventoryService { } } + /** Shared with the facility handling flow — see common/grn.util.ts. */ private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); - const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); - return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + return generateGrnNumber(direction, referenceId, date); } private async generateReleaseReference(item: WarehouseInventory): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 2508e373a..6bf03c93d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -25,6 +25,10 @@ import { InvoiceDocumentService, } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from "../notifications/resolve-company-phone.util"; import { WarehouseFeeService } from "./warehouse-fee.service"; import { WarehouseFeeInvoiceView, @@ -879,7 +883,7 @@ export class WarehouseInvoiceService { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE( NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''), last_vehicle.assigned_driver_name, @@ -892,6 +896,7 @@ export class WarehouseInvoiceService { FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id diff --git a/apps/edr-freight-api/src/scripts/seed-edr-trucks.ts b/apps/edr-freight-api/src/scripts/seed-edr-trucks.ts new file mode 100644 index 000000000..07d277937 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-edr-trucks.ts @@ -0,0 +1,39 @@ +import { AppDataSource } from '../data-source'; +import { EdrTruckFleetSeeder } from '../seed/edr-truck-fleet.seeder'; + +/** + * Seeds the 62-truck EDR fleet used by first-mile / last-mile. + * + * The seeder is idempotent (`ON CONFLICT (plate_number) DO NOTHING`), so a + * re-run will NOT overwrite a truck whose rate was tuned by hand. + */ +async function seedEdrTrucks() { + await AppDataSource.initialize(); + + try { + await new EdrTruckFleetSeeder(AppDataSource).run(); + + const summary = await AppDataSource.query(` + SELECT + COUNT(*)::int AS trucks, + COUNT(*) FILTER (WHERE status = 'ACTIVE')::int AS active, + COUNT(*) FILTER (WHERE availability = 'FREE')::int AS free, + COUNT(*) FILTER (WHERE price_per_km > 0)::int AS priced, + COUNT(*) FILTER (WHERE price_per_km IS NULL OR price_per_km <= 0)::int AS unpriced, + MIN(price_per_km)::text AS min_rate, + MAX(price_per_km)::text AS max_rate + FROM freight.vehicles + WHERE vehicle_type = 'TRUCK'; + `); + + console.table(summary); + console.log('Seeded EDR truck fleet.'); + } finally { + await AppDataSource.destroy(); + } +} + +seedEdrTrucks().catch((error) => { + console.error('Failed to seed EDR truck fleet:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts index 716532165..c333abf40 100644 --- a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -1,5 +1,9 @@ import { AppDataSource } from '../data-source'; import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering'; +import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers'; +import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers'; +import { WagonNumberPartialUnique2280000000000 } from '../migrations/2280000000000-WagonNumberPartialUnique'; +import { SeedWagonYardDoraleh2290000000000 } from '../migrations/2290000000000-SeedWagonYardDoraleh'; async function seedEdRWagons() { await AppDataSource.initialize(); @@ -10,7 +14,18 @@ async function seedEdRWagons() { await queryRunner.connect(); await queryRunner.startTransaction(); + // Fleet first (recreates every wagon with NULL yard + NULL runs), then the + // columns are ensured to exist, then the run roster and the yard are applied + // on top. Same order the migrations run in, so the script and a fresh + // migrate agree. await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner); + await new AddWagonTrainNumbers2270000000000().up(queryRunner); + // Not a wagon seed, but it owns wagon_number uniqueness — included so this + // script leaves the same schema a real `migration:run` would, rather than a + // database missing the partial unique index. + await new WagonNumberPartialUnique2280000000000().up(queryRunner); + await new SeedWagonRunNumbers2280000000000().up(queryRunner); + await new SeedWagonYardDoraleh2290000000000().up(queryRunner); const summary = await queryRunner.query(` SELECT @@ -20,7 +35,8 @@ async function seedEdRWagons() { MIN(w.wagon_number) AS first_wagon, MAX(w.wagon_number) AS last_wagon, COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available, - COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard + COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS no_yard, + COUNT(*) FILTER (WHERE w.export_train_number IS NOT NULL)::int AS on_a_run FROM freight.wagons w JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100' @@ -29,13 +45,45 @@ async function seedEdRWagons() { `); const [totals] = await queryRunner.query(` - SELECT COUNT(*)::int AS total FROM freight.wagons; + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run + FROM freight.wagons; + `); + + const yards = await queryRunner.query(` + SELECT + COALESCE(y.label, '(no yard)') AS yard, + COUNT(*)::int AS wagons + FROM freight.wagons w + LEFT JOIN freight.yards y ON y.id = w.current_yard_id + GROUP BY y.label + ORDER BY 2 DESC; + `); + + const runs = await queryRunner.query(` + SELECT + export_train_number AS export_run, + import_train_number AS import_run, + COUNT(*)::int AS wagons + FROM freight.wagons + WHERE export_train_number IS NOT NULL + GROUP BY export_train_number, import_train_number + ORDER BY export_train_number; `); await queryRunner.commitTransaction(); + console.log('\nFleet by wagon type:'); console.table(summary); - console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`); + console.log('Run roster (export/import pairs):'); + console.table(runs); + console.log('Fleet by yard:'); + console.table(yards); + console.log( + `Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100), ` + + `${totals.on_a_run} on a run (expected 533).`, + ); } catch (error) { await queryRunner.rollbackTransaction(); throw error; diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index a8abee67b..dbfd5cee6 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -209,7 +209,6 @@ async function ensureReferences(manager: any) { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 4f801330a..9cf1ef0cd 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -118,7 +118,6 @@ async function main() { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts index 367b79b44..b14ac8c15 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts @@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -115,7 +116,7 @@ async function main() { reeferQuantity: 0, vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)), totalVgmTons: Number((weightKg / 1000).toFixed(3)), - wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)), + wagonsRequired: Math.max(1, containerQuantity * wagonsPerUnitForSize(containerType!.sizeFt)), isOverweight: false, }), ); diff --git a/apps/edr-freight-api/src/scripts/update-wagon-runs.ts b/apps/edr-freight-api/src/scripts/update-wagon-runs.ts new file mode 100644 index 000000000..d24f6245e --- /dev/null +++ b/apps/edr-freight-api/src/scripts/update-wagon-runs.ts @@ -0,0 +1,174 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { AppDataSource } from '../data-source'; +import { TRAIN_RUN_PAIRS, toExportRun } from '../modules/wagons/train-runs.const'; + +/** + * Update wagon run numbers from a roster file — the tool for making the DB match + * the operator's sheet. + * + * pnpm seed:wagon-runs [--apply] + * + * CSV: two columns, header optional. Either half of a run pair is accepted, so + * "8001" and "8002" both mean the same train. + * + * wagon_number,run + * ER0744,8001 + * ER0458,8102 + * + * FULL REPLACEMENT: wagons absent from the file have their runs cleared, so the + * DB ends up matching the file exactly rather than accumulating stale rows. + * + * Dry run by default — it validates and prints what would change. Nothing is + * written without `--apply`. Validation is fatal on: an unknown run, a wagon not + * in the database, or the same wagon claimed by two runs (a wagon holds one run, + * so a double-booking has no correct answer and must be fixed in the sheet). + */ +interface Row { + line: number; + wagonNumber: string; + exportRun: string; +} + +function parseCsv(path: string) { + const text = readFileSync(path, 'utf8'); + const rows: Row[] = []; + const unknownRuns: string[] = []; + + text.split(/\r?\n/).forEach((raw, i) => { + const line = i + 1; + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith('#')) return; + + const [rawWagon = '', rawRun = ''] = trimmed.split(',').map((c) => c.trim()); + // Skip a header row without needing it to be declared. + if (/wagon/i.test(rawWagon) && /run|train/i.test(rawRun)) return; + if (!rawWagon || !rawRun) { + throw new Error(`line ${line}: expected "wagon_number,run", got "${trimmed}"`); + } + + const exportRun = toExportRun(rawRun); + if (!exportRun) { + unknownRuns.push(`line ${line}: "${rawRun}" (wagon ${rawWagon})`); + return; + } + rows.push({ line, wagonNumber: rawWagon.toUpperCase(), exportRun }); + }); + + return { rows, unknownRuns }; +} + +async function updateWagonRuns() { + const [fileArg, ...flags] = process.argv.slice(2); + const apply = flags.includes('--apply'); + + if (!fileArg) { + console.error('usage: pnpm seed:wagon-runs [--apply]'); + process.exit(2); + } + + const path = resolve(process.cwd(), fileArg); + const { rows, unknownRuns } = parseCsv(path); + + // A wagon in two runs cannot be represented — surface every instance rather + // than silently keeping whichever line happened to come first. + const seen = new Map(); + const doubleBooked: string[] = []; + for (const row of rows) { + const prior = seen.get(row.wagonNumber); + if (prior && prior.exportRun !== row.exportRun) { + doubleBooked.push( + `${row.wagonNumber}: run ${prior.exportRun} (line ${prior.line}) vs ${row.exportRun} (line ${row.line})`, + ); + continue; + } + if (!prior) seen.set(row.wagonNumber, row); + } + + await AppDataSource.initialize(); + try { + const wagonNumbers = [...seen.keys()]; + const existing: Array<{ wagon_number: string }> = wagonNumbers.length + ? await AppDataSource.query( + `SELECT wagon_number FROM freight.wagons + WHERE deleted_at IS NULL AND wagon_number = ANY($1::text[]);`, + [wagonNumbers], + ) + : []; + const known = new Set(existing.map((r) => r.wagon_number)); + const missing = wagonNumbers.filter((w) => !known.has(w)); + + const problems = [ + ...unknownRuns.map((u) => `unknown run ${u}`), + ...doubleBooked.map((d) => `double-booked ${d}`), + ...missing.map((m) => `not in database ${m}`), + ]; + + const perRun = new Map(); + for (const row of seen.values()) { + if (known.has(row.wagonNumber)) { + perRun.set(row.exportRun, (perRun.get(row.exportRun) ?? 0) + 1); + } + } + + console.log(`\nFile: ${path}`); + console.log(`Rows read: ${rows.length + unknownRuns.length} | assignable: ${known.size}`); + console.table( + Object.keys(TRAIN_RUN_PAIRS).map((exportRun) => ({ + export_run: exportRun, + import_run: TRAIN_RUN_PAIRS[exportRun], + wagons: perRun.get(exportRun) ?? 0, + })), + ); + + if (problems.length) { + console.error(`\n${problems.length} problem(s) — nothing was written:`); + problems.forEach((p) => console.error(` ${p}`)); + console.error('\nFix these in the source sheet, then re-run.'); + process.exit(1); + } + + if (!apply) { + console.log('\nDry run — no changes written. Re-run with --apply to write.'); + return; + } + + await AppDataSource.transaction(async (manager) => { + // Full replacement: clear first so a wagon dropped from the sheet does not + // keep a run it no longer has. + await manager.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + + for (const exportRun of new Set([...seen.values()].map((r) => r.exportRun))) { + const wagons = [...seen.values()] + .filter((r) => r.exportRun === exportRun) + .map((r) => r.wagonNumber); + await manager.query( + `UPDATE freight.wagons + SET export_train_number = $1, + import_train_number = $2, + updated_at = now() + WHERE wagon_number = ANY($3::text[]);`, + [exportRun, TRAIN_RUN_PAIRS[exportRun], wagons], + ); + } + }); + + const [totals] = await AppDataSource.query(` + SELECT COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run + FROM freight.wagons WHERE deleted_at IS NULL; + `); + console.log(`\nApplied. ${totals.on_a_run} wagons now on a run.`); + } finally { + await AppDataSource.destroy(); + } +} + +updateWagonRuns().catch((error) => { + console.error('Failed to update wagon runs:', error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 989e18bf1..2e234bcf0 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -11,6 +11,7 @@ import { } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -223,7 +224,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -276,7 +276,7 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { } const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 6fba64010..2c4e65ae1 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -99,11 +99,10 @@ Compensation shall be based on the market value of the cargo, in accordance with a( "pricing", "Contract Price and Payment Terms", - `Rail transport to Galaan Multipurpose Port: USD 59.4 per metric ton. -Djibouti handling (first-mile, port handling and loading, and documentation): USD 18 (eighteen) per metric ton for cargo from the Free Zone; USD 20 (twenty) per metric ton for cargo from the Old Port or DMP. -Lashing materials shall be charged at USD 150 (one hundred fifty) per wagon and wood at USD 50 (fifty) per wagon when provided by the Service Provider; the provision continues until the cargo reaches and is fully unloaded at the designated destination station. + `The applicable railway freight, Djibouti handling, and any additional service and surcharge rates for this contract are set out in the Rate Schedule immediately below, expressed as unit prices per origin → destination lane and per service. Each wagon shall be loaded up to a maximum of seventy (70) metric tons; for billing purposes one full wagon shall be deemed equivalent to this volume. -The price for last-mile delivery shall be determined once the cargo departs from the loading point and shall be communicated to the Client by official email upon the Client's request. +Where lashing materials and wood are provided by the Service Provider, they shall be charged at the applicable rate set out in the Rate Schedule; the provision continues until the cargo reaches and is fully unloaded at the designated destination station. +The price for last-mile delivery, where not listed in the Rate Schedule, shall be determined once the cargo departs from the loading point and shall be communicated to the Client by official email upon the Client's request. Payments shall be made 100% in advance in USD.`, ), a( @@ -228,15 +227,12 @@ A party wishing to claim protection in respect of a force majeure event shall, a a( "pricing", "Contract Price and Terms of Payment", - `The price of bulk cargo transportation from the loading station to Nagad shall be USD 696 (six hundred ninety-six) per wagon. + `The price of bulk cargo transportation from the loading station to Nagad, together with any applicable demurrage and surcharge rates, is set out in the Rate Schedule immediately below, expressed as unit prices per origin → destination lane and per wagon. Payment for transport services shall be made in Birr based on the selling price of USD to Birr on the date of payment set by the Commercial Bank of Ethiopia. If there is an increment or decrement of the USD exchange rate to Birr between the date of payment and the date the wagon/train number is provided to the Client, either the Client shall make the additional payment to the Service Provider or the Service Provider shall refund the difference from the initial payment to the Client. The cost of loading at the loading station and unloading at Nagad shall be covered by the Client and is not part of this contract agreement. The Client shall pay 100% of the contract price in advance. -The Client shall pay a demurrage fee for occupied wagons as follows: -- Wagons occupied between 1 and 3 days: USD 193 per wagon per day. -- Wagons occupied between 4 and 7 days: USD 290 per wagon per day. -- Wagons occupied 8 days and above: USD 590 per wagon per day. +The Client shall pay a demurrage fee for occupied wagons at the rate set out in the Rate Schedule for the applicable occupancy band. Demurrage payment shall be made in Birr based on the selling price of USD to Birr set by the Commercial Bank of Ethiopia on the date of the demurrage occurrence.`, ), a( @@ -361,7 +357,7 @@ The affected party shall notify the other party in writing within a reasonable p a( "pricing", "Contract Price", - `The price for transporting cargo from the origin freight yard to the destination freight yard shall be USD 400 (four hundred) per wagon. + `The price for transporting cargo from the origin freight yard to the destination freight yard is set out in the Rate Schedule immediately below, expressed as a unit price per origin → destination lane and per wagon. Each wagon shall be loaded with a maximum of 70 (seventy) metric tons. Payment for transport services may be made in Ethiopian Birr, based on the Commercial Bank of Ethiopia's official selling exchange rate of USD to Birr on the date of payment. If the exchange rate changes between the payment and the wagon assignment date, payment adjustments will be made accordingly. @@ -504,9 +500,7 @@ Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.` a( "pricing", "Contract Price and Terms of Payment", - `From SGTD to Dire Dawa dry port, the rate is USD 919 per one 40ft or USD 942 per two 20ft containers with empty return; USD 762 per one 40ft or USD 780 per two 20ft containers without empty return. -From SGTD to Modjo, the rate is USD 1,781 per one 40ft or USD 1,808 per two 20ft containers with empty return, and USD 1,507 per one 40ft or two 20ft containers without empty return. -From SGTD to Galaan Multipurpose Port, the rate is USD 1,916 per one 40ft or USD 1,944 per two 20ft containers with empty return, and USD 1,676 per one 40ft or USD 1,690 per two 20ft containers without empty return. + `The railway transportation rate for each corridor (per one 40ft container or per two 20ft containers, with or without empty return where applicable) is set out in the Rate Schedule immediately below, expressed as a unit price per origin → destination lane and per container. If cargo exceeds 40 tons per two 20ft containers of gross weight, additional charges apply proportionally. Gross weight shall be the total sum of cargo, packing, and container tare weight. Payment for any additional tonnage shall be made in advance before the container is loaded onto the wagon. @@ -645,12 +639,9 @@ Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.` a( "pricing", "Pricing and Payment Terms", - `Railway transportation charges from GMP to SGTD: USD 819 (eight hundred nineteen) per 40ft container; USD 834 (eight hundred thirty-four) per two (2) 20ft containers. -Railway transportation charges from Modjo to SGTD: USD 725 (seven hundred twenty-five) per 40ft container; USD 725 (seven hundred twenty-five) per two (2) 20ft containers. -Where the total cargo weight exceeds fifty (50) metric tons per two (2) 20ft containers, an additional charge of USD 10 (ten) shall apply for each excess metric ton. -Freight forwarding and customs clearance charges from GMP to SGTD: USD 540 (five hundred forty) per 40ft container; USD 349 (three hundred forty-nine) per 20ft container. -Freight forwarding and customs clearance charges from Modjo to SGTD: USD 569 (five hundred sixty-nine) per 40ft container; USD 389 (three hundred eighty-nine) per 20ft container. -For consolidated containers containing more than one (1) shipping document, the first document shall be included under the agreed contract rate; any additional document within the same container shall be subject to an extra charge of USD 50 per document. + `The railway transportation charges and the freight forwarding and customs clearance charges for each corridor (per 40ft container and per two 20ft containers) are set out in the Rate Schedule immediately below, expressed as unit prices per origin → destination lane and per container. +Where the total cargo weight exceeds fifty (50) metric tons per two (2) 20ft containers, an additional charge shall apply for each excess metric ton at the overweight rate set out in the Rate Schedule. +For consolidated containers containing more than one (1) shipping document, the first document shall be included under the agreed contract rate; any additional document within the same container shall be subject to the extra-document charge set out in the Rate Schedule. Payment must be supported by an official receipt before cargo departs from Galaan Multipurpose Port/Modjo. If the Client uses PIL Shipping Line, any local charge incurred will be covered by the Client as per the invoice issued by the shipping line. If storage or demurrage occurs due to Client-related issues (delay in document submission, payment delay, or any other Client-related reason), the Client shall pay the corresponding charges; charges apply per day after the free storage period, based on the invoice and SGTD tariff. @@ -797,7 +788,7 @@ Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.` a( "pricing", "Contract Price and Terms of Payment", - `The applicable rate per 40ft container or per two (2) 20ft containers for the agreed route shall be as per the prevailing EDR domestic container tariff, as set out in the commercial schedule of this contract. + `The applicable rate per 40ft container or per two (2) 20ft containers for the agreed route is set out in the Rate Schedule immediately below, expressed as a unit price per origin → destination lane and per container. If cargo exceeds 40 tons per two 20ft containers of gross weight, additional charges apply proportionally. Gross weight shall be the total sum of cargo, packing, and container tare weight. Payment for any additional tonnage shall be made in advance before the container is loaded onto the wagon. diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 3720c0e2a..20d2a3f8b 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -14,6 +14,7 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { wagonsPerUnitForSize } from "../modules/rule-engine/container-type.util"; import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; import { Container } from "../modules/container-management/entities/container.entity"; import { Route } from "../modules/routes/entities/route.entity"; @@ -300,7 +301,6 @@ export class DemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -400,7 +400,7 @@ export class DemoBookingsSeeder { .getRepository(BookingContainer) .delete({ bookingId: booking.id }); const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); await manager.getRepository(BookingContainer).insert({ id: randomUUID(), diff --git a/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts b/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts index e8349c806..95a0221c2 100644 --- a/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts @@ -34,6 +34,16 @@ const EDR_TRUCK_FLEET: ReadonlyArray = [ /** Fleet sequence numbers (1-based) that are 20ft-only. 6 of 62 — fill once confirmed. */ const TWENTY_FT_SEQS = new Set(); +/** + * Haulage rate for the EDR truck fleet, ETB per km. + * + * First/last-mile billing is `distance × pricePerKm` (see FirstMileService / + * LastMileService `setDistances`), and a truck with no rate is refused at + * assignment. Applies to the whole fleet — override per truck in the fleet UI + * where a specific truck differs. + */ +const TRUCK_PRICE_PER_KM_ETB = 20000; + @Injectable() export class EdrTruckFleetSeeder { private readonly logger = new Logger(EdrTruckFleetSeeder.name); @@ -50,7 +60,7 @@ export class EdrTruckFleetSeeder { const columns = [ 'code', 'plate_number', 'registration_number', 'power_plate_no', 'trailer_plate_no', 'vehicle_type', 'manufacturer', 'model', 'year', 'fuel_type', 'capacity', - 'status', 'ownership', 'currency', 'description', + 'status', 'ownership', 'currency', 'price_per_km', 'description', ]; const rows: unknown[][] = EDR_TRUCK_FLEET.map(([power, trailer], i) => { @@ -71,6 +81,7 @@ export class EdrTruckFleetSeeder { 'ACTIVE', 'EDR', 'ETB', + TRUCK_PRICE_PER_KM_ETB, `EDR-owned container truck configured for ${ft} containers.`, ]; }); 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 6e4ec81b1..d3b5bbb00 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -99,13 +99,28 @@ const RULE_ENGINE_PERMISSION_IDS: Record> = { + rates: 'b2000001-0001-4000-8000-000000000017', +}; + +export type RuleEngineApprovableSlug = 'rates'; + export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( (slug) => { const resource = slugToResourceKey(slug); const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug]; return [ perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ...(approveId + ? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)] + : []), ]; }, ); @@ -389,6 +404,8 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + approve: (slug: RuleEngineApprovableSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, }, allocation: { manage: 'edr_freight_app:allocation:manage', diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index e1c46d168..f60d7bb75 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -7,6 +7,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -145,7 +146,6 @@ export class PaidImportExportMileDemoSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -199,7 +199,7 @@ export class PaidImportExportMileDemoSeeder { const isImport = demoBooking.tradeDirection === 'IMPORT'; const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 872e909bb..072c559c6 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -115,7 +115,6 @@ export class PricingDataSeeder { code: "20FT", label: "20FT Standard", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: false, isOpenTop: false, isActive: true, @@ -125,7 +124,6 @@ export class PricingDataSeeder { code: "40FT", label: "40FT Standard", sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -135,7 +133,6 @@ export class PricingDataSeeder { code: "20FT_REEFER", label: "20FT Reefer", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: true, isOpenTop: false, isActive: true, @@ -145,7 +142,6 @@ export class PricingDataSeeder { code: "40FT_REEFER", label: "40FT Reefer", sizeFt: 40, - wagonsPerUnit: 1, isReefer: true, isOpenTop: false, isActive: true, @@ -420,6 +416,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, + // Cargo-securing / lashing — flat fee, billed once per booking whose + // cargo type has hasLashing = true. + { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" }, // ── First/last-mile road haulage (per km) — drives the mile invoices ── { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts new file mode 100644 index 000000000..47754bd93 --- /dev/null +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** + * EDR's load/unload facilities, mapped onto the yards that already represent them. + * + * The codes are historical and don't read like the facility names, so map by code + * and never by label: Indode is `KALITY` ("Gelan Multi Purpose Port (Indode)") and + * Sebeta is `LEGACY_DEST` ("Sebeta"). Creating fresh INDODE/SEBETA yards would + * split data that existing routes and bookings already point at. + * + * Only Indode stores cargo, so it is the only facility with a warehouse — the rest + * move cargo on and off the train, which is why they accrue no storage/demurrage. + * + * Negad is deliberately absent: there are two candidates (`NAGAD` "DCT/SGDT" in + * Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet + * settled which is the intercity facility. + */ +const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [ + { code: 'KALITY', facility: 'Indode', hasWarehouse: true }, + { code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false }, + { code: 'MOJO', facility: 'Modjo', hasWarehouse: false }, + { code: 'ADAMA', facility: 'Adama', hasWarehouse: false }, + { code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false }, +]; + +@Injectable() +export class YardFacilitiesSeeder { + private readonly logger = new Logger(YardFacilitiesSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + /** + * Idempotent: flags existing yards and upserts their facility record. Creates no + * yards — a missing code is logged and skipped rather than invented. + */ + async run(): Promise { + for (const { code, facility, hasWarehouse } of FACILITY_YARDS) { + const [yard]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`, + [code], + ); + if (!yard) { + this.logger.warn(`Yard ${code} (${facility}) not found — skipping facility flag`); + continue; + } + + await this.dataSource.query( + `UPDATE freight.yards + SET has_facility = true, updated_at = NOW() + WHERE id = $1 AND has_facility = false`, + [yard.id], + ); + + await this.dataSource.query( + `INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes) + VALUES ($1, $2, $3) + ON CONFLICT (yard_id) WHERE deleted_at IS NULL + DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`, + [yard.id, hasWarehouse, `${facility} load/unload facility`], + ); + } + this.logger.log( + `Yard facilities seeded: ${FACILITY_YARDS.map((f) => f.facility).join(', ')}`, + ); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0da9fe3f0..ce90554a6 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -24,6 +24,8 @@ import { Truck, Users, Wallet, + LifeBuoy, + TrainFront, } from "lucide-react"; import { useEffect } from "react"; import { @@ -121,6 +123,7 @@ import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPag import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import IntercityPage from "./pages/warehouses/IntercityPage"; import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; @@ -131,6 +134,7 @@ import { HealthCheck } from "./features/health/HealthCheck"; import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; import SetPassword from "./shared/components/SetPassword"; +import SupportInboxPage from "./pages/support/SupportInboxPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -142,14 +146,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "Staff", - href: "/user-management", - icon: , - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , + label: "Customers", + href: "/dashboard/customers", + icon: , }, { label: "Contracts", @@ -157,6 +156,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.view, }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). { @@ -165,11 +169,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.opsClearanceReview, }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, { label: "Payments", href: "/dashboard/payments", @@ -182,187 +181,194 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Support", + href: "/dashboard/support", + icon: , + }, ...demoItems, ], }, { - title: "Operations", + // title: "Port & Terminal", items: [ { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, ], }, { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", + label: "Fleet Management", icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Port & Terminal", - items: [ { label: "Imports", href: "/dashboard/import-warehouse", @@ -437,35 +443,49 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, - ], - }, - { - title: "Warehouse Management", - items: [ { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , + label: "Intercity", + href: "/dashboard/intercity", + icon: , + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + }, + ], }, { - label: "Warehouses", - href: "/dashboard/warehouses", + label: "Warehouse Management", icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], }, ], }, { - title: "Administration", + title: "Freight configuration", + mutedTitle: true, items: [ { label: "File settings", @@ -485,12 +505,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ { label: "Configuration", href: "/dashboard/configuration", @@ -513,6 +527,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: getCategorySidebarChildren("rules"), }, + + { + label: "Staff", + href: "/user-management", + icon: , + }, ], }, ]; @@ -599,7 +619,10 @@ const findActiveSidebarLabel = ( ): string | undefined => { const path = pathname.toLowerCase(); const candidates = flattenSidebarItems(sections) - .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .map(({ href, label }) => ({ + label, + href: href.split("?")[0].toLowerCase(), + })) .sort((a, b) => b.href.length - a.href.length); return candidates.find( @@ -674,10 +697,7 @@ const App = () => { } /> {/* } /> */} - } - /> + } /> } /> } /> @@ -713,6 +733,7 @@ const App = () => { } /> + } /> } /> } /> { } /> + + + + } + /> {/* GL (Path B) contract clearance review hub */} { } /> } /> } /> + } /> } /> } /> ; url?: string; + suppressErrorModal?: boolean; }; const api = axios.create({ @@ -100,8 +113,13 @@ api.interceptors.response.use( 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). - if (error.response && error.response.status !== 401) { + // (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); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx new file mode 100644 index 000000000..94061eee2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx @@ -0,0 +1,43 @@ +import { Link } from "react-router-dom"; + +/** + * The parent contract's reference, linking to that contract's detail page. + * + * Backoffice-local on purpose: the contract detail route differs per app + * (`/dashboard/contract-requests/:id` here vs `/contracts/:id` in the portal), + * so the portal keeps its own copy in `pages/bookings/booking-display.tsx` + * rather than the two sharing a component that would have to take the route as + * a prop at every call site. + * + * Renders nothing when either field is missing: `contractId` is nullable on the + * booking, and only the bookings list/detail endpoints join `contractReference` + * — other endpoints (warehouse, fleet, payments) return booking rows without it, + * and a link with no id would be a dead one. + * + * `stopPropagation` matters: booking rows are click-to-navigate, so without it a + * click here would race the row handler and land on the booking instead. + */ +export function ContractReferenceLink({ + contractId, + contractReference, + className, +}: { + contractId?: string | null; + contractReference?: string | null; + className?: string; +}) { + if (!contractId || !contractReference) return null; + + return ( + e.stopPropagation()} + className={ + className ?? + "block truncate font-mono text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground" + } + > + {contractReference} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index aa14fe8cd..3ee5d72a0 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -24,6 +24,7 @@ import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { NextStepBanner } from "@/components/bookings/NextStepBanner"; @@ -94,9 +95,15 @@ export function BookingRequestHero({ Booking reference - - {booking.reference} - + + + {booking.reference} + + + {booking.schedulingStatus ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 1b2c46bac..3dcf1ea9e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -32,7 +32,10 @@ import { isViewable } from "@edr/ui-common"; import { SectionCard } from "./SectionCard"; import { bookingsService } from "@/services/bookings.service"; -import { fileViewUrl } from "@/constants/apiConfig"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import { useFileViewer } from "@/hooks/useFileViewer"; export interface ClearanceReviewSectionProps { @@ -272,17 +275,17 @@ export function ClearanceReviewSection({ <> {isViewable({ name: doc.file.name, - url: fileViewUrl(doc.file.id), + url: "", }) && ( - view({ - name: doc.file!.name, - url: fileViewUrl(doc.file!.id), - }) + void fetchViewableFile( + doc.file!.id, + doc.file!.name, + ).then(view) } c="edr-green" style={{ @@ -298,10 +301,21 @@ export function ClearanceReviewSection({ )} + void downloadBookingFile( + doc.file!.id, + doc.file!.name, + ) + } c="edr-green" - style={{ display: "flex" }} + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} > @@ -544,7 +558,7 @@ function DocReviewCard({ {hasFile && isViewable({ name: doc.file!.name, - url: fileViewUrl(doc.file!.id), + url: "", }) && ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx index f2383b649..c85dc0b07 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -34,7 +34,10 @@ import { isViewable } from "@edr/ui-common"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { contractsService } from "@/services/contracts.service"; -import { fileViewUrl } from "@/constants/apiConfig"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import { useContractClearanceMutations } from "@/hooks/contracts/useContracts"; import { useFileViewer } from "@/hooks/useFileViewer"; @@ -293,17 +296,17 @@ export function ContractClearanceReviewSection({ <> {isViewable({ name: doc.file.name, - url: fileViewUrl(doc.file.id), + url: "", }) && ( - view({ - name: doc.file!.name, - url: fileViewUrl(doc.file!.id), - }) + void fetchViewableFile( + doc.file!.id, + doc.file!.name, + ).then(view) } c="edr-green" style={{ @@ -319,10 +322,21 @@ export function ContractClearanceReviewSection({ )} + void downloadBookingFile( + doc.file!.id, + doc.file!.name, + ) + } c="edr-green" - style={{ display: "flex" }} + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} > @@ -611,7 +625,7 @@ function DocReviewCard({ {hasFile && isViewable({ name: doc.file!.name, - url: fileViewUrl(doc.file!.id), + url: "", }) && ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts index bbd3acf90..c00091bfc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts @@ -2,7 +2,7 @@ import * as XLSX from "xlsx"; // Excel import for container shipments: one spreadsheet row per physical // container, mirroring the manual per-unit fields (number, seal, VGM) plus the -// hazardous/reefer flags when the contract allows them. The parser is +// hazardous/reefer/return flags when the contract allows them. The parser is // all-or-nothing — any bad row rejects the file with row-numbered errors so a // partial import can never silently drop containers. @@ -14,6 +14,8 @@ export interface ContainerExcelOptions { allowedSizes: string[]; includeHazardous: boolean; includeReefer: boolean; + /** Contract was created WITH_RETURN — offer the empty-return column. */ + includeReturn?: boolean; } export interface ImportedContainerRow { @@ -23,6 +25,7 @@ export interface ImportedContainerRow { vgmTons: string; hazardous: boolean; reefer: boolean; + withReturn: boolean; } export interface ContainerExcelResult { @@ -36,7 +39,8 @@ type ColumnKey = | "sealNumber" | "vgmTons" | "hazardous" - | "reefer"; + | "reefer" + | "withReturn"; /** Match a header cell to a known column, tolerant of casing/spacing/units. */ function headerKey(raw: string): ColumnKey | null { @@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null { if (h.includes("vgm") || h.includes("weight")) return "vgmTons"; if (h.includes("hazard")) return "hazardous"; if (h.includes("reefer") || h.includes("refrigerat")) return "reefer"; + if (h.includes("return")) return "withReturn"; // After the more specific matches: "Container Number", "Container No", … if (h.includes("container") || h.includes("number")) return "containerNumber"; return null; @@ -159,6 +164,7 @@ export async function parseContainerExcel( vgmTons: vgmRaw, hazardous: opts.includeHazardous && parseFlag(cell("hazardous")), reefer: opts.includeReefer && parseFlag(cell("reefer")), + withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")), }); } @@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) { const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"]; if (opts.includeHazardous) headers.push("Hazardous (YES/NO)"); if (opts.includeReefer) headers.push("Reefer (YES/NO)"); + if (opts.includeReturn) headers.push("With Return (YES/NO)"); const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"]; const sampleRows = sizes.map((size, i) => { @@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) { ]; if (opts.includeHazardous) row.push("NO"); if (opts.includeReefer) row.push("NO"); + if (opts.includeReturn) row.push("NO"); return row; }); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index a734c4f06..8c3e6f071 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -17,12 +17,14 @@ export interface GlShipmentTotal { /** A normalized view of the form quantities, freight-shape agnostic. */ export interface GlShipmentQuantities { isContainer: boolean; - /** Container lines: size + total qty + hazardous/reefer qty. */ + /** Container lines: size + total qty + hazardous/reefer/return qty. */ containers: Array<{ containerSize: string; quantity: number; hazardousQuantity: number; reeferQuantity: number; + /** Containers EDR takes back empty — only on WITH_RETURN contracts. */ + returnQuantity: number; }>; /** Bulk: tons (or item count) + hazardous/reefer qty. */ bulkQuantity: number; @@ -53,6 +55,7 @@ export function computeGlShipmentTotal( if (q.isContainer) { let hazardTotalQty = 0; let reeferTotalQty = 0; + let returnTotalQty = 0; for (const line of q.containers) { const qty = line.quantity; @@ -75,6 +78,7 @@ export function computeGlShipmentTotal( } hazardTotalQty += line.hazardousQuantity; reeferTotalQty += line.reeferQuantity; + returnTotalQty += line.returnQuantity; } if (contract.isHazardous && hazardTotalQty > 0) { @@ -101,6 +105,21 @@ export function computeGlShipmentTotal( }); } } + // Empty-container return is a container-only surcharge, priced per returning + // container rather than per line (contract-pricing.service emits the + // `with_return` rate only for WITH_RETURN contracts). + if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) { + const wr = rateFor((i) => i.conditionalOn === "with_return"); + if (wr) { + lines.push({ + label: wr.label, + unitPrice: wr.unitPrice, + unit: wr.unit, + quantity: returnTotalQty, + amount: wr.unitPrice * returnTotalQty, + }); + } + } } else { const qty = q.bulkQuantity; const rate = diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 371a83ccf..c7893659b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -23,7 +23,7 @@ import { import { useState } from "react"; import { useFileViewer } from "@edr/ui-common"; -import { fileViewUrl } from "@/constants/apiConfig"; +import { fetchViewableFile } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, CompanyChangeRequest } from "@/types/customer"; import { formatDate, humanize } from "./format"; @@ -236,10 +236,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - view({ - name: c.fileName ?? humanize(c.code), - url: fileViewUrl(c.fileId), - }) + void fetchViewableFile( + c.fileId, + c.fileName ?? humanize(c.code), + ).then(view) } style={{ textDecoration: @@ -269,10 +269,9 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - view({ - name: `Document ${i + 1}`, - url: fileViewUrl(fileId), - }) + void fetchViewableFile(fileId, `Document ${i + 1}`).then( + view, + ) } > Document {i + 1} @@ -307,10 +306,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - view({ - name: c.fileName ?? "License document", - url: fileViewUrl(c.fileId), - }) + void fetchViewableFile( + c.fileId, + c.fileName ?? "License document", + ).then(view) } style={{ textDecoration: diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 76555a014..6cb6759e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -280,13 +280,20 @@ export function InvoiceStatusBadge({ * Transitions: pending → approve / reject-with-note | rejected → approve (override) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. * Rejecting captures a note the customer sees so they can fix and reapply. + * + * `locked` (customer hasn't submitted onboarding) withholds the review decision + * only — there's no application to judge yet, and the API rejects the call + * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so + * an already-active profile is still managable. */ export function ProfileApprovalActions({ profileId, status, + locked = false, }: { profileId: string; status: ProfileStatus; + locked?: boolean; }) { const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), @@ -346,6 +353,18 @@ export function ProfileApprovalActions({ ); + // Pending/rejected are the two states awaiting a reviewer's decision — the + // exact pair the API gates on until the customer submits. + if (locked && (status === "pending" || status === "rejected")) { + return ( + + + Awaiting submission + + + ); + } + if (status === "pending") { return ( <> diff --git a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx index fb780261a..07c9a39d1 100644 --- a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx @@ -104,6 +104,11 @@ export function ApiErrorModal() { onClose={close} centered radius="md" + // Mounted at the app root, so its portal is FIRST in — at the + // default z-index (200) any page modal opened later (create schedule, + // allocation wizard, …) paints over it and the error hides underneath. + // Hoist above every Mantine overlay and the react-hot-toast layer (9999). + zIndex={10000} title={ diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 7262e7927..8686f9cdf 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -263,6 +263,14 @@ const FleetFormDialog = ({ return map; }, [fields]); + // Emptying one of these means "unset the column", so it submits an explicit + // null instead of being dropped from the payload like other empty fields. + const clearableByName = useMemo(() => { + const map: Record = {}; + fields.forEach((f) => (map[f.name] = Boolean(f.clearable))); + return map; + }, [fields]); + const handleSubmit = () => { // Hard gate: a driver record cannot be saved until its identity is verified // with Fayda. Mirrored server-side in DriversService. @@ -271,11 +279,17 @@ const FleetFormDialog = ({ return; } if (!validate()) return; + // Derived fields are never edited, so form state for them can be stale (or + // seeded from the record) — recompute before building the payload. + const submitted: Record = { ...values }; + fields.forEach((field) => { + if (field.derivedValue) submitted[field.name] = field.derivedValue(values); + }); const payload = Object.fromEntries( - Object.entries(values) + Object.entries(submitted) .map(([key, value]) => { - if (value === FLEET_SELECT_NONE || value === "") - return [key, undefined]; + if (value === FLEET_SELECT_NONE || value === "" || value == null) + return [key, clearableByName[key] ? null : undefined]; if (fieldTypeByName[key] === "number") { const num = Number(value); return [key, Number.isNaN(num) ? undefined : num]; @@ -294,6 +308,23 @@ const FleetFormDialog = ({ // only by verification and never hand-edited. const isDisabled = Boolean(field.disabled || field.faydaLocked); + // Computed from other fields (e.g. the import run implied by the export + // run) — read-only, and recomputed here rather than read from form state. + if (field.derivedValue) { + return ( + + ); + } + if (field.type === "radio") { return ( walk(section.items, section.title)); + sections.forEach((section, i) => + walk(section.items, section?.title ?? "" + i++), + ); return acc; }, [sections, isHrefActive, branchActive]); @@ -126,6 +128,7 @@ const FreightSidebar = ({ opened={isOpen} classNames={navClassNames(active)} onClick={() => toggle(key)} + childrenOffset="sm" rightSection={ @@ -166,6 +169,7 @@ const FreightSidebar = ({ active={active} component={Link} classNames={navClassNames(active)} + onClick={onClose} to={item.href!} /> ); @@ -177,19 +181,21 @@ const FreightSidebar = ({ () => sections.map((section) => ( - - {section.title} - + {section.title && ( + + {section.title} + + )} {section.items.map((item, i) => - renderItem(item, itemKey(section.title, item, i)), + renderItem(item, itemKey(section.title ?? "" + i, item, i)), )} @@ -257,7 +263,7 @@ const FreightSidebar = ({ px="sm" pb="md" > - {renderedSections} + {renderedSections} ); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts index 051f28839..2129e05e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -12,7 +12,7 @@ export interface SidebarItem { export interface SidebarSection { /** Section label shown above a group of nav items (e.g. "Main menu"). */ - title: string; + title?: string; items: SidebarItem[]; /** When true, section title uses muted grey instead of dark text. */ mutedTitle?: boolean; diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index e6c01c26d..1c9d94825 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -176,6 +176,7 @@ const RuleEngineFormDialog = ({ ) { return false; } + if (field.showIf && !field.showIf(values)) return false; return true; }), [fields, values], @@ -192,6 +193,22 @@ const RuleEngineFormDialog = ({ if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { next.rateUnit = ""; } + // The legal yards depend on what the rate is for and which way it runs, so + // a leg picked under the old answer is no longer valid — clear it instead + // of submitting a pair the API will reject. + if ( + (name === "appliesTo" || name === "tradeDirection") && + "originYardId" in current + ) { + next.originYardId = ""; + next.destinationYardId = ""; + } + // Intercity asks for a container type or a bulk cargo type, never both — + // switching kind drops whichever the other kind had filled in. + if (name === "intercityKind") { + next.containerTypeId = ""; + next.cargoTypeId = ""; + } return next; }); }; diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx index c121acd36..fc33ae45a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx @@ -1,5 +1,6 @@ import { Freight } from "@edr/types"; import { + Badge, Button, Checkbox, Group, @@ -24,11 +25,19 @@ export default function AvailableWagonsPanel({ yardLabel, onAssign, assigning, + exportTrainNumber, + importTrainNumber, }: AvailableWagonsPanelProps) { const [search, setSearch] = useState(""); const [typeFilter, setTypeFilter] = useState("ALL"); + const [runOnly, setRunOnly] = useState(false); const [selected, setSelected] = useState([]); + // The train's own run, e.g. "8001-8002" — only offered when the train has one. + const runLabel = exportTrainNumber + ? `${exportTrainNumber}${importTrainNumber ? `-${importTrainNumber}` : ""}` + : null; + const wagonsQuery = useQuery( api.wagons.list.queryOptions({ input: { @@ -42,10 +51,23 @@ export default function AvailableWagonsPanel({ const q = search.trim().toLowerCase(); return (wagonsQuery.data ?? []).filter((wagon) => { if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false; + // Rostered to this train's run — match on the export run, which fixes the + // import run anyway. + if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false; if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false; return true; }); - }, [wagonsQuery.data, search, typeFilter]); + }, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]); + + const runMatchCount = useMemo( + () => + exportTrainNumber + ? (wagonsQuery.data ?? []).filter( + (w) => w.exportTrainNumber === exportTrainNumber, + ).length + : 0, + [wagonsQuery.data, exportTrainNumber], + ); const typeOptions = useMemo(() => { const byId = new Map(); @@ -112,6 +134,15 @@ export default function AvailableWagonsPanel({ /> + {runLabel ? ( + setRunOnly(e.currentTarget.checked)} + /> + ) : null} + {wagons.length ? ( - - {wagon.wagonNumber} - + + + {wagon.wagonNumber} + + {wagon.exportTrainNumber ? ( + + {wagon.exportTrainNumber} + {wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""} + + ) : null} + {wagon.wagonType ? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap` @@ -183,4 +229,8 @@ export interface AvailableWagonsPanelProps { yardLabel?: string | null; onAssign: (wagonIds: string[]) => void; assigning: boolean; + /** This train's odd EXPORT run — drives the "only this run" filter. */ + exportTrainNumber?: string | null; + /** This train's even IMPORT run — label only; the export run does the matching. */ + importTrainNumber?: string | null; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 7f60cabe9..2cedad1eb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -16,6 +16,7 @@ import { useEffect, useState } from "react"; import { api } from "@/services/api"; import type { TrainComposition } from "@/services/trainBuilder.service"; import { useToast } from "@/hooks/use-toast"; +import { IMPORT_TRAIN_OPTIONS, exportRunFor } from "@/constants/trainRuns"; const parseError = (error: unknown, fallback: string) => { if (isAxiosError(error)) { @@ -26,10 +27,6 @@ const parseError = (error: unknown, fallback: string) => { return fallback; }; -// Run-number parity carries the trade direction: odd = export, even = import. -const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim()); -const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim()); - /** * Step one of the Train Builder: pick the yard it is being assembled in and * couple at least two locomotives from that yard. The train code is assigned by @@ -59,6 +56,12 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain setLocomotiveIds([]); }, [yardId]); + // The export run is fixed by the import run, so it tracks it rather than + // being entered by hand (and clears back to empty when the import is cleared). + useEffect(() => { + setExportTrainNumber(exportRunFor(importTrainNumber)); + }, [importTrainNumber]); + useEffect(() => { if (!opened) { setExportTrainNumber(""); @@ -78,9 +81,11 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain }); return; } - if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) { + // Both numbers come from the fixed run pairs, so parity cannot be wrong — + // only "nothing picked" is reachable here. + if (!exportTrainNumber || !importTrainNumber) { toast({ - title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)", + title: "Pick an import train number (e.g. 8002) — the export run follows it", variant: "destructive", }); return; @@ -133,31 +138,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain maxLength={100} /> + {/* Fixed by the import run — derived, never typed. */} setExportTrainNumber(e.currentTarget.value)} - maxLength={20} - error={ - exportTrainNumber && !isOddNumber(exportTrainNumber) - ? "Must be numeric and odd" - : undefined - } + readOnly + variant="filled" /> - setImportTrainNumber(e.currentTarget.value)} - maxLength={20} - error={ - importTrainNumber && !isEvenNumber(importTrainNumber) - ? "Must be numeric and even" - : undefined - } + data={IMPORT_TRAIN_OPTIONS} + value={importTrainNumber || null} + onChange={(value) => setImportTrainNumber(value ?? "")} + searchable + clearable /> { + setSort(v ?? "createdAt:DESC"); + resetPage(); + }} + allowDeselect={false} + radius="lg" + style={{ minWidth: 170 }} + aria-label="Sort contracts" + /> + + {total} record{total !== 1 ? "s" : ""} + + + + { + setStatusFilter(v); + resetPage(); + }} + clearable + searchable + radius="lg" + style={{ minWidth: 220 }} + aria-label="Filter by status" + /> + { + setFreightTypeFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 160 }} + aria-label="Filter by freight type" + /> + { + setCurrencyFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 140 }} + aria-label="Filter by payment currency" + /> + { + setCreatedFrom(v ? new Date(v) : null); + resetPage(); + }} + maxDate={createdTo ?? undefined} + clearable + radius="lg" + style={{ minWidth: 140 }} + aria-label="Created from" + /> + { + setCreatedTo(v ? new Date(v) : null); + resetPage(); + }} + minDate={createdFrom ?? undefined} + clearable + radius="lg" + style={{ minWidth: 140 }} + aria-label="Created to" + /> + {activeFilterCount > 0 ? ( + + ) : null} + + {showEmpty ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index daff48939..f68d6562b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { Alert, Badge, @@ -18,7 +18,6 @@ import { AlertCircle, ClipboardList, FileText, - PackagePlus, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -61,9 +60,12 @@ type GlClearanceDetail = async function loadGlClearanceDetail(id: string): Promise { try { + // Probe the contract endpoints first; a booking-id row 404s here by design + // and falls back to the booking lookup below. Suppress the global error + // modal so that expected 404 never surfaces to the user. const [clearance, contract] = await Promise.all([ - contractsService.getClearance(id), - contractsService.getById(id), + contractsService.getClearance(id, { suppressErrorModal: true }), + contractsService.getById(id, { suppressErrorModal: true }), ]); return { kind: "contract", @@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise { /** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */ export default function GlClearanceDetailPage() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const { user } = useAuth(); const { view, viewer } = useFileViewer(); const [uploadKind, setUploadKind] = useState(null); @@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() { {hasRo ? "Replace RO" : "Upload RO"} )} - {canCompleteBooking && shipmentBooking ? ( - - ) : null} } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index bb0edcadb..a43a6ff55 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -106,6 +106,18 @@ function statusColor(status: string): string { case "ACTIVE_SHIPMENT_IN_PROGRESS": case "IN_TRANSIT": return "teal"; + // Payment phase — booking selected / awaiting the customer's payment. + case "SELECTED_FOR_BATCH": + case "PNR_GENERATED": + case "AWAITING_PAYMENT": + case "PAYMENT_VERIFICATION_IN_PROGRESS": + return "violet"; + // Terminal rows kept as clearance history. + case "EXPIRED": + return "orange"; + case "CANCELLED": + case "REJECTED": + return "red"; default: return "gray"; } diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index dc682f1c7..b03a54835 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Alert, Anchor, Badge, Box, @@ -22,6 +23,7 @@ import { Download, Eye, FileText, + Hourglass, IdCard, LayoutGrid, Package, @@ -52,7 +54,10 @@ import { humanize, } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; -import { fileViewUrl } from "@/constants/apiConfig"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import { api } from "@/services/api"; import type { CompanyProfile, @@ -60,6 +65,7 @@ import type { CustomerDocument, CustomerPayment, } from "@/types/customer"; +import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer"; import type { Invoice } from "@/types/invoice"; import { DataTable, @@ -166,6 +172,13 @@ export default function CustomerDetailPage() { ); const paidCurrency = payments[0]?.currency ?? "ETB"; + // The company row is created on the wizard's first click, so a draft reaches + // this page with a placeholder name/TIN. `stillOnboarding` drives the banner + // and badge; `canReview` gates the approve/reject buttons and mirrors the + // API's rule exactly, so no button is offered that the server would reject. + const stillOnboarding = company ? isOnboardingDraft(company) : false; + const canReview = company ? hasSubmittedOnboarding(company) : true; + const profileColumns: ColumnDef[] = useMemo( () => [ { @@ -202,13 +215,7 @@ export default function CustomerDetailPage() { variant="subtle" color="gray" aria-label={`View ${f.name}`} - onClick={() => - view({ - name: f.name, - url: fileViewUrl(f.id), - mimeType: f.mimeType, - }) - } + onClick={() => void fetchViewableFile(f.id, f.name).then(view)} > @@ -217,13 +224,7 @@ export default function CustomerDetailPage() { type="button" size="xs" lineClamp={1} - onClick={() => - view({ - name: f.name, - url: fileViewUrl(f.id), - mimeType: f.mimeType, - }) - } + onClick={() => void fetchViewableFile(f.id, f.name).then(view)} style={{ maxWidth: 170, textAlign: "left", @@ -273,11 +274,12 @@ export default function CustomerDetailPage() { ), }, ], - [view], + [view, canReview], ); const bookingColumns: ColumnDef[] = useMemo( @@ -401,18 +403,19 @@ export default function CustomerDetailPage() { aria-label="View" data-stop-row-click onClick={() => - view({ - name: row.original.name, - url: fileViewUrl(row.original.id), - mimeType: row.original.mimeType, - }) + void fetchViewableFile(row.original.id, row.original.name).then( + view, + ) } > + void downloadBookingFile(row.original.id, row.original.name) + } variant="subtle" color="gray" aria-label="Download" @@ -602,7 +605,13 @@ export default function CustomerDetailPage() { meta={ - + {stillOnboarding ? ( + + Onboarding in progress + + ) : ( + + )} } @@ -631,6 +640,21 @@ export default function CustomerDetailPage() { {/* OVERVIEW */} + {stillOnboarding && ( + } + title="This customer hasn't submitted their application yet" + > + They're still filling in the onboarding wizard, so the details + below are an unfinished draft — the company name and TIN are + placeholders until they reach those steps. Role profiles become + reviewable once the application is submitted. + + )} + p.status === "pending", - ).length, + // A draft's profiles are all `pending` by construction, which + // would read as a review backlog that doesn't exist yet. + label: stillOnboarding + ? "Awaiting submission" + : "Pending approval", + value: stillOnboarding + ? "—" + : company.companyProfiles.filter( + (p) => p.status === "pending", + ).length, icon: IdCard, color: "yellow", }, @@ -801,11 +831,7 @@ export default function CustomerDetailPage() { size="sm" lineClamp={1} onClick={() => - view({ - name: doc.name, - url: fileViewUrl(doc.id), - mimeType: doc.mimeType, - }) + void fetchViewableFile(doc.id, doc.name).then(view) } > {doc.name} @@ -831,18 +857,17 @@ export default function CustomerDetailPage() { color="gray" aria-label={`Preview ${doc.name}`} onClick={() => - view({ - name: doc.name, - url: fileViewUrl(doc.id), - mimeType: doc.mimeType, - }) + void fetchViewableFile(doc.id, doc.name).then(view) } > + void downloadBookingFile(doc.id, doc.name) + } variant="subtle" color="gray" aria-label={`Download ${doc.name}`} @@ -942,11 +967,7 @@ export default function CustomerDetailPage() { component="button" type="button" onClick={() => - view({ - name: f.name, - url: fileViewUrl(f.id), - mimeType: f.mimeType, - }) + void fetchViewableFile(f.id, f.name).then(view) } size="xs" style={{ diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index aff353055..89325ae79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -16,6 +16,7 @@ import { Building2, CheckCircle2, Clock, + Hourglass, Mail, Phone, RefreshCw, @@ -36,6 +37,7 @@ import { import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import type { Company, CompanyStatus } from "@/types/customer"; +import { isOnboardingDraft } from "@/types/customer"; import { DataTable, DataTableFooter, @@ -43,22 +45,39 @@ import { type ColumnDef, } from "@edr/ui-common"; +/** + * The list's segmented views. "Pending approval" means submitted-and-awaiting- + * review, so it excludes drafts — a company row exists from the onboarding + * wizard's first click and would otherwise pad the review queue. Those drafts + * get their own view instead of disappearing, so staff can still chase them. + */ +type CustomerView = "all" | "pending" | "onboarding" | "active"; + +const VIEW_FILTERS: Record< + CustomerView, + { status?: CompanyStatus; onboardingCompleted?: boolean } +> = { + all: {}, + pending: { status: "pending", onboardingCompleted: true }, + onboarding: { onboardingCompleted: false }, + active: { status: "active" }, +}; + export default function CustomersPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); - // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review). - const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>(""); + const [view, setView] = useState("all"); const filter = useMemo( () => ({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, - status: statusFilter || undefined, + ...VIEW_FILTERS[view], }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], + [pagination.pageIndex, pagination.pageSize, debouncedQuery, view], ); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -114,6 +133,17 @@ export default function CustomersPage() { id: "status", header: "Status", cell: ({ row }) => { + // A draft's profiles are all `pending` by construction, so the + // "N pending" review hint would be a lie until they submit. + if (isOnboardingDraft(row.original)) { + return ( + + + Onboarding + + + ); + } const pending = (row.original.companyProfiles ?? []).filter( (p) => p.status === "pending", ).length; @@ -206,6 +236,12 @@ export default function CustomersPage() { { label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" }, { label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" }, { label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" }, + { + label: "Onboarding", + value: stats?.onboarding ?? "—", + icon: Hourglass, + color: "gray", + }, { label: "Blacklisted", value: stats?.blacklisted ?? "—", @@ -243,14 +279,15 @@ export default function CustomersPage() { { - setStatusFilter(v === "all" ? "" : (v as CompanyStatus)); + setView(v as CustomerView); setPagination((prev) => ({ ...prev, pageIndex: 0 })); }} data={[ { label: "All", value: "all" }, { label: "Pending approval", value: "pending" }, + { label: "Onboarding", value: "onboarding" }, { label: "Active", value: "active" }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx index b9599d23d..939a5a931 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx @@ -39,6 +39,18 @@ interface DraftField extends CreateFileUploadFieldDto { let draftCounter = 0; const nextKey = () => `draft-${Date.now()}-${++draftCounter}`; +/** + * Extensions offered as checkboxes. Mirrors DOC_EXTENSIONS in the API's + * file-upload-settings seeder — the only formats the document flows accept. + * + * The API validates `allowedExtensions` as plain strings, so free text let + * typos ("pd") through silently and the field then rejected every real upload. + * A fixed list makes that unrepresentable. + */ +const FILE_EXTENSION_OPTIONS = ["pdf", "jpg", "jpeg", "png"] as const; + +const KNOWN_EXTENSIONS = new Set(FILE_EXTENSION_OPTIONS); + function makeEmptyDraft(idx: number): DraftField { return { key: nextKey(), @@ -93,13 +105,19 @@ export default function ManageFileUploadFieldsDialog({ }), ); - const updateExtensions = (i: number, raw: string) => { - const list = raw - .split(",") - .map((s) => s.trim().toLowerCase().replace(/^\./, "")) - .filter(Boolean); - update(i, { allowedExtensions: list }); - }; + const toggleExtension = (i: number, ext: string, checked: boolean) => + setFields((prev) => + prev.map((f, idx) => { + if (idx !== i) return f; + const current = f.allowedExtensions; + if (checked) { + return current.includes(ext) + ? f + : { ...f, allowedExtensions: [...current, ext] }; + } + return { ...f, allowedExtensions: current.filter((e) => e !== ext) }; + }), + ); const remove = (i: number) => setFields((prev) => prev.filter((_, idx) => idx !== i)); @@ -214,7 +232,9 @@ export default function ManageFileUploadFieldsDialog({ index={i} total={fields.length} onChange={(patch) => update(i, patch)} - onChangeExtensions={(raw) => updateExtensions(i, raw)} + onToggleExtension={(ext, checked) => + toggleExtension(i, ext, checked) + } onMove={(dir) => move(i, dir)} onRemove={() => remove(i)} /> @@ -258,7 +278,7 @@ function FieldEditor({ index, total, onChange, - onChangeExtensions, + onToggleExtension, onMove, onRemove, }: { @@ -266,13 +286,23 @@ function FieldEditor({ index: number; total: number; onChange: (patch: Partial) => void; - onChangeExtensions: (raw: string) => void; + onToggleExtension: (ext: string, checked: boolean) => void; onMove: (dir: -1 | 1) => void; onRemove: () => void; }) { const minFiles = getMinFiles(field); const effectiveMax = field.isMultiple ? field.maxFiles : 1; + // A field saved before this list existed can hold anything the old free-text + // box accepted (e.g. the typo "pd"). Show those alongside the standard ones so + // they stay visible and removable instead of silently vanishing on save. + const extensionChoices = [ + ...FILE_EXTENSION_OPTIONS, + ...field.allowedExtensions.filter( + (ext) => !KNOWN_EXTENSIONS.has(ext), + ), + ]; + return (
@@ -348,16 +378,33 @@ function FieldEditor({
- - onChangeExtensions(e.target.value)} - placeholder="pdf, docx, jpg" - className="font-mono" - /> -

- Comma-separated, no leading dot. -

+ +
+ {extensionChoices.map((ext) => ( + + ))} +
+ {field.allowedExtensions.length === 0 ? ( +

Pick at least one extension.

+ ) : ( +

+ Uploads are rejected unless the file matches one of these. +

+ )}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index c97d7c418..3633b1942 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -38,6 +38,10 @@ import { driversService } from "@/services/drivers.service"; import { vehiclesService } from "@/services/vehicles.service"; import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service"; import { fileUploadSettingsService } from "@/services/fileUploadSettings.service"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import { useToast } from "@/hooks/use-toast"; const fmtDate = (iso?: string | null) => { @@ -155,10 +159,6 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => { onError: () => toast({ title: "Delete failed", variant: "destructive" }), }); - // /files/:id is a public inline-serving route; open directly for preview/download. - const fileUrl = (fileId: string, download = false) => - `${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`; - return ( @@ -211,10 +211,22 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => { {fmtDate(doc.createdAt)} - window.open(fileUrl(doc.id), "_blank")}> + + void fetchViewableFile(doc.id, doc.name).then((f) => + window.open(f.url, "_blank"), + ) + } + > - window.open(fileUrl(doc.id, true), "_blank")}> + void downloadBookingFile(doc.id, doc.name)} + > { const status = listFilterValues.status; const currentYardId = listFilterValues.currentYardId; const availability = listFilterValues.availability; + const trainNumber = listFilterValues.trainNumber; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } @@ -68,6 +69,9 @@ const FleetResourcePage = () => { if (availability && availability !== "ALL") { (filters as { availability?: string }).availability = availability; } + if (trainNumber && trainNumber !== "ALL") { + (filters as { trainNumber?: string }).trainNumber = trainNumber; + } if (slug !== "locomotives" && search.trim()) { filters.search = search.trim(); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 82c1eaa58..caa0ee6c4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -1,5 +1,10 @@ import { Freight } from "@edr/types"; import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources"; +import { + IMPORT_TRAIN_OPTIONS, + TRAIN_RUN_FILTER_OPTIONS, + exportRunFor, +} from "@/constants/trainRuns"; import { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles"; import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers"; @@ -40,6 +45,20 @@ export interface FleetResourceColumn { export interface FleetFormFieldDef extends FormFieldDef { dynamicOptions?: FleetDynamicOptions; noneOption?: boolean; + /** + * Read-only field whose value is computed from the other fields rather than + * typed. Rendered non-editable and recomputed on every change, so the stored + * form value for this field is never trusted — the function is the source of + * truth at both render and submit. + */ + derivedValue?: (values: Record) => string; + /** + * Field can be emptied back to NULL. Empty values are normally dropped from + * the payload (so a PATCH leaves them untouched); a clearable field instead + * submits an explicit `null`, which is what actually unsets the column. Also + * renders a clear button on a `select`. + */ + clearable?: boolean; /** * Field is owned by the Fayda identity — populated only by verification and * never hand-edited. Rendered disabled in the form. @@ -53,7 +72,7 @@ export interface FleetFormFieldDef extends FormFieldDef { } export interface FleetListFilterDef { - key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId"; + key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId" | "trainNumber"; label: string; options?: Array<{ value: string; label: string }>; allLabel?: string; @@ -118,6 +137,7 @@ const WAGON_STATUS_OPTIONS = [ + export const FLEET_RESOURCES: FleetResourceConfig[] = [ { slug: "locomotives", @@ -258,19 +278,58 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ allLabel: "All yards", dynamicOptions: "yards", }, + { + key: "trainNumber", + label: "Train number", + allLabel: "All trains", + options: TRAIN_RUN_FILTER_OPTIONS, + }, ], cardTitleKey: "wagonNumber", cardSubtitleKey: "currentYard", - searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"], + searchKeys: [ + "wagonNumber", + "wagonTypeId", + "trainId", + "exportTrainNumber", + "importTrainNumber", + "status", + "currentYardId", + ], columns: [ // Tare weight and payload capacity are not wagon columns — they belong to the // wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages). { id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" }, { id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" }, + // Unset on a wagon that is not on a run — renders as a dimmed dash. + { id: "exportTrainNumber", header: "Export train no.", accessorKey: "exportTrainNumber", format: "code" }, + { id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" }, { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], formFields: [ + // Run numbers are optional — a wagon sits in the fleet unassigned to any + // run until an operator picks an import run. The export run is fixed by + // that choice, so it is derived rather than typed. + { + name: "exportTrainNumber", + label: "Export train number", + type: "text", + description: "Odd — Ethiopia → Djibouti runs", + placeholder: "e.g. 8001", + derivedValue: (values) => exportRunFor(values.importTrainNumber), + // Follows the import run to NULL when that is cleared. + clearable: true, + }, + { + name: "importTrainNumber", + label: "Import train number", + type: "select", + description: "Even — Djibouti → Ethiopia runs", + placeholder: "e.g. 8002", + options: IMPORT_TRAIN_OPTIONS, + clearable: true, + }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, @@ -278,6 +337,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "notes", label: "Notes", type: "textarea" }, ], emptyValues: { + exportTrainNumber: "", + importTrainNumber: "", wagonNumber: "", wagonTypeId: "", currentYardId: "", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index cf917f5ff..dbf23b6ee 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -52,6 +52,8 @@ interface CargoNode extends RuleEngineRecord { code?: string; parentGroupId?: string | null; requiresDirectorApproval?: boolean; + /** When true, bookings of this cargo type incur the flat LASHING surcharge. */ + hasLashing?: boolean; /** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */ unitOfMeasure?: string | null; /** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */ @@ -97,6 +99,9 @@ const FORM_FIELDS: FormFieldDef[] = [ ((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id), }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, + // When on, every booking of this cargo type is charged the flat LASHING + // surcharge (a rate with trigger = Lashing). + { name: "hasLashing", label: "Charge lashing fee", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx new file mode 100644 index 000000000..6156762a0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx @@ -0,0 +1,247 @@ +import { useState } from "react"; +import { + Badge, + Button, + Card, + Collapse, + Group, + Stack, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import type { UseMutationResult } from "@tanstack/react-query"; +import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react"; + +import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service"; + +/** Field labels for the diff — anything not listed falls back to the raw key. */ +const FIELD_LABELS: Record = { + rateValue: "Rate", + currency: "Currency", + rateUnit: "Unit", + appliesTo: "Applies to", + trigger: "Trigger", + tradeDirection: "Direction", + containerTypeId: "Container type", + cargoTypeId: "Cargo type", +}; + +const fmtDateTime = (iso: string) => + new Date(iso).toLocaleString("en-GB", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +const fmtValue = (field: string, value: unknown): string => { + if (value === null || value === undefined || value === "") return "—"; + if (field === "rateValue") { + const num = Number(value); + return Number.isNaN(num) ? String(value) : num.toLocaleString(); + } + return String(value).replace(/_/g, " "); +}; + +/** "Ocean freight · 40HC" — what rate this change targets. */ +const rateSummary = (r: RateChangeRequest): string => { + const rate = (r.rate ?? {}) as Record; + const parts = [ + rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null, + rate.appliesTo ? String(rate.appliesTo) : null, + rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null, + ].filter(Boolean); + return parts.join(" · ") || "Rate"; +}; + +/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */ +const headline = (r: RateChangeRequest): string | null => { + if (!("rateValue" in r.payload)) return null; + const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record | undefined)?.currency ?? ""); + const before = fmtValue("rateValue", r.previousValues.rateValue); + const after = fmtValue("rateValue", r.payload.rateValue); + return `${before} → ${after}${currency ? ` ${currency}` : ""}`; +}; + +type Decide = UseMutationResult< + RateChangeRequest, + unknown, + { id: string; decisionNote?: string } +>; + +interface RateApprovalsSectionProps { + requests: RateChangeRequest[]; + /** Whether this user holds the rates approve permission. */ + canDecide: boolean; + approve: Decide; + reject: Decide; +} + +/** + * Pending edits to LIVE rates. Each row is a before→after diff: the left value + * is what pricing charges right now and keeps charging until someone approves. + * Rendered above the rates table. + */ +const RateApprovalsSection = ({ + requests, + canDecide, + approve, + reject, +}: RateApprovalsSectionProps) => { + const [openId, setOpenId] = useState(null); + const [notes, setNotes] = useState>({}); + + if (requests.length === 0) return null; + + const decidingId = approve.variables?.id ?? reject.variables?.id ?? null; + + return ( + + + + Pending rate changes + + {requests.length} + + + + Each rate below still charges its current value. Nothing changes until approved. + + + + {requests.map((r) => { + const isOpen = openId === r.id; + const fields = Object.keys(r.payload); + const summaryLine = headline(r); + // Only the row being decided shows a spinner — the mutation's + // isPending is shared across every row. + const busy = decidingId === r.id; + + return ( + + + + + + update + + + {rateSummary(r)} + + + + {summaryLine ? ( + + + {fmtValue("rateValue", r.previousValues.rateValue)} + + + + {fmtValue("rateValue", r.payload.rateValue)} + + + {String( + r.payload.currency ?? + r.previousValues.currency ?? + (r.rate as Record | undefined)?.currency ?? + "", + )} + + + ) : null} + + + + Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} + {fields.length === 1 ? "field" : "fields"} changed + + + + + + {canDecide ? ( + + + + + ) : ( + + + Awaiting approver + + + )} + + + + + {fields.map((field) => ( + + + {FIELD_LABELS[field] ?? field} + + + {fmtValue(field, r.previousValues[field])} + + + + {fmtValue(field, r.payload[field])} + + + ))} + {canDecide ? ( + +

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

+ +
+ + + + +
+
+
+
+
+ +
+
+ +
+ + + + + +
+
+ + + + + + + +
+ +
+ + + + + + + + +
JourneyDuplicate Bookings
+
+
+ + + + + diff --git a/booking-extractor.html b/booking-extractor.html new file mode 100644 index 000000000..844c98b2c --- /dev/null +++ b/booking-extractor.html @@ -0,0 +1,256 @@ + + + + + + EDR Booking Extractor + + + + +

EDR Booking Extractor

+ +
+ +
+ + Drop bookings.json here or click to browse +
+

Accepts a JSON array of bookings or an object with a bookings key.

+
+ + + +
+
+ +
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
+
+
+ + + + + diff --git a/booking-proxy.mjs b/booking-proxy.mjs new file mode 100644 index 000000000..27f9248ee --- /dev/null +++ b/booking-proxy.mjs @@ -0,0 +1,53 @@ +import http from 'http'; +import https from 'https'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const PORT = 8080; +const __dir = path.dirname(fileURLToPath(import.meta.url)); + +const server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + // Serve any .html file in the same directory + if (req.url === '/' || req.url.endsWith('.html')) { + const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); + const filepath = path.join(__dir, filename); + if (fs.existsSync(filepath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + fs.createReadStream(filepath).pipe(res); + } else { + res.writeHead(404); res.end('Not found'); + } + return; + } + + // Proxy /proxy?url= + if (req.url.startsWith('/proxy?url=')) { + const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); + const parsed = new URL(target); + const mod = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: req.method, + headers: { ...req.headers, host: parsed.hostname }, + }; + const proxy = mod.request(options, (apiRes) => { + res.writeHead(apiRes.statusCode, apiRes.headers); + apiRes.pipe(res); + }); + proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); + req.pipe(proxy); + return; + } + + res.writeHead(404); res.end(); +}); + +server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts b/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts index 39a0f60f8..d9174627b 100644 --- a/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts +++ b/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts @@ -2,6 +2,9 @@ export interface TelebirrWebhookPayload { merch_order_id: string; payment_order_id: string; trade_status: string; + /** Telebirr's webhook (notify) sends the transaction id as camelCase `transId`; + * the queryOrder/status-query biz_content uses snake_case `trans_id`. Support both. */ + transId?: string; trans_id?: string; total_amount?: string; trans_currency?: string; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 9aa36374a..362d3c6ef 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -729,14 +729,22 @@ export interface CreateContainerUnitDto { containerNumber: string; sealNumber?: string; vgmTons: number; + /** Per-container handling opt-ins, entered alongside this container's VGM. */ isHazardous?: boolean; isReefer?: boolean; + /** This container ships back empty (equipment return). */ + isReturn?: boolean; } export interface CreateBookingContainerLineDto { /** "20ft" | "40ft" — must be in the contract's cargo scope. */ containerSize: string; quantity: number; + /** + * Line totals, derived from the per-unit switches above. The API recomputes + * them from `units` whenever any unit carries a flag, so they are only + * authoritative for callers that don't send per-unit flags. + */ hazardousQuantity?: number; reeferQuantity?: number; /** diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dc973d9c1..a98c09b47 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -9,6 +9,7 @@ export * from "./contracts"; export * from "./clearance-files.catalog"; export * from "./notifications"; export * from "./booking-window-ws"; +export * from "./support-chat"; export enum TradeDirection { IMPORT = "IMPORT", @@ -853,7 +854,6 @@ export interface BookingReferenceContainerType { name: string; code: string; is_reefer: boolean; - wagons_per_unit: number; } export interface BookingReferenceContainerSizeGroup { @@ -949,6 +949,23 @@ export interface AvailableDaysResponse { days: string[]; } +/** + * Advisory free-wagon count for a shipment day a customer is considering — a + * planning hint, never enforced (the batch engine and the export gate are the + * real authorities). `trainsForDay` is false when no departure carries the leg. + * + * - EXPORT: `fits` = a single open train that day can carry the WHOLE booking + * (export never splits); `freeWagons` = the largest single-train leftover. + * - IMPORT/DOMESTIC: `freeWagons` = TOTAL room across the day's trains for the + * booking's wagon type; `fits` = that total covers the booking. The batch may + * still split the booking or defer a remainder to a later window. + */ +export interface DayAvailabilityResponse { + fits: boolean; + freeWagons: number; + trainsForDay: boolean; +} + export interface BookableScheduleLocomotive { id: string; code: string; diff --git a/packages/types/src/freight/support-chat.ts b/packages/types/src/freight/support-chat.ts new file mode 100644 index 000000000..84b9a7262 --- /dev/null +++ b/packages/types/src/freight/support-chat.ts @@ -0,0 +1,99 @@ +/** + * Shared contracts for the freight in-app customer-support chat. + * + * There is exactly **one conversation per customer company** — any portal user + * of that company sees and continues the same thread, and every backoffice agent + * works the same shared inbox (no assignment). The thread has no lifecycle: it + * is created lazily by whichever side speaks first and stays open forever. + * Messages are text-only for the MVP. + * + * Because the thread is implied by the caller's company, the portal contract is + * addressed as a singleton (`/support/conversation`) and never passes an id. + * Agents address threads by id, since they see every company's. + * + * Mirrors the notification system's contract shape (`notifications.ts`): DTO + * interfaces with string dates for the wire, plus frozen WS event/namespace + * constants shared by the gateway (emitter) and both web apps (subscribers). + */ + +/** Who authored a message — the customer side or a backoffice agent. */ +export enum SupportAuthorRole { + CUSTOMER = "CUSTOMER", + AGENT = "AGENT", +} + +/** A single chat message on the wire. */ +export interface SupportMessageDto { + id: string; + conversationId: string; + authorUserId: string; + authorRole: SupportAuthorRole; + /** Display name of the author, resolved at send time (best-effort). */ + authorName?: string | null; + body: string; + createdAt: string; +} + +/** A company's conversation on the wire, with denormalized last-message fields. */ +export interface SupportConversationDto { + id: string; + companyId: string; + companyName?: string | null; + /** Null when an agent opened the thread — no customer created it. */ + createdByUserId?: string | null; + lastMessageAt?: string | null; + lastMessagePreview?: string | null; + lastMessageAuthorRole?: SupportAuthorRole | null; + /** + * Unread count *for the caller's side* (messages authored by the other role + * after the caller's read cursor). Populated on list/detail responses only — + * WS payloads carry an unauthoritative 0, so clients must refetch, not trust it. + */ + unreadCount: number; + createdAt: string; + updatedAt: string; +} + +/** Post a message. The portal omits the id; the thread is implied by the company. */ +export interface SendSupportMessageDto { + body: string; +} + +/** Agent opens a thread with a company that has none yet. */ +export interface StartSupportConversationDto { + companyId: string; +} + +/** + * Result of a portal send: the thread (created on the fly if this was the first + * message) alongside the persisted message. + */ +export interface SendSupportMessageResult { + conversation: SupportConversationDto; + message: SupportMessageDto; +} + +/** Paginated list envelope for the agent conversations list endpoint. */ +export interface SupportConversationListResult { + items: SupportConversationDto[]; + count: number; + /** Total unread conversations for the caller's side (badge source). */ + unreadCount: number; +} + +/** Socket.io event names pushed server → client on the `support-chat` namespace. */ +export const SUPPORT_CHAT_WS_EVENTS = { + /** A new message was added to a conversation the socket can see. */ + MESSAGE_NEW: "support:message-new", + /** A conversation's metadata changed (last message, or a thread was opened). */ + CONVERSATION_UPDATED: "support:conversation-updated", +} as const; + +/** Socket.io namespace the support-chat gateway listens on. */ +export const SUPPORT_CHAT_WS_NAMESPACE = "support-chat"; + +/** Payload for {@link SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW}. */ +export interface SupportMessageEvent { + conversation: SupportConversationDto; + message: SupportMessageDto; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 108b45d8b..6ace365ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,6 +953,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -22123,6 +22126,14 @@ snapshots: date-fns: 3.6.0 react: 19.2.6 + react-day-picker@9.14.0(react@18.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.4.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-day-picker@9.14.0(react@19.2.6): dependencies: '@date-fns/tz': 1.5.0 diff --git a/ticket-extractor.html b/ticket-extractor.html new file mode 100644 index 000000000..17be60576 --- /dev/null +++ b/ticket-extractor.html @@ -0,0 +1,239 @@ + + + + + + EDR Ticket Extractor + + + + +

EDR Ticket Extractor

+ +
+ +
+ + Drop tickets.json here or click to browse +
+

Accepts a JSON array of tickets or an object with a tickets key.

+
+ + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
+
+
+ + + + +