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 2fb9b3032..e2a3d9b0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -34,6 +34,7 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants'; import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto' import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; +/** + * The allocated train as the backoffice booking detail page needs it: which + * train, its window phase, and both the planned and actual clock. Attached by + * `findById` only when the booking is on a schedule. + */ +export interface TrainScheduleSummary { + id: string; + reference: string | null; + trainNumber: string | null; + status: string | null; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + actualDepartureAt: string | null; + actualArrivalAt: string | null; + windowPhase: string | null; + paymentPhaseEndsAt: string | null; +} + /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { items: Booking[]; @@ -1738,6 +1757,20 @@ export class BookingsService { (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = pending.has(b.id); } + this.attachPaymentDrainEnds(bookings); + } + + /** + * Derived, no query: end of the settlement drain tail after `paymentDeadline`. + * The portal hides "Pay now" between the deadline and this instant — a payment + * started just before the buzzer is still settling, so offering to pay again + * would invite a double payment. + */ + private attachPaymentDrainEnds(bookings: Booking[]): void { + for (const b of bookings) { + (b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(b.paymentDeadline); + } } async findAll( @@ -2105,8 +2138,33 @@ export class BookingsService { .findOne({ where: { id: booking.trainScheduleId } }); (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = schedule?.status ?? null; + // Backoffice staff view: the allocated train's identity and clock, so the + // detail page can state which train the booking rides and when it runs + // without a second round-trip to the schedules API. + ( + booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } + ).trainScheduleSummary = schedule + ? { + id: schedule.id, + reference: schedule.reference ?? null, + trainNumber: schedule.trainNumber ?? null, + status: schedule.status ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null, + actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null, + actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, + windowPhase: schedule.windowPhase ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + } + : null; } + // End of this booking's own pay window including the settlement drain tail — + // the deadline staff should quote, since a payment landing inside the drain + // still counts (see paymentDrainEndsAtIso). + (booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(booking.paymentDeadline); + // A generated-but-unsigned handover means the customer must approve delivery // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: // per delivering truck (generated on truck exit), signed one by one. diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 78f8db410..de7a01484 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -210,6 +210,7 @@ export class CompaniesController { const data = await this.companiesService.fetchETradeData( dto.tin, companyId, + dto.licenceNumber, ); return new ETradeResponseDto(data); } 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 0ed91c9e3..5dd55e704 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -3541,9 +3541,10 @@ export class CompaniesService { /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ private async resolveEtradeRegistration( tin: string, + licenceNumber?: string, ): Promise { const { businessInfo, companyInfo } = - await this.etradeService.resolveCompanyData(tin); + await this.etradeService.resolveCompanyData(tin, licenceNumber); if (!businessInfo) { throw new BadRequestException( "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", @@ -3555,8 +3556,15 @@ export class CompaniesService { ); } - async fetchETradeData(tin: string, excludeCompanyId?: string) { - const registrationData = await this.resolveEtradeRegistration(tin); + async fetchETradeData( + tin: string, + excludeCompanyId?: string, + licenceNumber?: string, + ) { + const registrationData = await this.resolveEtradeRegistration( + tin, + licenceNumber, + ); const tinTaken = await this.companiesRepo.existsByTin( tin, excludeCompanyId, @@ -3583,7 +3591,13 @@ export class CompaniesService { if (!touched) return; const tin = dto.tin ?? company.tin; - const registration = await this.resolveEtradeRegistration(tin); + // Re-verify the licence the customer actually chose. Without it a TIN + // holding several licences would silently snap back to eTrade's first one on + // every save, overwriting the selection with a different business's record. + const registration = await this.resolveEtradeRegistration( + tin, + dto.licenceNumber ?? company.licenceNumber ?? undefined, + ); const fresh: Partial< Record<(typeof ETRADE_SOURCED_FIELDS)[number], string> > = { diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index bfe1f9b72..40b1f308e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -1,4 +1,4 @@ -import { CompanyRegistrationData } from "@edr/types"; +import { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types"; export class ETradeResponseDto implements CompanyRegistrationData { companyName!: string; @@ -19,6 +19,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerEmail?: string; managerPhone!: string; tinTaken?: boolean; + businesses?: ETradeBusinessOption[]; constructor(data: CompanyRegistrationData) { this.companyName = data.companyName; @@ -39,5 +40,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; this.tinTaken = data.tinTaken; + this.businesses = data.businesses; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 466c03ed6..9ca533835 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty } from "class-validator"; +import { IsString, IsNotEmpty, IsOptional, MaxLength } from "class-validator"; import { IsTin } from "../../../common/validators/is-tin.validator"; export class FetchETradeDto { @@ -6,4 +6,14 @@ export class FetchETradeDto { @IsNotEmpty() @IsTin({ message: "TIN must be exactly 10 digits" }) tin!: string; + + /** + * Which of the TIN's business licences to resolve. Omitted on the first + * lookup — the response lists them all so the customer can pick, and the pick + * comes back here. + */ + @IsOptional() + @IsString() + @MaxLength(100) + licenceNumber?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 214c3858a..c89326fa1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -36,13 +36,13 @@ export class UpdateProfileDto { @IsTin({ message: "TIN must be exactly 10 digits" }) tin?: string; - // Ethiopian VAT registration numbers are 10 digits, the same shape as the - // TIN. Both portal forms enforce that; without it here the API happily stored - // whatever a stale client sent, and the two layers disagreed about what the - // column may hold. + // Ethiopian VAT registration numbers are 10 digits (the same shape as the + // TIN), but some are issued with an 11th. Both portal forms enforce the same + // range; without it here the API happily stored whatever a stale client sent, + // and the two layers disagreed about what the column may hold. @IsOptional() @IsString() - @Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" }) + @Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) vatNumber?: string; // `fanNumber` is deliberately absent: the FAN is the Fayda number of the diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts new file mode 100644 index 000000000..0719d7fe8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts @@ -0,0 +1,87 @@ +import { ETradeService } from './etrade.service'; +import type { ETradeBusinessInfo, ETradeCompanyInfo } from '@edr/types'; + +/** + * A TIN routinely holds several business licences (import of vehicles, export of + * coffee, freight forwarding…), all under the same trade name. The customer + * picks one, and every later lookup has to resolve that same licence — snapping + * back to eTrade's first would silently swap their company record. + */ +const companyInfo = (): ETradeCompanyInfo => + ({ + Tin: '0045014036', + BusinessName: 'PAVE LOGISTICS AND TRADING P L C', + Businesses: [ + { + LicenceNumber: 'MT/AA/14/670/11551235/2017', + TradesName: 'PAVE LOGISTICS AND TRADING P L C', + RenewedTo: '7/7/2026', + SubGroups: [ + { Code: 66331, Description: 'Export trade in minerals' }, + ], + }, + { + LicenceNumber: 'MT/AA/14/670/128936/2007', + TradesName: 'PAVE LOGISTICS AND TRADING P L C', + RenewedTo: '7/7/2026', + SubGroups: [{ Code: 72131, Description: '(72131)Freight Forwarders' }], + }, + ], + }) as unknown as ETradeCompanyInfo; + +describe('ETradeService business selection', () => { + const build = () => { + const service = new ETradeService({} as never); + const fetched: string[] = []; + jest + .spyOn(service, 'getCompanyInfoByTin') + .mockResolvedValue(companyInfo()); + jest + .spyOn(service, 'getBusinessByLicenseNo') + .mockImplementation(async (licenceNo: string) => { + fetched.push(licenceNo); + return { LicenceNumber: licenceNo } as ETradeBusinessInfo; + }); + return { service, fetched }; + }; + + it('defaults to the first licence when none is chosen', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036'); + expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); + }); + + it('resolves the chosen licence', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036', 'MT/AA/14/670/128936/2007'); + expect(fetched).toEqual(['MT/AA/14/670/128936/2007']); + }); + + it('falls back to the first licence when the chosen one is gone', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036', 'NO/SUCH/LICENCE'); + expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); + }); + + it('lists every licence for the picker, code prefixes stripped', () => { + const { service } = build(); + const data = service.extractRegistrationData( + { LicenceNumber: 'x' } as ETradeBusinessInfo, + companyInfo(), + ); + expect(data.businesses).toEqual([ + { + licenceNumber: 'MT/AA/14/670/11551235/2017', + tradeName: 'PAVE LOGISTICS AND TRADING P L C', + activity: 'Export trade in minerals', + renewedTo: '7/7/2026', + }, + { + licenceNumber: 'MT/AA/14/670/128936/2007', + tradeName: 'PAVE LOGISTICS AND TRADING P L C', + activity: 'Freight Forwarders', + renewedTo: '7/7/2026', + }, + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 3292ee2d3..45cc8e840 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -66,7 +66,15 @@ export class ETradeService { } } - async resolveCompanyData(tin: string): Promise<{ + /** + * @param licenceNumber which of the TIN's licences to resolve. Defaults to the + * first one — a TIN with several licences is only unambiguous once the + * customer has picked one (see {@link ETradeBusinessOption}). + */ + async resolveCompanyData( + tin: string, + licenceNumber?: string, + ): Promise<{ companyInfo: ETradeCompanyInfo; businessInfo: ETradeBusinessInfo | null; }> { @@ -76,10 +84,15 @@ export class ETradeService { return { companyInfo, businessInfo: null }; } - const latestBusiness = companyInfo.Businesses[0]; + // An unknown licence falls back to the first rather than 400-ing: eTrade can + // drop or renumber a licence between the customer picking it and the save + // that re-verifies it, and that must not lock them out of their own profile. + const selected = + companyInfo.Businesses.find((b) => b.LicenceNumber === licenceNumber) ?? + companyInfo.Businesses[0]; try { const businessInfo = await this.getBusinessByLicenseNo( - latestBusiness.LicenceNumber, + selected.LicenceNumber, tin, ); return { companyInfo, businessInfo }; @@ -124,6 +137,16 @@ export class ETradeService { regularPhone: businessInfo.AddressInfo?.RegularPhone || "", managerName: primaryManager?.ManagerNameEng || "", managerPhone: primaryManager?.RegularPhone || "", + businesses: (companyInfo?.Businesses ?? []).map((b) => ({ + licenceNumber: b.LicenceNumber, + tradeName: b.TradesName?.trim() || "", + activity: (b.SubGroups ?? []) + // Some descriptions repeat the code inline ("(65611)Import trade …"). + .map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim()) + .filter(Boolean) + .join(", "), + renewedTo: b.RenewedTo || "", + })), }; } } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index c6910c7a7..3d9fa4254 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -91,11 +91,15 @@ export class LastMileRequestsController { return this.contractService.sign(id, dto, user?.id ?? null); } + // Customer-facing like :id/contract/view — the portal's confirm page opens + // this straight from the departure notification link before the customer + // has done anything else, so it can't be staff-only. Service ownership- + // checks against the resolved company; staff may also open it. @Get(':id') - @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @MixedAudience(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.requestsService.findById(id); + findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.requestsService.findById(id, user?.id ?? null); } // No @BookingStaff — the customer (portal) fills this, not backoffice staff. diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index b89f06479..ebdc93746 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -199,11 +199,25 @@ export class LastMileRequestsService { }; } - async findById(id: string): Promise { + /** + * `userId` is set only when a portal customer calls this directly (the + * confirm-page deep link from the departure notification, before they've + * submitted or signed anything) — staff and every internal caller pass + * nothing and skip the check, same convention as submit()/sign(). + */ + async findById(id: string, userId?: string | null): Promise { const record = await this.requestsRepository.findById(id, { relations: { booking: { company: true } }, }); if (!record) throw new NotFoundException(`Last-mile request ${id} not found`); + + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId && record.booking?.companyId && companyId !== record.booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + return record; } diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index ee7b703d2..92ff7dc38 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -69,6 +69,7 @@ export class ClientActionDto { "LAUNCH_APP", "INVOKE_BRIDGE", "COLLECT_OTP", + "AWAIT_PUSH", "SHOW_BILL_REFERENCE", ], }) @@ -77,6 +78,7 @@ export class ClientActionDto { | "LAUNCH_APP" | "INVOKE_BRIDGE" | "COLLECT_OTP" + | "AWAIT_PUSH" | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) @@ -104,9 +106,16 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) providerOrderId?: string; - @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + @ApiPropertyOptional({ + description: "Set when type=COLLECT_OTP or type=AWAIT_PUSH", + }) message?: string; + @ApiPropertyOptional({ + description: "Set when type=AWAIT_PUSH (masked MSISDN the push prompt went to)", + }) + payerAccountMasked?: string; + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", }) 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 23024bfdf..cb568171a 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 @@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit { // (provider query errored / payment still in flight) means we could not // confirm "not paid" — never expire on unknown; the next settle tick // asks again. - if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { - const reconcile = await this.billing.reconcilePayable(booking.id); - if (reconcile.paid) { - this.logger.log( - `[BATCH] expire skipped for ${booking.reference} — gateway ` + - `reconcile found a settled payment; payment.succeeded will allocate it`, - ); - return; - } - if (reconcile.unverifiable) { - this.logger.warn( - `[BATCH] expire deferred for ${booking.reference} — settlement ` + - `unverifiable at the gateway; retrying next settle tick`, - ); - return; - } - } + // TODO: CBE has no reconcile endpoint yet — re-enable once available. + // if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + // const reconcile = await this.billing.reconcilePayable(booking.id); + // if (reconcile.paid) { + // this.logger.log( + // `[BATCH] expire skipped for ${booking.reference} — gateway ` + + // `reconcile found a settled payment; payment.succeeded will allocate it`, + // ); + // return; + // } + // if (reconcile.unverifiable) { + // this.logger.warn( + // `[BATCH] expire deferred for ${booking.reference} — settlement ` + + // `unverifiable at the gateway; retrying next settle tick`, + // ); + // return; + // } + // } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index fbb5301fb..9da3cecf0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -510,6 +510,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, }); await this.resequenceWagons(manager, train.id); await this.syncLiveScheduleAfterConsistChange( @@ -544,6 +546,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Maintenance, + importTrainNumber: null, + exportTrainNumber: null, }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history @@ -761,7 +765,13 @@ export class TrainBuilderService { .getRepository(Wagon) .update( { trainId: train.id }, - { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }, ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); @@ -1021,6 +1031,10 @@ export class TrainBuilderService { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, + // Wagon inherits the train's run numbers on coupling — no per-wagon + // number entry, they ride whatever numbers the train was built with. + importTrainNumber: train.importTrainNumber, + exportTrainNumber: train.exportTrainNumber, }); } return toAttach; diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index e747b69f2..85c879f70 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -14,7 +14,8 @@ import { * A count-only wagon-transfer request. The requester picks source yard, wagon * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that - * type currently in the source yard, and a reason is mandatory. + * type currently in the source yard (enforced in the service, which is the only + * layer that can count them), and a reason is mandatory. */ export class CreateTransferRequestDto { @IsUUID() diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index 205d86450..8f7457b37 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -199,7 +199,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { }); describe('createRequest', () => { - it('accepts a count larger than what the yard holds today', async () => { + it('accepts a count up to what the yard holds today', async () => { wagonRepo.count.mockResolvedValue(20); await service.createRequest( @@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', - quantity: 50, + quantity: 20, reason: 'Grain campaign', }, 'user-1', ); expect(requestRepo.save).toHaveBeenCalled(); - expect(stored.quantity).toBe(50); + expect(stored.quantity).toBe(20); + }); + + it('refuses a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/only 20 wagon\(s\).*available/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses when the yard has nothing of that type available', async () => { + wagonRepo.count.mockResolvedValue(0); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/no available wagons/i); + expect(requestRepo.save).not.toHaveBeenCalled(); }); it('still refuses a same-yard move', async () => { diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 8d4159726..6b63460ad 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -75,10 +75,11 @@ export class WagonTransferRequestsService { ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, and the - * count is NOT capped by what the source yard holds today: OCC fulfils in - * instalments, so asking for 50 while only 20 sit there is a normal, useful - * request. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. Count-only — no wagons are picked here, but the + * count IS capped by what the source yard can hand over right now: a request + * may not exceed the AVAILABLE, uncoupled wagons of that type in the source + * yard (the same number the yard desk shows). A reason is mandatory and is + * shown on the OCC queue. */ async createRequest( dto: CreateTransferRequestDto, @@ -89,6 +90,20 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } + const available = await this.countAvailable( + dto.fromYardId, + dto.wagonTypeId, + ); + if (available === 0) { + throw new BadRequestException( + 'No available wagons of this type in the source yard', + ); + } + if (dto.quantity > available) { + throw new BadRequestException( + `Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`, + ); + } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx new file mode 100644 index 000000000..9b5cf91f2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -0,0 +1,258 @@ +import { useEffect, useState } from "react"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { CalendarClock } from "lucide-react"; + +import type { BookingDetail } from "@/types/booking"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingSchedulingWindowCardProps { + booking: BookingDetail; +} + +/** Full date + time — staff read these against the operating clock, so no time is dropped. */ +function formatStamp(iso: string | null | undefined): string | null { + if (!iso) return null; + const ms = new Date(iso).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */ +function formatRelative(iso: string, nowMs: number): string { + const diff = new Date(iso).getTime() - nowMs; + const past = diff < 0; + const totalMinutes = Math.floor(Math.abs(diff) / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + // Keep minutes when they're the only unit, so sub-hour gaps never read "0". + if (minutes || parts.length === 0) parts.push(`${minutes}m`); + + const span = parts.slice(0, 2).join(" "); + return past ? `${span} ago` : `in ${span}`; +} + +/** + * Length of a window as "1h 30m" / "45m". Null unless both ends are real and + * ordered — the pay window is configurable per schedule, so this is read off the + * actual stamps rather than assuming any fixed duration. + */ +function formatDuration( + from: string | null | undefined, + to: string | null | undefined, +): string | null { + if (!from || !to) return null; + const fromMs = new Date(from).getTime(); + const toMs = new Date(to).getTime(); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null; + const minutes = Math.round((toMs - fromMs) / 60_000); + if (minutes <= 0) return null; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${rest}m`; + return rest ? `${hours}h ${rest}m` : `${hours}h`; +} + +function Row({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string | null; + tone?: "muted" | "warning" | "danger"; +}) { + const valueColor = + tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark"; + return ( + + + {label} + + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + + ); +} + +/** + * Backoffice-only staff view of the scheduling clock: which batch/train the + * booking is scheduled for, when its pay window closes, and the train's + * planned vs actual departure/arrival (i.e. when the run actually ended). + */ +export function BookingSchedulingWindowCard({ + booking, +}: BookingSchedulingWindowCardProps) { + const schedule = booking.trainScheduleSummary ?? null; + + // The pay-window end staff should quote is the drain end (a payment landing + // inside the drain still counts); fall back to the raw deadline if the API + // predates that field. + const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null; + + // One shared ticking clock so every relative label in the card stays in sync. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const interval = setInterval(() => setNowMs(Date.now()), 30_000); + return () => clearInterval(interval); + }, []); + + // How long the customer actually had to pay: start → the raw deadline, NOT the + // drain end (the drain is settlement grace, not payable time). + const windowDuration = formatDuration( + booking.selectedForBatchAt, + booking.paymentDeadline, + ); + + const hasAnything = + Boolean(schedule) || + Boolean(payWindowEndsAt) || + Boolean(booking.selectedForBatchAt) || + Boolean(booking.holdExpiresAt); + if (!hasAnything) return null; + + const payWindowClosed = payWindowEndsAt + ? new Date(payWindowEndsAt).getTime() <= nowMs + : false; + + const trainLabel = + schedule?.trainNumber ?? + schedule?.reference ?? + (schedule ? "Assigned train" : null); + + return ( + } + > + + {trainLabel ? ( + + ) : ( + + )} + + {schedule?.status ? ( + + + Train status + + + {schedule.windowPhase ? ( + + {schedule.windowPhase.replace(/_/g, " ")} + + ) : null} + + {schedule.status} + + + + ) : null} + + {booking.selectedForBatchAt ? ( + + ) : null} + + {payWindowEndsAt ? ( + + ) : null} + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + ) : null} + + {schedule ? ( + <> + + + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index ecbb0488e..b0b024977 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; export * from "./BookingCompanyCard"; +export * from "./BookingSchedulingWindowCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index cfbc50528..83e80d11e 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -40,8 +40,6 @@ import type { SidebarItem, SidebarSection } from "./types"; import { FREIGHT_PERMS, hasPermission as hasFreightPermission, - isDjiboutiGl, - isEthiopianGl, isSuperAdmin, } from "@/lib/permissions"; import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources"; @@ -554,38 +552,10 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] }, ]; -/** Hrefs of the two document-clearance menu items (stable identifiers). */ -export const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; -export const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; - -// Routes a GL officer may reach beyond their clearance hub. Path B booking is -// part of their job (create/rebook under a cleared contract, then view that -// booking's clearance), but those routes live outside the clearance prefix — -// without this allowlist the single-prefix lock bounces them out of their own -// workflow. Matched against location.pathname (no query string). -export const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ - /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, - /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, - // The ET hub's rows open the shipment clearance detail at this URL. - /^\/dashboard\/clearance\/[^/]+(\/|$)/, -]; - -const isEtClearanceItem = (item: SidebarItem): boolean => - item.href === ET_CLEARANCE_HREF; -const isDjClearanceItem = (item: SidebarItem): boolean => - item.href === DJ_CLEARANCE_HREF; -const isClearanceItem = (item: SidebarItem): boolean => - isEtClearanceItem(item) || isDjClearanceItem(item); - /** * Keep only items the user is permitted to see; drop now-empty sections. - * - * Position-scoped visibility (super_admin sees everything): - * - Super Admin → sees all items (all permissions pass, all tabs visible) - * - Ethiopian GL → sees ONLY the ET document-clearance page. - * - Djibouti GL → sees ONLY the DJ clearance page. - * - Everyone else → sees everything they have permission for, EXCEPT the two - * clearance pages (those are GL-only). + * Super Admin sees everything; everyone else is filtered purely by each + * item's `permission` field (OR across the array when one is given). */ export const filterSidebarByPermission = ( sections: SidebarSection[], @@ -594,9 +564,6 @@ export const filterSidebarByPermission = ( // Superadmin sees every section and item — no permission filtering. if (isSuperAdmin(user)) return sections; - const etGl = isEthiopianGl(user); - const djGl = isDjiboutiGl(user); - const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; const keys = Array.isArray(item.permission) @@ -616,14 +583,6 @@ export const filterSidebarByPermission = ( : item, ) .filter((item) => { - if (etGl || djGl) { - // GL positions are locked to their single clearance page (parents - // survive only as the path to that page). - const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; - return isTarget(item) || (item.children?.length ?? 0) > 0; - } - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; if (!permissionAllowed(item)) return false; if (item.children) return item.children.length > 0; return true; diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 4a92695ca..cb9fdf86b 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => { /** * NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field - * for actions that move real wagons; omit it for a transfer REQUEST, which may - * legitimately ask for more than the yard holds today (OCC fulfils it in - * instalments) — the slider then just tracks the current value. + * to the wagons on hand; omitting it leaves the field unbounded and the slider + * simply tracks the current value. */ const QuantityField = ({ value, @@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro {availableCount} available - {/* No max: the request may exceed what the yard holds - today — OCC fulfils it in instalments. */} - + {/* Capped at the wagons actually available in this yard + right now (uncoupled + Available) — a request may not + ask for more than the yard can hand over. */} +