Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-21 13:24:21 +00:00
62 changed files with 4398 additions and 429 deletions

8
.gitignore vendored
View File

@@ -29,3 +29,11 @@ coverage/
\#*\#
.\#*
docker-compose.override.yml
# cypress e2e artifacts
e2e/**/cypress/videos/
e2e/**/cypress/screenshots/
e2e/**/cypress/downloads/
# e2e launcher state (ports of the running stack)
e2e/freight/.e2e-ports.json

View File

@@ -0,0 +1,61 @@
import { DataSource } from 'typeorm';
type MileRecord = {
bookingId?: string | null;
advancedPayment?: number | string | null;
booking?: {
cargoTotalWeightVgm?: number | string | null;
bookingContainers?: Array<{
units?: Array<{ vgmTons?: number | string | null }> | null;
}> | null;
} | null;
};
/**
* Display enrichment for first/last-mile lists (Assign Vehicle modal etc.):
* - Advance payment: mile records are created with advanced_payment 0 — the
* real advance is the FIRST_MILE/LAST_MILE line the customer already paid
* on the booking invoice.
* - Cargo tons: container bookings often carry tonnage on the per-unit VGMs
* while cargo_total_weight_vgm stays 0 — fall back to the summed units.
* Fills both in-memory on the loaded records; nothing is persisted.
*/
export async function attachMileFinancials(
dataSource: DataSource,
records: MileRecord[],
chargeType: 'FIRST_MILE' | 'LAST_MILE',
): Promise<void> {
for (const r of records) {
const b = r.booking;
if (!b || Number(b.cargoTotalWeightVgm) > 0) continue;
const unitTons = (b.bookingContainers ?? []).reduce(
(sum, bc) =>
sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0),
0,
);
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
}
const needAdvance = records.filter(
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
);
if (!needAdvance.length) return;
const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query(
`SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount
FROM freight.invoice_lines il
JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL
WHERE i.source = 'booking'
AND i.status = 'PAID'
AND i.source_id = ANY($1::text[])
AND il.charge_type = $2
AND il.deleted_at IS NULL
GROUP BY i.source_id`,
[needAdvance.map((r) => r.bookingId), chargeType],
);
const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)]));
for (const r of needAdvance) {
const paid = byBooking.get(r.bookingId as string);
if (paid) r.advancedPayment = paid;
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck EDR last-mile handovers. `truck_assignment_id` FKs
* customer_truck_assignments (self-haul only), so EDR trucks need their own
* link to the last-mile vehicle assignment that hauled the goods. Generated
* when the EDR truck exits the warehouse (with its exit paper) and signed by
* the customer in the portal — one per truck, or booking-level (both ids null)
* when the truck cannot be resolved.
*/
export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface {
name = 'AddHandoverEdrAssignment2440000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
ADD COLUMN IF NOT EXISTS edr_assignment_id uuid
REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck"
ON freight.booking_handovers (booking_id, edr_assignment_id)
WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`,
);
await queryRunner.query(
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`,
);
}
}

View File

@@ -7,6 +7,7 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -349,6 +350,22 @@ export class BookingTransitionService {
return fresh;
}
/**
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
* warehouses module delivered the goods and asks the booking to complete.
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
*/
@OnEvent('import.handover.completed')
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
try {
await this.complete(payload.bookingId);
} catch (err) {
this.logger.log(
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
);
}
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);

View File

@@ -1228,9 +1228,9 @@ export class BookingsService {
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
* dashboard) can show "Approve delivery" for exactly the generated→signed
* window. One query for the whole page.
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
* rows (portal dashboard) can show "Approve delivery" for exactly the
* generated→signed window. One query for the whole page.
*/
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
const ids = bookings.map((b) => b.id);
@@ -1239,8 +1239,7 @@ export class BookingsService {
`SELECT DISTINCT booking_id AS "bookingId"
FROM freight.booking_handovers
WHERE booking_id = ANY($1::uuid[])
AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'`,
AND signed_at IS NULL AND deleted_at IS NULL`,
[ids],
);
const pending = new Set(rows.map((r) => r.bookingId));
@@ -1583,14 +1582,12 @@ export class BookingsService {
schedule?.status ?? null;
}
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
// delivery from the portal (booking-based, one per booking). EDR last-mile
// handovers are per delivering truck and signed by the receiver at the door,
// so they never surface the portal "Approve delivery" action.
// A generated-but-unsigned handover means the customer must approve delivery
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
// per delivering truck (generated on truck exit), signed one by one.
const [pendingHandover] = await this.dataSource.query(
`SELECT 1 FROM freight.booking_handovers
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
LIMIT 1`,
[id],
);

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { attachMileFinancials } from '../../common/mile-financials.util';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { BookingsRepository } from "../bookings/bookings.repository";
import { DriversService } from "../drivers/drivers.service";
@@ -66,6 +67,7 @@ export class FirstMileService {
for (const r of records) {
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
}
await attachMileFinancials(this.dataSource, records, 'FIRST_MILE');
}
/** Resolve a vehicle's driver + human labels, for stamping mile events onto

View File

@@ -12,6 +12,7 @@ import {
SELF_HAUL_CONFLICT_MESSAGE,
usesEdrMileService,
} from '../../common/mile-haulage.util';
import { attachMileFinancials } from '../../common/mile-financials.util';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
@@ -88,6 +89,7 @@ export class LastMileService {
for (const r of records) {
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
}
await attachMileFinancials(this.dataSource, records, 'LAST_MILE');
}
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
@@ -333,6 +335,8 @@ export class LastMileService {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}>
> {
const [lm] = await this.lastMileRepository.findAll({
@@ -347,9 +351,11 @@ export class LastMileService {
? lm.vehicleAssignments.map((va) => ({
vehicle: va.vehicle,
containerNumber: va.containerNumber ?? null,
arrivedAt: va.arrivedAt ?? null,
departedAt: va.departedAt ?? null,
}))
: lm.vehicle
? [{ vehicle: lm.vehicle, containerNumber: null }]
? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }]
: [];
const out: Array<{
@@ -361,8 +367,10 @@ export class LastMileService {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}> = [];
for (const { vehicle, containerNumber } of sources) {
for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) {
if (!vehicle) continue;
let driverName = vehicle.assignedDriverName ?? null;
let driverLicense: string | null = null;
@@ -386,6 +394,8 @@ export class LastMileService {
driverPhone,
truckType: vehicle.vehicleType || null,
containerNumber,
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
});
}
return out;

View File

@@ -0,0 +1,61 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateVehicleDto } from './create-vehicle.dto';
const base = {
vehicleType: 'TRUCK',
manufacturer: 'IVECO',
model: 'HYT',
year: 2020,
fuelType: 'DIESEL',
capacity: 0,
status: 'ACTIVE',
};
const errorsFor = (over: Record<string, unknown>) =>
validate(plainToInstance(CreateVehicleDto, { ...base, ...over }));
const plateErrors = (
errors: Awaited<ReturnType<typeof errorsFor>>,
property: string,
) => errors.find((e) => e.property === property && e.constraints?.matches);
describe('CreateVehicleDto — plate format', () => {
it('accepts a plate like ET-9875', async () => {
expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined();
});
it('accepts a plate like AA-8642', async () => {
expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined();
});
it('upper-cases a lower-case plate before validating', async () => {
const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' });
expect(dto.plateNumber).toBe('ET-9875');
expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined();
});
it('rejects a free-text plate like assadasd', async () => {
expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined();
});
it('rejects a plate with no letters or no digits', async () => {
expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined();
expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined();
});
it('rejects a bad trailer plate but allows a valid one', async () => {
expect(
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'),
).toBeDefined();
expect(
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'),
).toBeUndefined();
});
it('allows an empty trailer plate (optional)', async () => {
const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' });
expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined();
});
});

View File

@@ -1,7 +1,30 @@
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
/**
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
* e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer
* all match and the message stays consistent.
*/
export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/;
export const VEHICLE_PLATE_MESSAGE =
'must be letters and numbers like ET-9875 or AA-8642';
/**
* Trim and upper-case a plate before validating, so "et-9875" is accepted. An
* empty optional plate (trailer/power) becomes undefined so @IsOptional skips it
* rather than failing the pattern.
*/
const normalizePlate = ({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim().toUpperCase();
return trimmed === '' ? undefined : trimmed;
};
export class CreateVehicleDto {
@Transform(normalizePlate)
@Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` })
@IsString()
plateNumber!: string;
@@ -47,10 +70,14 @@ export class CreateVehicleDto {
code?: string;
@IsOptional()
@Transform(normalizePlate)
@Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` })
@IsString()
powerPlateNo?: string;
@IsOptional()
@Transform(normalizePlate)
@Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` })
@IsString()
trailerPlateNo?: string;

View File

@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import {
WAREHOUSE_INVENTORY_STATUSES,
@@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto {
@IsOptional()
@IsString()
dateTo?: string;
// ── KPI drill-down filters ──────────────────────────────────────────────
// Each mirrors one opsStats() counter so a dashboard card's count always
// equals the length of the list it opens.
@ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' })
@IsOptional()
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
@IsBoolean()
receivedToday?: boolean;
@ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' })
@IsOptional()
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
@IsBoolean()
pendingInspection?: boolean;
@ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsInt()
@Min(1)
agingOverDays?: number;
}

View File

@@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
* One import handover. A booking has a single handover when one truck takes the
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
* multiple trucks are used. Self-haul handovers are generated on truck arrival
* and signed before the truck leaves; EDR last-mile handovers are generated at
* delivery (after exit).
* and signed before the truck leaves; EDR last-mile handovers are generated
* when the EDR truck exits the warehouse (with its exit paper) and signed by
* the customer in the portal on delivery — one signature per truck.
*/
@Entity({ schema: 'freight', name: 'booking_handovers' })
@Index(['bookingId'])
@@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity {
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
truckAssignmentId?: string | null;
/** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */
@Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true })
edrAssignmentId?: string | null;
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
truckPlate?: string | null;

View File

@@ -0,0 +1,66 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
// Exercises the per-truck [Exit Inspection] block helpers directly (no DI).
const svc = Object.create(WarehouseInventoryService.prototype) as any;
const arrivalA =
'[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t';
const arrivalB =
'[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED';
describe('per-truck exit inspection blocks', () => {
it('keeps truck A intact when truck B arrives', () => {
const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858');
const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562');
expect(afterB).toContain('Abebe Lemeno');
expect(afterB).toContain('Suleman Tamrat');
expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2);
expect(afterB.startsWith('Receive note')).toBe(true);
});
it("exit for truck A updates only A's block and preserves arrival data", () => {
const notes = svc.replaceExitInspectionNote(
svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'),
arrivalB,
'3-85957/48562',
);
const dto = svc.preserveTruckArrivalForExit(
{ truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' },
notes,
);
expect(dto.driverName).toBe('Abebe Lemeno');
expect(dto.tareWeight).toBe(12);
expect(dto.weighingSkipped).toBeUndefined();
const exitNote = svc.buildExitInspectionNote(dto);
const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber);
expect(replaced).toContain('Gross Weight: 40 t');
expect(replaced).toContain('Net Weight: 28 t');
expect(replaced).toContain('Suleman Tamrat'); // B untouched
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2);
});
it('skipped weighing records the container-derived net in the note', () => {
const dto = {
truckPlateNumber: '3-85957/48562',
driverName: 'Suleman Tamrat',
weighingSkipped: true,
netWeight: 27.5,
gateInTime: '2026-07-21T09:00:00.000Z',
gateOutTime: '2026-07-21T13:00:00.000Z',
};
const note = svc.buildExitInspectionNote(dto);
expect(note).toContain('Weighing: SKIPPED');
expect(note).toContain('Net Weight: 27.5 t');
});
it('matches a legacy comma-joined plate list and keeps foreign notes', () => {
const legacy =
'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}';
const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858');
expect(block).toContain('Abebe Lemeno');
const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858');
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1);
expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}');
expect(replaced).toContain('Receive note');
});
});

View File

@@ -1,9 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { DataSource, EntityManager, IsNull, Repository } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
@@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util';
* Import handover records. A booking has one handover per truck (single truck ⇒
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
* - EDR_LAST_MILE: generated at delivery (after exit).
* - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its
* exit paper), signed by the customer in the portal per truck; once every
* handover is signed the delivery auto-completes (inventory / cargo /
* booking → delivered).
*/
@Injectable()
export class HandoverService {
@@ -25,14 +28,22 @@ export class HandoverService {
) {}
/** Tell the customer a handover is ready and needs their signature. */
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
private async notifySignNeeded(
bookingId: string,
reference: string,
opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {},
): Promise<void> {
try {
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!b?.companyId) return;
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : '';
const body =
opts.mileType === 'EDR_LAST_MILE'
? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.`
: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
await this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
@@ -117,31 +128,98 @@ export class HandoverService {
return saved;
}
/**
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
* truck (by plate) or per booking. Idempotent by (booking, plate).
*/
async ensureAtDelivery(
/** Find an existing EDR handover by assignment, else by plate, else booking-level. */
private async findEdrHandover(
repo: Repository<BookingHandover>,
bookingId: string,
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
): Promise<BookingHandover | null> {
if (opts.edrAssignmentId) {
const byAssignment = await repo.findOne({
where: { bookingId, edrAssignmentId: opts.edrAssignmentId },
});
if (byAssignment) return byAssignment;
}
if (opts.truckPlate) {
return repo.findOne({
where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate },
});
}
return repo.findOne({
where: {
bookingId,
mileType: 'EDR_LAST_MILE',
truckPlate: IsNull(),
edrAssignmentId: IsNull(),
},
});
}
/**
* EDR last-mile: generate the handover when the EDR truck exits the warehouse
* (alongside its exit paper) and ask the customer to sign it from the portal.
* One per truck (multiple trucks ⇒ one each) or booking-level when the truck
* cannot be resolved. Idempotent by (booking, assignment) / (booking, plate).
*/
async ensureForDepartedEdrTruck(
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckPlate: opts.truckPlate ?? IsNull(),
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
const existing = await this.findEdrHandover(repo, bookingId, opts);
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
return repo.save(
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
edrAssignmentId: opts.edrAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(
`EDR handover ${reference} generated on truck exit for booking ${bookingId}` +
(opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''),
);
void this.notifySignNeeded(bookingId, reference, {
mileType: 'EDR_LAST_MILE',
truckPlate: opts.truckPlate,
});
return saved;
}
/**
* EDR last-mile: ensure a handover exists at delivery and stamp delivered_at.
* Normally the handover was already generated on truck exit — this only fills
* the delivery timestamp; a handover is created here only for legacy flows
* where the exit was recorded before this feature existed.
*/
async ensureAtDelivery(
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await this.findEdrHandover(repo, bookingId, opts);
if (existing) {
if (!existing.deliveredAt) {
existing.deliveredAt = new Date();
await repo.save(existing);
}
return existing;
}
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
edrAssignmentId: opts.edrAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
@@ -149,6 +227,25 @@ export class HandoverService {
deliveredAt: new Date(),
}),
);
void this.notifySignNeeded(bookingId, reference, {
mileType: 'EDR_LAST_MILE',
truckPlate: opts.truckPlate,
});
return saved;
}
/** Re-send the sign notification for every unsigned handover on the booking. */
async notifyUnsignedForBooking(bookingId: string): Promise<void> {
const unsigned = await this.dataSource.getRepository(BookingHandover).find({
where: { bookingId, signedAt: IsNull() },
order: { generatedAt: 'ASC' },
});
for (const h of unsigned) {
await this.notifySignNeeded(bookingId, h.reference, {
mileType: h.mileType,
truckPlate: h.truckPlate,
});
}
}
/**
@@ -182,6 +279,27 @@ export class HandoverService {
}
}
/**
* Sign one handover (EDR last-mile: the customer signs per truck). Returns the
* fresh handover; idempotent — an already-signed handover is returned as-is.
*/
async sign(
handoverId: string,
userId?: string | null,
signerName?: string | null,
): Promise<BookingHandover> {
const repo = this.dataSource.getRepository(BookingHandover);
const handover = await repo.findOne({ where: { id: handoverId } });
if (!handover) {
throw new NotFoundException(`Handover ${handoverId} not found`);
}
if (handover.signedAt) return handover;
handover.signedAt = new Date();
handover.signedByUserId = userId ?? null;
handover.signerName = signerName?.trim() || null;
return repo.save(handover);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(
bookingId: string,

View File

@@ -486,6 +486,21 @@ export class WarehouseInventoryController {
return this.handoverService.list(bookingId);
}
@Post('handovers/:handoverId/sign')
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
signHandover(
@Param('handoverId', ParseUUIDPipe) handoverId: string,
@Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } },
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.signHandover(
handoverId,
user?.id ?? req.user?.id ?? req.user?.sub,
dto.signerName,
);
}
@Post('bookings/:bookingId/request-handover-signature')
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
@@ -513,9 +528,16 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
async bookingHandoverDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Res() res: Response,
@Query('handoverId') handoverId?: string,
) {
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(
bookingId,
handoverId || undefined,
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);

View File

@@ -1,6 +1,19 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
Between,
DataSource,
EntityManager,
FindManyOptions,
FindOptionsWhere,
ILike,
In,
IsNull,
LessThanOrEqual,
MoreThanOrEqual,
Raw,
} from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { generateGrnNumber } from '../../common/grn.util';
@@ -13,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import {
@@ -68,6 +82,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
export interface InventoryInquiryResult {
id: string;
@@ -396,6 +411,7 @@ export class WarehouseInventoryService {
private readonly signatures: SignaturesService,
private readonly handover: HandoverService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
) {}
/**
@@ -508,12 +524,20 @@ export class WarehouseInventoryService {
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
-- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite()
((SELECT count(*)::int FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL)
+
(SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
AND status = ANY($1)
AND created_at < now() - interval '7 days') AS "itemsAging"`,
[this.IN_WAREHOUSE_STATUSES],
);
return {
receivedToday: row?.receivedToday ?? 0,
@@ -525,7 +549,7 @@ export class WarehouseInventoryService {
}
/** In-warehouse statuses used by the dwell / aging metrics. */
private readonly IN_WAREHOUSE_STATUSES = [
private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [
'RECEIVED',
'UNLOADED',
'STORED',
@@ -953,7 +977,7 @@ export class WarehouseInventoryService {
? LessThanOrEqual(new Date(filter.dateTo))
: undefined;
const base = {
const base: FindOptionsWhere<WarehouseInventory> = {
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
...(filter.yardId ? { yardId: filter.yardId } : {}),
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
@@ -967,6 +991,24 @@ export class WarehouseInventoryService {
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
};
// KPI drill-down filters — predicates mirror opsStats() exactly so the
// dashboard card's count equals the length of the list it opens.
if (filter.receivedToday) {
base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`);
}
if (filter.pendingInspection) {
base.status = 'RECEIVED';
base.inspectionStatus = IsNull();
}
if (filter.agingOverDays) {
if (!filter.status && !filter.pendingInspection) {
base.status = In(this.IN_WAREHOUSE_STATUSES);
}
base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, {
days: filter.agingOverDays,
});
}
const search = filter.search?.trim();
const where: FindManyOptions<WarehouseInventory>['where'] = search
? [
@@ -2972,12 +3014,29 @@ export class WarehouseInventoryService {
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = isTruckLeaving
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
: dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionDto = isTruckLeaving
? this.preserveTruckArrivalForExit(dto, item.notes)
: dto;
// One reference per item — the first truck's arrival mints it, later trucks
// (arrival or exit) reuse it so all exit papers share the release order.
const reference =
item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionDto = {
...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto),
};
// Weighbridge skipped on exit: the recorded net still comes from what the
// truck is holding — the summed cargo weight of its selected containers.
if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) {
const selected = (exitInspectionDto.containerNumber ?? '')
.split(/[,;\n]+/)
.map((n) => n.trim())
.filter(Boolean);
if (selected.length) {
const weights = await this.bookingContainerWeights(item.bookingId);
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
const heldTons = Number(
selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3),
);
if (heldTons > 0) exitInspectionDto.netWeight = heldTons;
}
}
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
// The load actually leaving on this truck, in TONNES (the weighing UI is in
@@ -2990,12 +3049,46 @@ export class WarehouseInventoryService {
? Math.round((grossTons - tareTons) * 1000) / 1000
: (exitInspectionDto.netWeight ?? null);
// The weight to record on the inventory when this truck leaves: prefer the
// item's own container cargo weight (a truck may carry other items too);
// fall back to the truck's recorded net. Fills an empty weight only.
let recordedItemTons: number | null = null;
if (isTruckLeaving && netTons != null) {
recordedItemTons = netTons;
if (item.containerId && item.bookingId) {
const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`,
[item.containerId],
);
const ownNumber = cont?.containerNumber?.trim().toUpperCase();
if (ownNumber) {
const weights = await this.bookingContainerWeights(item.bookingId);
const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber);
if (own && own.weightTons > 0) recordedItemTons = own.weightTons;
}
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
notes: this.replaceExitInspectionNote(
item.notes,
exitInspectionNote,
exitInspectionDto.truckPlateNumber,
),
});
// Even an unweighed truck records the inventory weight it is holding —
// without this the handover/exit papers print "0 t" for skipped weighings.
if (recordedItemTons != null) {
await manager.query(
`UPDATE freight.warehouse_inventory
SET weight = $2, updated_at = NOW()
WHERE id = $1 AND COALESCE(weight, 0) = 0`,
[id, recordedItemTons],
);
}
if (!isTruckLeaving && item.bookingId) {
// Per-truck arrival: mark the customer truck carrying THIS item's
// container as arrived (matched via the physical container number).
@@ -3058,7 +3151,7 @@ export class WarehouseInventoryService {
// EDR last-mile: this truck is leaving — record its exit and the load it
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
// minus everything already hauled away).
await manager.query(
const [edrDeparted] = (await manager.query(
`UPDATE freight.last_mile_vehicle_assignments va
SET departed_at = COALESCE($3::timestamptz, NOW()),
arrived_at = COALESCE(va.arrived_at, NOW()),
@@ -3072,7 +3165,8 @@ export class WarehouseInventoryService {
AND v.id = va.vehicle_id
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
AND va.departed_at IS NULL
AND va.deleted_at IS NULL`,
AND va.deleted_at IS NULL
RETURNING va.id`,
[
item.bookingId,
dto.truckPlateNumber.trim(),
@@ -3080,7 +3174,31 @@ export class WarehouseInventoryService {
grossTons,
netTons,
],
);
)) as [Array<{ id: string }>, unknown];
// EDR last-mile: the handover is generated the moment the truck exits
// (with its exit paper) — one per truck — and the customer is asked to
// sign it from the portal. Booking-level fallback when the plate matched
// no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled.
for (const row of edrDeparted) {
await this.handover.ensureForDepartedEdrTruck(
item.bookingId,
{ truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id },
manager,
);
}
if (!edrDeparted.length) {
const [lm]: Array<{ id: string }> = await manager.query(
`SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
[item.bookingId],
);
if (lm) {
await this.handover.ensureForDepartedEdrTruck(
item.bookingId,
{ truckPlate: dto.truckPlateNumber.trim() },
manager,
);
}
}
// Customer self-haul: the same exit record on the customer's own truck.
// Without it a self-haul bulk booking never draws down — hauled tonnage
// summed to zero and the booking could take unlimited trucks. Matched by
@@ -3130,11 +3248,43 @@ export class WarehouseInventoryService {
// the transaction and fire-and-forget: notifying must never fail the exit.
if (isTruckLeaving && item.bookingId) {
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
} else if (item.bookingId) {
// Gate-in: same single-path hook for the arrival side (self-haul + EDR).
void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null);
}
return this.findById(id);
}
/** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */
private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
await this.dataSource.query(
`SELECT company_id AS "companyId", reference
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking?.companyId) return;
const ref = booking.reference ?? bookingId;
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
const body = `${truck} has arrived at the warehouse for booking ${ref}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Truck arrived at the warehouse',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`);
}
}
/**
* Best-effort truck-departure notification to the booking's company across
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
@@ -3816,8 +3966,213 @@ export class WarehouseInventoryService {
};
}
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
/**
* Customer signs ONE handover from the portal (EDR last-mile: one per truck).
* When the last one is signed — and every EDR truck has left the warehouse —
* the delivery completes automatically: inventory + cargo delivered, last-mile
* leg DELIVERED (trucks freed), booking completed ("shipment delivered").
*/
async signHandover(
handoverId: string,
userId?: string,
signerName?: string,
): Promise<{
handoverId: string;
bookingId: string;
signedAt: string | null;
signerDisplayName: string;
allSigned: boolean;
}> {
if (!userId) {
throw new BadRequestException('Authentication is required to sign the handover');
}
const name = signerName?.trim();
if (!name) {
throw new BadRequestException('Please enter your full name to sign the handover');
}
const [h]: Array<{
bookingId: string;
reference: string;
truckPlate: string | null;
mileType: string;
edrAssignmentId: string | null;
}> = await this.dataSource.query(
`SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate",
mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId"
FROM freight.booking_handovers
WHERE id = $1 AND deleted_at IS NULL`,
[handoverId],
);
if (!h) throw new NotFoundException(`Handover ${handoverId} not found`);
// Self-haul stays a single booking-level signature via approve-delivery,
// which also enforces inspection-passed + truck-arrived. Per-truck signing
// is an EDR last-mile flow only.
if (h.mileType !== 'EDR_LAST_MILE') {
throw new BadRequestException(
'This handover is signed through Approve delivery, not per truck',
);
}
// Same gate as approve-delivery: storage/demurrage must be settled first.
const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
`SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1`,
[h.bookingId],
);
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
const signed = await this.handover.sign(handoverId, userId, name);
const allSigned = await this.handover.isFullySigned(h.bookingId);
if (inv) {
await this.activityLog.record({
activityType: 'INVENTORY_RELEASED',
inventoryId: inv.id,
warehouseId: inv.warehouseId,
description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`,
performedBy: name,
});
}
// EDR last-mile delivers PER TRUCK: this signature confirms receipt of the
// goods THIS truck carried, so only its containers become DELIVERED now.
// (Self-haul keeps the single booking-level handover + manual Deliver.)
if (h.mileType === 'EDR_LAST_MILE') {
try {
await this.deliverEdrTruckContainers(h, name);
} catch (err) {
this.logger.warn(
`Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`,
);
}
}
if (allSigned) {
void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) =>
this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`),
);
}
return {
handoverId,
bookingId: h.bookingId,
signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null,
signerDisplayName: name,
allSigned,
};
}
/**
* EDR last-mile auto-completion: once every handover is signed and every EDR
* truck has departed, deliver the remaining inventory, mark the last-mile leg
* DELIVERED and complete the booking. Self-haul bookings keep their manual
* Deliver flow (no last_mile record ⇒ no-op).
*/
private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise<void> {
const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query(
`SELECT id, status FROM freight.last_mile
WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
[bookingId],
);
if (!lm) return;
const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query(
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
[bookingId],
);
if (Number(pending?.notDeparted ?? 0) > 0) return;
if (!(await this.handover.isFullySigned(bookingId))) return;
const items: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`,
[bookingId],
);
for (const it of items) {
try {
await this.deliver(it.id, {
receiverName: signerName,
remarks: 'Auto-delivered on customer handover signature',
performedBy: signerName,
} as DeliverInventoryDto);
} catch (err) {
this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`);
}
}
if (lm.status !== 'DELIVERED') {
try {
await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto);
} catch (err) {
this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`);
}
}
// Booking → COMPLETED ("shipment delivered" notification) — owned by the
// bookings module; evented to avoid a warehouses→bookings service dependency.
this.events.emit('import.handover.completed', { bookingId });
}
/**
* EDR last-mile per-truck delivery: the customer signed THIS truck's handover,
* so only the container items that truck carried become DELIVERED. Bulk cargo
* (no container rows) is delivered by completeEdrDeliveryIfReady once every
* truck is signed off.
*/
private async deliverEdrTruckContainers(
h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null },
signerName: string,
): Promise<void> {
if (!h.edrAssignmentId && !h.truckPlate) return;
const items: Array<{ id: string }> = await this.dataSource.query(
`SELECT DISTINCT inv.id
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
LEFT JOIN freight.last_mile_vehicle_containers vc
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
JOIN freight.containers c
ON c.container_number = COALESCE(vc.container_number, va.container_number)
AND c.deleted_at IS NULL
JOIN freight.warehouse_inventory inv
ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL
WHERE l.booking_id = $1
AND va.deleted_at IS NULL
AND inv.status = 'READY_FOR_PICKUP'
AND (va.id = $2::uuid
OR ($2::uuid IS NULL
AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`,
[h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''],
);
for (const it of items) {
try {
await this.deliver(it.id, {
receiverName: signerName,
remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`,
performedBy: signerName,
} as DeliverInventoryDto);
} catch (err) {
this.logger.warn(
`Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`,
);
}
}
}
/**
* Handover PDF resolved by booking (for the portal, which only has bookingId).
* With `handoverId` the document is rendered for that specific handover — the
* per-truck EDR last-mile variant (truck plate + that truck's signature state).
*/
async handoverDocumentForBooking(
bookingId: string,
handoverId?: string,
): Promise<{ filename: string; buffer: Buffer }> {
const [inv]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
@@ -3828,7 +4183,29 @@ export class WarehouseInventoryService {
if (!inv) {
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
}
return this.handoverDocument(inv.id);
if (!handoverId) return this.handoverDocument(inv.id);
const [h]: Array<{
reference: string;
truckPlate: string | null;
signedAt: string | null;
signerName: string | null;
}> = await this.dataSource.query(
`SELECT reference, truck_plate AS "truckPlate",
signed_at AS "signedAt", signer_name AS "signerName"
FROM freight.booking_handovers
WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`,
[handoverId, bookingId],
);
if (!h) {
throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`);
}
return this.handoverDocument(inv.id, {
reference: h.reference,
truckPlate: h.truckPlate,
signedAt: h.signedAt ? new Date(h.signedAt) : null,
signerName: h.signerName,
});
}
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
@@ -3856,12 +4233,22 @@ export class WarehouseInventoryService {
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
async handoverDocument(
id: string,
perTruck?: {
reference: string;
truckPlate: string | null;
signedAt: Date | null;
signerName: string | null;
},
): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
inv.quantity,
inv.weight,
-- An unweighed item still reports the cargo weight it holds: fall
-- back to the item's container VGM when no weight was recorded.
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight,
inv.status,
inv.notes,
inv.inspection_status AS "inspectionStatus",
@@ -3913,6 +4300,16 @@ export class WarehouseInventoryService {
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN LATERAL (
SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id
AND bcu.deleted_at IS NULL
AND (container.container_number IS NULL
OR bcu.container_number = container.container_number)
) item_vgm ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -3931,12 +4328,14 @@ export class WarehouseInventoryService {
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const reference =
perTruck?.reference ||
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
if (!generatedAtValue) {
// Per-truck renders must not stamp their reference into the shared item notes.
if (!generatedAtValue && !perTruck) {
await this.inventoryRepository.update(id, {
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
});
@@ -3970,7 +4369,16 @@ export class WarehouseInventoryService {
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
truckPlate: perTruck?.truckPlate ?? null,
customerApproval: perTruck
? perTruck.signedAt
? {
approvedAt: perTruck.signedAt.toISOString(),
signerDisplayName: perTruck.signerName ?? '-',
signatureImageUrl: null,
}
: null
: this.extractCustomerDeliveryApproval(row.notes),
});
return {
@@ -4009,14 +4417,61 @@ export class WarehouseInventoryService {
if (!(await this.handover.isFullySigned(item.bookingId))) {
throw new BadRequestException('Handover must be signed before delivery');
}
const [left]: Array<{ n: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left"
FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (Number(left?.n ?? 0) === 0) {
const totalTrucks = Number(trucks?.total ?? 0);
const leftTrucks = Number(trucks?.left ?? 0);
if (leftTrucks === 0) {
throw new BadRequestException('Deliver is available only after the customer truck has left');
}
// Multi-truck booking: every assigned truck must arrive and leave —
// each is weighed out separately before the goods count as delivered.
if (leftTrucks < totalTrucks) {
throw new BadRequestException(
`Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`,
);
}
}
// EDR last-mile delivers per truck: a container item only needs the truck
// CARRYING IT to have left; bulk (no container) waits for every truck.
if (item.containerId) {
const [own]: Array<{ pending: string }> = await this.dataSource.query(
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
LEFT JOIN freight.last_mile_vehicle_containers vc
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL
WHERE l.booking_id = $1 AND va.deleted_at IS NULL
AND COALESCE(vc.container_number, va.container_number) = c.container_number`,
[item.bookingId, item.containerId],
);
if (Number(own?.pending ?? 0) > 0) {
throw new BadRequestException(
'Deliver is available only after the EDR truck carrying this container has left',
);
}
} else {
const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
[item.bookingId],
);
const lmTotal = Number(lm?.total ?? 0);
const lmLeft = Number(lm?.left ?? 0);
if (lmTotal > 0 && lmLeft < lmTotal) {
throw new BadRequestException(
`Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`,
);
}
}
}
@@ -4112,6 +4567,12 @@ export class WarehouseInventoryService {
}
});
// "Approve delivery" nudge: on Deliver the customer is reminded to sign any
// handover still unsigned (per truck for EDR last-mile). Fire-and-forget.
if (item.bookingId) {
void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined);
}
return this.findById(id);
}
@@ -4839,10 +5300,11 @@ export class WarehouseInventoryService {
releaseDate: Date | null;
trainSchedule: string | null;
lastMileDeliveryAddress: string | null;
truckPlate?: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
signatureImageUrl: string | null;
} | null;
}): string {
const esc = (value: unknown) =>
@@ -4888,6 +5350,7 @@ export class WarehouseInventoryService {
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []),
];
const approval = data.customerApproval;
@@ -5297,7 +5760,11 @@ export class WarehouseInventoryService {
weighingSkipped ? 'Weighing: SKIPPED' : null,
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
// Skipped weighing still records a net — the cargo weight of the
// containers the truck is holding, resolved by the caller.
(computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null
? null
: `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
@@ -5305,12 +5772,14 @@ export class WarehouseInventoryService {
}
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
const inspection = this.extractExitInspectionNote(notes);
const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber);
if (!inspection) return dto;
return {
...dto,
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
// The submitted plate wins: a legacy block may store a comma-joined list
// of plates, and the exit must be recorded against the ONE truck leaving.
truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
@@ -5324,25 +5793,94 @@ export class WarehouseInventoryService {
};
}
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
/**
* Split notes into exit-inspection blocks (one per truck, in order) and
* everything else. A block ends at the first line that isn't one of the
* known inspection labels, so appended notes (delivery approval, handover
* marker) are preserved as "other" content instead of being swallowed by
* the block they happen to follow.
*/
private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } {
const trimmed = notes?.trim();
if (!exitInspectionNote) return trimmed || null;
if (!trimmed) return exitInspectionNote;
const marker = '[Exit Inspection]';
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return `${trimmed}\n\n${exitInspectionNote}`;
if (!trimmed) return { others: [], blocks: [] };
const labelPattern =
/^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i;
const parts = trimmed.split(EXIT_INSPECTION_MARKER);
const others: string[] = [];
const blocks: string[] = [];
if (parts[0]?.trim()) others.push(parts[0].trim());
for (const part of parts.slice(1)) {
const lines = part.split('\n');
const kept: string[] = [];
let i = 0;
while (i < lines.length && !lines[i].trim()) i += 1;
for (; i < lines.length; i += 1) {
const line = lines[i].trim();
if (!line || !labelPattern.test(line)) break;
kept.push(line);
}
if (kept.length) blocks.push(kept.join('\n'));
const tail = lines.slice(i).join('\n').trim();
if (tail) others.push(tail);
}
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
return { others, blocks };
}
/**
* A block belongs to a plate when its stored `Truck Plate` equals it, or is a
* legacy comma-joined list ("P1, P2") containing it.
*/
private blockMatchesPlate(block: string, plateNumber?: string | null): boolean {
const plate = plateNumber?.trim().toUpperCase();
if (!plate) return false;
const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase();
if (!stored) return false;
if (stored === plate) return true;
return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate);
}
/**
* Replace THIS truck's inspection block (matched by plate), keeping every
* other truck's block untouched; append when the plate has no block yet.
* A single legacy block (comma-joined plates or plate-less caller) is
* replaced in place so old single-truck items keep their behaviour.
*/
private replaceExitInspectionNote(
notes: string | null | undefined,
exitInspectionNote: string | null,
plateNumber?: string | null,
): string | null {
const { others, blocks } = this.splitExitInspectionSections(notes);
if (exitInspectionNote) {
const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim();
const index = plateNumber
? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber))
: blocks.length - 1;
if (index >= 0) blocks[index] = content;
else blocks.push(content);
}
const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)];
return sections.join('\n\n') || null;
}
/** Latest truck's inspection block — legacy summary for documents. */
private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null;
const marker = '[Exit Inspection]';
const index = notes.lastIndexOf(marker);
if (index < 0) return null;
return notes.slice(index + marker.length).trim() || null;
const { blocks } = this.splitExitInspectionSections(notes);
return blocks.length ? blocks[blocks.length - 1] : null;
}
/**
* The inspection block for one truck. Falls back to a lone existing block so
* legacy single-truck items (saved before per-plate blocks) keep working.
*/
private extractExitInspectionForPlate(
notes: string | null | undefined,
plateNumber?: string | null,
): string | null {
const { blocks } = this.splitExitInspectionSections(notes);
const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber));
if (match) return match;
return blocks.length === 1 ? blocks[0] : null;
}
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {

View File

@@ -249,6 +249,16 @@ const FleetFormDialog = ({
}
}
}
// Format check (e.g. plate numbers). Skipped for an empty optional field —
// "required" above already owns the empty case. Upper-cased to match the
// server, which stores plates upper-case.
if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) {
const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase();
if (!field.pattern.regex.test(candidate)) {
next[field.name] = field.pattern.message;
}
}
});
setErrors(next);
return Object.keys(next).length === 0;

View File

@@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</ActionIcon>
</Tooltip>
{/* Primary stage action stays visible; the rest live under the kebab. */}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
{/* Stays visible after the first exit — multi-truck bookings
weigh each truck in and out until all have left. */}
{r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && (
<Button
size="compact-xs"
variant="light"
@@ -2655,7 +2657,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
leftSection={<Truck size={14} />}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Ready for pickup
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
{r.currentStatus === 'READY_FOR_PICKUP' && (
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!r.hasAssignedTruck}
@@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
>
{r.hasAssignedTruck
? r.releaseOrderReference
? 'Truck leaving'
? 'Truck arrival / leaving'
: 'Truck arrival'
: 'Truck arrival — assign a truck first'}
</Menu.Item>

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale, Truck } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
@@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill {
containerNumber?: string | null;
}
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
@@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) =>
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
item?.booking?.[key] == null ? '' : String(item.booking[key]);
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
@@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
};
const parseInspectionNote = (notes: string | null | undefined) => {
const marker = '[Exit Inspection]';
const index = notes?.lastIndexOf(marker) ?? -1;
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
return {
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
};
/** One truck's saved arrival/exit weighing, parsed from its inspection block. */
interface InspectionBlock {
truckPlateNumber: string;
trailerPlateNumber: string;
driverName: string;
driverLicense: string;
driverPhone: string;
truckType: string;
containerNumber: string;
gateInTime: string;
tareWeight: number | '';
grossWeight: number | '';
netWeight: number | '';
gateOutTime: string;
weighingSkipped: boolean;
}
const parseInspectionSection = (note: string): InspectionBlock => ({
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note),
});
/** Every truck's saved block — multi-truck bookings weigh each truck separately. */
const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] =>
(notes ?? '')
.split(EXIT_INSPECTION_MARKER)
.slice(1)
.map(parseInspectionSection)
.filter((block) => block.truckPlateNumber);
/** Match by plate; a legacy block may hold a comma-joined plate list. */
const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => {
const key = plate.trim().toUpperCase();
if (!key) return undefined;
return blocks.find((block) => {
const stored = block.truckPlateNumber.toUpperCase();
return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key);
});
};
const blockArrived = (block: InspectionBlock | undefined) =>
Boolean(block && (block.tareWeight !== '' || block.weighingSkipped));
const blockLeft = (block: InspectionBlock | undefined) =>
Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped));
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
// Some openers (inventory workbench) supply bookingId without the booking
// relation — fall back to it, or the truck/container-weight queries never run.
@@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
const [downloading, setDownloading] = useState(false);
// Plate whose saved block was last loaded into the form — stops the
// per-plate loader effect from clobbering operator edits in a loop.
const loadedPlateRef = useRef<string | null>(null);
useEffect(() => {
if (opened) {
const inspection = parseInspectionNote(item?.notes);
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
}
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
// truck query resolves, auto-fill the first assigned EDR truck — without
// overwriting anything the operator typed or the locked exit-step values.
useEffect(() => {
if (!opened || truckPrefill || isExitStep) return;
const first = lastMileTrucks[0];
if (!first) return;
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
setDriverName((p) => p || first.driverName || '');
setDriverLicense((p) => p || first.driverLicense || '');
setDriverPhone((p) => p || first.driverPhone || '');
setTruckType((p) => p || first.truckType || '');
setContainerNumbers((prev) =>
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
);
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
// The same for a customer self-haul truck. The prefill above reads the
// booking.customer_truck_* columns, but multi-truck self-haul writes the plate
// and driver to customer_truck_assignments and leaves those columns null — so
// a booking with a truck on file still opened this form blank. Only auto-fills
// a single truck: with several, the operator picks which one is at the gate.
useEffect(() => {
if (!opened || truckPrefill || isExitStep) return;
if (customerTrucks.length !== 1) return;
const [truck] = customerTrucks;
setTruckPlateNumber((p) => p || truck.plateNumber || '');
setDriverName((p) => p || truck.driverName || '');
setTruckType((p) => p || truck.truckType || '');
setContainerNumbers((prev) => {
const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean);
return prev.every((n) => !n) && loaded.length ? loaded : prev;
});
}, [opened, truckPrefill, isExitStep, customerTrucks]);
const savedBlocks = parseInspectionBlocks(item?.notes);
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
...(truckPrefill?.truckPlateNumber
...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
? [
{
value: truckPrefill.truckPlateNumber,
@@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: truckPrefill.driverName ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
arrived: false,
left: false,
},
]
: []),
@@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: t.driverName,
driverPhone: '',
truckType: t.truckType,
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
arrived: Boolean(t.arrivedAt),
left: Boolean(t.departedAt),
})),
...lastMileTrucks
.filter((t) => t.truckPlateNumber || t.vehicleId)
@@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: t.driverName ?? '',
driverPhone: t.driverPhone ?? '',
truckType: t.truckType ?? '',
containerNumbers: splitContainerNumbers(t.containerNumber),
arrived: Boolean(t.arrivedAt),
left: Boolean(t.departedAt),
})),
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
@@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const truckSelectOptions = [
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
];
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
// Per-truck progress: every truck is weighed in and out on its own; the saved
// blocks also cover walk-in trucks that were never formally assigned.
const truckProgress = new Map<string, { arrived: boolean; left: boolean }>();
for (const option of truckSelectOptions) {
truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left });
}
for (const block of savedBlocks) {
const key = block.truckPlateNumber.trim().toUpperCase();
const prior = truckProgress.get(key);
truckProgress.set(key, {
arrived: Boolean(prior?.arrived) || blockArrived(block),
left: Boolean(prior?.left) || blockLeft(block),
});
}
const totalTrucks = truckProgress.size;
const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length;
const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length;
// The step is decided PER TRUCK: the selected plate's saved block. A new plate
// (or a truck without a saved arrival) starts at the arrival step even when
// other trucks of the booking are already mid-flow or gone.
const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber);
const isExitStep = blockArrived(selectedBlock);
const hasTruckLeft = blockLeft(selectedBlock);
const isEntranceLocked = isExitStep;
const selectedOption = truckSelectOptions.find(
(option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(),
);
// Identity comes from the arrival record or the assignment — locked either
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
/** Load a truck into the form: its saved block if any, else its assignment. */
const applyTruckSelection = (plate: string) => {
const block = blockForPlate(savedBlocks, plate);
const option = truckSelectOptions.find(
(o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(),
);
loadedPlateRef.current = plate.trim().toUpperCase();
setTruckPlateNumber(plate);
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
setDriverName(block?.driverName || option?.driverName || '');
setDriverLicense(block?.driverLicense || '');
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
setTruckType(block?.truckType || option?.truckType || '');
const loaded = block
? splitContainerNumbers(block.containerNumber)
: (option?.containerNumbers ?? []);
setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, ''));
setGateInTime(block?.gateInTime ?? '');
setTareWeight(block?.tareWeight ?? '');
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
setGrossWeight(block?.grossWeight ?? '');
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
setGateOutTime(block?.gateOutTime ?? '');
};
useEffect(() => {
if (opened) {
loadedPlateRef.current = null;
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
// Initial truck: the caller's prefill, else the first truck still mid-flow
// (arrived but not left) — the operator can switch trucks in the select.
const prefillPlate =
truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
? truckPrefill.truckPlateNumber
: '';
const blocks = parseInspectionBlocks(item?.notes);
const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block));
// Legacy single-truck bookings stored the truck on the booking columns; a
// comma-joined value means several trucks, so the operator picks instead.
const bookingPlate = item?.booking?.customerTruckPlateNumber ?? '';
const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : '';
const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || '';
const block = blockForPlate(blocks, initialPlate);
loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null;
setTruckPlateNumber(initialPlate);
setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
setDriverName(
block?.driverName ||
truckPrefill?.driverName ||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''),
);
setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || '');
setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || '');
setTruckType(
block?.truckType ||
truckPrefill?.truckType ||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''),
);
setContainerNumbers(
initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''),
);
setGateInTime(block?.gateInTime ?? '');
setTareWeight(block?.tareWeight ?? '');
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
setGrossWeight(block?.grossWeight ?? '');
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
setGateOutTime(block?.gateOutTime ?? '');
}
}, [opened, item, truckPrefill]);
// No truck chosen yet and exactly one is assigned — load it. With several
// trucks the operator picks which one is at the gate.
useEffect(() => {
if (!opened || truckPlateNumber || loadedPlateRef.current) return;
if (truckSelectOptions.length !== 1) return;
applyTruckSelection(truckSelectOptions[0].value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]);
// A typed plate that matches a saved arrival reloads that truck's record, so
// the exit step opens with the weigh-in data instead of blank fields.
useEffect(() => {
if (!opened) return;
const key = truckPlateNumber.trim().toUpperCase();
if (!key || loadedPlateRef.current === key) return;
if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, truckPlateNumber]);
// Which containers ride this truck, and their combined cargo weight. When the
// booking has container weights, that sum is the authoritative net; the
@@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
);
// Skip is only offered for container bookings; bulk always weighs.
const skipWeighing = hasContainerWeights && weighTruck === 'no';
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
// Even an unweighed truck records the cargo weight it is holding — the
// selected containers' sum is the net that goes on the exit record.
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
const systemNetWeight = useContainerNet
? selectedCargoWeight
@@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (hasTruckLeft) {
toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` });
return;
}
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
toast({
variant: 'destructive',
@@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
weighingSkipped: skipWeighing || undefined,
tareWeight: skipWeighing ? undefined : Number(tareWeight),
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
// Skipped weighing still records the net from what the truck holds.
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] });
await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] });
if (!isExitStep) {
const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : '';
toast({
title: 'Truck arrival saved',
title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`,
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
});
onClose();
@@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const blob = response.data;
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : '';
toast({
title: 'Release exit paper issued',
description: opened
description: (opened
? 'The PDF opened in a browser tab for printing or saving.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
: 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit,
});
onClose();
} catch (error) {
@@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
</Text>
)}
</Alert>
{totalTrucks > 1 && (
<Alert icon={<Truck size={16} />} color="blue" variant="light">
<Group gap="xs">
<Text size="sm">
{totalTrucks} trucks on this booking each is weighed in and out separately.
</Text>
<Badge size="sm" variant="light" color={arrivedTrucks === totalTrucks ? 'green' : 'blue'}>
{arrivedTrucks}/{totalTrucks} arrived
</Badge>
<Badge size="sm" variant="light" color={leftTrucks === totalTrucks ? 'green' : 'gray'}>
{leftTrucks}/{totalTrucks} left
</Badge>
</Group>
</Alert>
)}
{hasTruckLeft && (
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
Truck {truckPlateNumber} has already left its exit record is locked. Pick another
truck to continue the remaining arrivals and exits.
</Text>
</Alert>
)}
<TextInput
label="Release document reference"
placeholder="e.g. REL-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked}
readOnly={referenceLocked}
/>
{noTruckAssigned && (
<Alert color="orange" variant="light" icon={<Info size={16} />}>
@@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
)}
{truckSelectOptions.length > 0 && (
<Select
label="Assigned first / last-mile truck"
label="Truck at the gate"
description="Pick which assigned truck is being processed — switching trucks loads that truck's own arrival/exit record."
placeholder="Select the assigned truck"
searchable
clearable
// Enabled at arrival so the operator picks which assigned truck came;
// only locked on the exit (leaving) step once identity is captured.
disabled={isEntranceLocked}
data={truckSelectOptions}
disabled={releaseMutation.isPending || downloading}
data={truckSelectOptions.map(({ value, label, arrived, left }) => ({
value,
label: `${label}${left ? ' · LEFT' : arrived ? ' · ON SITE' : ''}`,
}))}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
if (value) applyTruckSelection(value);
}}
/>
)}
@@ -481,6 +617,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
disabled={hasTruckLeft}
/>
) : (
<Stack gap={6}>
@@ -514,13 +651,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
disabled={isEntranceLocked}
/>
{skipWeighing && (
<Text size="xs" c="dimmed">Weighbridge skipped container passes without tare/gross.</Text>
<Text size="xs" c="dimmed">
Weighbridge skipped the selected containers' cargo weight is recorded as the net.
</Text>
)}
</Group>
)}
<Group grow>
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing || hasTruckLeft} />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}
@@ -532,7 +671,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} />
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
@@ -546,7 +685,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading} disabled={hasTruckLeft}>
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
</Button>
</Group>

View File

@@ -51,8 +51,10 @@ const actionColor: Record<InventoryAction, string> = {
deliver: 'green',
};
// After the first truck registers, the modal decides per truck whether it is
// arriving or leaving — the item-level label covers both for multi-truck.
const releaseActionLabel = (item: WarehouseInventoryItem) =>
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
item.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival';
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
@@ -276,6 +278,19 @@ export function WarehouseInventoryTable({
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{/* After the first exit the primary action flips to Deliver, but a
multi-truck booking still weighs its remaining trucks in and out. */}
{item.status === 'READY_FOR_PICKUP' && item.releaseDate && nextAction !== 'release' && (
<Button
size="compact-xs"
variant="light"
color="yellow"
loading={busy}
onClick={() => onAdvance(item, 'release')}
>
Truck Arrival / Leaving
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
<Button
size="compact-xs"

View File

@@ -23,23 +23,24 @@ export function WarehouseOpsKpiStrip() {
delta:
data != null ? data.receivedToday - data.receivedYesterday : undefined,
hint: "vs yesterday",
// The received cargo itself, on the inventory board.
href: "/dashboard/warehouse-inventory?status=RECEIVED",
// Exactly the items behind the counter: received today.
href: "/dashboard/warehouse-inventory?receivedToday=1",
},
{
label: "Pending inspection",
value: data?.pendingInspection ?? 0,
icon: ClipboardCheck,
color: "yellow",
// Received cargo still awaiting inspection lives in the RECEIVED bucket.
href: "/dashboard/warehouse-inventory?status=RECEIVED",
// RECEIVED items with no inspection recorded yet.
href: "/dashboard/warehouse-inventory?pendingInspection=1",
},
{
label: "Trucks on-site",
value: data?.trucksOnSite ?? 0,
icon: Truck,
color: "blue",
href: "/dashboard/trucks-on-site",
// Land on the On-site tab — the counter excludes inbound trucks.
href: "/dashboard/trucks-on-site?scope=ON_SITE",
},
{
label: "Items aging (>7d)",
@@ -47,8 +48,7 @@ export function WarehouseOpsKpiStrip() {
icon: AlertTriangle,
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
hint: "In warehouse over 7 days",
// No aging filter on the board; the inventory list is the landing.
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?agingOverDays=7",
},
]}
/>

View File

@@ -69,6 +69,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
* (e.g. a license expiry); "past" (default) = cannot be in the future.
*/
dateBound?: "past" | "future";
/**
* Format the value must match, checked on submit. The value is upper-cased and
* trimmed before the test, matching the server. Empty optional fields skip it.
*/
pattern?: { regex: RegExp; message: string; uppercase?: boolean };
}
export interface FleetListFilterDef {

View File

@@ -1,5 +1,15 @@
import type { FleetResourceConfig } from "./resources";
/**
* A plate is two or three letters, a hyphen, then two to six digits — ET-9875,
* AA-8642. Mirrors VEHICLE_PLATE_REGEX on the API so the form and the server
* agree on what a plate looks like.
*/
const PLATE_PATTERN = {
regex: /^[A-Z]{2,3}-\d{2,6}$/,
message: "Use letters and numbers like ET-9875 or AA-8642",
};
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
@@ -77,9 +87,9 @@ export const vehiclesConfig: FleetResourceConfig = {
],
formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -139,7 +140,13 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
export default function TrucksOnSitePage() {
const { data: trucks = [], isLoading } = useTrucksOnSite();
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">("ALL");
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
// deep-links here with ?scope=ON_SITE to land on the matching tab.
const [searchParams] = useSearchParams();
const scopeParam = searchParams.get("scope");
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
);
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
const [search, setSearch] = useState("");

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackageOpen, Search, Truck } from 'lucide-react';
import { PackageOpen, Search, Truck, X } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import {
@@ -25,14 +25,36 @@ export default function WarehouseInventoryPage() {
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
// KPI drill-downs arriving from the ops dashboard cards; each shows as a
// dismissible chip so the list can be widened back out in place.
const [quick, setQuick] = useState<
Pick<InventoryFilter, 'receivedToday' | 'pendingInspection' | 'agingOverDays'>
>(() => {
const aging = Number(searchParams.get('agingOverDays'));
return {
receivedToday: searchParams.get('receivedToday') ? true : undefined,
pendingInspection: searchParams.get('pendingInspection') ? true : undefined,
agingOverDays: Number.isFinite(aging) && aging > 0 ? aging : undefined,
};
});
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
() => ({ ...filter, ...quick, direction, search: debouncedSearch || undefined }),
[filter, quick, direction, debouncedSearch],
);
const quickChips: Array<{ key: keyof typeof quick; label: string }> = [
...(quick.receivedToday ? [{ key: 'receivedToday' as const, label: 'Received today' }] : []),
...(quick.pendingInspection
? [{ key: 'pendingInspection' as const, label: 'Pending inspection' }]
: []),
...(quick.agingOverDays
? [{ key: 'agingOverDays' as const, label: `In warehouse >${quick.agingOverDays}d` }]
: []),
];
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(filter.warehouseId);
const zonesQuery = useWarehouseZones(filter.yardId);
@@ -136,6 +158,17 @@ export default function WarehouseInventoryPage() {
}
w={200}
/>
{quickChips.map((chip) => (
<Button
key={chip.key}
size="compact-xs"
variant="light"
rightSection={<X size={12} />}
onClick={() => setQuick((q) => ({ ...q, [chip.key]: undefined }))}
>
{chip.label}
</Button>
))}
</Group>
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />

View File

@@ -144,6 +144,8 @@ export interface LastMileArrivalTruck {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}
export const warehouseService = {

View File

@@ -1083,6 +1083,10 @@ export interface InventoryFilter {
search?: string;
dateFrom?: string;
dateTo?: string;
/** KPI drill-downs — mirror the ops-stats counters exactly. */
receivedToday?: boolean;
pendingInspection?: boolean;
agingOverDays?: number;
}
export interface InventoryInquiryFilter {

View File

@@ -34,6 +34,7 @@ import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { ScheduleCard } from "./components/ScheduleCard";
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
import { WarehouseLocationCard } from "./components/WarehouseLocationCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
@@ -273,6 +274,8 @@ export function ReadonlyBookingView({
<ContainersCard booking={booking} />
<WarehouseLocationCard bookingId={booking.id} />
<ContractInfoCard booking={booking} />
<ShipmentTrackingCard bookingId={booking.id} />

View File

@@ -0,0 +1,71 @@
import { Group, Paper, Stack, Text, Divider } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Warehouse as WarehouseIcon } from "lucide-react";
import { warehouseService } from "@/services/warehouse.service";
interface WarehouseLocationCardProps {
bookingId: string;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={500} ta="right">
{value}
</Text>
</Group>
);
}
export function WarehouseLocationCard({ bookingId }: WarehouseLocationCardProps) {
const { data, isLoading } = useQuery({
queryKey: ["warehouse-inventory", bookingId],
queryFn: () => warehouseService.listInventory({ bookingId }),
});
const items = data ?? [];
const latest = items[0];
return (
<Paper withBorder radius="md" padding="lg">
<Stack gap="md">
<Group gap="xs">
<WarehouseIcon size={18} />
<Text fw={700}>Warehouse Location</Text>
</Group>
<Divider />
{isLoading ? (
<Text size="sm" c="dimmed">
Loading
</Text>
) : !latest ? (
<Text size="sm" c="dimmed">
Your cargo will appear here once it arrives at the warehouse.
</Text>
) : (
<Stack gap="xs">
<Row
label="Warehouse"
value={latest.warehouse ? `${latest.warehouse.name} (${latest.warehouse.code})` : "—"}
/>
<Row
label="Yard"
value={latest.yard ? `${latest.yard.name} (${latest.yard.code})` : "—"}
/>
<Row
label="Zone"
value={latest.zone ? `${latest.zone.name} (${latest.zone.code})` : "—"}
/>
<Row label="Arrived At" value={latest.arrivedAt ? new Date(latest.arrivedAt).toLocaleString() : "—"} />
</Stack>
)}
</Stack>
</Paper>
);
}

View File

@@ -1,4 +1,15 @@
import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
import {
Alert,
Badge,
Button,
Group,
Loader,
Modal,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Info } from "lucide-react";
import { useEffect, useState } from "react";
@@ -36,7 +47,10 @@ const downloadBlob = (blob: Blob, filename: string) => {
/**
* Approve-delivery flow: open the handover document for the customer to review,
* then apply their saved signature (approve) and hand back the signed PDF.
* then sign it with their typed full name (saved signature applied when present).
* Self-haul: one booking-level handover, signed once. EDR last-mile: one
* handover per delivering truck — the customer signs each; when the last one is
* signed the delivery completes automatically.
*/
export function ApproveDeliveryModal({
bookingId,
@@ -48,15 +62,33 @@ export function ApproveDeliveryModal({
const queryClient = useQueryClient();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [signerName, setSignerName] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data: handovers } = useQuery({
queryKey: ["booking-handovers", bookingId],
queryFn: () => bookingsService.listBookingHandovers(bookingId),
enabled: opened && Boolean(bookingId),
staleTime: 0,
});
// Per-truck mode: any EDR last-mile handover means one signature per truck.
const edrMode = (handovers ?? []).some((h) => h.mileType === "EDR_LAST_MILE");
const unsigned = (handovers ?? []).filter((h) => !h.signedAt);
const selected =
(handovers ?? []).find((h) => h.id === selectedId && !h.signedAt) ?? unsigned[0] ?? null;
const {
data: docBlob,
isLoading,
isError,
} = useQuery({
queryKey: ["booking-handover-doc", bookingId],
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
enabled: opened && Boolean(bookingId),
queryKey: ["booking-handover-doc", bookingId, edrMode ? selected?.id : "booking"],
queryFn: () =>
bookingsService.downloadBookingHandoverDocument(
bookingId,
edrMode ? selected?.id : undefined,
),
enabled: opened && Boolean(bookingId) && (!edrMode || Boolean(selected)),
staleTime: 0,
});
@@ -70,10 +102,32 @@ export function ApproveDeliveryModal({
return () => URL.revokeObjectURL(url);
}, [docBlob]);
const invalidateBooking = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: bookingId }),
}),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
const onSignError = (error: unknown) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
onClose();
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
onClose();
navigate("/billing");
}
};
const handoverMutation = useMutation(
api.bookings.downloadHandoverDocument.mutationOptions(),
);
// Booking-level (self-haul) approval — signs every handover at once.
const approve = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async (result) => {
@@ -87,30 +141,35 @@ export function ApproveDeliveryModal({
toast.success("Delivery approved and handover signed");
toast.error("Signed handover document could not be downloaded");
}
await Promise.all([
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: bookingId }),
}),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
await invalidateBooking();
onApproved?.();
onClose();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
onClose();
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
onClose();
navigate("/billing");
}
},
onError: onSignError,
});
const busy = approve.isPending || handoverMutation.isPending;
// Per-truck (EDR last-mile) signature — one handover at a time.
const signOne = useMutation({
mutationFn: ({ handoverId, name }: { handoverId: string; name: string }) =>
bookingsService.signHandover(handoverId, name),
onSuccess: async (result) => {
await queryClient.invalidateQueries({
queryKey: ["booking-handovers", bookingId],
});
setSelectedId(null);
if (result.allSigned) {
toast.success("All handovers signed — delivery confirmed");
await invalidateBooking();
onApproved?.();
onClose();
} else {
toast.success("Handover signed — please sign the remaining truck(s)");
}
},
onError: onSignError,
});
const busy = approve.isPending || handoverMutation.isPending || signOne.isPending;
return (
<Modal
@@ -123,12 +182,42 @@ export function ApproveDeliveryModal({
<Stack gap="md">
<Alert color="blue" variant="light" icon={<Info size={16} />}>
<Text size="sm">
Review the handover document below, then type your full name to sign and
confirm you received the goods. Your saved signature is applied automatically
if you have one.
{edrMode
? "Your goods were delivered by EDR truck(s). Review and sign the handover for each truck to confirm you received the goods — delivery completes once every truck is signed."
: "Review the handover document below, then type your full name to sign and confirm you received the goods. Your saved signature is applied automatically if you have one."}
</Text>
</Alert>
{edrMode && (handovers?.length ?? 0) > 0 && (
<Stack gap={4}>
{handovers!.map((h) => (
<UnstyledButton
key={h.id}
onClick={() => !h.signedAt && setSelectedId(h.id)}
style={{
padding: "8px 12px",
borderRadius: 8,
border:
selected?.id === h.id
? "1px solid var(--mantine-color-edr-green-6)"
: "1px solid var(--mantine-color-gray-3)",
cursor: h.signedAt ? "default" : "pointer",
}}
>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600}>
{h.truckPlate ? `Truck ${h.truckPlate}` : "Booking handover"} {" "}
{h.reference}
</Text>
<Badge color={h.signedAt ? "green" : "yellow"} variant="light">
{h.signedAt ? `Signed${h.signerName ? `${h.signerName}` : ""}` : "Awaiting signature"}
</Badge>
</Group>
</UnstyledButton>
))}
</Stack>
)}
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
@@ -170,10 +259,21 @@ export function ApproveDeliveryModal({
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={busy}
disabled={isLoading || isError || !signerName.trim()}
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
disabled={
isLoading ||
isError ||
!signerName.trim() ||
(edrMode && !selected)
}
onClick={() =>
edrMode && selected
? signOne.mutate({ handoverId: selected.id, name: signerName.trim() })
: approve.mutate({ id: bookingId, signerName: signerName.trim() })
}
>
Approve &amp; sign delivery
{edrMode && selected?.truckPlate
? `Sign for truck ${selected.truckPlate}`
: "Approve & sign delivery"}
</Button>
</Group>
</Stack>

View File

@@ -137,6 +137,26 @@ export interface ApproveDeliveryResponse {
signerDisplayName: string;
}
/** One import handover record — booking-level or per truck (EDR last-mile). */
export interface BookingHandoverRecord {
id: string;
reference: string;
truckPlate: string | null;
mileType: "SELF_HAUL" | "EDR_LAST_MILE";
generatedAt: string;
signedAt: string | null;
signerName: string | null;
deliveredAt: string | null;
}
export interface SignHandoverResponse {
handoverId: string;
bookingId: string;
signedAt: string | null;
signerDisplayName: string;
allSigned: boolean;
}
export interface CustomerTruckAssignmentPayload {
truckPlateNumber: string;
driverName: string;
@@ -206,13 +226,36 @@ export const bookingsService = {
);
return data;
},
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
downloadBookingHandoverDocument: async (
bookingId: string,
handoverId?: string,
): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
{ responseType: "blob" },
{ responseType: "blob", params: handoverId ? { handoverId } : undefined },
);
return data;
},
listBookingHandovers: async (
bookingId: string,
): Promise<BookingHandoverRecord[]> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handovers`,
);
return data.data ?? data;
},
signHandover: async (
handoverId: string,
signerName: string,
): Promise<SignHandoverResponse> => {
const { data } = await client.post(
`/api/warehouse-inventory/handovers/${handoverId}/sign`,
{ signerName },
);
return data.data ?? data;
},
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,

View File

@@ -0,0 +1,56 @@
import { client } from "@/utils/api";
export interface InventoryFilter {
bookingId?: string;
}
export interface WarehouseInventoryItem {
id: string;
bookingId: string | null;
warehouseId: string;
yardId: string;
zoneId: string;
status: string;
arrivedAt: string | null;
warehouse?: {
id: string;
name: string;
code: string;
} | null;
yard?: {
id: string;
name: string;
code: string;
} | null;
zone?: {
id: string;
name: string;
code: string;
} | null;
}
export interface BookingScheduleView {
schedule: {
status: string;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
} | null;
wagon?: {
wagonNumber: string | null;
sequenceNo: number | null;
} | null;
}
export const warehouseService = {
listInventory: async (filter?: InventoryFilter): Promise<WarehouseInventoryItem[]> => {
const { data } = await client.get("/warehouse-inventory", {
params: filter,
});
return data?.data ?? data ?? [];
},
bookingSchedule: async (bookingId: string): Promise<BookingScheduleView> => {
const { data } = await client.get(`/warehouse-inventory/booking-schedule/${bookingId}`);
return data?.data ?? data ?? { schedule: null, wagon: null };
},
};

View File

@@ -580,7 +580,7 @@ model BookingSeat {
bookingId String
seatId String
leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
scheduleId String // which schedule this seat belongs to
scheduleId String? // which schedule this seat belongs to
passengerName String
dateOfBirth DateTime?
passengerCategory PassengerCategory @default(ADULT)

View File

@@ -826,6 +826,19 @@ export class PaymentsService {
});
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
// Idempotency guard — but still repair missing tickets. They can be absent
// when the first finalization threw from generate() after the transaction
// committed: the caller got a 500, retried, and now hits this early-return.
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
if (ticketCount === 0) {
try {
await this.ticketsService.generate(intent.bookingId);
} catch (err) {
this.logger.error(
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
return { alreadyFinalized: true };
}
if (intent.status === PaymentIntentStatus.CANCELLED) {
@@ -877,9 +890,8 @@ export class PaymentsService {
await this.ticketsService.generate(booking.id);
} catch (err) {
this.logger.error(
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
`Error generating ticket for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`,
);
throw err;
}
try {

View File

@@ -18,10 +18,10 @@ export class ReportsController {
return this.service.generateReport(dto);
}
@Get("schedules")
@ApiOperation({ summary: "List schedules for the passengers report picker" })
listSchedulesForPicker() {
return this.service.listSchedulesForPicker();
@Get('schedules')
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
listSchedulesForPicker(@Query('all') all?: string) {
return this.service.listSchedulesForPicker(all === 'true');
}
@Get("passengers/list")

View File

@@ -264,13 +264,10 @@ export class ReportsService {
},
bookings: {
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
include: {
seats: {
where: { leg: 1 },
include: {
seat: { include: { coach: { include: { coachType: true } } } },
},
},
select: {
id: true,
originStationId: true,
destinationStationId: true,
},
},
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
@@ -411,10 +408,10 @@ export class ReportsService {
};
}
async listSchedulesForPicker() {
async listSchedulesForPicker(all = false) {
const now = new Date();
const schedules = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: now } },
where: all ? undefined : { departureAt: { gte: now } },
select: {
id: true,
departureAt: true,
@@ -423,7 +420,7 @@ export class ReportsService {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: 'asc' },
orderBy: { departureAt: all ? 'desc' : 'asc' },
take: 200,
});
return schedules.map((s) => ({
@@ -439,8 +436,9 @@ export class ReportsService {
async getPassengerList(scheduleId: string) {
const seats = await this.prisma.bookingSeat.findMany({
where: {
scheduleId,
leg: 1,
booking: { scheduleId, status: { in: ["CONFIRMED", "BOARDED"] } },
booking: { status: { in: ["CONFIRMED", "BOARDED"] } },
},
include: {
booking: {
@@ -507,8 +505,9 @@ export class ReportsService {
// Booked seats — exclude dining coaches
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
scheduleId,
leg: 1,
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } },
booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } },
seat: { coach: { coachType: { type: { not: 'dining' } } } },
},
include: {

View File

@@ -1069,6 +1069,7 @@ export class SeatsService {
booking: {
select: {
id: true, bookingRef: true, scheduleId: true,
originStationId: true, destinationStationId: true,
createdAt: true, contactPhone: true,
},
},
@@ -1086,7 +1087,8 @@ export class SeatsService {
});
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
// Group BookingSeat rows by (seatId::leg) to detect duplicates
// Group BookingSeat rows by seatId::leg to find candidate duplicates,
// then filter to only those whose booking segments actually overlap.
type BS = (typeof bookingSeats)[number];
const groups = new Map<string, BS[]>();
for (const bs of bookingSeats) {
@@ -1095,6 +1097,59 @@ export class SeatsService {
groups.get(key)!.push(bs);
}
// Build stop-sequence map for this schedule once
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: schedule.id },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string | null | undefined): number | undefined =>
stationId ? stopTimes.find(s => s.stationId === stationId)?.sequence : undefined;
// Fetch JourneySegment ranges for all booking IDs in candidate groups
const candidateBookingIds = [...new Set(
[...groups.values()].filter(g => g.length > 1).flatMap(g => g.map(bs => bs.booking.id)),
)];
const candidateSegments = candidateBookingIds.length > 0
? await this.prisma.journeySegment.findMany({
where: {
scheduleId: schedule.id,
journey: { bookingId: { in: candidateBookingIds }, status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { departureStationId: true, arrivalStationId: true, journey: { select: { bookingId: true } } },
})
: [];
// Collapse per-booking segments into a single [from, to) range
const rangeByBookingId = new Map<string, { from: number; to: number }>();
for (const seg of candidateSegments) {
const bookingId = seg.journey.bookingId;
if (!bookingId) continue;
const depSeq = seqOf(seg.departureStationId);
const arrSeq = seqOf(seg.arrivalStationId);
if (depSeq === undefined || arrSeq === undefined) continue;
const existing = rangeByBookingId.get(bookingId);
rangeByBookingId.set(bookingId, existing
? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) }
: { from: depSeq, to: arrSeq });
}
// Fall back to booking-level origin/destination when JourneySegments are missing
const rangeForBooking = (bs: BS): { from: number; to: number } | null => {
const fromSegments = rangeByBookingId.get(bs.booking.id);
if (fromSegments) return fromSegments;
// BookingSeat.scheduleId tells us which leg this seat belongs to
const bsScheduleId = bs.scheduleId ?? bs.booking.scheduleId;
if (bsScheduleId !== schedule.id) return null;
const from = seqOf(bs.booking.originStationId);
const to = seqOf(bs.booking.destinationStationId);
if (from === undefined || to === undefined) return null;
return { from, to };
};
// Two bookings are true duplicates only if their segments overlap
const segmentsOverlap = (a: { from: number; to: number }, b: { from: number; to: number }) =>
a.from < b.to && b.from < a.to;
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
// are missing (e.g. created via enhanced-seats path without bookingId) are still
@@ -1114,13 +1169,30 @@ export class SeatsService {
for (const [key, group] of groups) {
if (group.length <= 1) continue;
if (group[0].seat.coachId !== coach.id) continue;
// Filter to bookings that actually have overlapping segments
const overlapping: BS[] = [];
for (let i = 0; i < group.length; i++) {
const rangeA = rangeForBooking(group[i]);
for (let j = i + 1; j < group.length; j++) {
const rangeB = rangeForBooking(group[j]);
// If either range is unknown, conservatively treat as overlap
const isOverlap = !rangeA || !rangeB || segmentsOverlap(rangeA, rangeB);
if (isOverlap) {
if (!overlapping.includes(group[i])) overlapping.push(group[i]);
if (!overlapping.includes(group[j])) overlapping.push(group[j]);
}
}
}
if (overlapping.length <= 1) continue;
const [seatId] = key.split('::');
const seat = coach.seats.find(s => s.id === seatId);
duplicates.push({
seatId,
seatNumber: seat?.seatNumber ?? seatId,
leg: group[0].leg,
bookings: group.map(bs => ({
leg: overlapping[0].leg,
bookings: overlapping.map(bs => ({
bookingSeatId: bs.id,
bookingId: bs.booking.id,
bookingRef: bs.booking.bookingRef,

View File

@@ -56,11 +56,9 @@ interface PassengerRow {
seatClassName: string | null;
coachNumber: string | null;
coachType: string | null;
coachSeat: string | null;
nationality: string | null;
origin: string | null;
destination: string | null;
departureAt: string | null;
amountPaidMinor: number;
currency: string;
isGroupBooking: boolean;
@@ -73,7 +71,6 @@ export default function PassengersReportPage() {
const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("occupancy");
const [listSearch, setListSearch] = useState("");
const [filterCoach, setFilterCoach] = useState("");
const [filterOrigin, setFilterOrigin] = useState("");
const [filterSeatClass, setFilterSeatClass] = useState("");
const [filterCoachNumber, setFilterCoachNumber] = useState("");
@@ -81,8 +78,8 @@ export default function PassengersReportPage() {
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
ScheduleOption[]
>({
queryKey: ["report-schedules"],
queryFn: () => apiClient.get("/reports/schedules"),
queryKey: ["report-schedules-all"],
queryFn: () => apiClient.get("/reports/schedules?all=true"),
});
const schedules = schedulesRaw ?? [];
@@ -102,7 +99,7 @@ export default function PassengersReportPage() {
enabled: !!scheduleId,
});
const coachOptions = [
const coachNumberOptions = [
...new Set(passengerList.map((p) => p.coachNumber).filter(Boolean)),
].sort() as string[];
const seatClassOptions = [
@@ -111,11 +108,9 @@ export default function PassengersReportPage() {
const originOptions = [
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
].sort() as string[];
const coachNumberOptions = coachOptions;
const filteredList = passengerList
.filter((p) => {
if (filterCoach && p.coachNumber !== filterCoach) return false;
if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false;
if (filterOrigin && p.origin !== filterOrigin) return false;
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
@@ -161,29 +156,24 @@ export default function PassengersReportPage() {
};
const doExportList = () => {
if (!passengerList.length) return;
const headers = [
"#",
"Name",
"Coach·Seat",
"Origin",
"Destination",
"Date",
"Booking Ref",
];
const rows = passengerList.map((p, i) =>
if (!filteredList.length) return;
const headers = ['Name', 'Nationality', 'Passport Number', 'Seat Class', 'Coach', 'Seat', 'Origin', 'Destination', 'Amount Paid (ETB)', 'Booking Ref'];
const rows = filteredList.map((p) =>
[
String(i + 1),
p.passengerName,
p.coachSeat,
p.origin,
p.destination,
p.departureAt ? formatDateTime(p.departureAt) : "—",
p.nationality ?? '—',
p.passportNumber ?? '—',
p.seatClassName ?? '—',
p.coachNumber ?? '—',
p.seatNumber ?? '—',
p.origin ?? '—',
p.destination ?? '—',
(p.amountPaidMinor / 100).toFixed(2),
p.bookingRef,
].map((v) => `"${String(v).replace(/"/g, '""')}"`),
);
downloadCsv(
[headers.join(","), ...rows.map((r) => r.join(","))].join("\n"),
[headers.join(','), ...rows.map((r) => r.join(','))].join('\n'),
`passengers-${scheduleId}.csv`,
);
};
@@ -211,7 +201,6 @@ export default function PassengersReportPage() {
setScheduleId(e.target.value);
setTab("occupancy");
setListSearch("");
setFilterCoach("");
setFilterCoachNumber("");
setFilterOrigin("");
setFilterSeatClass("");
@@ -520,94 +509,41 @@ export default function PassengersReportPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Nationality</th>
<th className="pb-2 pr-4">Passport</th>
<th className="pb-2 pr-4">Coach</th>
<th className="pb-2 pr-4">Seat</th>
<th className="pb-2 pr-4">Class</th>
<th className="pb-2 pr-4">Origin</th>
<th className="pb-2 pr-4">Destination</th>
<th className="pb-2 pr-4">Amount Paid</th>
<th className="pb-2">Booking Ref</th>
<th className="pb-2 pr-4 whitespace-nowrap">Name</th>
<th className="pb-2 pr-4 whitespace-nowrap">Nationality</th>
<th className="pb-2 pr-4 whitespace-nowrap">Seat Class · Coach · Seat</th>
<th className="pb-2 pr-4 whitespace-nowrap">Route</th>
<th className="pb-2 pr-4 whitespace-nowrap">Amount Paid</th>
<th className="pb-2 whitespace-nowrap">Booking Ref</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredList.map((p, i) => (
<tr
key={`${p.bookingRef}-${i}`}
className="hover:bg-muted/30"
>
<td className="py-2 pr-4 font-medium">
{p.passengerName}
</td>
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
<td className="py-2 pr-4 font-medium whitespace-nowrap">{p.passengerName}</td>
<td className="py-2 pr-4 text-xs">
{p.passportNumber ? (
<>
<span className="text-muted-foreground">
{p.passportCountry ?? "Intl"}
</span>
<span className="ml-1 font-mono">
{p.passportNumber}
</span>
</>
) : (
<span className="text-muted-foreground">
{p.idDocumentNumber ?? "—"}
</span>
<span className="text-muted-foreground">{p.nationality ?? '—'}</span>
{p.passportNumber && (
<span className="ml-1.5 font-mono text-foreground">{p.passportNumber}</span>
)}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{p.coachNumber && p.seatLabel ? (
<>
{p.coachNumber} · {p.seatLabel}
{p.coachType && (
<span className="font-sans text-muted-foreground ml-1">
({p.coachType})
</span>
)}
</>
) : (
(p.coachNumber ?? p.seatLabel ?? "—")
)}
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">
{p.origin && p.destination
? `${p.origin}${p.destination}`
: (p.origin ?? p.destination ?? "—")}
</td>
<td className="py-2 pr-4 text-xs">
<div className="flex items-center gap-1.5">
<span className="tabular-nums font-medium">
{(p.amountPaidMinor / 100).toLocaleString(
"en-US",
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
)}{" "}
{p.currency}
</span>
{p.isGroupBooking && (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Group
</span>
)}
</div>
<span className="font-medium">{p.seatClassName ?? '—'}</span>
{p.coachNumber && <span className="text-muted-foreground"> · {p.coachNumber}</span>}
{p.seatNumber && <span className="text-muted-foreground"> · #{p.seatNumber}</span>}
</td>
<td className="py-2 font-mono text-xs">
{p.bookingRef}
<td className="py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap">
{p.origin && p.destination ? `${p.origin}${p.destination}` : (p.origin ?? p.destination ?? '—')}
</td>
<td className="py-2 pr-4 tabular-nums text-xs">
{(p.amountPaidMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} {p.currency}
</td>
<td className="py-2 font-mono text-xs">{p.bookingRef}</td>
</tr>
))}
{filteredList.length === 0 && (
<tr>
<td
colSpan={6}
className="py-8 text-center text-sm text-muted-foreground"
>
No passengers found
</td>
<td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td>
</tr>
)}
</tbody>

View File

@@ -260,7 +260,7 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | n
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(schedule.origin.code, margin + padX, topY + 7);
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
doc.text(schedule.origin.city || schedule.origin.name, margin + padX, topY + 11.5);
doc.text(schedule.origin.name, margin + padX, topY + 11.5);
const dep = new Date(schedule.departureAt);
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
@@ -274,7 +274,7 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | n
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(schedule.destination.code, dx, topY + 7, { align: 'right' });
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
doc.text(schedule.destination.city || schedule.destination.name, dx, topY + 11.5, { align: 'right' });
doc.text(schedule.destination.name, dx, topY + 11.5, { align: 'right' });
const arr = new Date(schedule.arrivalAt);
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');

194
docker-compose.e2e.yaml Normal file
View File

@@ -0,0 +1,194 @@
# EDR Freight — ephemeral Cypress e2e stack.
# Fully isolated from dev: own ports, own throwaway Postgres (tmpfs — data
# vanishes on `down`), seeded test users. Requires the same .npmrc as the main
# docker-compose.yaml (GitHub Packages auth for @tria-plc).
#
# Preferred entrypoint: the launcher (auto-up + free-port picking):
# pnpm e2e:freight:run|open|ci|up|down → e2e/freight/scripts/e2e.mjs
#
# Host ports are env-parameterized (E2E_*_PORT). Defaults below avoid the dev
# stacks (5273/5283/3221 are taken by the second dev checkout in
# ~/projects/nathnael/edr-platform); when a default is busy the launcher scans
# upward for a free port and remembers the choice in e2e/freight/.e2e-ports.json
# while the stack is up:
# freight-api 3101 portal 5373 backoffice 5383
# postgres 5533 minio 9310 (console 9311)
name: edr-freight-e2e
services:
postgres-freight-e2e:
image: postgres:16-alpine
environment:
POSTGRES_DB: edr_freight_e2e
POSTGRES_USER: edr_e2e
POSTGRES_PASSWORD: edr_e2e
tmpfs:
- /var/lib/postgresql/data
ports:
- "${E2E_DB_PORT:-5533}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U edr_e2e -d edr_freight_e2e"]
interval: 2s
timeout: 3s
retries: 30
minio-e2e:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: e2e-minio
MINIO_ROOT_PASSWORD: e2e-minio-secret
tmpfs:
- /data
ports:
- "${E2E_MINIO_PORT:-9310}:9000"
- "${E2E_MINIO_CONSOLE_PORT:-9311}:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 12
# tmpfs wipes MinIO on every restart — recreate the app bucket each boot.
minio-init-e2e:
image: minio/mc:latest
depends_on:
minio-e2e:
condition: service_healthy
entrypoint:
- /bin/sh
- -c
- mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc
restart: "no"
freight-api-e2e:
build:
context: .
dockerfile: apps/edr-freight-api/Dockerfile
secrets:
- npmrc
depends_on:
postgres-freight-e2e:
condition: service_healthy
minio-e2e:
condition: service_healthy
minio-init-e2e:
condition: service_completed_successfully
environment:
PORT: "3001"
DB_HOST: postgres-freight-e2e
DB_PORT: "5432"
DB_USER: edr_e2e
DB_PASSWORD: edr_e2e
DB_NAME: edr_freight_e2e
# e2e-only secrets — never reuse outside this stack
JWT_SECRET: e2e-jwt-secret
JWT_ACCESS_TOKEN_SECRET: e2e-access-secret
JWT_REFRESH_TOKEN_SECRET: e2e-refresh-secret
JWT_EXPIRES_IN: 1d
JWT_ACCESS_TOKEN_EXPIRES: 1d
JWT_REFRESH_TOKEN_EXPIRES: 7d
SERVICE_AUTH_TOKEN: e2e-service-token
# Org/unit/position boot seeders (env-gated in app code). Test USERS are
# NOT seeded by the API — Cypress inserts them via
# e2e/freight/cypress/fixtures/seed-users.sql before specs run.
SEED_EDR_ORG: "true"
SUPER_ADMIN_EMAIL: superadmin@tria.com
SUPER_ADMIN_PHONE: "+251900000000"
# Object storage
MINIO_ENDPOINT: minio-e2e
MINIO_PORT: "9000"
MINIO_USE_SSL: "false"
MINIO_ACCESS_KEY: e2e-minio
MINIO_SECRET_KEY: e2e-minio-secret
MINIO_REGION: us-east-1
# External integrations off
RABBITMQ_ENABLED: "false"
FAYDA_ENABLED: "false"
# SMS strategy has no kill switch and defaults to a real dev endpoint —
# blackhole it so e2e never sends SMS (failures are logged, non-fatal).
OZIKING_SMS_URL: http://127.0.0.1:9/sms
FREIGHT_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373}
ports:
- "${E2E_API_PORT:-3101}:3001"
healthcheck:
# Boot runs 240+ migrations + seeders on first start — generous start_period.
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3001/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 5s
timeout: 5s
retries: 12
start_period: 180s
freight-portal-e2e:
build:
context: .
dockerfile: infrastructure/docker/Dockerfile.web
args:
TURBO_FILTER: "@edr/freight-portal"
APP_PATH: apps/edr-freight-web/portal
# Baked at build time: browser (host or host-networked cypress
# container) reaches the API through the published host port. A
# non-default API port therefore forces a web image rebuild.
VITE_API_URL: http://localhost:${E2E_API_PORT:-3101}
VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101}
VITE_USER_MANAGEMENT_BASE: /_um
VITE_GOOGLE_MAPS_API_KEY: ""
VITE_POSTHOG_KEY: ""
VITE_POSTHOG_HOST: ""
secrets:
- npmrc
ports:
- "${E2E_PORTAL_PORT:-5373}:80"
freight-backoffice-e2e:
build:
context: .
dockerfile: infrastructure/docker/Dockerfile.web
args:
TURBO_FILTER: "@edr/freight-backoffice"
APP_PATH: apps/edr-freight-web/backoffice
VITE_API_URL: http://localhost:${E2E_API_PORT:-3101}
VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101}
VITE_USER_MANAGEMENT_BASE: /_um
VITE_GOOGLE_MAPS_API_KEY: ""
VITE_POSTHOG_KEY: ""
VITE_POSTHOG_HOST: ""
secrets:
- npmrc
ports:
- "${E2E_BACKOFFICE_PORT:-5383}:80"
# Headless runner — opt-in via `--profile cypress`. host network so the
# in-container browser uses the exact same localhost URLs as `cypress open`
# on the host (Linux only; on macOS/Windows run Cypress from the host).
cypress:
# Keep in sync with the cypress version in e2e/freight/package.json.
image: cypress/included:${CYPRESS_VERSION:-15.18.1}
profiles: ["cypress"]
network_mode: host
depends_on:
freight-api-e2e:
condition: service_healthy
working_dir: /repo/e2e/freight
# NOTE: host network shares the abstract X-socket namespace with the host.
# Cypress spawns its Xvfb on :99 — run only ONE cypress container at a
# time, and don't run it on a host whose X server occupies :99.
entrypoint: ["cypress", "run", "--browser", "chrome"]
environment:
CI: "true"
E2E_DB_URL: postgres://edr_e2e:edr_e2e@localhost:${E2E_DB_PORT:-5533}/edr_freight_e2e
CYPRESS_BASE_URL: http://localhost:${E2E_BACKOFFICE_PORT:-5383}
CYPRESS_API_URL: http://localhost:${E2E_API_PORT:-3101}
CYPRESS_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373}
volumes:
- .:/repo
secrets:
npmrc:
file: .npmrc

108
e2e/freight/README.md Normal file
View File

@@ -0,0 +1,108 @@
# @edr/freight-e2e — Cypress e2e suite for the freight system
Containerized, fully isolated e2e environment: throwaway Postgres (tmpfs),
MinIO, freight-api, portal, and backoffice — plus a Cypress runner that works
both headless-in-Docker and interactively from the host against the same URLs.
## Stack (`docker-compose.e2e.yaml`, project name `edr-freight-e2e`)
| Service | Default port | Notes |
| ----------------------- | ------------ | ---------------------------------------------- |
| `freight-api-e2e` | 3101 | migrations + seeders run at boot |
| `freight-portal-e2e` | 5373 | nginx static build, API URL baked at build |
| `freight-backoffice-e2e`| 5383 | nginx static build, API URL baked at build |
| `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` |
| `minio-e2e` | 9310/9311 | object storage for file features |
| `cypress` | (host net) | profile `cypress`, headless chrome |
Ports are env-parameterized (`E2E_API_PORT`, `E2E_PORTAL_PORT`,
`E2E_BACKOFFICE_PORT`, `E2E_DB_PORT`, `E2E_MINIO_PORT`,
`E2E_MINIO_CONSOLE_PORT`). Defaults avoid the dev stacks; if a default is
busy anyway, the launcher scans upward for a free port, remembers the choice
in `.e2e-ports.json` (gitignored) while the stack is up, and passes matching
URLs to both compose and Cypress. The dev database is never touched.
## Usage (from repo root)
One command — the launcher (`scripts/e2e.mjs`) auto-builds and starts the
stack if it isn't running, waits for healthchecks, then runs Cypress against
whatever ports were picked:
```bash
pnpm e2e:freight:run # headless run from the host (auto-up)
pnpm e2e:freight:open # interactive Cypress on the host (auto-up)
pnpm e2e:freight:ci # headless run inside the cypress container (auto-up)
pnpm e2e:freight:up # just start the stack
pnpm e2e:freight:down # teardown, drop all data + forget ports
pnpm e2e:freight:run --spec 'cypress/e2e/flows/**' # extra args → cypress
```
First `up` is slow (image builds + 240 migrations + seeders — healthcheck
allows 3 min). Later runs against a live stack skip docker entirely. Requires
the same root `.npmrc` (GitHub Packages auth for `@tria-plc`) as the main
compose file. Note: a non-default API port forces a web-image rebuild (the
API URL is baked into the static builds).
The `cypress` service uses `network_mode: host` (Linux). On macOS/Windows run
Cypress from the host (`e2e:freight:open` / `e2e:freight:run`) instead of the
container.
## Test users
Inserted by Cypress itself — a global `before()` hook runs
`cy.task("db:seedUsers")`, which executes `cypress/fixtures/seed-users.sql`
then `cypress/fixtures/seed-company.sql` (idempotent, pre-hashed argon2
passwords) against the e2e database. No API code is involved; the app's user
seeders stay disabled. The API's always-on boot seeders must have run first
(org/unit/positions) — guaranteed once `freight-api-e2e` is healthy.
- Staff (backoffice): `linestaff|chief|director|ceo|marketer|operation|gl-et|gl-dj@edr.local`
— password `password@tria`
- Customers (portal): `user@gmail.com`, `user2@gmail.com`
— password `12345678`
`seed-company.sql` additionally gives `user@gmail.com` an ACTIVE company
("E2E Logistics PLC", TIN `0102030405`) with an approved importer profile —
the contract wizard's precondition — and grants `chief` the
`edr_freight_app:admin` permission (customer-profile approval is
FreightAdmin-guarded and no seeded position carries it otherwise).
Full map in `cypress/fixtures/users.json`.
## Conventions
- **Programmatic login** everywhere except the two dedicated UI-login specs:
`cy.loginBackoffice(email?)` / `cy.loginPortal(email?)``cy.session`-cached
(across specs), `POST /api/auth/login`, sets the `auth-token` /
`refresh-token` cookies the apps read.
- **Origins**: `baseUrl` is the backoffice (5383). Portal specs `cy.visit`
the absolute portal URL; a test that touches *both* apps wraps portal steps
in `cy.origin()` (different port = different origin). Cookies ignore ports —
always call the matching login command right before switching apps so
`cy.session` restores the right cookie snapshot.
- **DB access**: `cy.task("db:query", { sql, params })` runs SQL against the
e2e database (`E2E_DB_URL`, default `localhost:5533`). Use for seeding
edge-case data and asserting side effects — it can never reach the dev DB.
- **OTPs**: SMS/email delivery is disabled in e2e, but codes are still stored
in `freight.otp_verifications``cy.getOtp(emailOrPhone)` polls them out.
Used by signup verification and contract customer-signing.
- **Spec layout**:
- `cypress/e2e/api/` — API contract via `cy.request` (no browser)
- `cypress/e2e/backoffice/` — staff app
- `cypress/e2e/portal/` — customer app
- `cypress/e2e/flows/` — cross-app journeys (both directions):
- `onboarding.cy.ts` — signup → OTP → wizard (docs + license upload) →
backoffice approval → customer can contract
- `contract-lifecycle.cy.ts` — wizard → submit → accept → 2-step approval
→ PDF → customer OTP-sign → staff counter-sign → `CONTRACT_ACTIVE`
- **Journey specs** (`flows/onboarding`, `flows/contract-lifecycle`) run with
`retries: 0` and resolve mid-journey state (user, company, contract) from
the DB at the start of each test: switching origin between tests reloads
the spec bundle, so module-level variables do NOT survive across tests.
## Extending
Deep module flows (booking wizard → staff approval → scheduling → billing)
belong in `flows/`. Pattern: arrange via API/`db:query`, act through the UI of
one app, assert through the UI of the other + a `db:query` cross-check. Prefer
adding `data-testid` attributes to app code over brittle text selectors.

View File

@@ -0,0 +1,83 @@
import { defineConfig } from "cypress";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Client } from "pg";
/**
* Freight e2e suite. Three origins:
* backoffice http://localhost:5383 (baseUrl — most specs live here)
* portal http://localhost:5373 (env.portalUrl; portal specs cy.visit it,
* cross-app flows reach it via cy.origin)
* api http://localhost:3101 (env.apiUrl; cy.request only)
*
* All URLs are host-published ports from docker-compose.e2e.yaml. The cypress
* container in that compose file runs with network_mode: host, so the same
* localhost URLs work identically for `cypress open` on the host and for the
* containerized headless run.
*/
export default defineConfig({
e2e: {
baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383",
specPattern: "cypress/e2e/**/*.cy.ts",
supportFile: "cypress/support/e2e.ts",
video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true",
screenshotOnRunFailure: true,
viewportWidth: 1440,
viewportHeight: 900,
defaultCommandTimeout: 10000,
requestTimeout: 15000,
retries: { runMode: 1, openMode: 0 },
env: {
apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101",
portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383",
// Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml.
defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria",
// Demo portal users: hardcoded in DemoUsersSeeder.
demoPassword: "12345678",
},
setupNodeEvents(on) {
const dbUrl =
process.env.E2E_DB_URL ??
"postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e";
on("task", {
/** Run an arbitrary SQL statement against the ephemeral e2e database. */
async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) {
const client = new Client({ connectionString: dbUrl });
await client.connect();
try {
const result = await client.query(sql, params as never[]);
return { rowCount: result.rowCount, rows: result.rows };
} finally {
await client.end();
}
},
/**
* Seed the test users (staff + demo) directly in SQL. The API's
* user seeders are disabled in app code, so the fixture replicates
* their output. Idempotent — safe to run before every spec file.
*/
async "db:seedUsers"() {
// cwd = the e2e/freight project root when Cypress runs.
// seed-company.sql depends on rows from seed-users.sql — keep order.
const client = new Client({ connectionString: dbUrl });
await client.connect();
try {
for (const file of ["seed-users.sql", "seed-company.sql"]) {
const sql = readFileSync(
join(process.cwd(), "cypress", "fixtures", file),
"utf8",
);
await client.query(sql);
}
return true;
} finally {
await client.end();
}
},
});
},
},
});

View File

@@ -0,0 +1,79 @@
/**
* API contract smoke — no browser, pure cy.request against freight-api.
* Verifies the containerized stack booted: migrations ran, seeders ran,
* auth issues tokens.
*/
const api = () => Cypress.env("apiUrl") as string;
describe("freight-api: health + auth contract", () => {
it("GET /api/health responds", () => {
cy.request(`${api()}/api/health`).its("status").should("eq", 200);
});
it("rejects bad credentials", () => {
cy.request({
method: "POST",
url: `${api()}/api/auth/login`,
body: { email: "nobody@edr.local", password: "wrong-password" },
failOnStatusCode: false,
})
.its("status")
.should("be.oneOf", [400, 401, 404]);
});
it("logs in every seeded staff user", () => {
cy.fixture("users.json").then((users) => {
Object.values<{ email: string }>(users.staff).forEach(({ email }) => {
cy.apiLogin(email);
});
});
});
it("staff token can read /api/me", () => {
cy.apiLogin("ceo@edr.local").then(({ token }) => {
cy.request({
url: `${api()}/api/me`,
headers: { Authorization: `Bearer ${token}` },
}).then((response) => {
expect(response.status).to.eq(200);
});
});
});
it("refresh-token rotates the session", () => {
cy.apiLogin("chief@edr.local").then(({ refreshToken }) => {
cy.request("POST", `${api()}/api/auth/refresh-token`, { refreshToken })
.its("body.token")
.should("be.a", "string");
});
});
it("demo portal users are seeded", () => {
// DemoUsersSeeder hardcodes this password (staff users use DEFAULT_PASSWORD)
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword"));
cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword"));
});
});
describe("freight-api: seeded database", () => {
it("migrations table is populated", () => {
cy.task<{ rowCount: number }>("db:query", {
sql: "select count(*)::int as count from migrations",
}).then(({ rows }: any) => {
expect(rows[0].count).to.be.greaterThan(100);
});
});
it("staff users exist with credentials", () => {
cy.task("db:query", {
sql: `select u.email from iam.users u
join iam.user_credentials c on c.user_id = u.id
where u.email like '%@edr.local' order by u.email`,
}).then(({ rows }: any) => {
const emails = rows.map((row: { email: string }) => row.email);
expect(emails).to.include.members(["ceo@edr.local", "linestaff@edr.local"]);
});
});
});
export {};

View File

@@ -0,0 +1,41 @@
/**
* The one UI-driven login spec for backoffice — every other spec uses the
* programmatic cy.loginBackoffice() session.
* Login form: apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx
* (Mantine inputs, matched by placeholder).
*/
describe("backoffice: UI login", () => {
it("redirects unauthenticated users to /auth", () => {
cy.clearCookies();
cy.visit("/dashboard/overview");
cy.location("pathname").should("eq", "/auth");
});
it("logs in via the form and lands on the dashboard", () => {
cy.clearCookies();
cy.visit("/auth");
cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local");
cy.get('input[placeholder="Enter your password"]').type(
Cypress.env("defaultPassword"),
{ log: false },
);
cy.get('button[type="submit"]').click();
cy.location("pathname", { timeout: 20000 }).should("match", /^\/dashboard/);
cy.getCookie("auth-token").should("exist");
cy.getCookie("refresh-token").should("exist");
});
it("shows an error for wrong credentials and stays on /auth", () => {
cy.clearCookies();
cy.visit("/auth");
cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local");
cy.get('input[placeholder="Enter your password"]').type("definitely-wrong");
cy.get('button[type="submit"]').click();
cy.location("pathname").should("eq", "/auth");
cy.getCookie("auth-token").should("not.exist");
});
});
export {};

View File

@@ -0,0 +1,49 @@
/**
* Route-level smoke over the backoffice shell: each key module page loads
* without bouncing back to /auth and without an unhandled crash. Deep
* per-module behavior belongs in dedicated specs — this catches the broad
* "page is broken / route is dead / guard rejects seeded role" class.
* Routes from apps/edr-freight-web/backoffice/src/App.tsx.
*/
const ROUTES = [
"/dashboard/overview",
"/dashboard/booking-requests",
"/dashboard/customers",
"/dashboard/invoices",
"/dashboard/contract-requests",
"/dashboard/shipment-requests",
"/dashboard/profile",
];
describe("backoffice: route smoke (ceo)", () => {
beforeEach(() => {
cy.loginBackoffice("ceo@edr.local");
});
ROUTES.forEach((route) => {
it(`renders ${route}`, () => {
cy.visit(route);
cy.location("pathname").should("not.eq", "/auth");
cy.location("pathname").should("contain", "/dashboard");
cy.get("#root").should("not.be.empty");
});
});
});
describe("backoffice: role-based access", () => {
it("line staff can reach the dashboard shell", () => {
cy.loginBackoffice("linestaff@edr.local");
cy.visit("/dashboard/overview");
cy.location("pathname").should("not.eq", "/auth");
cy.get("#root").should("not.be.empty");
});
it("operations officer can reach the dashboard shell", () => {
cy.loginBackoffice("operation@edr.local");
cy.visit("/dashboard/overview");
cy.location("pathname").should("not.eq", "/auth");
cy.get("#root").should("not.be.empty");
});
});
export {};

View File

@@ -0,0 +1,216 @@
/**
* Contract creation → finalization, spanning portal + backoffice:
*
* 1. portal (user@gmail.com, company seeded active by seed-company.sql):
* wizard → GENERAL / Import / Container / 20ft → submit + approve quote
* 2. backoffice marketer: "Accept for approval" (validity) + approve the
* LINE_STAFF step
* 3. backoffice director: approve the DIRECTOR step → PDF → CONTRACT_READY
* 4. portal customer: scroll contract, agree, draw signature, OTP-sign
* → SIGNED_CUSTOMER
* 5. backoffice marketer: counter-sign as staff → GENERAL contract goes
* CONTRACT_ACTIVE (per-booking clearance, no contract-level gate)
*
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql
/**
* The journey's contract = the seeded company's latest contract. Each test
* resolves it from the DB instead of sharing module state — tests stay
* independently runnable against the current DB state.
*/
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
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 expectStatus(expected: string) {
dbContract().then(({ rows }) => {
expect(rows[0]?.status, `contract status`).to.eq(expected);
});
}
describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
it("customer creates and submits a GENERAL import container contract", () => {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");
// Step 0 — Setup.
cy.mantineSelect(/^Operation Type/, /^Import$/);
cy.mantineSelect(/^Contract Kind/, "General Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("Rail Transport Only", { timeout: 15000 }).click();
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route.
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type(
"E2E electronics shipment scope",
);
cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal");
cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port");
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");
cy.contains("Submitted", { timeout: 15000 }).should("be.visible");
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-/);
});
});
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();
// Validity defaults to the first configured option in the accept modal.
cy.contains("button", "Accept & start approval", { timeout: 20000 })
.should("not.be.disabled")
.click();
// Approval chain instantiated: LINE_STAFF → DIRECTOR. Approve step 1.
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");
expectStatus("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();
// Final approval renders the contract PDF synchronously → CONTRACT_READY.
// The approval-chain card unmounts once the contract leaves approval, so
// assert on the signing CTA that replaces it.
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
expectStatus("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.
// Retried because the iframe can re-render (query refetch) after a scroll.
const unlockConsent = (attempt: number) => {
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(
($f) => {
const win = ($f[0] as HTMLIFrameElement).contentWindow;
// documentElement can be null while the srcDoc is (re)parsing —
// skip this round and let the retry pick it up.
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();
// Signature modal: name + drawn signature.
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();
// OTP modal — code goes to the signer's registered contacts (email only
// for the seeded demo user); read it from the DB.
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",
);
expectStatus("SIGNED_CUSTOMER");
});
it("staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE", () => {
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();
// Scoped to the modal — the toolbar behind it has its own "Approve & sign".
cy.get(".mantine-Modal-content")
.contains("button", /^Confirm signature$|^Approve & sign$/)
.click();
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
// GENERAL → clearance runs per booking, contract goes straight active.
expectStatus("CONTRACT_ACTIVE");
// Both signatures recorded.
withContract((c) => {
cy.task<{ rows: Array<{ role: string }> }>("db:query", {
sql: `SELECT s.role FROM freight.contract_signatures s
WHERE s.contract_id = $1 ORDER BY s.role`,
params: [c.id],
}).then(({ rows }) => {
expect(rows.map((r) => r.role)).to.include.members(["CUSTOMER", "STAFF"]);
});
});
});
});
export {};

View File

@@ -0,0 +1,69 @@
/**
* Cross-app flow: same business objects seen from both directions —
* customer (portal, port 5373) and staff (backoffice, port 5383 = baseUrl).
* Different ports = different origins, so portal steps inside a test that
* also touches backoffice run inside cy.origin().
*
* Cookie caveat: cookies ignore ports, so both apps share the localhost
* cookie jar. Always call the matching login command immediately before
* switching apps — cy.session restores the right cookie snapshot.
*
* This spec is the template for full journeys (booking → approval →
* scheduling → billing). It verifies both sides of the fence against
* seeded data via UI + API cross-checks.
*/
const portal = () => Cypress.env("portalUrl") as string;
const api = () => Cypress.env("apiUrl") as string;
describe("flow: customer and staff see the same world", () => {
it("staff views booking requests, customer views bookings", () => {
// Staff side on the primary origin (baseUrl) first — the first origin a
// test visits becomes primary; every other origin needs cy.origin().
cy.loginBackoffice("ceo@edr.local");
cy.visit("/dashboard/booking-requests");
cy.location("pathname").should("eq", "/dashboard/booking-requests");
cy.get("#root").should("not.be.empty");
// Customer side — switch session first, then enter the portal origin.
cy.loginPortal("user@gmail.com");
cy.origin(portal(), () => {
cy.visit("/bookings");
cy.location("pathname").should("not.eq", "/login");
cy.get("#root").should("not.be.empty");
});
});
it("staff and customer both resolve their own /api/me identity", () => {
cy.apiLogin("ceo@edr.local").then(({ token }) => {
cy.request({
url: `${api()}/api/me`,
headers: { Authorization: `Bearer ${token}` },
})
.its("status")
.should("eq", 200);
});
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")).then(({ token }) => {
cy.request({
url: `${api()}/api/me`,
headers: { Authorization: `Bearer ${token}` },
})
.its("status")
.should("eq", 200);
});
});
it("cy.origin: staff dashboard then portal in a single test", () => {
cy.loginBackoffice("ceo@edr.local");
cy.visit("/dashboard/overview");
cy.get("#root").should("not.be.empty");
cy.loginPortal("user@gmail.com");
cy.origin(Cypress.env("portalUrl") as string, () => {
cy.visit("/portal");
cy.location("pathname").should("not.eq", "/login");
cy.get("#root").should("not.be.empty");
});
});
});
export {};

View File

@@ -0,0 +1,203 @@
/**
* Full customer onboarding journey, both apps:
*
* 1. portal — /signup form → OTP (read from DB, delivery is off in e2e)
* → account created → onboarding wizard (nationality/role →
* company → personnel → contact → PoA → documents incl. the
* per-role business license) → "Submit for review"
* 2. backoffice — staff (chief, holds edr_freight_app:admin) approves the
* importer profile on /dashboard/customers/:id
* 3. portal — the new customer is active: contract wizard reachable
*
* Tests are sequential steps of ONE journey (fresh unique user per run), so
* retries are disabled — a mid-journey retry would replay a non-idempotent
* step against already-advanced state.
*
* NOTE: switching origin between tests (portal 5373 ↔ backoffice 5383)
* reloads the spec bundle and resets module state — later tests resolve the
* journey's user/company from the DB instead of module variables.
*/
const stamp = Date.now();
const email = `e2e.onboard.${stamp}@example.com`;
// Ethiopian mobile: 9 + 8 digits, unique per run.
const phoneNational = `9${String(stamp).slice(-8)}`;
const signupPassword = "Password@e2e1";
const companyName = `E2E Onboard Co ${stamp}`;
const tin = String(stamp).slice(-10).padStart(10, "1");
const vat = String(stamp + 1).slice(-10).padStart(10, "2");
const fan = String(stamp).slice(-13).padStart(16, "3");
const portal = () => Cypress.env("portalUrl") as string;
/** The journey's company/user = the latest e2e.onboard.* signup in the DB. */
function latestOnboardJourney() {
return cy.task<{ rows: Array<{ name: string; email: string }> }>("db:query", {
sql: `SELECT c.name, u.email
FROM freight.companies c
JOIN freight.external_profiles ep ON ep.company_id = c.id
JOIN iam.users u ON u.id = ep.user_id
WHERE u.email LIKE 'e2e.onboard.%'
ORDER BY c.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 });
});
}
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
function fillPhone(index: number, national: string) {
cy.get('.mantine-Modal-content input[type="tel"]')
.eq(index)
.clear({ force: true })
.type(national, { force: true });
}
describe("customer onboarding journey", { retries: 0 }, () => {
it("signs up with OTP and completes the onboarding wizard", () => {
// The eTrade TIN lookup 400s in e2e (external service unreachable). The
// form handles it ("fill in the details manually") but axios also throws
// an uncaught rejection — ignore just that one.
cy.on("uncaught:exception", (err) =>
err.message.includes("Request failed with status code 400") ? false : true,
);
cy.visit(`${portal()}/signup`);
fill(/^First name/, "Onboard");
fill(/^Last name/, "Tester");
fill(/^Email/, email);
cy.get('input[type="tel"]').first().type(phoneNational, { force: true });
fill(/^Password/, signupPassword);
fill(/^Confirm password/, signupPassword);
cy.contains("button", "Continue").click();
// OTP stage — the code is generated + stored even though delivery is off.
cy.contains("Verify", { timeout: 15000 }).should("be.visible");
cy.getOtp(email).then((otp) => cy.typeOtp(otp));
cy.contains("button", "Verify & create account").click();
// Signed in → /portal → wizard auto-opens on the nationality/role step.
cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
cy.contains("Where is your company registered?", { timeout: 15000 }).should(
"be.visible",
);
cy.contains("button", "Ethiopian Company").click();
cy.contains("button", "Importer").click();
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Company step. TIN first — the eTrade auto-lookup fails in e2e (no
// external network) and the form allows manual entry.
cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin);
fill(/^Company Name/, companyName);
fill(/^Company Email/, `ops.${stamp}@example.com`);
fillPhone(0, "911234567");
fill(/^Location/, "Addis Ababa, Ethiopia");
fill(/^VAT Number/, vat);
cy.get('input[placeholder="1234567890123456"]').type(fan);
cy.mantineSelect(/^Region/, "Addis Ababa");
fill(/^Zone/, "Zone 1");
fill(/^Woreda/, "Woreda 1");
fill(/^Kebele/, "Kebele 1");
fill(/^House No/, "123");
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Personnel (general manager).
fill(/^Name/, "General Manager");
fill(/^Email/, `gm.${stamp}@example.com`);
fillPhone(0, "911234568");
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Contact person.
fill(/^Name/, "Contact Person");
fillPhone(0, "911234569");
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// PoA — optional for an importer.
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Documents: no company docs are configured in e2e, but every role needs
// a business license.
cy.contains("Business license", { timeout: 15000 }).should("be.visible");
cy.get('.mantine-Modal-content input[type="file"]')
.first()
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
cy.get(".mantine-Modal-content")
.contains("button", "Submit for review")
.click();
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
// DB cross-check: submitted, awaiting approval.
cy.task<{ rows: Array<{ status: string; onboarding_completed: boolean }> }>(
"db:query",
{
sql: `SELECT c.status, ep.onboarding_completed
FROM freight.companies c
JOIN freight.external_profiles ep ON ep.company_id = c.id
JOIN iam.users u ON u.id = ep.user_id
WHERE u.email = $1`,
params: [email],
},
).then(({ rows }) => {
expect(rows, "company row").to.have.length(1);
expect(rows[0].status).to.eq("pending");
expect(rows[0].onboarding_completed).to.eq(true);
});
});
it("backoffice staff approves the submitted importer profile", () => {
cy.loginBackoffice("chief@edr.local");
cy.visit("/dashboard/customers");
latestOnboardJourney().then(({ rows }) => {
expect(rows, "onboarded company").to.have.length(1);
const company = rows[0].name;
cy.get('input[placeholder*="Search by company"]').type(company);
cy.contains(company, { timeout: 15000 }).click();
// Role profiles table → approve the pending importer profile. Once
// active, the row's action flips to "Suspend".
cy.contains("button", "Approve", { timeout: 15000 }).click();
cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
cy.task<{ rows: Array<{ status: string; reference: string | null; company_status: string }> }>(
"db:query",
{
sql: `SELECT p.status, p.reference, c.status AS company_status
FROM freight.company_profiles p
JOIN freight.companies c ON c.id = p.company_id
WHERE c.name = $1 AND p.type = 'importer'`,
params: [company],
},
).then(({ rows: profiles }) => {
expect(profiles, "importer profile").to.have.length(1);
expect(profiles[0].status).to.eq("active");
expect(profiles[0].reference, "minted reference").to.be.a("string").and
.not.be.empty;
expect(profiles[0].company_status).to.eq("active");
});
});
});
it("the approved customer can reach the contract wizard", () => {
latestOnboardJourney().then(({ rows }) => {
cy.loginPortal(rows[0].email, signupPassword);
});
cy.visitPortal("/contracts/new");
// No "Awaiting Approval" gate — the wizard's first step renders.
cy.contains("label", "Operation Type", { timeout: 15000 }).should(
"be.visible",
);
cy.contains("Awaiting Approval").should("not.exist");
});
});
export {};

View File

@@ -0,0 +1,36 @@
/**
* UI login for the customer portal (seeded demo user).
* Form: apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx.
* Portal is a different origin (port 5373), so specs visit it via absolute
* URL — each test here stays on that single origin, no cy.origin needed.
*/
const portal = () => Cypress.env("portalUrl") as string;
describe("portal: UI login", () => {
it("logs in via the form", () => {
cy.clearCookies();
cy.visit(`${portal()}/login`);
cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com");
cy.get('input[placeholder="Enter your password"]').type(
Cypress.env("demoPassword"),
{ log: false },
);
cy.get('button[type="submit"]').click();
cy.location("pathname", { timeout: 20000 }).should("not.eq", "/login");
cy.getCookie("auth-token").should("exist");
});
it("rejects wrong credentials", () => {
cy.clearCookies();
cy.visit(`${portal()}/login`);
cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com");
cy.get('input[placeholder="Enter your password"]').type("definitely-wrong");
cy.get('button[type="submit"]').click();
cy.location("pathname").should("eq", "/login");
cy.getCookie("auth-token").should("not.exist");
});
});
export {};

View File

@@ -0,0 +1,29 @@
/**
* Route-level smoke over the customer portal.
* Routes from apps/edr-freight-web/portal/src/App.tsx.
*/
const portal = () => Cypress.env("portalUrl") as string;
const ROUTES = ["/portal", "/bookings", "/contracts", "/billing", "/tracking"];
describe("portal: route smoke (demo customer)", () => {
beforeEach(() => {
cy.loginPortal("user@gmail.com");
});
ROUTES.forEach((route) => {
it(`renders ${route}`, () => {
cy.visit(`${portal()}${route}`);
cy.location("pathname").should("not.eq", "/login");
cy.get("#root").should("not.be.empty");
});
});
it("new booking wizard opens", () => {
cy.visit(`${portal()}/bookings/new`);
cy.location("pathname").should("eq", "/bookings/new");
cy.get("#root").should("not.be.empty");
});
});
export {};

View File

@@ -0,0 +1,11 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj
xref
0 4
0000000000 65535 f
trailer<</Size 4/Root 1 0 R>>
startxref
0
%%EOF

View File

@@ -0,0 +1,51 @@
-- Arrange-data for the contract lifecycle specs, applied after seed-users.sql.
-- Idempotent. Two things the app cannot provide without manual steps:
--
-- 1. chief gets `edr_freight_app:admin` (customer-profile approval is
-- FreightAdmin-guarded and no seeded position carries it).
-- 2. user@gmail.com gets an ACTIVE company + approved importer profile so the
-- contract wizard is reachable without first running the onboarding journey.
-- 1. chief → edr_freight_app:admin
INSERT INTO iam.position_permissions (id, position_id, permission_id)
SELECT gen_random_uuid(), p.id, perm.id
FROM iam.positions p
JOIN iam.permissions perm ON perm.key = 'edr_freight_app:admin'
WHERE p.key = 'chief'
AND NOT EXISTS (
SELECT 1 FROM iam.position_permissions pp
WHERE pp.position_id = p.id AND pp.permission_id = perm.id
);
-- 2a. Active customer company (TIN is the idempotency key).
INSERT INTO freight.companies
(id, name, type, status, tin, fan_number, country, address, phone, email,
nationality, kind, attributes)
SELECT gen_random_uuid(), 'E2E Logistics PLC', 'customer', 'active',
'0102030405', '1234567890123456', 'Ethiopia', 'Addis Ababa, Ethiopia',
'+251911000001', 'ops@e2e-logistics.test', 'ethiopian', 'commercial',
'{"contactPersonName":"Test Contact","contactPersonPhone":"+251911000002","generalManagerName":"Test GM","generalManagerEmail":"gm@e2e-logistics.test","generalManagerPhone":"+251911000003"}'::jsonb
WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = '0102030405');
-- 2b. Approved importer profile (reference normally minted on approval).
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
SELECT gen_random_uuid(), c.id, 'importer', 'active', 'IMP-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 = 'importer'
);
-- 2c. Link the demo portal user to the company, onboarding already done.
INSERT INTO freight.external_profiles
(id, user_id, company_id, first_name, last_name, is_primary_contact,
active_profile_type, onboarding_step, onboarding_completed)
SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true,
'importer', 'done', true
FROM iam.users u
JOIN freight.companies c ON c.tin = '0102030405'
WHERE u.email = 'user@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep WHERE ep.user_id = u.id
);

View File

@@ -0,0 +1,137 @@
-- Test users for the freight e2e stack — replicates what the (disabled)
-- FreightStaffUsersSeeder + DemoUsersSeeder would write, without touching
-- API code. Idempotent: every insert is guarded by WHERE NOT EXISTS.
--
-- Prerequisites (created by the API's always-on boot seeders):
-- iam.organizations key='edr_freight', iam.units key='edr_freight_app',
-- iam.positions keys ceo/chief/director/marketer/operation/ethiopian_gl/djibouti_gl.
--
-- Passwords are pre-hashed (argon2id):
-- staff (@edr.local) → password@tria
-- demo (gmail.com) → 12345678
-- ── Demo organization ────────────────────────────────────────────────────────
insert into iam.organizations (id, name, key, is_super_admin, is_government_organization, status)
select gen_random_uuid(), '{"en":"Demo IAM"}'::jsonb, 'demo_iam', false, true, 'Active'
where not exists (select 1 from iam.organizations where key = 'demo_iam');
-- ── Roles ────────────────────────────────────────────────────────────────────
insert into iam.roles (id, key, name)
select gen_random_uuid(), v.key, jsonb_build_object('en', v.name)
from (values
('edr_line_staff', 'edr_line_staff'),
('edr_org_manager', 'edr_org_manager'),
('edr_director', 'edr_director'),
('edr_ceo', 'edr_ceo'),
('edr_marketing', 'edr_marketing'),
('edr_operations_officer', 'edr_operations_officer'),
('edr_gl_ethiopia', 'edr_gl_ethiopia'),
('edr_gl_djibouti', 'edr_gl_djibouti'),
('demo_user1', 'Demo User1'),
('demo_user2', 'Demo User2')
) v(key, name)
where not exists (select 1 from iam.roles r where r.key = v.key);
-- ── Demo permissions + role grants ───────────────────────────────────────────
insert into iam.permissions (id, key, name)
select gen_random_uuid(), v.key, jsonb_build_object('en', v.name)
from (values
('can:demo:user1', 'Can access demo user1'),
('can:demo:user2', 'Can access demo user2')
) v(key, name)
where not exists (select 1 from iam.permissions p where p.key = v.key);
insert into iam.role_permissions (id, role_id, permission_id)
select gen_random_uuid(), r.id, p.id
from (values ('demo_user1', 'can:demo:user1'), ('demo_user2', 'can:demo:user2')) v(role_key, perm_key)
join iam.roles r on r.key = v.role_key
join iam.permissions p on p.key = v.perm_key
where not exists (
select 1 from iam.role_permissions rp where rp.role_id = r.id and rp.permission_id = p.id
);
-- ── Users ────────────────────────────────────────────────────────────────────
insert into iam.users (id, email, username, name, status, is_active, has_set_password, user_type)
select gen_random_uuid(), v.email, v.username, jsonb_build_object('en', v.display),
'accepted', true, true, 'employee'
from (values
('linestaff@edr.local', 'linestaff', 'linestaff'),
('chief@edr.local', 'chief', 'chief'),
('director@edr.local', 'director', 'director'),
('ceo@edr.local', 'ceo', 'ceo'),
('marketer@edr.local', 'marketer', 'marketer'),
('operation@edr.local', 'operation', 'operation'),
('gl-et@edr.local', 'gl_et', 'gl_et'),
('gl-dj@edr.local', 'gl_dj', 'gl_dj'),
('user@gmail.com', 'user', 'Demo User 1'),
('user2@gmail.com', 'user2', 'Demo User 2')
) v(email, username, display)
where not exists (select 1 from iam.users u where u.email = v.email);
-- ── Credentials ──────────────────────────────────────────────────────────────
insert into iam.user_credentials (id, user_id, password, is_active)
select gen_random_uuid(), u.id,
case when u.email like '%@edr.local'
then '$argon2id$v=19$m=65536,t=3,p=4$JFEcHu4Kp55fsrVDPbHDPg$0NfnGzaE39T/qdmzte73oCkohC0Ri+f8DcrvAF4kyH4' -- password@tria
else '$argon2id$v=19$m=65536,t=3,p=4$aBwVFf7I74pqSJqe9cBoig$gCIKa+6dCAb2X86G+0IjgPtil127cx6A6mwhvLj00Bw' -- 12345678
end,
true
from iam.users u
where (u.email like '%@edr.local' or u.email in ('user@gmail.com', 'user2@gmail.com'))
and not exists (select 1 from iam.user_credentials c where c.user_id = u.id);
-- ── User → role (staff under edr_freight, demo under demo_iam) ──────────────
insert into iam.user_roles (id, user_id, role_id, organization_id)
select gen_random_uuid(), u.id, r.id, o.id
from (values
('linestaff@edr.local', 'edr_line_staff', 'edr_freight'),
('chief@edr.local', 'edr_org_manager', 'edr_freight'),
('director@edr.local', 'edr_director', 'edr_freight'),
('ceo@edr.local', 'edr_ceo', 'edr_freight'),
('marketer@edr.local', 'edr_marketing', 'edr_freight'),
('operation@edr.local', 'edr_operations_officer', 'edr_freight'),
('gl-et@edr.local', 'edr_gl_ethiopia', 'edr_freight'),
('gl-dj@edr.local', 'edr_gl_djibouti', 'edr_freight'),
('user@gmail.com', 'demo_user1', 'demo_iam'),
('user2@gmail.com', 'demo_user2', 'demo_iam')
) v(email, role_key, org_key)
join iam.users u on u.email = v.email
join iam.roles r on r.key = v.role_key
join iam.organizations o on o.key = v.org_key
where not exists (select 1 from iam.user_roles ur where ur.user_id = u.id and ur.role_id = r.id);
-- ── Staff employees + position assignment (drives permissions) ───────────────
insert into iam.employees (id, is_current, status, name, organization_id, unit_id, user_id)
select gen_random_uuid(), true, 'pending', u.name, o.id, un.id, u.id
from iam.users u
join iam.organizations o on o.key = 'edr_freight'
join iam.units un on un.key = 'edr_freight_app' and un.organization_id = o.id
where u.email like '%@edr.local'
and not exists (select 1 from iam.employees e where e.user_id = u.id);
-- start_date must be set: the login query filters positions on
-- start_date <= NOW(), and a NULL start_date silently drops the position
-- (and with it every permission) from the JWT.
insert into iam.employee_positions (id, is_delegate, is_current, status, start_date, unit_id, employee_id, position_id)
select gen_random_uuid(), false, true, 'APPROVED', now() - interval '1 day', un.id, e.id, p.id
from (values
('linestaff@edr.local', 'operation'),
('chief@edr.local', 'chief'),
('director@edr.local', 'director'),
('ceo@edr.local', 'ceo'),
('marketer@edr.local', 'marketer'),
('operation@edr.local', 'operation'),
('gl-et@edr.local', 'ethiopian_gl'),
('gl-dj@edr.local', 'djibouti_gl')
) v(email, position_key)
join iam.users u on u.email = v.email
join iam.employees e on e.user_id = u.id
join iam.units un on un.key = 'edr_freight_app'
join iam.positions p on p.key = v.position_key and p.unit_id = un.id
where not exists (
select 1 from iam.employee_positions ep where ep.employee_id = e.id and ep.position_id = p.id
);
-- Backfill for rows created before start_date was included above.
update iam.employee_positions set start_date = now() - interval '1 day'
where start_date is null;

View File

@@ -0,0 +1,16 @@
{
"staff": {
"lineStaff": { "email": "linestaff@edr.local", "role": "edr_line_staff" },
"chief": { "email": "chief@edr.local", "role": "edr_org_manager" },
"director": { "email": "director@edr.local", "role": "edr_director" },
"ceo": { "email": "ceo@edr.local", "role": "edr_ceo" },
"marketer": { "email": "marketer@edr.local", "role": "edr_marketing" },
"operation": { "email": "operation@edr.local", "role": "edr_operations_officer" },
"glEthiopia": { "email": "gl-et@edr.local", "role": "edr_gl_ethiopia" },
"glDjibouti": { "email": "gl-dj@edr.local", "role": "edr_gl_djibouti" }
},
"customers": {
"demo1": { "email": "user@gmail.com" },
"demo2": { "email": "user2@gmail.com" }
}
}

View File

@@ -0,0 +1,176 @@
/**
* Auth model (see apps/edr-freight-api + freight web apps):
* - POST {api}/api/auth/login { email, password }
* → flattened body { success, token, refreshToken } (response interceptor
* flattens /api/auth responses — no .data nesting).
* - Both web apps read cookies `auth-token` / `refresh-token` and attach
* `Authorization: Bearer <token>`.
* - Cookies are port-agnostic on localhost, so portal and backoffice share
* one cookie jar. cy.session snapshots/restores cookies per session id,
* which keeps staff and customer sessions from clobbering each other —
* but inside a single test, switching apps requires re-invoking the
* matching login command first (see flows specs).
*/
export interface LoginBody {
success: boolean;
token: string;
refreshToken: string;
}
const apiUrl = () => Cypress.env("apiUrl") as string;
const password = () => Cypress.env("defaultPassword") as string;
function apiLogin(email: string, pass?: string): Cypress.Chainable<LoginBody> {
return cy
.request<LoginBody>("POST", `${apiUrl()}/api/auth/login`, {
email,
password: pass ?? password(),
})
.then((response) => {
expect(response.status).to.eq(201);
expect(response.body.token, "login token").to.be.a("string");
return cy.wrap(response.body, { log: false });
});
}
function sessionFor(app: "backoffice" | "portal", email: string, pass?: string) {
cy.session(
[app, email],
() => {
apiLogin(email, pass).then(({ token, refreshToken }) => {
cy.setCookie("auth-token", token);
cy.setCookie("refresh-token", refreshToken);
});
},
{
cacheAcrossSpecs: true,
validate() {
cy.getCookie("auth-token").then((cookie) => {
expect(cookie, "auth-token cookie").to.exist;
cy.request({
url: `${apiUrl()}/api/me`,
headers: { Authorization: `Bearer ${cookie!.value}` },
})
.its("status")
.should("eq", 200);
});
},
},
);
}
Cypress.Commands.add("apiLogin", (email: string, pass?: string) => apiLogin(email, pass));
Cypress.Commands.add("loginBackoffice", (email = "ceo@edr.local", pass?: string) => {
sessionFor("backoffice", email, pass);
});
Cypress.Commands.add("loginPortal", (email = "user@gmail.com", pass?: string) => {
// Demo portal users are seeded with a hardcoded password (DemoUsersSeeder),
// unlike staff users which use DEFAULT_PASSWORD.
sessionFor("portal", email, pass ?? (Cypress.env("demoPassword") as string));
});
Cypress.Commands.add("visitPortal", (path = "/") => {
cy.visit(`${Cypress.env("portalUrl")}${path}`);
});
/**
* Read the latest OTP the API generated for a contact. SMS/email delivery is
* disabled in e2e (RABBITMQ_ENABLED=false) but the code is still stored in
* freight.otp_verifications — keyed by normalized email (lowercased) or E.164
* phone. Polls because the row is written async to the UI action.
*/
Cypress.Commands.add("getOtp", (target: string) => {
const read = (attempt: number): Cypress.Chainable<string> =>
cy
.task<{ rows: Array<{ otp: string }> }>(
"db:query",
{
sql: `SELECT otp FROM freight.otp_verifications
WHERE email = $1 OR phone = $1
ORDER BY updated_at DESC LIMIT 1`,
params: [target],
},
{ log: false },
)
.then((res) => {
if (res.rows.length > 0) return cy.wrap(res.rows[0].otp, { log: false });
expect(attempt, `OTP row for ${target}`).to.be.lessThan(20);
return cy.wait(500, { log: false }).then(() => read(attempt + 1));
});
return read(0);
});
/** Open a Mantine <Select> by its label and pick an option by exact text. */
Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string | RegExp) => {
cy.contains("label", label)
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).click({ force: true });
});
cy.get('[role="option"]').contains(option).click();
});
/** Type a 6-digit code into a Mantine PinInput. */
Cypress.Commands.add("typeOtp", (code: string) => {
cy.get(".mantine-PinInput-root input").should("have.length.at.least", code.length);
code.split("").forEach((digit, i) => {
cy.get(".mantine-PinInput-root input").eq(i).type(digit, { force: true });
});
});
/**
* Draw a squiggle on the signature-pad canvas (mouse events). When the account
* already has a saved signature the modal opens in "Approve signature" mode
* with no canvas — nothing to draw, the saved image is used as-is.
*/
Cypress.Commands.add("drawSignature", () => {
cy.get(".mantine-Modal-content").then(($modals) => {
if ($modals.find("canvas").length === 0) return;
drawOnCanvas();
});
});
function drawOnCanvas() {
cy.get(".mantine-Modal-content canvas")
.first()
.then(($canvas) => {
const rect = $canvas[0].getBoundingClientRect();
const midX = rect.left + rect.width / 2;
const midY = rect.top + rect.height / 2;
cy.wrap($canvas)
.trigger("mousedown", { clientX: midX - 60, clientY: midY, force: true })
.trigger("mousemove", { clientX: midX - 20, clientY: midY - 15, force: true })
.trigger("mousemove", { clientX: midX + 20, clientY: midY + 15, force: true })
.trigger("mousemove", { clientX: midX + 60, clientY: midY, force: true })
.trigger("mouseup", { force: true });
});
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/** POST /api/auth/login, returns the flattened token body. */
apiLogin(email: string, pass?: string): Chainable<LoginBody>;
/** Cached programmatic staff session (default ceo@edr.local). */
loginBackoffice(email?: string, pass?: string): Chainable<void>;
/** Cached programmatic customer session (default user@gmail.com). */
loginPortal(email?: string, pass?: string): Chainable<void>;
/** cy.visit against the portal origin (env.portalUrl). */
visitPortal(path?: string): Chainable<void>;
/** Latest OTP stored for an email/phone (delivery is off in e2e). */
getOtp(target: string): Chainable<string>;
/** Open a Mantine Select by label, pick an option. */
mantineSelect(label: string | RegExp, option: string | RegExp): Chainable<void>;
/** Fill a Mantine PinInput with a code. */
typeOtp(code: string): Chainable<void>;
/** Scribble on the signature-pad canvas inside the open modal. */
drawSignature(): Chainable<void>;
}
}
}
export {};

View File

@@ -0,0 +1,18 @@
import "./commands";
// The API's user seeders are disabled in app code — the SQL fixture creates
// the staff + demo test users instead. Idempotent, runs before each spec file.
before(() => {
cy.task("db:seedUsers");
});
// Third-party noise (PostHog, Google Maps, socket.io reconnects) can throw
// uncaught exceptions that are irrelevant to the assertion under test. App
// errors still fail tests via failed assertions / failed intercepts.
Cypress.on("uncaught:exception", (err) => {
const ignorable = [/posthog/i, /google/i, /websocket/i, /socket\.io/i, /ResizeObserver/i];
if (ignorable.some((pattern) => pattern.test(err.message))) {
return false;
}
return true;
});

22
e2e/freight/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "@edr/freight-e2e",
"version": "0.0.0",
"private": true,
"description": "Cypress end-to-end tests for the EDR freight system (portal + backoffice + API)",
"scripts": {
"cy:open": "cypress open --e2e",
"cy:run": "cypress run",
"cy:run:backoffice": "cypress run --spec 'cypress/e2e/backoffice/**'",
"cy:run:portal": "cypress run --spec 'cypress/e2e/portal/**'",
"cy:run:api": "cypress run --spec 'cypress/e2e/api/**'",
"cy:run:flows": "cypress run --spec 'cypress/e2e/flows/**'",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"cypress": "^15.3.0",
"pg": "^8.13.0",
"typescript": "^5.5.4",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0"
}
}

193
e2e/freight/scripts/e2e.mjs Normal file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* Freight e2e launcher — one command, no manual steps:
*
* node e2e/freight/scripts/e2e.mjs <up|run|open|ci|down> [cypress args...]
*
* - run/open/ci auto-start the docker stack (build + wait healthy) if it
* isn't already running, then launch Cypress pointed at the right ports.
* - Host ports default to 3101/5373/5383/5533/9310/9311; any default that is
* busy is replaced by the next free port. Chosen ports are written to
* e2e/freight/.e2e-ports.json (gitignored) and reused while the stack is
* up, so cypress and compose always agree.
* - Extra args are forwarded to Cypress: `pnpm e2e:freight:run --spec ...`.
*
* No dependencies — plain Node, spawns `docker compose` and `pnpm`.
*/
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const e2eDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = resolve(e2eDir, "..", "..");
const stateFile = join(e2eDir, ".e2e-ports.json");
const composeBase = ["compose", "-f", join(repoRoot, "docker-compose.e2e.yaml")];
const DEFAULT_PORTS = {
E2E_API_PORT: 3101,
E2E_PORTAL_PORT: 5373,
E2E_BACKOFFICE_PORT: 5383,
E2E_DB_PORT: 5533,
E2E_MINIO_PORT: 9310,
E2E_MINIO_CONSOLE_PORT: 9311,
};
// Long-running services that must be up before tests (minio-init exits).
const SERVICES = [
"postgres-freight-e2e",
"minio-e2e",
"freight-api-e2e",
"freight-portal-e2e",
"freight-backoffice-e2e",
];
function fail(msg) {
console.error(`\ne2e: ${msg}`);
process.exit(1);
}
function preflight() {
try {
execFileSync("docker", ["info"], { stdio: "ignore" });
} catch {
fail("docker is not running (or not installed) — start Docker and retry.");
}
if (!existsSync(join(repoRoot, ".npmrc"))) {
fail(
".npmrc missing at repo root — image builds need GitHub Packages auth " +
"for @tria-plc (same file the main docker-compose.yaml uses).",
);
}
}
function isPortFree(port) {
return new Promise((res) => {
const srv = createServer();
srv.once("error", () => res(false));
srv.once("listening", () => srv.close(() => res(true)));
srv.listen(port);
});
}
function stackRunning(env) {
try {
const out = execFileSync(
"docker",
[...composeBase, "ps", "--services", "--status", "running"],
{ encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] },
);
const running = new Set(out.split("\n").filter(Boolean));
return SERVICES.every((s) => running.has(s));
} catch {
return false;
}
}
/**
* While the stack runs, ports are whatever it was started with (state file,
* else the defaults — a hand-started stack used the compose defaults). Only a
* fresh start gets to scan for free ports.
*/
async function resolvePorts() {
if (stackRunning(process.env)) {
return existsSync(stateFile)
? JSON.parse(readFileSync(stateFile, "utf8"))
: { ...DEFAULT_PORTS };
}
const ports = {};
const taken = new Set();
for (const [name, preferred] of Object.entries(DEFAULT_PORTS)) {
let port = preferred;
while (taken.has(port) || !(await isPortFree(port))) port += 1;
taken.add(port);
ports[name] = port;
if (port !== preferred)
console.log(`e2e: port ${preferred} busy → ${name}=${port}`);
}
return ports;
}
function envFor(ports) {
return {
...process.env,
...Object.fromEntries(
Object.entries(ports).map(([k, v]) => [k, String(v)]),
),
CYPRESS_BASE_URL: `http://localhost:${ports.E2E_BACKOFFICE_PORT}`,
CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`,
CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`,
E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`,
};
}
function compose(args, env) {
const { status } = spawnSync("docker", [...composeBase, ...args], {
stdio: "inherit",
env,
});
return status ?? 1;
}
function up(ports, env) {
preflight();
console.log(
`e2e: starting stack — api :${ports.E2E_API_PORT} portal :${ports.E2E_PORTAL_PORT} backoffice :${ports.E2E_BACKOFFICE_PORT} db :${ports.E2E_DB_PORT}`,
);
const status = compose(["up", "-d", "--build", "--wait"], env);
if (status !== 0)
fail(
"stack failed to become healthy. Inspect with:\n" +
" docker compose -f docker-compose.e2e.yaml logs freight-api-e2e",
);
writeFileSync(stateFile, JSON.stringify(ports, null, 2) + "\n");
}
function ensureUp(ports, env) {
if (stackRunning(env)) return;
up(ports, env);
}
function runPnpm(script, extra, env) {
const { status } = spawnSync(
"pnpm",
["--filter", "@edr/freight-e2e", "run", script, ...extra],
{ cwd: repoRoot, stdio: "inherit", env },
);
process.exit(status ?? 1);
}
const [cmd, ...extra] = process.argv.slice(2);
const ports = await resolvePorts();
const env = envFor(ports);
switch (cmd) {
case "up":
up(ports, env);
break;
case "run":
ensureUp(ports, env);
runPnpm("cy:run", extra, env);
break;
case "open":
ensureUp(ports, env);
runPnpm("cy:open", extra, env);
break;
case "ci":
ensureUp(ports, env);
process.exit(compose(["--profile", "cypress", "run", "--rm", "cypress", ...extra], env));
break;
case "down":
process.exit(
(() => {
const status = compose(["down", "-v", "--remove-orphans"], env);
rmSync(stateFile, { force: true });
return status;
})(),
);
break;
default:
fail(`unknown command "${cmd ?? ""}" — use up | run | open | ci | down`);
}

16
e2e/freight/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["cypress", "node"]
},
"include": ["cypress/**/*.ts", "cypress.config.ts"]
}

View File

@@ -23,6 +23,11 @@
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
"docker:build": "docker compose build",
"docker:up": "docker compose up -d",
"e2e:freight:up": "node e2e/freight/scripts/e2e.mjs up",
"e2e:freight:open": "node e2e/freight/scripts/e2e.mjs open",
"e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run",
"e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci",
"e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down",
"prepare": "husky"
},
"devDependencies": {

View File

@@ -546,6 +546,7 @@ export interface ICustomerTruck {
truckType: string;
assignedAt: string;
arrivedAt?: string | null;
departedAt?: string | null;
containers?: ICustomerTruckContainer[];
}

812
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ packages:
- "apps/edr-passenger-web/*"
- "packages/*"
- "packages/config/*"
- "e2e/*"
verifyDepsBeforeRun: warn
allowBuilds:
"@nestjs/core": true
@@ -14,6 +15,7 @@ allowBuilds:
argon2: true
bcrypt: true
core-js: true
cypress: true
es5-ext: true
esbuild: true
highlight.js: true