mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & sign delivery
|
||||
{edrMode && selected?.truckPlate
|
||||
? `Sign for truck ${selected.truckPlate}`
|
||||
: "Approve & sign delivery"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
194
docker-compose.e2e.yaml
Normal 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
108
e2e/freight/README.md
Normal 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.
|
||||
83
e2e/freight/cypress.config.ts
Normal file
83
e2e/freight/cypress.config.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
79
e2e/freight/cypress/e2e/api/health-and-auth.cy.ts
Normal file
79
e2e/freight/cypress/e2e/api/health-and-auth.cy.ts
Normal 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 {};
|
||||
41
e2e/freight/cypress/e2e/backoffice/login.cy.ts
Normal file
41
e2e/freight/cypress/e2e/backoffice/login.cy.ts
Normal 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 {};
|
||||
49
e2e/freight/cypress/e2e/backoffice/smoke.cy.ts
Normal file
49
e2e/freight/cypress/e2e/backoffice/smoke.cy.ts
Normal 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 {};
|
||||
216
e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
Normal file
216
e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
Normal 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 {};
|
||||
69
e2e/freight/cypress/e2e/flows/cross-app.cy.ts
Normal file
69
e2e/freight/cypress/e2e/flows/cross-app.cy.ts
Normal 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 {};
|
||||
203
e2e/freight/cypress/e2e/flows/onboarding.cy.ts
Normal file
203
e2e/freight/cypress/e2e/flows/onboarding.cy.ts
Normal 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 {};
|
||||
36
e2e/freight/cypress/e2e/portal/login.cy.ts
Normal file
36
e2e/freight/cypress/e2e/portal/login.cy.ts
Normal 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 {};
|
||||
29
e2e/freight/cypress/e2e/portal/smoke.cy.ts
Normal file
29
e2e/freight/cypress/e2e/portal/smoke.cy.ts
Normal 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 {};
|
||||
11
e2e/freight/cypress/fixtures/docs/license.pdf
Normal file
11
e2e/freight/cypress/fixtures/docs/license.pdf
Normal 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
|
||||
51
e2e/freight/cypress/fixtures/seed-company.sql
Normal file
51
e2e/freight/cypress/fixtures/seed-company.sql
Normal 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
|
||||
);
|
||||
137
e2e/freight/cypress/fixtures/seed-users.sql
Normal file
137
e2e/freight/cypress/fixtures/seed-users.sql
Normal 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;
|
||||
16
e2e/freight/cypress/fixtures/users.json
Normal file
16
e2e/freight/cypress/fixtures/users.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
176
e2e/freight/cypress/support/commands.ts
Normal file
176
e2e/freight/cypress/support/commands.ts
Normal 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 {};
|
||||
18
e2e/freight/cypress/support/e2e.ts
Normal file
18
e2e/freight/cypress/support/e2e.ts
Normal 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
22
e2e/freight/package.json
Normal 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
193
e2e/freight/scripts/e2e.mjs
Normal 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
16
e2e/freight/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -546,6 +546,7 @@ export interface ICustomerTruck {
|
||||
truckType: string;
|
||||
assignedAt: string;
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
containers?: ICustomerTruckContainer[];
|
||||
}
|
||||
|
||||
|
||||
812
pnpm-lock.yaml
generated
812
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user