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

This commit is contained in:
Marshal
2026-06-24 23:56:07 +00:00
88 changed files with 4215 additions and 3624 deletions

View File

@@ -285,6 +285,7 @@ export class CompaniesService {
async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
return company;
}

View File

@@ -55,6 +55,7 @@ export class CreateFirstMileDto {
nullable: true,
})
@IsOptional()
@Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -55,6 +55,13 @@ export class FirstMileController {
return this.firstMileService.findById(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.firstMileService.acceptBooking(reference);
}
@Post()
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a first-mile leg' })

View File

@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([FirstMile])],
imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
@@ -25,7 +26,32 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
@Injectable()
export class FirstMileService {
constructor(private readonly firstMileRepository: FirstMileRepository) {}
constructor(
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
) {}
/**
* Look up a booking by its human-readable reference and confirm it has been
* paid before any first-mile work proceeds. Throws if the reference is
* unknown or the booking has not reached PAID status.
*/
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
return null;
}
if (booking.paymentStatus !== 'PAID') {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
}
async findAll(filter: FirstMileListFilter = {}): Promise<{
data: FirstMile[];
@@ -45,7 +71,10 @@ export class FirstMileService {
const [data, total] = await this.firstMileRepository.findAndCount({
where,
relations: { booking: true, vehicle: true },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
@@ -64,7 +93,10 @@ export class FirstMileService {
async findById(id: string): Promise<FirstMile> {
const record = await this.firstMileRepository.findById(id, {
relations: { booking: true, vehicle: true },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
});
if (!record) {

View File

@@ -55,6 +55,7 @@ export class CreateLastMileDto {
nullable: true,
})
@IsOptional()
@Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -55,6 +55,13 @@ export class LastMileController {
return this.lastMileService.findById(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.lastMileService.acceptBooking(reference);
}
@Post()
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a last-mile leg' })

View File

@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([LastMile])],
imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
@@ -25,7 +26,29 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
@Injectable()
export class LastMileService {
constructor(private readonly lastMileRepository: LastMileRepository) {}
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
throw new NotFoundException(`Booking ${bookingReference} not found`);
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
);
}
return this.create({
bookingId: booking.id,
advancedPayment: booking.totalAmount,
});
}
async findAll(filter: LastMileListFilter = {}): Promise<{
data: LastMile[];
@@ -45,7 +68,10 @@ export class LastMileService {
const [data, total] = await this.lastMileRepository.findAndCount({
where,
relations: { booking: true, vehicle: true },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
@@ -64,7 +90,10 @@ export class LastMileService {
async findById(id: string): Promise<LastMile> {
const record = await this.lastMileRepository.findById(id, {
relations: { booking: true, vehicle: true },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
});
if (!record) {

View File

@@ -35,6 +35,7 @@ import {
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
import { FirstMileService } from "../first-mile/first-mile.service";
/** Setting code holding the global ordering window (months) for general contracts. */
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
@@ -60,6 +61,7 @@ export class PaymentService {
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly dropdownSettings: DropdownSettingsService,
private readonly firstMileService: FirstMileService,
) { }
/** Configured general-contract ordering window in months (defaults to 3). */
@@ -342,6 +344,8 @@ export class PaymentService {
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" },
);
await this.firstMileService.acceptBooking(input.bookingId);
});
if (isGeneralContract) {

View File

@@ -2272,6 +2272,7 @@ export class TrainSchedulingService {
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
})) ?? [],
};
}

View File

@@ -261,6 +261,54 @@ export class PricingDataSeeder {
isActive: true,
displayOrder: i + 1,
})),
{
code: "GRAIN",
cargoTypeName: "Grain / Cereals",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 1,
},
{
code: "FERTILIZER",
cargoTypeName: "Fertilizer",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 2,
},
{
code: "CEMENT",
cargoTypeName: "Cement / Clinker",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 3,
},
{
code: "STEEL",
cargoTypeName: "Steel / Rebar",
showFreeTextBox: false,
requiresDirectorApproval: true,
isActive: true,
displayOrder: 4,
},
{
code: "MACHINERY",
cargoTypeName: "Heavy Machinery",
showFreeTextBox: false,
requiresDirectorApproval: true,
isActive: true,
displayOrder: 5,
},
{
code: "OTHER_BULK",
cargoTypeName: "Other Bulk Cargo",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 6,
},
],
{ conflictPaths: { code: true } },
);
@@ -484,15 +532,29 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
slByCode: Map<string, any>,
cargoByCode: Map<string, any>,
): Promise<void> {
const djibouti = yardByCode.get("DJIBOUTI")!;
const addis = yardByCode.get("ADDIS_ABABA")!;
const railContainer = stByCode.get("RAIL_CONTAINER")!;
const railBulk = stByCode.get("RAIL_BULK")!;
const maersk = slByCode.get("MAERSK")!;
const grain = cargoByCode.get("GRAIN")!;
const twenty = ctByCode.get("20FT")!;
const forty = ctByCode.get("40FT")!;
const twentyReefer = ctByCode.get("20FT_REEFER")!;
const djibouti = yardByCode.get("DJIBOUTI");
const addis = yardByCode.get("ADDIS_ABABA");
const railContainer = stByCode.get("RAIL_CONTAINER");
const railBulk = stByCode.get("RAIL_BULK");
const maersk = slByCode.get("MAERSK");
const grain = cargoByCode.get("GRAIN");
const twenty = ctByCode.get("20FT");
const forty = ctByCode.get("40FT");
const twentyReefer = ctByCode.get("20FT_REEFER");
const missing: string[] = [];
if (!djibouti) missing.push("yard:DJIBOUTI");
if (!addis) missing.push("yard:ADDIS_ABABA");
if (!railContainer) missing.push("serviceType:RAIL_CONTAINER");
if (!railBulk) missing.push("serviceType:RAIL_BULK");
if (!grain) missing.push("cargoType:GRAIN");
if (!twenty) missing.push("containerType:20FT");
if (!forty) missing.push("containerType:40FT");
if (!twentyReefer) missing.push("containerType:20FT_REEFER");
if (missing.length > 0) {
this.logger.warn(`seedDraftBookings: skipping — missing reference data: ${missing.join(", ")}`);
return;
}
const drafts = [
{