mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -33,6 +33,13 @@ async function bootstrap() {
|
||||
"delegator-position-id",
|
||||
"current-project-id",
|
||||
"current-position-id",
|
||||
// x-prefixed variants sent by the user-management / record-management
|
||||
// frontend modules (same values, different naming convention)
|
||||
"x-organization-unit-id",
|
||||
"x-delegator-id",
|
||||
"x-delegator-position-id",
|
||||
"x-current-project-id",
|
||||
"x-current-position-id",
|
||||
],
|
||||
exposedHeaders: ["Content-Disposition"],
|
||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the
|
||||
* vehicle link is optional and only for acquisitions that ARE a fleet vehicle.
|
||||
*/
|
||||
export class AddAcquisitionItemName2470000000000 implements MigrationInterface {
|
||||
name = 'AddAcquisitionItemName2470000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.asset_acquisitions
|
||||
ADD COLUMN IF NOT EXISTS item_name varchar(200)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.asset_acquisitions
|
||||
DROP COLUMN IF EXISTS item_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -480,6 +480,20 @@ export class BookingsController {
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/bulk')
|
||||
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.addBulkTrucks(id, payload.trucks);
|
||||
}
|
||||
|
||||
@Patch(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||
async updateCustomerTruck(
|
||||
|
||||
@@ -576,4 +576,35 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
|
||||
async addBulkTrucks(
|
||||
bookingId: string,
|
||||
dtos: AddCustomerTruckDto[],
|
||||
): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ row: number; truck: string; reason: string }>;
|
||||
}> {
|
||||
const errors: Array<{ row: number; truck: string; reason: string }> = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < dtos.length; i++) {
|
||||
try {
|
||||
await this.addTruck(bookingId, dtos[i]);
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
errors.push({
|
||||
row: i + 2, // Row 1 is header
|
||||
truck: dtos[i].truckPlateNumber,
|
||||
reason: err.message || 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
|
||||
export class BulkCustomerTruckRow {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container must be ISO format (e.g. ABCD1234567)',
|
||||
})
|
||||
containerNumbers?: (string | null)[];
|
||||
}
|
||||
|
||||
export class BulkCustomerTrucksDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
trucks!: BulkCustomerTruckRow[];
|
||||
}
|
||||
|
||||
export interface BulkTruckUploadResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{
|
||||
row: number;
|
||||
truck: string;
|
||||
reason: string;
|
||||
}>;
|
||||
created: Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
containers: number;
|
||||
}>;
|
||||
}
|
||||
@@ -196,7 +196,10 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Staff see every contract; customers are force-scoped to their own company.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
return this.contractsService.findAll(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
@@ -273,7 +276,8 @@ export class ContractsController {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments)
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
@@ -475,7 +479,10 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
const { view, html, signatures } =
|
||||
@@ -510,7 +517,10 @@ export class ContractsController {
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
const { stream, record } = await this.transitionService.streamContractPdf(id);
|
||||
@@ -566,9 +576,12 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// H12(c): a customer may only renew a contract their company owns. Staff
|
||||
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
|
||||
// with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.transitionService.renew(id, resolveAuthUserId(user));
|
||||
@@ -592,9 +605,12 @@ export class ContractsController {
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
// H12(c): only the owning company's customer may upload clearance docs.
|
||||
// Staff with bookings.view bypass, mirroring the other contract handlers.
|
||||
// Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { VendorType } from '../entities/vendor.entity';
|
||||
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
|
||||
@@ -72,6 +73,11 @@ export class UpdateVendorDto {
|
||||
}
|
||||
|
||||
export class CreateAcquisitionDto {
|
||||
/** WHAT was acquired — required so an acquisition can't be saved empty. */
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
itemName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
vehicleId?: string;
|
||||
@@ -120,6 +126,11 @@ export class CreateAcquisitionDto {
|
||||
}
|
||||
|
||||
export class UpdateAcquisitionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
itemName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
vehicleId?: string;
|
||||
|
||||
@@ -18,6 +18,12 @@ export enum AcquisitionStatus {
|
||||
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
|
||||
@Index(['vehicleId', 'acquisitionDate'])
|
||||
export class AssetAcquisition extends BaseEntity {
|
||||
/** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */
|
||||
@Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true })
|
||||
itemName?: string;
|
||||
|
||||
/** Optional link — only when the acquisition IS a fleet vehicle. Parts and
|
||||
* general procurement stay unlinked so reports don't misattribute them. */
|
||||
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
||||
vehicleId?: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ProcurementService } from './procurement.service';
|
||||
import { AcquisitionType } from './entities/asset-acquisition.entity';
|
||||
|
||||
// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may.
|
||||
describe('ProcurementService acquisition lease-field guard', () => {
|
||||
const repo = {
|
||||
createAcquisition: jest.fn(async (dto) => dto),
|
||||
findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })),
|
||||
updateAcquisition: jest.fn(async (_id, dto) => dto),
|
||||
};
|
||||
const svc = new ProcurementService(repo as never);
|
||||
|
||||
it('rejects a PURCHASE with lease dates', async () => {
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Brake pads',
|
||||
acquisitionType: AcquisitionType.PURCHASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
leaseStart: '2026-07-01',
|
||||
} as never),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts a LEASE with lease dates and a plain PURCHASE', async () => {
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Rented crane',
|
||||
acquisitionType: AcquisitionType.LEASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
leaseStart: '2026-07-01',
|
||||
leaseEnd: '2027-07-01',
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Brake pads',
|
||||
acquisitionType: AcquisitionType.PURCHASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => {
|
||||
await expect(
|
||||
svc.updateAcquisition('a1', { monthlyPayment: 500 } as never),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ProcurementRepository } from './procurement.repository';
|
||||
import { Vendor } from './entities/vendor.entity';
|
||||
import { AssetAcquisition } from './entities/asset-acquisition.entity';
|
||||
import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity';
|
||||
import { AssetDisposal } from './entities/asset-disposal.entity';
|
||||
import {
|
||||
CreateVendorDto,
|
||||
@@ -51,7 +51,23 @@ export class ProcurementService {
|
||||
}
|
||||
|
||||
// ---- Acquisitions ----
|
||||
/** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */
|
||||
private assertLeaseFieldsValid(dto: {
|
||||
acquisitionType?: string;
|
||||
leaseStart?: string;
|
||||
leaseEnd?: string;
|
||||
monthlyPayment?: number;
|
||||
}): void {
|
||||
if (dto.acquisitionType !== AcquisitionType.PURCHASE) return;
|
||||
if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) {
|
||||
throw new BadRequestException(
|
||||
'Lease start/end and monthly payment are not valid for a PURCHASE acquisition',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
|
||||
this.assertLeaseFieldsValid(dto);
|
||||
return this.procurementRepository.createAcquisition(dto);
|
||||
}
|
||||
|
||||
@@ -64,6 +80,20 @@ export class ProcurementService {
|
||||
}
|
||||
|
||||
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
|
||||
// Validate against the resulting record, not just the patch — switching an
|
||||
// acquisition to PURCHASE must also shed any stored lease terms.
|
||||
const existing = await this.procurementRepository.findAcquisitionById(id);
|
||||
if (existing) {
|
||||
const next = { ...existing, ...dto };
|
||||
if (next.acquisitionType === AcquisitionType.PURCHASE) {
|
||||
this.assertLeaseFieldsValid({
|
||||
acquisitionType: next.acquisitionType,
|
||||
leaseStart: dto.leaseStart,
|
||||
leaseEnd: dto.leaseEnd,
|
||||
monthlyPayment: dto.monthlyPayment,
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.procurementRepository.updateAcquisition(id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -253,12 +253,18 @@ export function minLocomotiveLimits(
|
||||
maxTrainLengthMeters: Math.min(
|
||||
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
|
||||
),
|
||||
// Weakest locomotive's tolerance governs the set, same as its caps.
|
||||
overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))),
|
||||
overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))),
|
||||
// Weakest CONFIGURED tolerance governs the set — a locomotive with no
|
||||
// tolerance set has no opinion, it does not zero out the others.
|
||||
overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
|
||||
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
|
||||
};
|
||||
}
|
||||
|
||||
function minConfigured(values: Array<number | string | null | undefined>): number {
|
||||
const configured = values.filter((v) => v != null).map((v) => num(v));
|
||||
return configured.length ? Math.min(...configured) : 0;
|
||||
}
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
|
||||
@@ -2811,13 +2811,12 @@ export class TrainSchedulingService {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="6">EMPTY — no cargo allocated</td>
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
@@ -2826,8 +2825,6 @@ export class TrainSchedulingService {
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
@@ -2909,8 +2906,6 @@ export class TrainSchedulingService {
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
@@ -2918,7 +2913,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -1578,6 +1578,14 @@ export class WarehouseInventoryService {
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
truckEntrance,
|
||||
});
|
||||
|
||||
// Validate capacity before saving
|
||||
const weight = Number(booking.weight) || 0;
|
||||
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
|
||||
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, 0, containerCount);
|
||||
this.assertCapacity('Zone', zone, weight, 0, containerCount);
|
||||
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
@@ -1585,7 +1593,7 @@ export class WarehouseInventoryService {
|
||||
zoneId: dto.zoneId,
|
||||
bookingId,
|
||||
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
weight,
|
||||
grnNumber,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
@@ -1593,6 +1601,9 @@ export class WarehouseInventoryService {
|
||||
}),
|
||||
);
|
||||
|
||||
// Update warehouse/yard/zone capacity counters
|
||||
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
||||
|
||||
// Receiving the booking flags every container unit as received into the
|
||||
// port (self-haul export: the delivering truck's goods are now in) so
|
||||
// staff can raise the per-container GRN over what's received.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
@@ -44,6 +44,7 @@ export class WarehouseYardsService {
|
||||
// Ensure the parent warehouse exists.
|
||||
await this.warehousesService.findById(warehouseId);
|
||||
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
||||
await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||
|
||||
return this.yardsRepository.create({
|
||||
warehouseId,
|
||||
@@ -69,14 +70,22 @@ export class WarehouseYardsService {
|
||||
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||
|
||||
// Validate updated capacity doesn't exceed warehouse limits
|
||||
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||
await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.yardsRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
capacityWeight: newCapacityWeight,
|
||||
capacityContainers: newCapacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
@@ -97,4 +106,39 @@ export class WarehouseYardsService {
|
||||
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCapacityWithinWarehouse(
|
||||
warehouseId: string,
|
||||
newCapacityWeight: number | null,
|
||||
newCapacityContainers: number | null,
|
||||
excludeYardId?: string,
|
||||
): Promise<void> {
|
||||
const warehouse = await this.warehousesService.findById(warehouseId);
|
||||
const yards = await this.findByWarehouse(warehouseId);
|
||||
|
||||
// Sum existing yard capacities, excluding the yard being updated if provided
|
||||
const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards;
|
||||
const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0);
|
||||
const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0);
|
||||
|
||||
// Check weight capacity
|
||||
if (newCapacityWeight !== null && warehouse.capacityWeight != null) {
|
||||
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||
if (totalWeight > warehouse.capacityWeight) {
|
||||
throw new BadRequestException(
|
||||
`Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check container capacity
|
||||
if (newCapacityContainers !== null && warehouse.capacityContainers != null) {
|
||||
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||
if (totalContainers > warehouse.capacityContainers) {
|
||||
throw new BadRequestException(
|
||||
`Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
@@ -43,6 +43,7 @@ export class WarehouseZonesService {
|
||||
// Ensure the parent yard exists.
|
||||
await this.yardsService.findById(yardId);
|
||||
await this.assertCodeUnique(yardId, dto.code.trim());
|
||||
await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||
|
||||
return this.zonesRepository.create({
|
||||
yardId,
|
||||
@@ -68,14 +69,22 @@ export class WarehouseZonesService {
|
||||
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||
|
||||
// Validate updated capacity doesn't exceed yard limits
|
||||
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||
await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.zonesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
capacityWeight: newCapacityWeight,
|
||||
capacityContainers: newCapacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
@@ -96,4 +105,39 @@ export class WarehouseZonesService {
|
||||
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCapacityWithinYard(
|
||||
yardId: string,
|
||||
newCapacityWeight: number | null,
|
||||
newCapacityContainers: number | null,
|
||||
excludeZoneId?: string,
|
||||
): Promise<void> {
|
||||
const yard = await this.yardsService.findById(yardId);
|
||||
const zones = await this.findByYard(yardId);
|
||||
|
||||
// Sum existing zone capacities, excluding the zone being updated if provided
|
||||
const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones;
|
||||
const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0);
|
||||
const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0);
|
||||
|
||||
// Check weight capacity
|
||||
if (newCapacityWeight !== null && yard.capacityWeight != null) {
|
||||
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||
if (totalWeight > yard.capacityWeight) {
|
||||
throw new BadRequestException(
|
||||
`Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check container capacity
|
||||
if (newCapacityContainers !== null && yard.capacityContainers != null) {
|
||||
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||
if (totalContainers > yard.capacityContainers) {
|
||||
throw new BadRequestException(
|
||||
`Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,41 +808,41 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
// permission catalog (all CRUD across bookings, contracts, scheduling,
|
||||
// fleet, warehouse, mile, finance, settings, staff).
|
||||
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
|
||||
// Dispatcher: warehouse floor operations — receive/GRN, move, load/unload,
|
||||
// inspect, dispatch, gate, release/deliver, interchange docs, fee invoices,
|
||||
// plus truck dispatch on the mile legs and read-only operational context.
|
||||
// Allocation & fee rules are VIEW-ONLY — never create/update/delete.
|
||||
// Dispatcher: full CRUD on warehouse management (incl. import/export/intercity
|
||||
// inventory flows) and fleet management, plus truck dispatch on the mile legs
|
||||
// and operational context. The ONE carve-out: allocation & fee rules stay
|
||||
// VIEW-ONLY — a dispatcher never creates/updates/deletes those rules.
|
||||
dispatcher: dedupe([
|
||||
// Warehouse management — full CRUD.
|
||||
FREIGHT_PERMS.warehouseDashboard.view,
|
||||
FREIGHT_PERMS.warehouses.view,
|
||||
FREIGHT_PERMS.warehouseYards.view,
|
||||
FREIGHT_PERMS.warehouseZones.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseInventory.receive,
|
||||
FREIGHT_PERMS.warehouseInventory.move,
|
||||
FREIGHT_PERMS.warehouseInventory.load,
|
||||
FREIGHT_PERMS.warehouseInventory.unload,
|
||||
FREIGHT_PERMS.warehouseInventory.dispatch,
|
||||
FREIGHT_PERMS.warehouseInventory.gatePass,
|
||||
FREIGHT_PERMS.warehouseInventory.release,
|
||||
FREIGHT_PERMS.warehouseInventory.deliver,
|
||||
FREIGHT_PERMS.warehouseInventory.inspect,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.view,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.create,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.update,
|
||||
FREIGHT_PERMS.interchangeDocuments.view,
|
||||
FREIGHT_PERMS.interchangeDocuments.generate,
|
||||
FREIGHT_PERMS.interchangeDocuments.acknowledge,
|
||||
FREIGHT_PERMS.warehouseFeeInvoices.view,
|
||||
FREIGHT_PERMS.warehouseFeeInvoices.generate,
|
||||
...Object.values(FREIGHT_PERMS.warehouses),
|
||||
...Object.values(FREIGHT_PERMS.warehouseYards),
|
||||
...Object.values(FREIGHT_PERMS.warehouseZones),
|
||||
...Object.values(FREIGHT_PERMS.warehouseInventory),
|
||||
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
|
||||
...Object.values(FREIGHT_PERMS.interchangeDocuments),
|
||||
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
|
||||
// View-only on the rules that govern allocation and fees.
|
||||
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||
// Fleet management — full CRUD.
|
||||
...Object.values(FREIGHT_PERMS.fleet),
|
||||
FREIGHT_PERMS.fleetDashboard.view,
|
||||
...Object.values(FREIGHT_PERMS.fleetReports),
|
||||
...Object.values(FREIGHT_PERMS.vehicles),
|
||||
...Object.values(FREIGHT_PERMS.drivers),
|
||||
...Object.values(FREIGHT_PERMS.tracking),
|
||||
...Object.values(FREIGHT_PERMS.fuel),
|
||||
...Object.values(FREIGHT_PERMS.maintenance),
|
||||
...Object.values(FREIGHT_PERMS.locomotives),
|
||||
...Object.values(FREIGHT_PERMS.wagons),
|
||||
...Object.values(FREIGHT_PERMS.trains),
|
||||
...Object.values(FREIGHT_PERMS.routes),
|
||||
...Object.values(FREIGHT_PERMS.containers),
|
||||
...Object.values(FREIGHT_PERMS.cargoes),
|
||||
// Truck dispatch on the EDR mile legs + operational context.
|
||||
FREIGHT_PERMS.firstMile.view,
|
||||
FREIGHT_PERMS.firstMile.assignVehicles,
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
FREIGHT_PERMS.lastMile.assignVehicles,
|
||||
...Object.values(FREIGHT_PERMS.firstMile),
|
||||
...Object.values(FREIGHT_PERMS.lastMile),
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
]),
|
||||
|
||||
@@ -146,6 +146,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Overview",
|
||||
href: "/dashboard/overview",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.overview.view,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
@@ -189,7 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Support",
|
||||
href: "/dashboard/support",
|
||||
icon: <LifeBuoy />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
permission: FREIGHT_PERMS.support.view,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
|
||||
/**
|
||||
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
||||
@@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (isBackdated(pickupDate)) {
|
||||
toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliver.mutateAsync({
|
||||
id: cargoId,
|
||||
@@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
<Label>Pickup date</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
min={nowLocalDateTimeInput()}
|
||||
value={pickupDate}
|
||||
onChange={(e) => setPickupDate(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated } from '@/lib/no-backdate';
|
||||
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
||||
|
||||
interface TruckDetentionModalProps {
|
||||
@@ -111,6 +112,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
@@ -118,11 +120,26 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" loading={saveTimes.isPending} onClick={() => saveTimes.mutate()}>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={saveTimes.isPending}
|
||||
onClick={() => {
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (isBackdated(arrived) || isBackdated(delivered)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Detention times cannot be in the past',
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
}}
|
||||
>
|
||||
Save times
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
@@ -440,6 +441,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
});
|
||||
return;
|
||||
}
|
||||
// No backdating: gate times are recorded as they happen. The locked
|
||||
// entrance (exit step) keeps its original past gate-in untouched.
|
||||
if (!isEntranceLocked && isBackdated(gateInTime)) {
|
||||
toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@@ -447,6 +454,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isExitStep && isBackdated(gateOutTime)) {
|
||||
toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
@@ -646,7 +657,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Gate in time" type="datetime-local" min={isEntranceLocked ? undefined : nowLocalDateTimeInput()} value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
{hasContainerWeights && (
|
||||
<Group gap="md" align="center">
|
||||
@@ -679,7 +690,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
<TextInput label="Gate out time" type="datetime-local" min={nowLocalDateTimeInput()} value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
|
||||
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Backdating guard for operational time entries (gate in/out, mile truck
|
||||
* times, delivery pickups): times must be recorded as they happen, never
|
||||
* dated back. A one-hour grace covers real-world lag (weighbridge queue,
|
||||
* operator finishing the form after the event).
|
||||
*/
|
||||
export const BACKDATE_GRACE_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */
|
||||
export const nowLocalDateTimeInput = (): string =>
|
||||
new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
|
||||
/** True when the value is more than the grace period in the past. */
|
||||
export const isBackdated = (value: string | Date | null | undefined): boolean => {
|
||||
if (!value) return false;
|
||||
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS;
|
||||
};
|
||||
@@ -2,6 +2,12 @@ import type { AuthUser } from "@/auth/types";
|
||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
|
||||
export const FREIGHT_PERMS = {
|
||||
overview: {
|
||||
view: "edr_freight_app:overview:view",
|
||||
},
|
||||
support: {
|
||||
view: "edr_freight_app:support:view",
|
||||
},
|
||||
bookings: {
|
||||
view: "edr_freight_app:bookings:view",
|
||||
create: "edr_freight_app:bookings:create",
|
||||
|
||||
@@ -61,6 +61,7 @@ const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
|
||||
) as Partial<T>;
|
||||
|
||||
const emptyAcquisition = {
|
||||
itemName: "",
|
||||
vehicleId: "",
|
||||
vendorId: "",
|
||||
acquisitionType: "PURCHASE" as AcquisitionType,
|
||||
@@ -239,6 +240,7 @@ export default function ProcurementPage() {
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Item / Asset</Table.Th>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
@@ -249,7 +251,7 @@ export default function ProcurementPage() {
|
||||
<Table.Tbody>
|
||||
{loadingAcquisitions ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Table.Td colSpan={6}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
@@ -257,7 +259,7 @@ export default function ProcurementPage() {
|
||||
</Table.Tr>
|
||||
) : acquisitions.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No acquisitions recorded yet.
|
||||
</Text>
|
||||
@@ -266,6 +268,7 @@ export default function ProcurementPage() {
|
||||
) : null}
|
||||
{acquisitions.map((a: AssetAcquisition) => (
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td>{a.itemName || "—"}</Table.Td>
|
||||
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
|
||||
@@ -411,31 +414,51 @@ export default function ProcurementPage() {
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Item / Asset"
|
||||
placeholder="What was acquired — e.g. brake pads, tyres, truck 3-15288"
|
||||
value={acqForm.itemName}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, itemName: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
label="Related vehicle (optional)"
|
||||
description="Only when the acquisition is a fleet vehicle itself — parts and general procurement stay unlinked."
|
||||
placeholder="Not tied to a vehicle"
|
||||
data={vehicleOptions}
|
||||
value={acqForm.vehicleId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Button variant="light" size="sm" onClick={() => setVendorModalOpen(true)}>
|
||||
Register vendor
|
||||
</Button>
|
||||
</Group>
|
||||
<Select
|
||||
label="Acquisition Type"
|
||||
data={ACQUISITION_TYPES}
|
||||
value={acqForm.acquisitionType}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" })
|
||||
}
|
||||
onChange={(val) => {
|
||||
const acquisitionType = (val as AcquisitionType) || "PURCHASE";
|
||||
// Lease terms are invalid on a purchase — drop them on switch.
|
||||
setAcqForm(
|
||||
acquisitionType === "PURCHASE"
|
||||
? { ...acqForm, acquisitionType, leaseStart: "", leaseEnd: "", monthlyPayment: undefined }
|
||||
: { ...acqForm, acquisitionType },
|
||||
);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
@@ -471,28 +494,32 @@ export default function ProcurementPage() {
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
{acqForm.acquisitionType !== "PURCHASE" && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
label="Status"
|
||||
data={ACQUISITION_STATUSES}
|
||||
@@ -514,7 +541,7 @@ export default function ProcurementPage() {
|
||||
<Button
|
||||
onClick={() => createAcquisition.mutate()}
|
||||
loading={createAcquisition.isPending}
|
||||
disabled={!acqForm.acquisitionDate}
|
||||
disabled={!acqForm.acquisitionDate || acqForm.itemName.trim().length < 2}
|
||||
>
|
||||
Save Acquisition
|
||||
</Button>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Vendor {
|
||||
|
||||
export interface AssetAcquisition {
|
||||
id: string;
|
||||
itemName?: string | null;
|
||||
vehicleId?: string | null;
|
||||
vendorId?: string | null;
|
||||
acquisitionType: AcquisitionType;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core";
|
||||
import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
|
||||
|
||||
interface BulkTruckUploadModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
bookingId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function BulkTruckUploadModal({
|
||||
opened,
|
||||
onClose,
|
||||
bookingId,
|
||||
onSuccess,
|
||||
}: BulkTruckUploadModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<
|
||||
Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumbers?: string[];
|
||||
}>
|
||||
>([]);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, {
|
||||
trucks: parsed,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileSelect = async (selectedFile: File | null) => {
|
||||
if (!selectedFile) {
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
setParseError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setParseError(null);
|
||||
const trucks = await parseTruckAssignmentFile(selectedFile);
|
||||
setFile(selectedFile);
|
||||
setParsed(trucks);
|
||||
} catch (err: any) {
|
||||
setParseError(err.message || "Failed to parse Excel file");
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
generateTruckAssignmentTemplate("truck-assignments.xlsx");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Bulk Upload Truck Assignments"
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Alert icon={<AlertCircle size={16} />} color="blue">
|
||||
Download template, fill with truck data, upload Excel file to bulk-create truck assignments.
|
||||
</Alert>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<Download size={16} />}
|
||||
variant="light"
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<FileInput
|
||||
label="Select Excel File"
|
||||
placeholder="Choose .xlsx file"
|
||||
accept=".xlsx,.xls"
|
||||
value={file}
|
||||
onChange={handleFileSelect}
|
||||
leftSection={<Upload size={14} />}
|
||||
/>
|
||||
|
||||
{parseError && (
|
||||
<Alert icon={<AlertTriangle size={16} />} color="red" title="Parse Error">
|
||||
{parseError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{parsed.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={600} mb="xs">
|
||||
Preview ({parsed.length} trucks)
|
||||
</Text>
|
||||
<Table striped highlightOnHover size="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate Number</Table.Th>
|
||||
<Table.Th>Driver Name</Table.Th>
|
||||
<Table.Th>Truck Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{parsed.map((truck, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{truck.truckPlateNumber}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{truck.driverName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{truck.truckType}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{truck.containerNumbers?.length ? (
|
||||
<Group gap="xs">
|
||||
{truck.containerNumbers.map((c) => (
|
||||
<Badge key={c} size="sm">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Ready to upload {parsed.length} truck(s)
|
||||
</Text>
|
||||
<Button
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
leftSection={<CheckCircle size={16} />}
|
||||
>
|
||||
Upload Trucks
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<Alert icon={<AlertTriangle size={16} />} color="red">
|
||||
{uploadMutation.error instanceof Error
|
||||
? uploadMutation.error.message
|
||||
: "Upload failed"}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react";
|
||||
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -23,6 +23,7 @@ import { api } from "@/services/api";
|
||||
import { customerTrucksService } from "@/services/customer-trucks.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
|
||||
|
||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||
|
||||
@@ -65,6 +66,7 @@ export function CustomerTruckAssignmentCard({
|
||||
const [containers, setContainers] = useState<string[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
||||
|
||||
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||
const assignedNumbers = new Set(
|
||||
@@ -163,6 +165,14 @@ export function CustomerTruckAssignmentCard({
|
||||
<CardTitle>External Truck Assignment</CardTitle>
|
||||
</Group>
|
||||
<Group gap={12}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Upload size={14} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
{pendingAssignmentCount > 0 && (
|
||||
<Text size="sm" fw={600} c="#b45309">
|
||||
{pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
|
||||
@@ -325,6 +335,16 @@ export function CustomerTruckAssignmentCard({
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<BulkTruckUploadModal
|
||||
opened={bulkModalOpen}
|
||||
onClose={() => setBulkModalOpen(false)}
|
||||
bookingId={booking.id}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: trucksKey });
|
||||
onAssigned();
|
||||
}}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void {
|
||||
const data = [
|
||||
{
|
||||
'Truck Plate Number': '3-12345/67890',
|
||||
'Driver Name': 'John Doe',
|
||||
'Truck Type': 'Flatbed',
|
||||
'Container 1': 'MAEU1234567',
|
||||
'Container 2': 'HLXU7654321',
|
||||
},
|
||||
{
|
||||
'Truck Plate Number': '3-98765/43210',
|
||||
'Driver Name': 'Jane Smith',
|
||||
'Truck Type': 'Flatbed',
|
||||
'Container 1': 'COSCO1111111',
|
||||
'Container 2': '',
|
||||
},
|
||||
];
|
||||
|
||||
const instructions = [
|
||||
['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'],
|
||||
[],
|
||||
['Column', 'Required', 'Notes'],
|
||||
['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'],
|
||||
['Driver Name', 'Yes', 'Full name of truck driver'],
|
||||
['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'],
|
||||
['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'],
|
||||
['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'],
|
||||
[],
|
||||
['CONTAINER RULES'],
|
||||
['- A 40ft container fills one truck (max 1 per truck)'],
|
||||
['- Two 20ft containers fit on one truck (max 2 per truck)'],
|
||||
['- No size mixing on same truck'],
|
||||
['- Containers must be from the booking'],
|
||||
[],
|
||||
['Example Data Below →'],
|
||||
];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Instructions sheet
|
||||
const wsInstructions = XLSX.utils.aoa_to_sheet(instructions);
|
||||
wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }];
|
||||
XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions');
|
||||
|
||||
// Data template sheet
|
||||
const wsData = XLSX.utils.json_to_sheet(data, {
|
||||
header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'],
|
||||
});
|
||||
wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }];
|
||||
XLSX.utils.book_append_sheet(wb, wsData, 'Trucks');
|
||||
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
export function parseTruckAssignmentFile(
|
||||
file: File,
|
||||
): Promise<
|
||||
Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumbers?: string[];
|
||||
}>
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result as ArrayBuffer;
|
||||
const wb = XLSX.read(data, { type: 'array' });
|
||||
const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0];
|
||||
|
||||
if (!wsData) {
|
||||
reject(new Error('No data sheet found in Excel file'));
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonData = XLSX.utils.sheet_to_json(wsData) as Array<Record<string, any>>;
|
||||
|
||||
const trucks = jsonData.map((row) => {
|
||||
const containers = [
|
||||
row['Container 1'],
|
||||
row['Container 2'],
|
||||
]
|
||||
.filter((c) => c && c.trim())
|
||||
.map((c) => c.trim().toUpperCase());
|
||||
|
||||
return {
|
||||
truckPlateNumber: row['Truck Plate Number']?.trim() || '',
|
||||
driverName: row['Driver Name']?.trim() || '',
|
||||
truckType: row['Truck Type']?.trim() || '',
|
||||
containerNumbers: containers.length > 0 ? containers : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
resolve(trucks);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
@@ -101,6 +102,7 @@ export class BookingsService {
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly seatsService: SeatsService,
|
||||
private readonly ticketsService: TicketsService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
@@ -1903,6 +1905,39 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing
|
||||
// (ticket generation failed silently after payment — see finalizePaymentSuccess in
|
||||
// payments.service.ts), attempt to generate them now so the confirmation page
|
||||
// doesn't show "Not yet issued".
|
||||
if (
|
||||
booking.status === 'CONFIRMED' &&
|
||||
(booking as any).tickets?.length === 0 &&
|
||||
(booking as any).paymentIntent?.status === 'SUCCEEDED'
|
||||
) {
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(`getByRef: generate failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}. Trying smart assign.`);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(booking.id);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(`getByRef: smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
||||
}
|
||||
}
|
||||
// Re-fetch to include any newly created tickets
|
||||
const refreshed = await this.prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, tickets: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
});
|
||||
if (refreshed) Object.assign(booking, refreshed);
|
||||
}
|
||||
|
||||
const outboundSegment = this.resolveSegmentStations(
|
||||
(booking as any).schedule,
|
||||
(booking as any).originStationId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
@@ -897,12 +898,25 @@ export class PaymentsService {
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error generating ticket for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Re-throw so callers (e.g. force-confirm) know tickets weren't issued.
|
||||
// Webhook handlers catch this themselves and still return 200 to avoid redelivery.
|
||||
throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Only reassign seats when a *different* booking genuinely holds the seat
|
||||
// (ConflictException). Any other error (transient DB issue, etc.) is logged
|
||||
// and swallowed — the passenger keeps their original seat and the ticket can
|
||||
// be retried via "Generate Missing" in the backoffice.
|
||||
if (err instanceof ConflictException) {
|
||||
this.logger.warn(
|
||||
`Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(booking.id);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,17 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('generate-missing')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Generate tickets for all confirmed bookings that are missing them',
|
||||
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.',
|
||||
})
|
||||
generateMissing() {
|
||||
return this.service.generateMissing();
|
||||
}
|
||||
|
||||
@Post('smart-assign/:bookingId')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@@ -23,10 +34,11 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('generate/:bookingId')
|
||||
@SetMetadata('isPublic', true)
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Generate ticket for booking (confirmation page)',
|
||||
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.'
|
||||
summary: 'Generate ticket for booking',
|
||||
description: 'Creates a ticket for a confirmed booking with succeeded payment. Requires payment to be SUCCEEDED and booking to be CONFIRMED.'
|
||||
})
|
||||
generateTicket(@Param('bookingId') bookingId: string) {
|
||||
return this.service.generate(bookingId);
|
||||
|
||||
@@ -210,15 +210,6 @@ export class TicketsService {
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
// Seats taken by other confirmed/boarded bookings on this schedule
|
||||
const takenByOthers = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
}).then(rows => new Set(rows.map(r => r.seatId)));
|
||||
|
||||
// Seats held by any active SeatHold (not yet expired)
|
||||
const heldSeatIds = await this.prisma.seatHold.findMany({
|
||||
where: { expiresAt: { gt: new Date() } },
|
||||
@@ -230,42 +221,62 @@ export class TicketsService {
|
||||
select: { seatId: true },
|
||||
}).then(rows => new Set(rows.map(r => r.seatId)));
|
||||
|
||||
// Union of all unavailable seat IDs (excluding the booking's own seats)
|
||||
const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string));
|
||||
const reassigned: { seatNumber: string; newSeatNumber: string }[] = [];
|
||||
|
||||
// Track newly assigned seats so the same seat isn't given to two passengers
|
||||
const unavailableIds = new Set([
|
||||
...[...takenByOthers].filter(id => !ownSeatIds.has(id)),
|
||||
...[...heldSeatIds],
|
||||
...[...blockedSeatIds],
|
||||
]);
|
||||
|
||||
const reassigned: { seatNumber: string; newSeatNumber: string }[] = [];
|
||||
|
||||
for (const bs of (booking as any).seats) {
|
||||
const originalSeatId: string = bs.seatId;
|
||||
// Use the per-seat scheduleId — for ROUND_TRIP leg 2 this is the return schedule,
|
||||
// not booking.scheduleId (the outbound schedule).
|
||||
const legScheduleId: string = bs.scheduleId ?? booking.scheduleId;
|
||||
|
||||
// Case 1: original seat is still free — nothing to do
|
||||
if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue;
|
||||
// Seats taken by other confirmed/boarded bookings on THIS leg's schedule
|
||||
const takenByOthersOnLeg = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
seat: { coach: { assignments: { some: { scheduleId: legScheduleId } } } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
}).then(rows => new Set(rows.map(r => r.seatId)));
|
||||
|
||||
// Case 2: original seat is unavailable — find a truly available seat in the same coach type
|
||||
// Case 1: original seat is still free on this leg — nothing to do
|
||||
if (
|
||||
!takenByOthersOnLeg.has(originalSeatId) &&
|
||||
!heldSeatIds.has(originalSeatId) &&
|
||||
!blockedSeatIds.has(originalSeatId)
|
||||
) continue;
|
||||
|
||||
// Case 2: original seat is unavailable — find a free seat of the same coach type on this leg's schedule
|
||||
const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId;
|
||||
|
||||
const allUnavailable = new Set([
|
||||
...[...takenByOthersOnLeg].filter(id => !ownSeatIds.has(id)),
|
||||
...[...unavailableIds],
|
||||
]);
|
||||
|
||||
const candidate = await this.prisma.seat.findFirst({
|
||||
where: {
|
||||
status: 'AVAILABLE',
|
||||
seatNumber: { not: '' },
|
||||
NOT: [
|
||||
{ seatNumber: { startsWith: '-' } },
|
||||
{ id: { in: [...unavailableIds] } },
|
||||
{ id: { in: [...allUnavailable] } },
|
||||
],
|
||||
coach: {
|
||||
assignments: { some: { scheduleId: booking.scheduleId } },
|
||||
assignments: { some: { scheduleId: legScheduleId } },
|
||||
...(coachTypeId ? { coachTypeId } : {}),
|
||||
},
|
||||
},
|
||||
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
// Case 3: no seats left in that class
|
||||
// Case 3: no seats left in that class on this leg
|
||||
if (!candidate) {
|
||||
const className = bs.seat?.coach?.coachType?.name ?? 'the same class';
|
||||
throw new ConflictException(
|
||||
@@ -278,10 +289,7 @@ export class TicketsService {
|
||||
data: { seatId: candidate.id },
|
||||
});
|
||||
|
||||
// Mark the newly assigned seat as taken so subsequent passengers in the
|
||||
// same booking don't get assigned the same seat.
|
||||
unavailableIds.add(candidate.id);
|
||||
|
||||
reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber });
|
||||
}
|
||||
|
||||
@@ -352,26 +360,93 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for seat conflicts before deleting existing tickets or issuing new ones
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Remove any SeatBlock rows left over from a previous generate() run for this
|
||||
// booking — they reference the old ticket IDs which are now deleted, and would
|
||||
// otherwise cause the conflict check below to see this booking's own seats as
|
||||
// blocked by another booking.
|
||||
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
|
||||
const conflictingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
seatId: { in: seatIds },
|
||||
booking: {
|
||||
id: { not: bookingId },
|
||||
status: { in: ['CONFIRMED', 'BOARDED'] },
|
||||
},
|
||||
},
|
||||
include: { seat: true },
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM' },
|
||||
});
|
||||
if (conflictingSeats.length > 0) {
|
||||
const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', ');
|
||||
throw new ConflictException(
|
||||
`Seat(s) ${labels} are already confirmed for another booking.`,
|
||||
);
|
||||
|
||||
// Check for seat conflicts — only seats confirmed/boarded by a *different* booking
|
||||
// on the SAME schedule AND with OVERLAPPING segments are a real conflict.
|
||||
// Segment overlap: two bookings conflict on a seat when their stop-sequence ranges
|
||||
// overlap: A.originSeq < B.destSeq AND B.originSeq < A.destSeq.
|
||||
// We resolve sequences via TripStopTime using each booking's originStationId /
|
||||
// destinationStationId. Bookings with no station IDs (full-route) are treated as
|
||||
// seq 0 → ∞ and always overlap.
|
||||
const thisBookingSeats = (booking as any).seats as Array<{ seatId: string; scheduleId: string | null }>;
|
||||
|
||||
// Resolve this booking's stop sequences per leg schedule
|
||||
const thisSeqMap = new Map<string, { originSeq: number; destSeq: number }>();
|
||||
const legScheduleIds = [...new Set(thisBookingSeats.map(bs => bs.scheduleId ?? booking.scheduleId))];
|
||||
for (const schedId of legScheduleIds) {
|
||||
const originId = (booking as any).originStationId;
|
||||
const destId = (booking as any).destinationStationId;
|
||||
if (!originId || !destId) {
|
||||
thisSeqMap.set(schedId, { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER });
|
||||
continue;
|
||||
}
|
||||
const stops = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: schedId, stationId: { in: [originId, destId] } },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const oStop = stops.find(s => s.stationId === originId);
|
||||
const dStop = stops.find(s => s.stationId === destId);
|
||||
thisSeqMap.set(schedId, {
|
||||
originSeq: oStop?.sequence ?? 0,
|
||||
destSeq: dStop?.sequence ?? Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
// Find other confirmed/boarded bookings that share any (seatId, scheduleId) pair
|
||||
const candidateConflicts = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
OR: thisBookingSeats.map(bs => ({
|
||||
seatId: bs.seatId,
|
||||
scheduleId: bs.scheduleId ?? booking.scheduleId,
|
||||
booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
})),
|
||||
},
|
||||
include: {
|
||||
seat: true,
|
||||
booking: { select: { id: true, originStationId: true, destinationStationId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const trueConflicts: string[] = [];
|
||||
for (const other of candidateConflicts) {
|
||||
const legScheduleId = other.scheduleId ?? booking.scheduleId;
|
||||
const thisSeq = thisSeqMap.get(legScheduleId) ?? { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER };
|
||||
|
||||
const otherOriginId = (other.booking as any).originStationId;
|
||||
const otherDestId = (other.booking as any).destinationStationId;
|
||||
let otherOriginSeq = 0;
|
||||
let otherDestSeq = Number.MAX_SAFE_INTEGER;
|
||||
if (otherOriginId && otherDestId) {
|
||||
const stops = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: legScheduleId, stationId: { in: [otherOriginId, otherDestId] } },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
otherOriginSeq = stops.find(s => s.stationId === otherOriginId)?.sequence ?? 0;
|
||||
otherDestSeq = stops.find(s => s.stationId === otherDestId)?.sequence ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
// Segments overlap when: thisOrigin < otherDest AND otherOrigin < thisDest
|
||||
if (thisSeq.originSeq < otherDestSeq && otherOriginSeq < thisSeq.destSeq) {
|
||||
trueConflicts.push((other as any).seat.seatNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (trueConflicts.length > 0) {
|
||||
const labels = [...new Set(trueConflicts)].join(', ');
|
||||
throw new ConflictException(
|
||||
`Seat(s) ${labels} are already confirmed for another booking on the same schedule and overlapping segment.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate one ticket per passenger per leg.
|
||||
// Round-trip / transit bookings have seats on multiple legs — each leg needs its own
|
||||
@@ -830,6 +905,34 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> {
|
||||
const confirmedWithNoTickets = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'CONFIRMED',
|
||||
tickets: { none: {} },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
select: { id: true, bookingRef: true },
|
||||
});
|
||||
|
||||
const details: any[] = [];
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const booking of confirmedWithNoTickets) {
|
||||
try {
|
||||
await this.generate(booking.id);
|
||||
generated++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: confirmedWithNoTickets.length, generated, failed, details };
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
@@ -98,6 +98,21 @@ export default function TicketsPage() {
|
||||
queryFn: () => apiClient.get('/fleet/coaches'),
|
||||
});
|
||||
|
||||
const [generateMissingResult, setGenerateMissingResult] = useState<any>(null);
|
||||
|
||||
const generateMissingMutation = useMutation({
|
||||
mutationFn: () => ticketsApi.generateMissing(),
|
||||
onSuccess: (result: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
setGenerateMissingResult(result);
|
||||
setSuccessMessage(`Generated ${result.generated} ticket(s) for ${result.processed} booking(s)${result.failed ? ` (${result.failed} failed)` : ''}`);
|
||||
setTimeout(() => setSuccessMessage(''), 6000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(error?.response?.data?.message || error?.message || 'Failed to generate missing tickets');
|
||||
},
|
||||
});
|
||||
|
||||
const boardMutation = useMutation({
|
||||
mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) =>
|
||||
ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }),
|
||||
@@ -546,7 +561,17 @@ export default function TicketsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
|
||||
<p className="text-muted-foreground">Manage tickets and validations</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
loading={generateMissingMutation.isPending}
|
||||
onClick={() => generateMissingMutation.mutate()}
|
||||
>
|
||||
Generate Missing
|
||||
</ActionButton>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
|
||||
@@ -224,6 +224,7 @@ export const ticketsApi = {
|
||||
return Array.isArray(response) ? { items: response } : response;
|
||||
},
|
||||
getById: (id: string) => apiClient.get<any>(`/tickets/${id}`),
|
||||
generateMissing: () => apiClient.post<any>('/tickets/generate-missing', {}),
|
||||
validate: (ticketId: string, data: any) => apiClient.post<any>(`/tickets/${ticketId}/validate`, data),
|
||||
scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post<any>(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data),
|
||||
regenerate: (ticketId: string) => apiClient.post<any>(`/tickets/${ticketId}/regenerate`),
|
||||
|
||||
@@ -70,7 +70,9 @@ export default function ConfirmationPage() {
|
||||
const CONFIRMATION_GRACE_PERIOD_MS = 10_000;
|
||||
const FAST_POLL_INTERVAL_MS = 2_500;
|
||||
const SLOW_POLL_INTERVAL_MS = 10_000;
|
||||
const MAX_TICKET_POLL_ATTEMPTS = 12; // 12 × 2.5s = 30s max wait for tickets
|
||||
const mountTimeRef = useRef(Date.now());
|
||||
const ticketPollAttemptsRef = useRef(0);
|
||||
const [withinGracePeriod, setWithinGracePeriod] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,7 +124,13 @@ export default function ConfirmationPage() {
|
||||
if (!data || data.status !== "CONFIRMED") return false;
|
||||
const adultCount = searchCriteria?.adultCount ?? passengers.filter((p) => !isChild(p)).length;
|
||||
const expectedTickets = Math.max(1, adultCount);
|
||||
return (data.tickets?.length ?? 0) >= expectedTickets ? false : FAST_POLL_INTERVAL_MS;
|
||||
if ((data.tickets?.length ?? 0) >= expectedTickets) {
|
||||
ticketPollAttemptsRef.current = 0;
|
||||
return false;
|
||||
}
|
||||
if (ticketPollAttemptsRef.current >= MAX_TICKET_POLL_ATTEMPTS) return false;
|
||||
ticketPollAttemptsRef.current += 1;
|
||||
return FAST_POLL_INTERVAL_MS;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -117,6 +117,19 @@ function BookingDetailContent() {
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
// When the booking is CONFIRMED but has no tickets (generate failed silently at
|
||||
// payment time), call generate now so tickets are ready before the user clicks Download.
|
||||
useEffect(() => {
|
||||
if (
|
||||
booking?.status === 'CONFIRMED' &&
|
||||
booking?.id &&
|
||||
Array.isArray(booking?.tickets) &&
|
||||
booking.tickets.length === 0
|
||||
) {
|
||||
apiClient.post(`/tickets/generate/${booking.id}`, {}).then(() => refetch()).catch(() => {});
|
||||
}
|
||||
}, [booking?.id, booking?.status, booking?.tickets?.length]);
|
||||
|
||||
const { data: paymentMethods } = useQuery<any[]>({
|
||||
queryKey: ["payment-methods"],
|
||||
queryFn: () => apiClient.get("/payments/methods"),
|
||||
@@ -246,19 +259,9 @@ function BookingDetailContent() {
|
||||
alert("Booking data not available. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVoucher(true);
|
||||
try {
|
||||
// If tickets are missing (generate failed silently at payment time), issue them now.
|
||||
if (!booking.tickets?.length && booking.id) {
|
||||
try {
|
||||
await apiClient.post(`/tickets/generate/${booking.id}`, {});
|
||||
} catch {
|
||||
// ignore — generate() will throw if payment not succeeded; voucher will show
|
||||
// "Not yet issued" in that case, which is correct
|
||||
}
|
||||
}
|
||||
// Always refetch so the voucher has the latest ticket barcodes.
|
||||
// Always fetch fresh booking data so tickets are included.
|
||||
const fresh = await apiClient.get<any>(`/bookings/${booking.bookingRef}`);
|
||||
const bookingData = (fresh as any)?.data || fresh;
|
||||
const { generateVoucherPDF } = await import("@/lib/generate-voucher");
|
||||
|
||||
@@ -59,6 +59,22 @@ export default defineConfig({
|
||||
* user seeders are disabled in app code, so the fixture replicates
|
||||
* their output. Idempotent — safe to run before every spec file.
|
||||
*/
|
||||
/** Apply one idempotent SQL fixture from cypress/fixtures (arrange-data). */
|
||||
async "db:seedFile"(file: string) {
|
||||
const client = new Client({ connectionString: dbUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
const sql = readFileSync(
|
||||
join(process.cwd(), "cypress", "fixtures", file),
|
||||
"utf8",
|
||||
);
|
||||
await client.query(sql);
|
||||
return true;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
|
||||
async "db:seedUsers"() {
|
||||
// cwd = the e2e/freight project root when Cypress runs.
|
||||
// seed-company.sql depends on rows from seed-users.sql — keep order.
|
||||
|
||||
524
e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
Normal file
524
e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Export ONE_TIME journeys — two contracts ride the same export train
|
||||
* (Mojo Dry Port → Dire Dawa Yard → Djibouti Port Terminal):
|
||||
*
|
||||
* A. CONTAINER (20ft + 40ft):
|
||||
* portal wizard → staff approval chain → OTP sign → counter-sign
|
||||
* → AWAITING_CLEARANCE_DOCUMENTS (export self-clear has no required
|
||||
* docs in e2e) → ops finalize → FULLY_EXECUTED → customer books
|
||||
* 2 × 20ft + 1 × 40ft picking a real Shipment day → ops accepts the
|
||||
* operation request → EXPORT is FCFS, so accept reserves the train slot
|
||||
* immediately: SELECTED_FOR_BATCH with a pay deadline clamped to the
|
||||
* export window close → staff mark-paid → PAID + SCHEDULED + linked.
|
||||
*
|
||||
* B. BULK (E2E Wheat, 60 tons): same journey through the bulk wizard and
|
||||
* bulk booking form, riding CW4 covered wagons on the same train.
|
||||
*
|
||||
* Infrastructure (route, built train, distances, rates, cargo types) comes
|
||||
* from seed-intercity.sql + seed-export.sql; the export route and the
|
||||
* departing-today schedule are created through the UI when missing.
|
||||
*
|
||||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||
*/
|
||||
|
||||
const customer = "user@gmail.com";
|
||||
const companyTin = "0102030405"; // seed-company.sql
|
||||
const opsStaff = "operation@edr.local";
|
||||
|
||||
const ORIGIN_YARD = "Mojo Dry Port";
|
||||
const MID_YARD = "Dire Dawa Yard";
|
||||
const PORT_YARD = "Djibouti Port Terminal";
|
||||
const TRAIN_CODE = "TRN-E2E-1";
|
||||
|
||||
// Container numbers must be ISO (4 letters + 7 digits) and unused — stamp per run.
|
||||
const stamp = String(Date.now());
|
||||
const isoNumber = (prefix: string, offset: number) =>
|
||||
`${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`;
|
||||
|
||||
const apiUrl = () => Cypress.env("apiUrl") as string;
|
||||
|
||||
function dbContract(freight: "CONTAINER" | "BULK") {
|
||||
return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>(
|
||||
"db:query",
|
||||
{
|
||||
sql: `SELECT ct.id, ct.reference, ct.status
|
||||
FROM freight.contracts ct
|
||||
JOIN freight.companies c ON c.id = ct.company_id
|
||||
WHERE c.tin = $1 AND ct.trade_direction = 'EXPORT' AND ct.freight_type = $2
|
||||
ORDER BY ct.created_at DESC LIMIT 1`,
|
||||
params: [companyTin, freight],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function withContract(
|
||||
freight: "CONTAINER" | "BULK",
|
||||
fn: (c: { id: string; reference: string; status: string }) => void,
|
||||
) {
|
||||
dbContract(freight).then(({ rows }) => {
|
||||
expect(rows, `latest EXPORT ${freight} contract`).to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function expectContractStatus(freight: "CONTAINER" | "BULK", expected: string) {
|
||||
dbContract(freight).then(({ rows }) => {
|
||||
expect(rows[0]?.status, "contract status").to.eq(expected);
|
||||
});
|
||||
}
|
||||
|
||||
function dbBooking(freight: "CONTAINER" | "BULK") {
|
||||
return cy.task<{
|
||||
rows: Array<{
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
scheduling_status: string;
|
||||
train_schedule_id: string | null;
|
||||
payment_deadline: string | null;
|
||||
scheduled_date: string | null;
|
||||
}>;
|
||||
}>("db:query", {
|
||||
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
|
||||
b.train_schedule_id, b.payment_deadline, b.scheduled_date
|
||||
FROM freight.bookings b
|
||||
JOIN freight.companies c ON c.id = b.company_id
|
||||
WHERE c.tin = $1 AND b.trade_direction = 'EXPORT' AND b.freight_type = $2
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
params: [companyTin, freight],
|
||||
});
|
||||
}
|
||||
|
||||
function withBooking(
|
||||
freight: "CONTAINER" | "BULK",
|
||||
fn: (b: {
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
scheduling_status: string;
|
||||
train_schedule_id: string | null;
|
||||
payment_deadline: string | null;
|
||||
scheduled_date: string | null;
|
||||
}) => void,
|
||||
) {
|
||||
dbBooking(freight).then(({ rows }) => {
|
||||
expect(rows, `EXPORT ${freight} booking`).to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The journey's export schedule. Export trains must be scheduled ≥ the booking
|
||||
* lead (24h) ahead, and their window OPENS at departure − lead — so the spec
|
||||
* departs at now + 24h + a couple of minutes: creatable now, window opens
|
||||
* minutes later. (The intercity spec's train leaves in 2 days — outside 25h.)
|
||||
*/
|
||||
function dbUpcomingSchedule() {
|
||||
return cy.task<{
|
||||
rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>;
|
||||
}>("db:query", {
|
||||
sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status
|
||||
FROM freight.train_schedules ts
|
||||
WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL
|
||||
AND ts.scheduled_departure_date > now()
|
||||
AND ts.scheduled_departure_date < now() + interval '25 hours'
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
});
|
||||
}
|
||||
|
||||
/** The train departs ~24h out — bookings ride its departure day (tomorrow). */
|
||||
const SHIPMENT_DAY = new Date(Date.now() + 24 * 3_600_000 + 150_000);
|
||||
|
||||
function fill(label: string | RegExp, value: string) {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */
|
||||
function fillNth(label: RegExp, index: number, value: string) {
|
||||
cy.get("label").then(($labels) => {
|
||||
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
|
||||
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
|
||||
const id = matches.eq(index).attr("for");
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the train's departure day on the Schedule card's INLINE calendar
|
||||
* (cargo-aware: days only unlock once the cargo details above are valid).
|
||||
*/
|
||||
function pickShipmentDay() {
|
||||
cy.contains(/available day/, { timeout: 30000 }).should("exist");
|
||||
// Day cells are plain buttons in a div grid; only bookable days are enabled
|
||||
// (out-of-month duplicates stay disabled).
|
||||
const day = String(SHIPMENT_DAY.getDate());
|
||||
cy.get("button:not(:disabled)", { timeout: 15000 })
|
||||
.contains(new RegExp(`^${day}$`))
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
/** Shared staff steps: accept + LINE_STAFF approve, then director approve. */
|
||||
function approveChain(freight: "CONTAINER" | "BULK") {
|
||||
cy.loginBackoffice("marketer@edr.local");
|
||||
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
||||
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
cy.contains("Approval chain", { timeout: 20000 }).should("be.visible");
|
||||
cy.contains("button", "Approve", { timeout: 20000 }).click();
|
||||
cy.contains("button", "Confirm approval").click();
|
||||
cy.contains("1/2", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
cy.loginBackoffice("director@edr.local");
|
||||
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
cy.contains("button", "Approve", { timeout: 20000 }).click();
|
||||
cy.contains("button", "Confirm approval").click();
|
||||
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
|
||||
expectContractStatus(freight, "CONTRACT_READY");
|
||||
}
|
||||
|
||||
/** Shared customer OTP-signature step. */
|
||||
function customerSigns(freight: "CONTAINER" | "BULK") {
|
||||
cy.loginPortal(customer);
|
||||
withContract(freight, (c) => cy.visitPortal(`/contracts/${c.id}/view`));
|
||||
|
||||
const unlockConsent = (attempt: number) => {
|
||||
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => {
|
||||
const win = ($f[0] as HTMLIFrameElement).contentWindow;
|
||||
const el = win?.document?.scrollingElement ?? win?.document?.documentElement;
|
||||
if (win && el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
win.dispatchEvent(new Event("scroll"));
|
||||
}
|
||||
});
|
||||
cy.wait(500).then(() => {
|
||||
cy.get("body").then(($b) => {
|
||||
if ($b.text().includes("I have read the entire contract")) return;
|
||||
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
||||
unlockConsent(attempt + 1);
|
||||
});
|
||||
});
|
||||
};
|
||||
unlockConsent(0);
|
||||
|
||||
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
|
||||
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
||||
cy.contains("label", "Full name")
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
||||
});
|
||||
cy.drawSignature();
|
||||
cy.contains("button", "Continue to verification").click();
|
||||
cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
|
||||
cy.getOtp(customer).then((otp) => cy.typeOtp(otp));
|
||||
cy.contains("button", "Verify & sign").click();
|
||||
cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible");
|
||||
expectContractStatus(freight, "SIGNED_CUSTOMER");
|
||||
}
|
||||
|
||||
/** Shared counter-sign + ops finalize (export self-clear: no required docs in e2e). */
|
||||
function counterSignAndFinalize(freight: "CONTAINER" | "BULK") {
|
||||
cy.loginBackoffice("marketer@edr.local");
|
||||
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
|
||||
cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click();
|
||||
cy.contains("label", "Full name")
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
||||
});
|
||||
cy.drawSignature();
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
||||
.click();
|
||||
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
|
||||
expectContractStatus(freight, "AWAITING_CLEARANCE_DOCUMENTS");
|
||||
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
|
||||
cy.contains("button", "Finalize document approval", { timeout: 30000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
cy.contains("finalized", { timeout: 30000 }).should("be.visible");
|
||||
expectContractStatus(freight, "FULLY_EXECUTED");
|
||||
}
|
||||
|
||||
/** Ops accept: EXPORT is FCFS — accept reserves the slot and opens the pay window. */
|
||||
function acceptAndAssertReserved(freight: "CONTAINER" | "BULK") {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withBooking(freight, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
|
||||
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
|
||||
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
|
||||
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
|
||||
cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist");
|
||||
|
||||
dbUpcomingSchedule().then(({ rows: schedules }) => {
|
||||
expect(schedules, "departing-today export schedule").to.have.length(1);
|
||||
withBooking(freight, (b) => {
|
||||
expect(b.status, "FCFS reservation").to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id).to.eq(schedules[0].id);
|
||||
// Export parity: the pay window never outlives the booking window close.
|
||||
expect(b.payment_deadline, "pay deadline set").to.be.a("string");
|
||||
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
|
||||
new Date(schedules[0].window_closes_at).getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function markPaidAndAssertAllocated(freight: "CONTAINER" | "BULK") {
|
||||
withBooking(freight, (b) => {
|
||||
cy.apiLogin(opsStaff).then(({ token }) => {
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
});
|
||||
withBooking(freight, (b) => {
|
||||
expect(b.status).to.eq("PAID");
|
||||
expect(b.scheduling_status).to.eq("SCHEDULED");
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
params: [b.id],
|
||||
}).then(({ rows }) => {
|
||||
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("export one-time journeys: container + bulk on one train", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-intercity.sql");
|
||||
cy.task("db:seedFile", "seed-export.sql");
|
||||
});
|
||||
|
||||
// ── Shared infrastructure ─────────────────────────────────────────────────
|
||||
|
||||
it("operations ensures the export route exists", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n
|
||||
FROM freight.routes r
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
|
||||
WHERE r.deleted_at IS NULL`,
|
||||
}).then(({ rows }) => {
|
||||
if (Number(rows[0].n) > 0) return;
|
||||
|
||||
cy.visit("/dashboard/routes");
|
||||
cy.contains("button", "Add route", { timeout: 20000 }).click();
|
||||
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
|
||||
const pickYard = (index: number, yard: string) => {
|
||||
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
|
||||
.eq(index)
|
||||
.click({ force: true });
|
||||
cy.get('[role="option"]:visible').contains(yard).click();
|
||||
};
|
||||
pickYard(0, ORIGIN_YARD);
|
||||
pickYard(1, MID_YARD);
|
||||
pickYard(2, PORT_YARD);
|
||||
cy.get(".mantine-Modal-content").contains("button", "Save").click();
|
||||
});
|
||||
});
|
||||
|
||||
it("operations schedules the export train — booking window opens", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
|
||||
dbUpcomingSchedule().then(({ rows }) => {
|
||||
if (rows.length > 0) return;
|
||||
|
||||
cy.visit("/dashboard/operations/train-scheduling-v2");
|
||||
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||||
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||||
cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
|
||||
|
||||
// Just past the 24h scheduling lead: creatable now, and the export
|
||||
// window (opens departure − lead) flips OPEN a couple of minutes later.
|
||||
const local = new Date(
|
||||
SHIPMENT_DAY.getTime() - SHIPMENT_DAY.getTimezoneOffset() * 60000,
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
cy.get('.mantine-Modal-content input[type="datetime-local"]')
|
||||
.clear({ force: true })
|
||||
.type(local, { force: true });
|
||||
cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE));
|
||||
cy.get(".mantine-Modal-content").contains("button", "Create").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should(
|
||||
"match",
|
||||
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
|
||||
);
|
||||
});
|
||||
|
||||
// The 10s window tick flips PRE_WINDOW → OPEN once the lead moment passes.
|
||||
const waitForOpenWindow = (attempt: number) => {
|
||||
dbUpcomingSchedule().then(({ rows }) => {
|
||||
expect(rows, "upcoming export schedule").to.have.length(1);
|
||||
if (rows[0].booking_window_status === "OPEN") return;
|
||||
expect(attempt, "export booking window OPEN").to.be.lessThan(40);
|
||||
cy.wait(10000).then(() => waitForOpenWindow(attempt + 1));
|
||||
});
|
||||
};
|
||||
waitForOpenWindow(0);
|
||||
});
|
||||
|
||||
// ── Journey A: container 20ft + 40ft ──────────────────────────────────────
|
||||
|
||||
it("customer submits an export container contract (20ft + 40ft)", () => {
|
||||
cy.loginPortal(customer);
|
||||
cy.visitPortal("/contracts/new");
|
||||
|
||||
cy.mantineSelect(/^Operation Type/, /^Export$/);
|
||||
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
|
||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
|
||||
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
|
||||
cy.get('[role="checkbox"][aria-label="40ft Container"]').click();
|
||||
cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics");
|
||||
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
|
||||
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
cy.contains("button", "Submit").click({ force: true });
|
||||
cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Approve & submit").click();
|
||||
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
|
||||
|
||||
expectContractStatus("CONTAINER", "SUBMITTED");
|
||||
});
|
||||
|
||||
it("staff approve the container contract (marketer + director)", () => {
|
||||
approveChain("CONTAINER");
|
||||
});
|
||||
|
||||
it("customer signs the container contract with OTP", () => {
|
||||
customerSigns("CONTAINER");
|
||||
});
|
||||
|
||||
it("staff counter-sign and operations finalize the container contract", () => {
|
||||
counterSignAndFinalize("CONTAINER");
|
||||
});
|
||||
|
||||
it("customer books 2 × 20ft + 1 × 40ft with a shipment day", () => {
|
||||
cy.loginPortal(customer);
|
||||
withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
// Two size editors, each with its own "Quantity *" (20ft first, then 40ft).
|
||||
fillNth(/^Quantity/, 0, "2");
|
||||
fillNth(/^Quantity/, 1, "1");
|
||||
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3);
|
||||
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0));
|
||||
cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", 1));
|
||||
cy.get('input[placeholder*="MSCU"]').eq(2).type(isoNumber("FSCU", 2));
|
||||
|
||||
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
||||
cy.wrap($input).clear({ force: true }).type("10", { force: true });
|
||||
});
|
||||
|
||||
pickShipmentDay();
|
||||
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
|
||||
withBooking("CONTAINER", (b) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
|
||||
});
|
||||
});
|
||||
|
||||
it("operations accepts the container request — FCFS reserves today's train", () => {
|
||||
acceptAndAssertReserved("CONTAINER");
|
||||
});
|
||||
|
||||
it("staff mark the container booking paid — allocated onto the train", () => {
|
||||
markPaidAndAssertAllocated("CONTAINER");
|
||||
});
|
||||
|
||||
// ── Journey B: bulk (E2E Wheat) ───────────────────────────────────────────
|
||||
|
||||
it("customer submits an export bulk contract (wheat)", () => {
|
||||
cy.loginPortal(customer);
|
||||
cy.visitPortal("/contracts/new");
|
||||
|
||||
cy.mantineSelect(/^Operation Type/, /^Export$/);
|
||||
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
|
||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/);
|
||||
cy.mantineSelect(/^Bulk Cargo Type/, "E2E Grains");
|
||||
cy.mantineSelect(/^Commodity/, "E2E Wheat");
|
||||
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
|
||||
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
cy.contains("button", "Submit").click({ force: true });
|
||||
cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Approve & submit").click();
|
||||
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
|
||||
|
||||
expectContractStatus("BULK", "SUBMITTED");
|
||||
});
|
||||
|
||||
it("staff approve the bulk contract (marketer + director)", () => {
|
||||
approveChain("BULK");
|
||||
});
|
||||
|
||||
it("customer signs the bulk contract with OTP", () => {
|
||||
customerSigns("BULK");
|
||||
});
|
||||
|
||||
it("staff counter-sign and operations finalize the bulk contract", () => {
|
||||
counterSignAndFinalize("BULK");
|
||||
});
|
||||
|
||||
it("customer books 60 tons of wheat with a shipment day", () => {
|
||||
cy.loginPortal(customer);
|
||||
withContract("BULK", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
fill(/^Quantity \(tons\)/, "60");
|
||||
pickShipmentDay();
|
||||
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
|
||||
withBooking("BULK", (b) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
});
|
||||
});
|
||||
|
||||
it("operations accepts the bulk request — FCFS reserves today's train", () => {
|
||||
acceptAndAssertReserved("BULK");
|
||||
});
|
||||
|
||||
it("staff mark the bulk booking paid — allocated onto the train", () => {
|
||||
markPaidAndAssertAllocated("BULK");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
574
e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts
Normal file
574
e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Intercity ONE_TIME journey — the full life of a domestic ride-along shipment:
|
||||
*
|
||||
* 1. portal — customer creates an INTERCITY / One-Time / Container contract
|
||||
* (Mojo Dry Port → Dire Dawa Yard) and submits it
|
||||
* 2. backoffice — marketer REJECTS it with a reason
|
||||
* 3. portal — customer sees the rejection banner + reason, then submits a
|
||||
* fresh contract
|
||||
* 4. backoffice — marketer accepts + approves LINE_STAFF, director approves
|
||||
* → CONTRACT_READY
|
||||
* 5. portal — customer OTP-signs → SIGNED_CUSTOMER
|
||||
* 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS
|
||||
* (ONE_TIME intercity always routes through the
|
||||
* intercity-documents step; the fixture seeds one REQUIRED
|
||||
* document so the step is real)
|
||||
* 6b. portal — customer uploads the required intercity document
|
||||
* → CLEARANCE_UNDER_REVIEW
|
||||
* 7. backoffice — operations approves the document, then finalizes document
|
||||
* approval → FULLY_EXECUTED
|
||||
* 8. portal — customer books 2 × 20ft under the contract (intercity has
|
||||
* no shipment date) → booking OPERATION_REQUEST_PENDING
|
||||
* 9. backoffice — operations accepts the operation request → booking
|
||||
* FULLY_EXECUTED (intercity waiting pool)
|
||||
* 10. backoffice — operations creates the EXPORT route
|
||||
* Mojo → Dire Dawa → Djibouti Port (distances seeded)
|
||||
* 11. backoffice — operations schedules the export train (built Train-Builder
|
||||
* train seeded by seed-intercity.sql)
|
||||
* 12. backoffice — operations accepts the intercity booking onto the train
|
||||
* (Workspace → Intercity ride-along) → SELECTED_FOR_BATCH with
|
||||
* a pay deadline that never outlives the export window close
|
||||
* 13. staff mark-paid (API — the batch panel has no mounted UI button)
|
||||
* → PAID + SCHEDULED + linked to the schedule
|
||||
*
|
||||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||
*/
|
||||
|
||||
const customer = "user@gmail.com";
|
||||
const companyTin = "0102030405"; // seed-company.sql
|
||||
const opsStaff = "operation@edr.local";
|
||||
|
||||
const ORIGIN_YARD = "Mojo Dry Port";
|
||||
const DEST_YARD = "Dire Dawa Yard";
|
||||
const PORT_YARD = "Djibouti Port Terminal";
|
||||
const TRAIN_CODE = "TRN-E2E-1";
|
||||
|
||||
const apiUrl = () => Cypress.env("apiUrl") as string;
|
||||
|
||||
/** Latest contract of the seeded company — the journey's contract. */
|
||||
function dbContract() {
|
||||
return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>(
|
||||
"db:query",
|
||||
{
|
||||
sql: `SELECT ct.id, ct.reference, ct.status
|
||||
FROM freight.contracts ct
|
||||
JOIN freight.companies c ON c.id = ct.company_id
|
||||
WHERE c.tin = $1 AND ct.trade_direction = 'DOMESTIC'
|
||||
ORDER BY ct.created_at DESC LIMIT 1`,
|
||||
params: [companyTin],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function withContract(fn: (c: { id: string; reference: string; status: string }) => void) {
|
||||
dbContract().then(({ rows }) => {
|
||||
expect(rows, "latest contract for the seeded company").to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function expectContractStatus(expected: string) {
|
||||
dbContract().then(({ rows }) => {
|
||||
expect(rows[0]?.status, "contract status").to.eq(expected);
|
||||
});
|
||||
}
|
||||
|
||||
/** Latest DOMESTIC booking of the seeded company — the journey's booking. */
|
||||
function dbBooking() {
|
||||
return cy.task<{
|
||||
rows: Array<{
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
scheduling_status: string;
|
||||
train_schedule_id: string | null;
|
||||
payment_deadline: string | null;
|
||||
scheduled_date: string | null;
|
||||
}>;
|
||||
}>("db:query", {
|
||||
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
|
||||
b.train_schedule_id, b.payment_deadline, b.scheduled_date
|
||||
FROM freight.bookings b
|
||||
JOIN freight.companies c ON c.id = b.company_id
|
||||
WHERE c.tin = $1 AND b.trade_direction = 'DOMESTIC'
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
params: [companyTin],
|
||||
});
|
||||
}
|
||||
|
||||
function withBooking(
|
||||
fn: (b: {
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
scheduling_status: string;
|
||||
train_schedule_id: string | null;
|
||||
payment_deadline: string | null;
|
||||
scheduled_date: string | null;
|
||||
}) => void,
|
||||
) {
|
||||
dbBooking().then(({ rows }) => {
|
||||
expect(rows, "intercity booking for the seeded company").to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/** Latest export schedule created by this journey. */
|
||||
function dbSchedule() {
|
||||
return cy.task<{
|
||||
rows: Array<{ id: string; status: string; direction: string; window_closes_at: string }>;
|
||||
}>("db:query", {
|
||||
sql: `SELECT ts.id, ts.status, ts.direction, ts.window_closes_at
|
||||
FROM freight.train_schedules ts
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill a labelled Mantine input (label[for] → input id). */
|
||||
function fill(label: string | RegExp, value: string) {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the portal wizard for an INTERCITY / One-Time / Container contract and
|
||||
* submit it. Reused for the initial (to-be-rejected) and the second contract.
|
||||
*/
|
||||
function createIntercityContract() {
|
||||
cy.loginPortal(customer);
|
||||
cy.visitPortal("/contracts/new");
|
||||
|
||||
// Step 0 — Setup. Intercity forces ETB and hides the customs section.
|
||||
cy.mantineSelect(/^Operation Type/, /^Intercity$/);
|
||||
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
|
||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
// Step 1 — Cargo & Route (Ethiopian yards only for intercity).
|
||||
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
|
||||
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
|
||||
cy.get('textarea[placeholder*="Electronics"]').type(
|
||||
"E2E intercity electronics between Ethiopian yards",
|
||||
);
|
||||
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
|
||||
cy.mantineSelect(/^Destination Yard/, DEST_YARD);
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
// Step 2 — Review & Submit → quotation modal.
|
||||
cy.contains("button", "Submit").click({ force: true });
|
||||
cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Approve & submit").click();
|
||||
|
||||
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
|
||||
|
||||
dbContract().then(({ rows }) => {
|
||||
expect(rows, "contract row").to.have.length(1);
|
||||
expect(rows[0].status).to.eq("SUBMITTED");
|
||||
expect(rows[0].reference).to.match(/^CTR-/);
|
||||
});
|
||||
}
|
||||
|
||||
describe("intercity one-time journey: contract → booking → export train", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
// Container types, locomotives, built train, yard distances — the
|
||||
// infrastructure the UI journey cannot create in-flow.
|
||||
cy.task("db:seedFile", "seed-intercity.sql");
|
||||
});
|
||||
|
||||
// ── Contract: submit → reject → resubmit → approve → sign ────────────────
|
||||
|
||||
it("customer submits an intercity one-time contract", () => {
|
||||
createIntercityContract();
|
||||
});
|
||||
|
||||
it("marketer rejects the submission with a reason", () => {
|
||||
cy.loginBackoffice("marketer@edr.local");
|
||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
|
||||
cy.contains("button", "Reject contract", { timeout: 20000 }).click();
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("label", "Reason for rejection")
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).type("E2E rejection — cargo details incomplete");
|
||||
});
|
||||
cy.get(".mantine-Modal-content").contains("button", /^Reject$/).click();
|
||||
|
||||
// Modal closes on success; the status pill can sit inside clipped layout,
|
||||
// so the authoritative check is the DB row.
|
||||
cy.get(".mantine-Modal-content", { timeout: 20000 }).should("not.exist");
|
||||
expectContractStatus("REJECTED");
|
||||
});
|
||||
|
||||
it("customer sees the rejection reason on the contracts list", () => {
|
||||
cy.loginPortal(customer);
|
||||
cy.visitPortal("/contracts");
|
||||
|
||||
// The list is a collapsed table — expand the rejected contract's row to
|
||||
// reveal its step banner with the staff reason.
|
||||
withContract((c) => {
|
||||
cy.contains("tr", c.reference, { timeout: 20000 })
|
||||
.find("button")
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
cy.contains("This contract was rejected.", { timeout: 20000 }).should("be.visible");
|
||||
cy.contains("Reason: E2E rejection — cargo details incomplete").should("be.visible");
|
||||
});
|
||||
|
||||
it("customer submits a fresh intercity contract", () => {
|
||||
createIntercityContract();
|
||||
});
|
||||
|
||||
it("marketer accepts the submission and approves the LINE_STAFF step", () => {
|
||||
cy.loginBackoffice("marketer@edr.local");
|
||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
|
||||
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
||||
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
cy.contains("Approval chain", { timeout: 20000 }).should("be.visible");
|
||||
cy.contains("button", "Approve", { timeout: 20000 }).click();
|
||||
cy.contains("button", "Confirm approval").click();
|
||||
cy.contains("1/2", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
expectContractStatus("PENDING_APPROVAL");
|
||||
});
|
||||
|
||||
it("director approves the final step — contract PDF becomes ready", () => {
|
||||
cy.loginBackoffice("director@edr.local");
|
||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
|
||||
cy.contains("button", "Approve", { timeout: 20000 }).click();
|
||||
cy.contains("button", "Confirm approval").click();
|
||||
|
||||
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
|
||||
expectContractStatus("CONTRACT_READY");
|
||||
});
|
||||
|
||||
it("customer signs the contract with OTP", () => {
|
||||
cy.loginPortal(customer);
|
||||
withContract((c) => cy.visitPortal(`/contracts/${c.id}/view`));
|
||||
|
||||
// Scroll the contract iframe to the bottom so the consent bar unlocks.
|
||||
const unlockConsent = (attempt: number) => {
|
||||
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => {
|
||||
const win = ($f[0] as HTMLIFrameElement).contentWindow;
|
||||
const el = win?.document?.scrollingElement ?? win?.document?.documentElement;
|
||||
if (win && el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
win.dispatchEvent(new Event("scroll"));
|
||||
}
|
||||
});
|
||||
cy.wait(500).then(() => {
|
||||
cy.get("body").then(($b) => {
|
||||
if ($b.text().includes("I have read the entire contract")) return;
|
||||
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
||||
unlockConsent(attempt + 1);
|
||||
});
|
||||
});
|
||||
};
|
||||
unlockConsent(0);
|
||||
|
||||
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
|
||||
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
||||
|
||||
cy.contains("label", "Full name")
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
||||
});
|
||||
cy.drawSignature();
|
||||
cy.contains("button", "Continue to verification").click();
|
||||
|
||||
cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
|
||||
cy.getOtp(customer).then((otp) => cy.typeOtp(otp));
|
||||
cy.contains("button", "Verify & sign").click();
|
||||
|
||||
cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible");
|
||||
expectContractStatus("SIGNED_CUSTOMER");
|
||||
});
|
||||
|
||||
it("marketer counter-signs — intercity one-time enters the documents step", () => {
|
||||
cy.loginBackoffice("marketer@edr.local");
|
||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
|
||||
|
||||
cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click();
|
||||
cy.contains("label", "Full name")
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
||||
});
|
||||
cy.drawSignature();
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
||||
.click();
|
||||
|
||||
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
|
||||
|
||||
// DOMESTIC one-time always routes through the intercity-documents step —
|
||||
// unlike GENERAL, it does NOT go straight to CONTRACT_ACTIVE.
|
||||
expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS");
|
||||
});
|
||||
|
||||
it("customer uploads the required intercity document", () => {
|
||||
cy.loginPortal(customer);
|
||||
// Deep link auto-opens the clearance documents modal.
|
||||
withContract((c) => cy.visitPortal(`/contracts/${c.id}?action=clearance`));
|
||||
|
||||
cy.contains("Cargo Manifest", { timeout: 30000 }).should("exist");
|
||||
cy.get('.mantine-Modal-content input[type="file"]')
|
||||
.first()
|
||||
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
|
||||
cy.contains("button", "Submit documents", { timeout: 15000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
// Upload hands the contract to Operations review — the card flips to
|
||||
// "Under review" (the host modal may linger while queries refetch).
|
||||
cy.contains("Under review", { timeout: 30000 }).should("exist");
|
||||
expectContractStatus("CLEARANCE_UNDER_REVIEW");
|
||||
});
|
||||
|
||||
it("operations approves the document and finalizes — contract fully executed", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
|
||||
// The clearance review section lives behind its own tab on the detail page.
|
||||
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
|
||||
|
||||
// Approve the uploaded Cargo Manifest, then finalize.
|
||||
cy.contains("button", /Approve all/, { timeout: 30000 }).click();
|
||||
cy.contains("1/1 approved", { timeout: 30000 }).should("exist");
|
||||
|
||||
cy.contains("button", "Finalize document approval", { timeout: 30000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
cy.contains("finalized", { timeout: 30000 }).should("be.visible");
|
||||
expectContractStatus("FULLY_EXECUTED");
|
||||
});
|
||||
|
||||
// ── Booking under the contract ────────────────────────────────────────────
|
||||
|
||||
it("customer books 2 × 20ft under the contract (no shipment date for intercity)", () => {
|
||||
cy.loginPortal(customer);
|
||||
withContract((c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
|
||||
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
// 20ft quantities must be even (pairs share a wagon).
|
||||
fill(/^Quantity/, "2");
|
||||
|
||||
// One ISO container number per unit.
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
|
||||
cy.get('input[placeholder*="MSCU"]').eq(0).type("MSCU1234567");
|
||||
cy.get('input[placeholder*="MSCU"]').eq(1).type("TCLU7654321");
|
||||
|
||||
// VGM per unit — column inputs carry a placeholder, not a linked label.
|
||||
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
||||
cy.wrap($input).clear({ force: true }).type("10", { force: true });
|
||||
});
|
||||
|
||||
// Intercity: no "Shipment day" picker — the ride-along note renders instead.
|
||||
cy.contains("Shipment day").should("not.exist");
|
||||
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
|
||||
withBooking((b) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("operations accepts the operation request — booking joins the intercity pool", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
|
||||
|
||||
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
|
||||
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
|
||||
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
|
||||
|
||||
withBooking((b) => {
|
||||
expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED");
|
||||
expect(b.train_schedule_id).to.eq(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route + export schedule ───────────────────────────────────────────────
|
||||
|
||||
it("operations creates the export route Mojo → Dire Dawa → Djibouti Port", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
|
||||
// Skip creation when a previous run already added the route (unique yards
|
||||
// pair) — the journey stays re-runnable against a warm DB.
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n
|
||||
FROM freight.routes r
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
|
||||
WHERE r.deleted_at IS NULL`,
|
||||
}).then(({ rows }) => {
|
||||
if (Number(rows[0].n) > 0) return;
|
||||
|
||||
cy.visit("/dashboard/routes");
|
||||
cy.contains("button", "Add route", { timeout: 20000 }).click();
|
||||
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
|
||||
|
||||
// Third stop row, then fill Origin / Milestone / Destination in order.
|
||||
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
|
||||
const pickYard = (index: number, yard: string) => {
|
||||
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
|
||||
.eq(index)
|
||||
.click({ force: true });
|
||||
// Three yard selects share option texts — only the open dropdown counts.
|
||||
cy.get('[role="option"]:visible').contains(yard).click();
|
||||
};
|
||||
pickYard(0, ORIGIN_YARD);
|
||||
pickYard(1, DEST_YARD);
|
||||
pickYard(2, PORT_YARD);
|
||||
|
||||
// Distances (when the build has them) resolve from the seeded rows.
|
||||
cy.get(".mantine-Modal-content").contains("button", "Save").click();
|
||||
});
|
||||
|
||||
cy.task<{ rows: Array<{ direction: string }> }>("db:query", {
|
||||
sql: `SELECT r.direction
|
||||
FROM freight.routes r
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
|
||||
WHERE r.deleted_at IS NULL`,
|
||||
}).then(({ rows }) => {
|
||||
expect(rows, "export route").to.have.length.at.least(1);
|
||||
expect(rows[0].direction).to.eq("EXPORT");
|
||||
});
|
||||
});
|
||||
|
||||
it("operations schedules the export train from the built consist", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
|
||||
// One departure per route per day — a warm DB from a previous run already
|
||||
// has this train scheduled, so only create when none is live.
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.routes r ON r.id = ts.route_id
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||||
WHERE ts.status IN ('DRAFT', 'SCHEDULED') AND ts.deleted_at IS NULL`,
|
||||
}).then(({ rows }) => {
|
||||
if (Number(rows[0].n) > 0) return;
|
||||
|
||||
cy.visit("/dashboard/operations/train-scheduling-v2");
|
||||
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||||
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||||
|
||||
cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
|
||||
|
||||
// Two days out, local datetime-local format.
|
||||
const departure = new Date(Date.now() + 2 * 86400000);
|
||||
const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
cy.get('.mantine-Modal-content input[type="datetime-local"]')
|
||||
.clear({ force: true })
|
||||
.type(local, { force: true });
|
||||
|
||||
cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE));
|
||||
cy.get(".mantine-Modal-content").contains("button", "Create").click();
|
||||
|
||||
// Create navigates straight to the new schedule's detail page.
|
||||
cy.location("pathname", { timeout: 30000 }).should(
|
||||
"match",
|
||||
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
|
||||
);
|
||||
});
|
||||
|
||||
dbSchedule().then(({ rows }) => {
|
||||
expect(rows, "created schedule").to.have.length(1);
|
||||
expect(rows[0].direction).to.eq("EXPORT");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Intercity ride-along: accept → pay → allocated ────────────────────────
|
||||
|
||||
it("operations accepts the intercity booking onto the export train", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
dbSchedule().then(({ rows: schedules }) => {
|
||||
cy.visit(`/dashboard/operations/train-scheduling-v2/${schedules[0].id}`);
|
||||
});
|
||||
|
||||
cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click();
|
||||
// Presence, not viewport visibility — the panel can sit below the fold /
|
||||
// inside clipped layout once earlier runs' rows stack up.
|
||||
cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist");
|
||||
|
||||
withBooking((b) => {
|
||||
cy.contains("tr", b.reference, { timeout: 30000 })
|
||||
.find('input[type="checkbox"]')
|
||||
.check({ force: true });
|
||||
cy.contains("button", /Accept .*onto this train/).click();
|
||||
|
||||
// Accepted table shows the pay-window state (inside a horizontal
|
||||
// Table.ScrollContainer — assert presence, not viewport visibility).
|
||||
cy.contains("Awaiting payment", { timeout: 30000 }).should("exist");
|
||||
});
|
||||
|
||||
// Export parity: the ride-along's pay deadline never outlives the export
|
||||
// booking window (reserve() clamps it to window_closes_at).
|
||||
dbSchedule().then(({ rows: schedules }) => {
|
||||
withBooking((b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id).to.eq(schedules[0].id);
|
||||
expect(b.payment_deadline, "pay deadline set").to.be.a("string");
|
||||
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
|
||||
new Date(schedules[0].window_closes_at).getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("staff mark the ride-along paid — booking allocates onto the train", () => {
|
||||
// ScheduleBatchPanel (the only "Mark paid" button) is not mounted in the
|
||||
// current UI, so drive the staff override endpoint directly.
|
||||
withBooking((b) => {
|
||||
cy.apiLogin(opsStaff).then(({ token }) => {
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
});
|
||||
|
||||
withBooking((b) => {
|
||||
expect(b.status).to.eq("PAID");
|
||||
expect(b.scheduling_status).to.eq("SCHEDULED");
|
||||
expect(b.train_schedule_id, "still pinned to the export train").to.be.a("string");
|
||||
|
||||
// The schedule↔booking link row is what makes the booking visible on the
|
||||
// train board, in yard work, and to the wagon planner.
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
params: [b.id],
|
||||
}).then(({ rows }) => {
|
||||
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
85
e2e/freight/cypress/fixtures/seed-export.sql
Normal file
85
e2e/freight/cypress/fixtures/seed-export.sql
Normal file
@@ -0,0 +1,85 @@
|
||||
-- Arrange-data for flows/export_one_time.cy.ts. Idempotent.
|
||||
-- Run AFTER seed-intercity.sql (reuses its container types, locomotives,
|
||||
-- built train TRN-E2E-1 and yard distances).
|
||||
--
|
||||
-- 1. bulk cargo hierarchy: group "E2E Grains" → commodity "E2E Wheat",
|
||||
-- carried on CW4 covered wagons
|
||||
-- 2. two CW4 wagons coupled onto the train (bulk capacity)
|
||||
-- 3. LIVE export rates for Mojo → Djibouti Port: container (per container)
|
||||
-- and bulk (per ton) — booking pricing hard-blocks without them
|
||||
|
||||
-- 0. Approved exporter profile — picking "Export" in the wizard opens the
|
||||
-- "Set up your exporter profile" modal unless the company already has an
|
||||
-- active exporter profile (seed-company.sql only creates the importer).
|
||||
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
|
||||
SELECT gen_random_uuid(), c.id, 'exporter', 'active', 'EXP-E2E-0001'
|
||||
FROM freight.companies c
|
||||
WHERE c.tin = '0102030405'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles p
|
||||
WHERE p.company_id = c.id AND p.type = 'exporter'
|
||||
);
|
||||
|
||||
-- 1a. Cargo type group + commodity.
|
||||
INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active)
|
||||
SELECT gen_random_uuid(), 'E2E_GRAINS', 'E2E Grains', true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_GRAINS');
|
||||
|
||||
INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active)
|
||||
SELECT gen_random_uuid(), 'E2E_WHEAT', 'E2E Wheat', g.id, true
|
||||
FROM freight.cargo_types g
|
||||
WHERE g.code = 'E2E_GRAINS'
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_WHEAT');
|
||||
|
||||
-- 1b. Wheat rides CW4 covered wagons.
|
||||
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
||||
SELECT ct.id, wt.id
|
||||
FROM freight.cargo_types ct
|
||||
JOIN freight.wagon_types wt ON wt.code = 'CW4'
|
||||
WHERE ct.code IN ('E2E_WHEAT', 'E2E_GRAINS')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.cargo_type_wagon_types x
|
||||
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||
);
|
||||
|
||||
-- 2. Couple two free CW4 wagons onto the train, parked at Mojo with it.
|
||||
UPDATE freight.wagons w
|
||||
SET train_id = t.id,
|
||||
sequence_number = 100 + sub.rn,
|
||||
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
|
||||
FROM freight.trains t,
|
||||
LATERAL (
|
||||
SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
|
||||
FROM freight.wagons w2
|
||||
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4'
|
||||
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
|
||||
ORDER BY w2.wagon_number
|
||||
LIMIT 2
|
||||
) sub
|
||||
WHERE t.code = 'TRN-E2E-1'
|
||||
AND w.id = sub.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.wagons wx
|
||||
JOIN freight.wagon_types wxt ON wxt.id = wx.wagon_type_id AND wxt.code = 'CW4'
|
||||
WHERE wx.train_id = t.id
|
||||
);
|
||||
|
||||
-- 3. LIVE export rates Mojo → Djibouti Port.
|
||||
INSERT INTO freight.rates
|
||||
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||
origin_yard_id, destination_yard_id, proposed_by_staff_id)
|
||||
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
|
||||
v.unit, 'LIVE', a.id, b.id, u.id
|
||||
FROM (VALUES
|
||||
('CONTAINER_EXPORT', 'CONTAINER', 600, 'PER_CONTAINER'),
|
||||
('BULK_EXPORT', 'BULK', 25, 'PER_TON')
|
||||
) AS v(rate_type, applies_to, value, unit)
|
||||
JOIN freight.yards a ON a.code = 'MOJO'
|
||||
JOIN freight.yards b ON b.code = 'DJIB_PORT'
|
||||
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.rates r
|
||||
WHERE r.rate_type = v.rate_type
|
||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||
AND r.deleted_at IS NULL
|
||||
);
|
||||
131
e2e/freight/cypress/fixtures/seed-intercity.sql
Normal file
131
e2e/freight/cypress/fixtures/seed-intercity.sql
Normal file
@@ -0,0 +1,131 @@
|
||||
-- Arrange-data for flows/intercity_one_time.cy.ts. Idempotent.
|
||||
--
|
||||
-- The e2e DB boots with yards + a wagon fleet only: no container types, no
|
||||
-- locomotives, no Train-Builder train, no yard distances, no routes. The spec
|
||||
-- drives route + schedule creation through the UI; this fixture provides only
|
||||
-- the infrastructure the UI journey cannot reasonably create in-flow:
|
||||
--
|
||||
-- 1. container types (booking form resolves 20ft/40ft by size_ft)
|
||||
-- 2. container-type → wagon-type allow-list (wagon planner)
|
||||
-- 3. two locomotives (a schedulable train needs >= 2)
|
||||
-- 4. a built Train-Builder train at Mojo with four NW5 flat wagons
|
||||
-- 5. yard distances for Mojo–Dire Dawa–Djibouti Port (route creation
|
||||
-- refuses unconfigured pairs)
|
||||
|
||||
-- 1. Container types.
|
||||
INSERT INTO freight.container_types (id, code, label, size_ft, is_active)
|
||||
SELECT gen_random_uuid(), v.code, v.label, v.size_ft, true
|
||||
FROM (VALUES ('20FT', '20FT', 20), ('40FT', '40FT', 40)) AS v(code, label, size_ft)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.container_types t WHERE t.code = v.code);
|
||||
|
||||
-- 2. 20ft/40ft containers ride NW5 flat wagons.
|
||||
INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id)
|
||||
SELECT ct.id, wt.id
|
||||
FROM freight.container_types ct
|
||||
JOIN freight.wagon_types wt ON wt.code = 'NW5'
|
||||
WHERE ct.code IN ('20FT', '40FT')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.container_type_wagon_types x
|
||||
WHERE x.container_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||
);
|
||||
|
||||
-- 3. Two locomotives at Mojo (status defaults to AVAILABLE).
|
||||
INSERT INTO freight.locomotives
|
||||
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
|
||||
SELECT gen_random_uuid(), v.code, 4000, 760, y.id
|
||||
FROM (VALUES ('LOCO-E2E-1'), ('LOCO-E2E-2')) AS v(code)
|
||||
JOIN freight.yards y ON y.code = 'MOJO'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
||||
|
||||
-- 4a. Built train at Mojo (status defaults to AVAILABLE).
|
||||
INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
|
||||
SELECT gen_random_uuid(), 'TRN-E2E-1', 'E2E Export Carrier', 2000, y.id
|
||||
FROM freight.yards y
|
||||
WHERE y.code = 'MOJO'
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-E2E-1');
|
||||
|
||||
-- 4b. Couple both locomotives (available-trains filter requires >= 2).
|
||||
INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
|
||||
SELECT gen_random_uuid(), t.id, l.id,
|
||||
row_number() OVER (ORDER BY l.code) - 1
|
||||
FROM freight.trains t
|
||||
JOIN freight.locomotives l ON l.code IN ('LOCO-E2E-1', 'LOCO-E2E-2')
|
||||
WHERE t.code = 'TRN-E2E-1'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.train_locomotives tl
|
||||
WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
|
||||
);
|
||||
|
||||
-- 4c. Couple four free NW5 flat wagons onto the train and park them at Mojo
|
||||
-- with it (the seeded fleet sits at Doraleh; the planner reads the consist by
|
||||
-- train_id, the yard only matters for warnings).
|
||||
UPDATE freight.wagons w
|
||||
SET train_id = t.id,
|
||||
sequence_number = sub.rn,
|
||||
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
|
||||
FROM freight.trains t,
|
||||
LATERAL (
|
||||
SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
|
||||
FROM freight.wagons w2
|
||||
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5'
|
||||
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
|
||||
ORDER BY w2.wagon_number
|
||||
LIMIT 4
|
||||
) sub
|
||||
WHERE t.code = 'TRN-E2E-1'
|
||||
AND w.id = sub.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id);
|
||||
|
||||
-- 4d. One REQUIRED intercity clearance document, so the journey exercises the
|
||||
-- real customer-upload → ops-review → finalize step (the seeder leaves the
|
||||
-- intercity_documents setting empty).
|
||||
INSERT INTO freight.file_upload_fields
|
||||
(id, setting_id, file_key, file_label, is_required, is_multiple, max_files,
|
||||
allowed_extensions, max_size_mb, display_order)
|
||||
SELECT gen_random_uuid(), s.id, 'cargo_manifest', 'Cargo Manifest', true, false, 1,
|
||||
'{pdf,jpg,jpeg,png}'::text[], 10, 1
|
||||
FROM freight.file_upload_settings s
|
||||
WHERE s.code = 'intercity_documents'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.file_upload_fields f
|
||||
WHERE f.setting_id = s.id AND f.file_key = 'cargo_manifest' AND f.deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing
|
||||
-- hard-blocks any container line without a rate on its exact leg; rates are
|
||||
-- configured in USD and converted to the booking currency).
|
||||
INSERT INTO freight.rates
|
||||
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||
origin_yard_id, destination_yard_id, proposed_by_staff_id)
|
||||
SELECT gen_random_uuid(), 'INTERCITY_CONTAINER', 'INTERCITY', 'ALWAYS', 'USD', 500,
|
||||
'PER_CONTAINER', 'LIVE', a.id, b.id, u.id
|
||||
FROM freight.yards a
|
||||
JOIN freight.yards b ON b.code = 'DIRE_DAWA'
|
||||
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||
WHERE a.code = 'MOJO'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.rates r
|
||||
WHERE r.rate_type = 'INTERCITY_CONTAINER'
|
||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||
AND r.deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- 6. Segment distances (symmetric — one row covers both directions). Guarded:
|
||||
-- an e2e image built from a branch that predates the yard_distances feature
|
||||
-- has no table, and its route form doesn't require distances either.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('freight.yard_distances') IS NOT NULL THEN
|
||||
INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
|
||||
SELECT gen_random_uuid(), a.id, b.id, v.km
|
||||
FROM (VALUES ('MOJO', 'DIRE_DAWA', 300), ('DIRE_DAWA', 'DJIB_PORT', 450))
|
||||
AS v(from_code, to_code, km)
|
||||
JOIN freight.yards a ON a.code = v.from_code
|
||||
JOIN freight.yards b ON b.code = v.to_code
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.yard_distances d
|
||||
WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id)
|
||||
OR (d.from_yard_id = b.id AND d.to_yard_id = a.id)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -110,7 +110,10 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string |
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).click({ force: true });
|
||||
});
|
||||
cy.get('[role="option"]').contains(option).click();
|
||||
// :visible — closed dropdowns can linger in the DOM, and two selects on one
|
||||
// page may list the same option text (e.g. the intercity wizard's origin +
|
||||
// destination both list every Ethiopian yard).
|
||||
cy.get('[role="option"]:visible').contains(option).click();
|
||||
});
|
||||
|
||||
/** Type a 6-digit code into a Mantine PinInput. */
|
||||
|
||||
Reference in New Issue
Block a user