mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 12:00:59 +00:00
Merge freight/develop into Warehouse_updates
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
@@ -69,7 +68,12 @@ export class BookingTransitionService {
|
||||
priorityScore,
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
|
||||
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
|
||||
// arrives. The returned status reflects that outcome.
|
||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||
updated!.id,
|
||||
);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
@@ -143,7 +147,10 @@ export class BookingTransitionService {
|
||||
},
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
// Same consolidation treatment as the direct submit path.
|
||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||
updated!.id,
|
||||
);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
@@ -188,18 +195,11 @@ export class BookingTransitionService {
|
||||
|
||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
// Consolidation gate: a booking whose containers don't fill whole wagons
|
||||
// cannot be accepted until it is paired with a complementary booking.
|
||||
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
||||
if (gate.blocked) {
|
||||
throw new ConflictException(
|
||||
gate.message ??
|
||||
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
@@ -117,8 +118,22 @@ export class BookingsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
findAll(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
async findAll(
|
||||
@Query() filter: FilterBookingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Staff (backoffice) see every booking. Customers (portal) are always
|
||||
// force-scoped to their own company, regardless of any companyId they pass.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
const companyId =
|
||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||
if (!companyId) return { items: [], total: 0 };
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@@ -166,18 +181,60 @@ export class BookingsController {
|
||||
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
async findByReference(@Param('reference') reference: string) {
|
||||
async findByReference(
|
||||
@Param('reference') reference: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: 'Shipment tracking timeline for a booking',
|
||||
description:
|
||||
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||||
'tracking events. Scoped to the customer\'s own company.',
|
||||
})
|
||||
async findTracking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.bookingsService.getBookingTracking(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
|
||||
@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
// Only pair bookings the customer has committed (SUBMITTED) or that are
|
||||
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
|
||||
// pairing never prematurely submits an unfinished/unpriced draft.
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
@@ -686,6 +689,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: string;
|
||||
trainScheduleId?: string;
|
||||
/**
|
||||
* EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
|
||||
* the whole (route, day) pool rather than bookings pre-targeted to one train.
|
||||
*/
|
||||
day?: string;
|
||||
}): Promise<Booking[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
@@ -703,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
|
||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
||||
if (options.trainScheduleId) {
|
||||
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
|
||||
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
|
||||
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
|
||||
// booking that still carries train_schedule_id).
|
||||
if (options.day) {
|
||||
qb.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day: options.day },
|
||||
);
|
||||
} else if (options.trainScheduleId) {
|
||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||
trainScheduleId: options.trainScheduleId,
|
||||
});
|
||||
@@ -762,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level batch pool: ready, not-yet-allocated bookings on a route for one
|
||||
* EAT calendar day, regardless of which train they end up on. Same status
|
||||
* rules and ordering as {@link findBatchPool}, but keyed on
|
||||
* (origin, destination, day) instead of train_schedule_id — the engine then
|
||||
* distributes these across all trains departing that day.
|
||||
*/
|
||||
findBatchPoolByRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
||||
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||
destinationYardId,
|
||||
})
|
||||
.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -52,6 +57,8 @@ export class BookingsService {
|
||||
private readonly minioService: MinioService,
|
||||
// private readonly customersService: CustomersService,
|
||||
private readonly companiesService: CompaniesService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
@@ -146,13 +153,17 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Enable consolidation when any container line leaves a wagon partially filled
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
|
||||
*
|
||||
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
|
||||
* half-empty wagon, so `explicit === false` is ignored when consolidation is
|
||||
* actually needed. The opt-in flag only matters for cargo that already fills
|
||||
* whole wagons (where consolidation is moot anyway).
|
||||
*/
|
||||
private async resolveConsolidation(
|
||||
containers: CreateBookingContainerDto[],
|
||||
explicit?: boolean,
|
||||
): Promise<boolean> {
|
||||
if (explicit === false) return false;
|
||||
const needs = await this.consolidationService.needsConsolidation(
|
||||
containers.map((c) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
@@ -193,10 +204,11 @@ export class BookingsService {
|
||||
return { booking: paired, messages };
|
||||
}
|
||||
|
||||
if (booking.status === 'DRAFT') {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
// No partner yet — park the booking so it waits. Applies both pre-submit
|
||||
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
|
||||
// reach this method.
|
||||
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
}
|
||||
|
||||
const pending = await this.findById(booking.id);
|
||||
@@ -205,45 +217,24 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
||||
* paired) booking plus whether it still needs a consolidation partner.
|
||||
* When a booking needs consolidation and none is found, it is parked in
|
||||
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
||||
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
|
||||
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
|
||||
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
|
||||
* waits for a later complementary booking to complete the wagon.
|
||||
*
|
||||
* Returns the re-fetched booking, so callers can reflect the resulting status
|
||||
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
|
||||
*/
|
||||
async resolveConsolidationGate(bookingId: string): Promise<{
|
||||
booking: Booking;
|
||||
blocked: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
let booking = await this.findById(bookingId);
|
||||
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
// Already paired — passes the gate.
|
||||
// Already paired (e.g. a partner submitted first) — nothing to do.
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false };
|
||||
return booking;
|
||||
}
|
||||
|
||||
const needs =
|
||||
await this.consolidationService.needsConsolidationFromBooking(booking);
|
||||
if (!needs) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
// A partner may have appeared since submission — try to pair now.
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
booking = result.booking;
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false, message: result.messages.join(' ') };
|
||||
}
|
||||
|
||||
// Still no partner — park it and block the accept.
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
booking = await this.findById(booking.id);
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
return {
|
||||
booking,
|
||||
blocked: true,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
return result.booking;
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
@@ -283,8 +274,8 @@ export class BookingsService {
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
||||
if (dto.trainScheduleId) {
|
||||
// Staff manual pin: the schedule must be OPEN and on the same route.
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: dto.trainScheduleId } });
|
||||
@@ -300,6 +291,22 @@ export class BookingsService {
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
} else {
|
||||
// Day-level pool: the customer picked a DAY — require that the route has at
|
||||
// least one OPEN departure on that EAT day. The batch engine assigns the
|
||||
// train later.
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
day,
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
@@ -575,6 +582,7 @@ export class BookingsService {
|
||||
/** Return a paginated list of bookings matching the filter. */
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
@@ -587,7 +595,9 @@ export class BookingsService {
|
||||
...statusFilter,
|
||||
...schedulingStatusFilter,
|
||||
assignedToSchedule: filter.assignedToSchedule,
|
||||
companyId: filter.companyId,
|
||||
// A forced company scope (portal/customer) overrides any caller-provided
|
||||
// companyId so a customer can only ever see their own company's bookings.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
@@ -631,6 +641,109 @@ export class BookingsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company a customer user belongs to, for scoping their own
|
||||
* bookings. Returns null when no profile/company is linked yet.
|
||||
*/
|
||||
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
return company?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||
* to the company the authenticated user is linked to — otherwise it is hidden
|
||||
* behind a NotFound so booking IDs can't be probed.
|
||||
*/
|
||||
async assertCustomerCanAccessBooking(
|
||||
userId: string | undefined,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new ForbiddenException('Authentication required');
|
||||
}
|
||||
const companyId = await this.resolveCustomerCompanyId(userId);
|
||||
if (!companyId || booking.companyId !== companyId) {
|
||||
// Don't reveal that the booking exists for another company.
|
||||
throw new NotFoundException(`Booking ${booking.id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer-facing shipment tracking payload for a booking from the
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
* responsible for authorizing access to the booking first.
|
||||
*
|
||||
* When the booking has not been assigned to a train yet, returns a valid
|
||||
* "no schedule" payload so the UI can show a pre-dispatch state.
|
||||
*/
|
||||
async getBookingTracking(
|
||||
bookingId: string,
|
||||
): Promise<Freight.IBookingTracking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
const empty: Freight.IBookingTracking = {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
hasSchedule: false,
|
||||
scheduleId: null,
|
||||
trainNumber: null,
|
||||
scheduleStatus: null,
|
||||
direction: null,
|
||||
origin: null,
|
||||
destination: null,
|
||||
stations: [],
|
||||
checkpoints: [],
|
||||
currentSequenceNo: -1,
|
||||
actualDepartureAt: null,
|
||||
actualArrivalAt: null,
|
||||
scheduledDepartureAt: null,
|
||||
scheduledArrivalAt: null,
|
||||
};
|
||||
|
||||
if (!booking.trainScheduleId) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// Pull the live corridor + checkpoints for the assigned schedule. If the
|
||||
// schedule was removed, fall back to the pre-dispatch state rather than 500.
|
||||
let track: Awaited<
|
||||
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
|
||||
>;
|
||||
try {
|
||||
track = await this.trainSchedulingService.getScheduleCheckpoints(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
hasSchedule: true,
|
||||
scheduleId: track.scheduleId,
|
||||
trainNumber: track.trainNumber,
|
||||
scheduleStatus: track.status as Freight.TrainScheduleStatus,
|
||||
direction: track.direction,
|
||||
origin: track.origin,
|
||||
destination: track.destination,
|
||||
stations: track.stations,
|
||||
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
|
||||
currentSequenceNo: track.currentSequenceNo,
|
||||
actualDepartureAt: track.actualDepartureAt,
|
||||
actualArrivalAt: track.actualArrivalAt,
|
||||
scheduledDepartureAt: track.scheduledDepartureAt,
|
||||
scheduledArrivalAt: track.scheduledArrivalAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||
const page = filter.page ?? 1;
|
||||
|
||||
@@ -91,12 +91,21 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
||||
/**
|
||||
* Staff-only manual pin to a specific train. Customers omit this — they pick a
|
||||
* DAY via {@link scheduledDate} and the batch engine assigns a train within
|
||||
* that (route, day) pool. When provided, the schedule must be OPEN and on the
|
||||
* booking route.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
/** The day the customer wants to ship (the pool day key). */
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@@ -280,7 +280,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
||||
/**
|
||||
* The train this booking is assigned to. FK to train_schedules.
|
||||
*
|
||||
* Day-level pooling: customers no longer pick a train — they pick a DAY, and
|
||||
* this stays null at creation. The batch engine sets it when it assigns the
|
||||
* booking to a specific train within its (route, day) pool; staff may also
|
||||
* pin it manually. The day-level pool is keyed on
|
||||
* (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
|
||||
*/
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { FreightAdmin } from '../../common/booking-guards';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { ResponseCompanyDto } from './dto/response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
||||
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
} from "./dto/response-company.dto";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
@@ -25,36 +41,47 @@ interface CurrentIamUser {
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
@ApiTags('Companies')
|
||||
@Controller('companies')
|
||||
@ApiTags("Companies")
|
||||
@Controller("companies")
|
||||
export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
@Get('getInfo')
|
||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
||||
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
@Get("getInfo")
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
|
||||
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
@Get("profile")
|
||||
@ApiOperation({ summary: "Get flattened profile for the settings page" })
|
||||
async getProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new ProfileResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
||||
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
||||
@Get("dashboard")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
|
||||
})
|
||||
async getDashboard(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<DashboardSummaryResponseDto> {
|
||||
return this.companiesService.getDashboardSummary(user.id);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||
@Patch("profile")
|
||||
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
||||
async updateProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
@@ -62,145 +89,153 @@ export class CompaniesController {
|
||||
return this.companiesService.updateProfile(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
||||
@Post("company-profiles")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
|
||||
})
|
||||
async addCompanyProfiles(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: AddCompanyProfilesDto,
|
||||
): Promise<ResponseCompanyProfileDto[]> {
|
||||
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
||||
user.id,
|
||||
dto.types,
|
||||
);
|
||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||
}
|
||||
|
||||
// Used by portal
|
||||
@Post("create")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a company with its associated external profile (onboarding)",
|
||||
})
|
||||
async createWithProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CreateCompanyWithProfileDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const nameParts = (user.name?.en ?? '').split(' ');
|
||||
const { profile, company } = await this.companiesService.createCompanyWithProfile(
|
||||
{
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || '',
|
||||
lastName: nameParts.slice(-1)[0] || '',
|
||||
email: user.email ?? '',
|
||||
phone: user.phoneNumber ?? '',
|
||||
},
|
||||
dto,
|
||||
);
|
||||
const nameParts = (user.name?.en ?? "").split(" ");
|
||||
const { profile, company } =
|
||||
await this.companiesService.createCompanyWithProfile(
|
||||
{
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(-1)[0] || "",
|
||||
email: user.email ?? "",
|
||||
phone: user.phoneNumber ?? "",
|
||||
},
|
||||
dto,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
// Used by backoffice
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
||||
})
|
||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.createCompany(dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all companies' })
|
||||
@ApiOperation({ summary: "List all companies" })
|
||||
async findAll(): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get('type/:type')
|
||||
@ApiOperation({ summary: 'Find companies by type' })
|
||||
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
|
||||
@Get("type/:type")
|
||||
@ApiOperation({ summary: "Find companies by type" })
|
||||
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
|
||||
return companies
|
||||
.filter((c) => c.type === type)
|
||||
.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Search companies by name' })
|
||||
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
|
||||
@Get("search")
|
||||
@ApiOperation({ summary: "Search companies by name" })
|
||||
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies
|
||||
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
||||
.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get company by ID' })
|
||||
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get company by ID" })
|
||||
async findById(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.findCompanyById(id);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Update a company' })
|
||||
@ApiOperation({ summary: "Update a company" })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.updateCompany(id, dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||
@ApiOperation({ summary: "Soft-delete a company" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.companiesService.deleteCompany(id);
|
||||
}
|
||||
|
||||
@Post(':companyId/documents')
|
||||
@Post(":companyId/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
||||
async uploadDocuments(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
) {
|
||||
return this.filesService.uploadMany(companyId, 'companies', files);
|
||||
return this.filesService.uploadMany(companyId, "companies", files);
|
||||
}
|
||||
|
||||
@Post(':companyId/profiles')
|
||||
@Post(":companyId/profiles")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
async createProfile(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@Body() dto: CreateExternalProfileDto,
|
||||
): Promise<ResponseExternalProfileDto> {
|
||||
const profile = await this.companiesService.createProfile({ ...dto, companyId });
|
||||
const profile = await this.companiesService.createProfile({
|
||||
...dto,
|
||||
companyId,
|
||||
});
|
||||
return new ResponseExternalProfileDto(profile);
|
||||
}
|
||||
|
||||
@Get(':companyId/profiles')
|
||||
@ApiOperation({ summary: 'List profiles for a company' })
|
||||
@Get(":companyId/profiles")
|
||||
@ApiOperation({ summary: "List profiles for a company" })
|
||||
async listProfiles(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<ResponseExternalProfileDto[]> {
|
||||
const profiles = await this.companiesService.findProfilesByCompany(companyId);
|
||||
const profiles =
|
||||
await this.companiesService.findProfilesByCompany(companyId);
|
||||
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
||||
}
|
||||
|
||||
@Get('profile/user/:userId')
|
||||
@ApiOperation({ summary: 'Get profile by IAM user ID' })
|
||||
@Get("profile/user/:userId")
|
||||
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||
async findProfileByUser(
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
@Param("userId", ParseUUIDPipe) userId: string,
|
||||
): Promise<ResponseExternalProfileDto> {
|
||||
const profile = await this.companiesService.findProfileByUserId(userId);
|
||||
return new ResponseExternalProfileDto(profile);
|
||||
}
|
||||
|
||||
@Post('ff-clients')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||
const client = await this.companiesService.createFFClient(dto);
|
||||
return new ResponseFFClientDto(client);
|
||||
}
|
||||
|
||||
@Get(':forwarderCompanyId/clients')
|
||||
@ApiOperation({ summary: 'List clients of a forwarder' })
|
||||
async listFFClients(
|
||||
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
|
||||
): Promise<ResponseFFClientDto[]> {
|
||||
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
|
||||
return clients.map((c) => new ResponseFFClientDto(c));
|
||||
}
|
||||
|
||||
@Delete('ff-clients/:id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.companiesService.deleteFFClient(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { CompaniesController } from "./companies.controller";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||
import { Company } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
||||
providers: [
|
||||
CompaniesService,
|
||||
CompaniesRepository,
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyDashboardRepository,
|
||||
],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
export class CompaniesModule { }
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
import { Company } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
CompanyProfile,
|
||||
ProfileType,
|
||||
ProfileStatus,
|
||||
} from "./entities/company-profile.entity";
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
@@ -27,10 +35,10 @@ export interface UserIdentity {
|
||||
export class CompaniesService {
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly ffClientsRepo: FFClientRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
@@ -40,27 +48,33 @@ export class CompaniesService {
|
||||
return this.companiesRepo.create(dto);
|
||||
}
|
||||
|
||||
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
|
||||
async createCompanyWithProfile(
|
||||
identity: UserIdentity,
|
||||
dto: CreateCompanyWithProfileDto,
|
||||
): Promise<{ company: Company; profile: ExternalProfile }> {
|
||||
if (dto.tin) {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
if (exists) {
|
||||
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
||||
throw new ConflictException(
|
||||
`Company with TIN ${dto.tin} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
||||
if (existingProfile) {
|
||||
throw new ConflictException(`Profile with email ${identity.email} already exists`);
|
||||
throw new ConflictException(
|
||||
`Profile with email ${identity.email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: dto.companyName,
|
||||
type: dto.companyType,
|
||||
tin: dto.tin ?? '',
|
||||
tin: dto.tin ?? "",
|
||||
vatNumber: dto.vatNumber ?? null,
|
||||
businessLicense: dto.fanNumber ?? null,
|
||||
fanNumber: dto.fanNumber ?? null,
|
||||
country: dto.companyLocation ?? 'Ethiopia',
|
||||
country: dto.companyLocation ?? "Ethiopia",
|
||||
address: dto.companyAddress ?? null,
|
||||
phone: dto.companyPhone ?? null,
|
||||
email: dto.companyEmail ?? null,
|
||||
@@ -78,11 +92,39 @@ export class CompaniesService {
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
});
|
||||
|
||||
// Persist the operational role(s) chosen during onboarding. Types are
|
||||
// already constrained to the company type on the client; any that don't
|
||||
// match are skipped defensively rather than failing the whole signup.
|
||||
if (dto.companyProfiles?.length) {
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
for (const input of dto.companyProfiles) {
|
||||
if (!allowedTypes.includes(input.type)) continue;
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
company.id,
|
||||
input.type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const reference = await this.companyProfilesRepo.generateReference(
|
||||
input.type,
|
||||
);
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId: company.id,
|
||||
type: input.type,
|
||||
reference,
|
||||
businessLicense: input.businessLicense ?? null,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||
company.id,
|
||||
);
|
||||
}
|
||||
|
||||
return { company, profile };
|
||||
}
|
||||
|
||||
async findAllCompanies(): Promise<Company[]> {
|
||||
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
|
||||
return this.companiesRepo.findAll({ order: { name: "ASC" } });
|
||||
}
|
||||
|
||||
async findCompanyById(id: string): Promise<Company> {
|
||||
@@ -91,12 +133,21 @@ export class CompaniesService {
|
||||
return company;
|
||||
}
|
||||
|
||||
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
async getCompanyInfoByUserId(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const company = profile.company;
|
||||
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
|
||||
if (!company)
|
||||
throw new NotFoundException(
|
||||
`Company for profile ${profile.id} not found`,
|
||||
);
|
||||
|
||||
company.companyProfiles =
|
||||
await this.companyProfilesRepo.findByCompanyId(company.id);
|
||||
|
||||
return { profile, company };
|
||||
}
|
||||
@@ -114,7 +165,9 @@ export class CompaniesService {
|
||||
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||
* delivered/completed status.
|
||||
*/
|
||||
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
|
||||
async getDashboardSummary(
|
||||
userId: string,
|
||||
): Promise<DashboardSummaryResponseDto> {
|
||||
// A user without a company profile has no bookings — return an empty summary
|
||||
// rather than 404, so the portal home still renders.
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
@@ -125,7 +178,9 @@ export class CompaniesService {
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||
// Same point in the previous year, so YoY compares like-for-like windows.
|
||||
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
|
||||
const prevYearToDate = new Date(
|
||||
prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
|
||||
);
|
||||
|
||||
const [
|
||||
deliveredThis,
|
||||
@@ -139,20 +194,36 @@ export class CompaniesService {
|
||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(
|
||||
companyId,
|
||||
prevYearStart,
|
||||
prevYearToDate,
|
||||
),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
||||
this.dashboardRepo.sumCommittedTonnage(
|
||||
companyId,
|
||||
prevYearStart,
|
||||
prevYearToDate,
|
||||
),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(
|
||||
companyId,
|
||||
this.monthsAgo(now, 5),
|
||||
now,
|
||||
),
|
||||
]);
|
||||
|
||||
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
||||
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||
const spendPrev =
|
||||
spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||
|
||||
return {
|
||||
deliveredCount: deliveredThis,
|
||||
// Share of committed bookings that reached delivered/completed.
|
||||
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
|
||||
completionRate:
|
||||
committedThis > 0
|
||||
? Math.round((deliveredThis / committedThis) * 100)
|
||||
: 0,
|
||||
spendYtd: spend.total,
|
||||
spendCurrency: spend.currency,
|
||||
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||
@@ -172,12 +243,12 @@ export class CompaniesService {
|
||||
deliveredCount: 0,
|
||||
completionRate: 0,
|
||||
spendYtd: 0,
|
||||
spendCurrency: 'ETB',
|
||||
spendCurrency: "ETB",
|
||||
spendYtdChangePct: 0,
|
||||
freightVolume: {
|
||||
totalTonnes: 0,
|
||||
totalValue: 0,
|
||||
currency: 'ETB',
|
||||
currency: "ETB",
|
||||
ytdChangePct: 0,
|
||||
monthly: this.buildMonthlySeries(now, []),
|
||||
},
|
||||
@@ -190,8 +261,11 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
||||
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
|
||||
currency: string;
|
||||
total: number;
|
||||
} {
|
||||
if (totals.length === 0) return { currency: "ETB", total: 0 };
|
||||
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||
}
|
||||
|
||||
@@ -206,13 +280,29 @@ export class CompaniesService {
|
||||
now: Date,
|
||||
rows: { year: number; month: number; tonnes: number }[],
|
||||
): { month: string; tonnes: number }[] {
|
||||
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const labels = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||
const series: { month: string; tonnes: number }[] = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
|
||||
series.push({
|
||||
month: labels[d.getMonth()],
|
||||
tonnes: Math.round(byKey.get(key) ?? 0),
|
||||
});
|
||||
}
|
||||
return series;
|
||||
}
|
||||
@@ -224,7 +314,10 @@ export class CompaniesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
@@ -233,30 +326,38 @@ export class CompaniesService {
|
||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
||||
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
|
||||
if (dto.companyLocation !== undefined)
|
||||
companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined)
|
||||
companyUpdates.address = dto.companyAddress;
|
||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) {
|
||||
companyUpdates.businessLicense = dto.fanNumber;
|
||||
companyUpdates.fanNumber = dto.fanNumber;
|
||||
}
|
||||
|
||||
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||
if (dto.contactPersonName !== undefined)
|
||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
if (dto.contactPersonPhone !== undefined)
|
||||
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||
if (dto.generalManagerName !== undefined)
|
||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined)
|
||||
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||
if (dto.generalManagerPhone !== undefined)
|
||||
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaLocation !== undefined)
|
||||
attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||
|
||||
companyUpdates.attributes = attrUpdates;
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
@@ -270,7 +371,9 @@ export class CompaniesService {
|
||||
|
||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Profile with email ${dto.email} already exists`);
|
||||
throw new ConflictException(
|
||||
`Profile with email ${dto.email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.profilesRepo.create(dto);
|
||||
@@ -278,7 +381,8 @@ export class CompaniesService {
|
||||
|
||||
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -286,28 +390,119 @@ export class CompaniesService {
|
||||
return this.profilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
|
||||
await this.findCompanyById(dto.forwarderCompanyId);
|
||||
await this.findCompanyById(dto.clientCompanyId);
|
||||
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
|
||||
switch (companyType) {
|
||||
case "customer":
|
||||
return [ProfileType.importer, ProfileType.exporter];
|
||||
case "freight_forwarder":
|
||||
return [ProfileType.freightForwarder];
|
||||
case "dj_freight_forwarder":
|
||||
return [ProfileType.djFreightForwarder];
|
||||
case "transporter":
|
||||
return [ProfileType.transporter];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.ffClientsRepo.findRelationship(
|
||||
dto.forwarderCompanyId,
|
||||
dto.clientCompanyId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException('This forwarder-client relationship already exists');
|
||||
async createCompanyProfile(
|
||||
companyId: string,
|
||||
profileType?: ProfileType,
|
||||
): Promise<CompanyProfile> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
|
||||
const type = profileType ?? allowedTypes[0];
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.ffClientsRepo.create(dto);
|
||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Company already has a ${type} profile (${existing.reference})`,
|
||||
);
|
||||
}
|
||||
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
|
||||
return this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
|
||||
async createDefaultProfilesForCompany(
|
||||
companyId: string,
|
||||
): Promise<CompanyProfile[]> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const types = this.getProfileTypeForCompanyType(company.type);
|
||||
|
||||
const profiles: CompanyProfile[] = [];
|
||||
for (const type of types) {
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
if (!existing) {
|
||||
profiles.push(await this.createCompanyProfile(companyId, type));
|
||||
}
|
||||
}
|
||||
|
||||
if (profiles.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Company of type "${company.type}" must have at least one operational profile`,
|
||||
);
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
async deleteFFClient(id: string): Promise<void> {
|
||||
const client = await this.ffClientsRepo.findById(id);
|
||||
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
|
||||
await this.ffClientsRepo.softDelete(id);
|
||||
/**
|
||||
* Add operational profile(s) to the current user's company (portal settings).
|
||||
* Add-only and idempotent: each requested type must be allowed for the
|
||||
* company's type, profiles that already exist are skipped (not re-created or
|
||||
* rejected), and the full updated list is returned.
|
||||
*/
|
||||
async addCompanyProfilesForUser(
|
||||
userId: string,
|
||||
types: ProfileType[],
|
||||
): Promise<CompanyProfile[]> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
|
||||
for (const type of types) {
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
if (existing) continue;
|
||||
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
|
||||
|
||||
const SEQUENCE_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.exporter]: "seq_company_profile_ex",
|
||||
[ProfileType.importer]: "seq_company_profile_im",
|
||||
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
|
||||
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
|
||||
[ProfileType.transporter]: "seq_company_profile_tr",
|
||||
};
|
||||
|
||||
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.exporter]: "EX",
|
||||
[ProfileType.importer]: "IM",
|
||||
[ProfileType.freightForwarder]: "FFE",
|
||||
[ProfileType.djFreightForwarder]: "FWJ",
|
||||
[ProfileType.transporter]: "TR",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyProfile)
|
||||
repo: Repository<CompanyProfile>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async generateReference(type: ProfileType): Promise<string> {
|
||||
const seqName = SEQUENCE_MAP[type];
|
||||
const result = await this.repository.query(
|
||||
`SELECT nextval('${seqName}') AS next_id`,
|
||||
);
|
||||
const nextId = result[0].next_id as number;
|
||||
const prefix = PREFIX_MAP[type];
|
||||
return `${prefix}-${String(nextId).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
relations: ["company"],
|
||||
});
|
||||
}
|
||||
|
||||
async findByType(
|
||||
companyId: string,
|
||||
type: ProfileType,
|
||||
): Promise<CompanyProfile | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, type },
|
||||
});
|
||||
}
|
||||
|
||||
async findByReference(reference: string): Promise<CompanyProfile | null> {
|
||||
return this.repository.findOne({ where: { reference } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class AddCompanyProfilesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(ProfileType, { each: true })
|
||||
types!: ProfileType[];
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class CompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
}
|
||||
|
||||
export class CreateCompanyWithProfileDto {
|
||||
@IsEnum(CompanyType)
|
||||
@@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
attributes?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CompanyProfileInputDto)
|
||||
companyProfiles?: CompanyProfileInputDto[];
|
||||
}
|
||||
|
||||
@@ -25,11 +25,6 @@ export class CreateCompanyDto {
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
|
||||
import { FFClientRelationship } from '../entities/ff-client.entity';
|
||||
|
||||
export class CreateFFClientDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
forwarderCompanyId!: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
clientCompanyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(FFClientRelationship)
|
||||
relationshipType?: FFClientRelationship;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canBookOnBehalf?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canViewDocuments?: boolean;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||
|
||||
export class ProfileResponseDto {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
companyType: string;
|
||||
companyEmail: string | null;
|
||||
companyPhone: string | null;
|
||||
companyLocation: string;
|
||||
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
|
||||
vatNumber: string | null;
|
||||
fanNumber: string | null;
|
||||
|
||||
companyProfiles: ResponseCompanyProfileDto[];
|
||||
|
||||
contactPersonName: string | null;
|
||||
contactPersonPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
this.companyId = company.id;
|
||||
this.companyName = company.name;
|
||||
this.companyType = company.type;
|
||||
this.companyProfiles =
|
||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||
[];
|
||||
this.companyEmail = company.email ?? null;
|
||||
this.companyPhone = company.phone ?? null;
|
||||
this.companyLocation = company.country;
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { CompanyProfile } from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class ResponseCompanyProfileDto {
|
||||
id: string;
|
||||
type: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
businessLicense?: string | null;
|
||||
attributes?: Record<string, any> | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: CompanyProfile) {
|
||||
this.id = profile.id;
|
||||
this.type = profile.type;
|
||||
this.reference = profile.reference;
|
||||
this.status = profile.status;
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.attributes = profile.attributes;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseCompanyDto {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,7 +31,6 @@ export class ResponseCompanyDto {
|
||||
status: CompanyStatus;
|
||||
tin: string;
|
||||
vatNumber?: string | null;
|
||||
businessLicense?: string | null;
|
||||
fanNumber?: string | null;
|
||||
country: string;
|
||||
address?: string | null;
|
||||
@@ -17,6 +39,7 @@ export class ResponseCompanyDto {
|
||||
website?: string | null;
|
||||
attributes?: Record<string, any> | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
companyProfiles?: ResponseCompanyProfileDto[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -27,7 +50,6 @@ export class ResponseCompanyDto {
|
||||
this.status = company.status;
|
||||
this.tin = company.tin;
|
||||
this.vatNumber = company.vatNumber;
|
||||
this.businessLicense = company.businessLicense;
|
||||
this.fanNumber = company.fanNumber;
|
||||
this.country = company.country;
|
||||
this.address = company.address;
|
||||
@@ -36,6 +58,7 @@ export class ResponseCompanyDto {
|
||||
this.website = company.website;
|
||||
this.attributes = company.attributes;
|
||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
|
||||
|
||||
export class ResponseFFClientDto {
|
||||
id: string;
|
||||
forwarderCompanyId: string;
|
||||
clientCompanyId: string;
|
||||
relationshipType: FFClientRelationship;
|
||||
canBookOnBehalf: boolean;
|
||||
canViewDocuments: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(client: FFClient) {
|
||||
this.id = client.id;
|
||||
this.forwarderCompanyId = client.forwarderCompanyId;
|
||||
this.clientCompanyId = client.clientCompanyId;
|
||||
this.relationshipType = client.relationshipType;
|
||||
this.canBookOnBehalf = client.canBookOnBehalf;
|
||||
this.canViewDocuments = client.canViewDocuments;
|
||||
this.createdAt = client.createdAt;
|
||||
this.updatedAt = client.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateFFClientDto } from './create-ff-client.dto';
|
||||
|
||||
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
export enum ProfileType {
|
||||
importer = "importer",
|
||||
exporter = "exporter",
|
||||
freightForwarder = "freight_forwarder",
|
||||
djFreightForwarder = "dj_freight_forwarder",
|
||||
transporter = "transporter",
|
||||
}
|
||||
|
||||
export enum ProfileStatus {
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
Suspended = "suspended",
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_profiles" })
|
||||
@Index(["reference"], { unique: true })
|
||||
@Index(["type"])
|
||||
@Index(["companyId"])
|
||||
export class CompanyProfile extends BaseEntity {
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, (company) => company.companyProfiles)
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company!: Company;
|
||||
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
|
||||
type!: ProfileType;
|
||||
|
||||
@Column({
|
||||
name: "reference",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: false,
|
||||
unique: true,
|
||||
})
|
||||
reference!: string;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: ProfileStatus.Active,
|
||||
})
|
||||
status!: ProfileStatus;
|
||||
|
||||
@Column({
|
||||
name: "business_license",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
businessLicense?: string | null;
|
||||
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
}
|
||||
@@ -1,79 +1,110 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { ExternalProfile } from './external-profile.entity';
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, OneToMany } from "typeorm";
|
||||
import { ExternalProfile } from "./external-profile.entity";
|
||||
import { CompanyProfile } from "./company-profile.entity";
|
||||
|
||||
export enum CompanyType {
|
||||
Customer = 'customer',
|
||||
Forwarder = 'forwarder',
|
||||
Transporter = 'transporter',
|
||||
Broker = 'broker',
|
||||
Customer = "customer",
|
||||
FreightForwarder = "freight_forwarder",
|
||||
DJFreightForwarder = "dj_freight_forwarder",
|
||||
Transporter = "transporter",
|
||||
}
|
||||
|
||||
export enum CompanyStatus {
|
||||
Active = 'active',
|
||||
Pending = 'pending',
|
||||
Suspended = 'suspended',
|
||||
Blacklisted = 'blacklisted',
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
Suspended = "suspended",
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'companies' })
|
||||
@Index(['tin'])
|
||||
@Index(['type'])
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
export class Company extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||
type!: CompanyType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: CompanyStatus.Pending,
|
||||
})
|
||||
status!: CompanyStatus;
|
||||
|
||||
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
|
||||
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||
tin!: string;
|
||||
|
||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
||||
@Column({ name: "vat_number", type: "varchar", length: 50, nullable: true })
|
||||
vatNumber?: string | null;
|
||||
|
||||
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
|
||||
businessLicense?: string | null;
|
||||
|
||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
|
||||
@Column({ name: "fan_number", type: "varchar", length: 16, nullable: true })
|
||||
fanNumber?: string | null;
|
||||
|
||||
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
|
||||
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
|
||||
country!: string;
|
||||
|
||||
@Column({ name: 'address', type: 'text', nullable: true })
|
||||
@Column({ name: "address", type: "text", nullable: true })
|
||||
address?: string | null;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||
@Column({ name: "phone", type: "varchar", length: 20, nullable: true })
|
||||
phone?: string | null;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
||||
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
|
||||
email?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
|
||||
@Column({
|
||||
name: "contact_person_name",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
contactPersonName?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
|
||||
@Column({
|
||||
name: "contact_person_phone",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: true,
|
||||
})
|
||||
contactPersonPhone?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
|
||||
@Column({
|
||||
name: "general_manager_name",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerName?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
|
||||
@Column({
|
||||
name: "general_manager_email",
|
||||
type: "varchar",
|
||||
length: 150,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerEmail?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
|
||||
@Column({
|
||||
name: "general_manager_phone",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerPhone?: string | null;
|
||||
|
||||
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
||||
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
|
||||
website?: string | null;
|
||||
|
||||
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||
profiles?: ExternalProfile[];
|
||||
|
||||
@OneToMany(() => CompanyProfile, (profile) => profile.company)
|
||||
companyProfiles?: CompanyProfile[];
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
|
||||
export enum FFClientRelationship {
|
||||
ManagedAccount = 'managed_account',
|
||||
SubAgent = 'sub_agent',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'ff_clients' })
|
||||
@Unique(['forwarderCompanyId', 'clientCompanyId'])
|
||||
@Index(['forwarderCompanyId'])
|
||||
@Index(['clientCompanyId'])
|
||||
export class FFClient extends BaseEntity {
|
||||
@Column({ name: 'forwarder_company_id', type: 'uuid' })
|
||||
forwarderCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'forwarder_company_id' })
|
||||
forwarderCompany!: Company;
|
||||
|
||||
@Column({ name: 'client_company_id', type: 'uuid' })
|
||||
clientCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'client_company_id' })
|
||||
clientCompany!: Company;
|
||||
|
||||
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
|
||||
relationshipType!: FFClientRelationship;
|
||||
|
||||
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
|
||||
canBookOnBehalf!: boolean;
|
||||
|
||||
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
|
||||
canViewDocuments!: boolean;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FFClientRepository extends BaseRepository<FFClient> {
|
||||
constructor(
|
||||
@InjectRepository(FFClient)
|
||||
repo: Repository<FFClient>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||
return this.repository.find({ where: { forwarderCompanyId } as any });
|
||||
}
|
||||
|
||||
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
|
||||
return this.repository.find({ where: { clientCompanyId } as any });
|
||||
}
|
||||
|
||||
async findRelationship(
|
||||
forwarderCompanyId: string,
|
||||
clientCompanyId: string,
|
||||
): Promise<FFClient | null> {
|
||||
return this.repository.findOne({
|
||||
where: { forwarderCompanyId, clientCompanyId } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// src/modules/customers/customers.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Controller("customers")
|
||||
@FreightAdmin()
|
||||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
|
||||
return this.customersService.create(createCustomerDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(): Promise<Customer[]> {
|
||||
return this.customersService.findAll();
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@ApiOperation({ summary: "Get customer statistics" })
|
||||
getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||
return this.customersService.getStats();
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
searchByName(@Query("name") name: string): Promise<Customer[]> {
|
||||
return this.customersService.searchByName(name);
|
||||
}
|
||||
|
||||
@Get("email/:email")
|
||||
findByEmail(@Param("email") email: string): Promise<Customer> {
|
||||
return this.customersService.findByEmail(email);
|
||||
}
|
||||
|
||||
@Get("vat/:vatNumber")
|
||||
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
|
||||
return this.customersService.findByVatNumber(vatNumber);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
|
||||
return this.customersService.findById(id);
|
||||
}
|
||||
|
||||
// @Get("user/:userId")
|
||||
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
||||
// return this.customersService.findByUserId(userId);
|
||||
// }
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update a customer" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCustomerDto,
|
||||
): Promise<Customer> {
|
||||
return this.customersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@ApiOperation({ summary: "Soft-delete a customer" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||
return this.customersService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersRepository } from "./customers.repository";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Customer])],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService, CustomersRepository],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -1,117 +0,0 @@
|
||||
// import { BaseRepository } from "@edr/api-common";
|
||||
// import { EntityRepository } from "typeorm";
|
||||
|
||||
// src/modules/customers/customers.repository.ts
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
|
||||
@Injectable()
|
||||
export class CustomersRepository {
|
||||
constructor(
|
||||
@InjectRepository(Customer)
|
||||
private readonly repository: Repository<Customer>,
|
||||
) { }
|
||||
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const customer = this.repository.create(dto);
|
||||
return await this.repository.save(customer);
|
||||
}
|
||||
|
||||
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
|
||||
return await this.repository.find(options);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Customer[]> {
|
||||
return await this.repository
|
||||
.createQueryBuilder("customer")
|
||||
.where("customer.companyName ILIKE :name", { name: `%${name}%` })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
|
||||
if (!email && !vatNumber) return null;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('customer');
|
||||
|
||||
if (email && vatNumber) {
|
||||
queryBuilder.where('customer.email = :email', { email })
|
||||
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
} else if (email) {
|
||||
queryBuilder.where('customer.email = :email', { email });
|
||||
} else if (vatNumber) {
|
||||
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
}
|
||||
|
||||
return await queryBuilder.getOne();
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
|
||||
await this.repository.update(id, updates);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete(id);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
async count(where?: any): Promise<number> {
|
||||
if (where?.createdAt) {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('customer')
|
||||
.where('customer.createdAt >= :date', { date: where.createdAt })
|
||||
.getCount();
|
||||
return result;
|
||||
}
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
|
||||
const queryBuilder = this.repository.createQueryBuilder('customer')
|
||||
.where('customer.email = :email', { email });
|
||||
|
||||
if (vatNumber) {
|
||||
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
}
|
||||
|
||||
const count = await queryBuilder.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async countWithVatNumber(): Promise<number> {
|
||||
const count = await this.repository
|
||||
.createQueryBuilder('customer')
|
||||
.where('customer.vatNumber IS NOT NULL')
|
||||
.andWhere("customer.vatNumber != ''")
|
||||
.getCount();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
getRepository(): Repository<Customer> {
|
||||
return this.repository;
|
||||
}
|
||||
softDelete(id: string): any {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { CustomersRepository } from "./customers.repository";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||
|
||||
/** Create a new customer */
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const exists = await this.customersRepository.existsByUniqueFields(
|
||||
dto.email,
|
||||
dto.vatNumber,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
throw new ConflictException(
|
||||
"Customer with same email or VAT number already exists",
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
return this.customersRepository.create(dto);
|
||||
}
|
||||
|
||||
/** Get all customers */
|
||||
findAll(): Promise<Customer[]> {
|
||||
return this.customersRepository.findAll({
|
||||
order: { companyName: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get customer by ID */
|
||||
async findById(id: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findById(id);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
// async findByUserId(userId: string): Promise<Customer> {
|
||||
// const customer = await this.customersRepository.findByUserId(userId);
|
||||
|
||||
// if (!customer) {
|
||||
// throw new NotFoundException(`Customer with ID ${userId} not found`);
|
||||
// }
|
||||
|
||||
// return customer;
|
||||
//}
|
||||
|
||||
/** Get customer by email */
|
||||
async findByEmail(email: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByEmail(email);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer with email ${email} not found`);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Get customer by VAT number */
|
||||
async findByVatNumber(vatNumber: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByVatNumber(vatNumber);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(
|
||||
`Customer with VAT number ${vatNumber} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Search customers by name */
|
||||
searchByName(name: string): Promise<Customer[]> {
|
||||
return this.customersRepository.findByName(name);
|
||||
}
|
||||
|
||||
/** Update customer */
|
||||
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
|
||||
await this.findById(id);
|
||||
|
||||
// Validate VAT number if provided
|
||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
// // Check email conflict
|
||||
// if (dto.email) {
|
||||
// const existing = await this.customersRepository.findByEmail(dto.email);
|
||||
|
||||
// // if (existing && existing.userId !== id) {
|
||||
// // throw new ConflictException(
|
||||
// // `Customer with email "${dto.email}" already exists`,
|
||||
// // );
|
||||
// // }
|
||||
// }
|
||||
|
||||
const updated = await this.customersRepository.update(id, dto);
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Customer ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Delete customer (soft delete) */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.customersRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Get customer statistics */
|
||||
async getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||
const total = await this.customersRepository.count();
|
||||
const withVatNumber = await this.customersRepository.countWithVatNumber();
|
||||
|
||||
return { total, withVatNumber };
|
||||
}
|
||||
|
||||
delete(id: string): any {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
IsNotEmpty,
|
||||
Length,
|
||||
Matches,
|
||||
} from "class-validator";
|
||||
|
||||
// Enums
|
||||
export enum CustomerStatusDto {
|
||||
Active = "Active",
|
||||
Pending = "Pending",
|
||||
Inactive = "Inactive",
|
||||
}
|
||||
|
||||
export enum CustomerTypeDto {
|
||||
Importer = "Importer",
|
||||
Exporter = "Exporter",
|
||||
Supplier = "Supplier",
|
||||
}
|
||||
|
||||
// DTO
|
||||
export class CreateCustomerDto {
|
||||
// Basic identity
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
phone!: string;
|
||||
|
||||
// Company info
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
companyName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
companyEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
companyPhone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
companyLocation!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
companyAddress!: string;
|
||||
|
||||
// Classification
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerTypeDto)
|
||||
customerType?: CustomerTypeDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerStatusDto)
|
||||
status?: CustomerStatusDto;
|
||||
|
||||
// Legal identifiers
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
|
||||
tinNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(16, 16)
|
||||
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
|
||||
fanNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
vatNumber!: string;
|
||||
|
||||
// Contact person
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
contactPersonName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
contactPersonPhone!: string;
|
||||
|
||||
// Management
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
generalManagerName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
generalManagerEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
generalManagerPhone!: string;
|
||||
|
||||
// POA (Power of Attorney)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
poaName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
poaPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
poaEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
poaLocation?: string;
|
||||
|
||||
// Extra
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// src/modules/customers/dto/response-customer.dto.ts
|
||||
import { Customer } from '../entities/customer.entity';
|
||||
|
||||
export class ResponseCustomerDto {
|
||||
//UserId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
companyPhone: string;
|
||||
companyLocation: string;
|
||||
companyAddress: string;
|
||||
contactPersonName: string;
|
||||
contactPersonPhone: string;
|
||||
tinNumber: string;
|
||||
vatNumber?: string;
|
||||
fanNumber: string;
|
||||
generalManagerName: string;
|
||||
generalManagerEmail: string;
|
||||
generalManagerPhone: string;
|
||||
poaName?: string;
|
||||
poaPhone?: string;
|
||||
poaAddress?: string;
|
||||
poaEmail?: string;
|
||||
poaLocation?: string;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(customer: Customer) {
|
||||
//this.UserId = customer.userId;
|
||||
this.firstName = customer.firstName;
|
||||
this.lastName = customer.lastName;
|
||||
this.email = customer.email;
|
||||
this.phone = customer.phone;
|
||||
this.companyName = customer.companyName;
|
||||
this.companyEmail = customer.companyEmail;
|
||||
this.companyPhone = customer.companyPhone;
|
||||
this.companyLocation = customer.companyLocation;
|
||||
this.companyAddress = customer.companyAddress;
|
||||
this.contactPersonName = customer.contactPersonName;
|
||||
this.contactPersonPhone = customer.contactPersonPhone;
|
||||
this.tinNumber = customer.tinNumber;
|
||||
this.vatNumber = customer.vatNumber ?? undefined;
|
||||
this.fanNumber = customer.fanNumber;
|
||||
this.generalManagerName = customer.generalManagerName;
|
||||
this.generalManagerEmail = customer.generalManagerEmail;
|
||||
this.generalManagerPhone = customer.generalManagerPhone;
|
||||
this.poaName = customer.poaName ?? '';
|
||||
this.poaPhone = customer.poaPhone ?? '';
|
||||
this.poaAddress = customer.poaAddress ?? '';
|
||||
this.poaEmail = customer.poaEmail ?? '';
|
||||
this.poaLocation = customer.poaLocation ?? '';
|
||||
this.notes = customer.notes ?? '';
|
||||
this.createdAt = customer.createdAt;
|
||||
this.updatedAt = customer.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// src/modules/customers/dto/update-customer.dto.ts
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCustomerDto } from './create-customer.dto';
|
||||
|
||||
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
|
||||
email?: string;
|
||||
vatNumber?: string;
|
||||
// Add any other properties you need to access directly
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'customers' })
|
||||
@Index(['email'])
|
||||
//@Index(['userId'])
|
||||
@Index(['tinNumber'])
|
||||
@Index(['fanNumber'])
|
||||
export class Customer extends BaseEntity {
|
||||
//@Column({ name: 'user_id', type: 'uuid' })
|
||||
//userId!: string;
|
||||
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||
firstName!: string;
|
||||
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||
lastName!: string;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20 })
|
||||
phone!: string;
|
||||
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200 })
|
||||
companyName!: string;
|
||||
|
||||
@Column({ name: 'company_email', type: 'varchar', length: 150 })
|
||||
companyEmail!: string;
|
||||
|
||||
@Column({ name: 'company_phone', type: 'varchar', length: 20 })
|
||||
companyPhone!: string;
|
||||
|
||||
@Column({ name: 'company_location', type: 'varchar', length: 100 })
|
||||
companyLocation!: string;
|
||||
|
||||
@Column({ name: 'company_address', type: 'text' })
|
||||
companyAddress!: string;
|
||||
|
||||
@Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true })
|
||||
customerType?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, nullable: true })
|
||||
status?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_name', type: 'varchar', length: 100 })
|
||||
contactPersonName!: string;
|
||||
|
||||
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20 })
|
||||
contactPersonPhone!: string;
|
||||
|
||||
@Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true })
|
||||
tinNumber!: string;
|
||||
|
||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
||||
vatNumber?: string | null;
|
||||
|
||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true })
|
||||
fanNumber!: string;
|
||||
|
||||
@Column({ name: 'general_manager_name', type: 'varchar', length: 100 })
|
||||
generalManagerName!: string;
|
||||
|
||||
@Column({ name: 'general_manager_email', type: 'varchar', length: 150 })
|
||||
generalManagerEmail!: string;
|
||||
|
||||
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20 })
|
||||
generalManagerPhone!: string;
|
||||
|
||||
@Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true })
|
||||
poaName?: string | null;
|
||||
|
||||
@Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true })
|
||||
poaPhone?: string | null;
|
||||
|
||||
@Column({ name: 'poa_address', type: 'text', nullable: true })
|
||||
poaAddress?: string | null;
|
||||
|
||||
@Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true })
|
||||
poaEmail?: string | null;
|
||||
|
||||
@Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true })
|
||||
poaLocation?: string | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Employee } from '@tria-plc/iamapi-common';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { Employee } from "@tria-plc/iamapi-common";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Customer } from '../customers/entities/customer.entity';
|
||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { OverviewController } from './overview.controller';
|
||||
import { OverviewRepository } from './overview.repository';
|
||||
import { OverviewService } from './overview.service';
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||
import { Container } from "../container-management/entities/container.entity";
|
||||
import { Company } from "../companies/entities/company.entity";
|
||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||
import { Train } from "../trains/entities/train.entity";
|
||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||
import { OverviewController } from "./overview.controller";
|
||||
import { OverviewRepository } from "./overview.repository";
|
||||
import { OverviewService } from "./overview.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Booking,
|
||||
PaymentEntity,
|
||||
Customer,
|
||||
Company,
|
||||
Train,
|
||||
Wagon,
|
||||
Container,
|
||||
@@ -31,4 +31,4 @@ import { OverviewService } from './overview.service';
|
||||
controllers: [OverviewController],
|
||||
providers: [OverviewService, OverviewRepository],
|
||||
})
|
||||
export class OverviewModule {}
|
||||
export class OverviewModule { }
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { Employee } from '@tria-plc/iamapi-common';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Repository, ObjectLiteral } from 'typeorm';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
import { Employee } from "@tria-plc/iamapi-common";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Repository, ObjectLiteral } from "typeorm";
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Customer } from '../customers/entities/customer.entity';
|
||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||
import { Container } from "../container-management/entities/container.entity";
|
||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||
import { Train } from "../trains/entities/train.entity";
|
||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||
import {
|
||||
OVERVIEW_CLOSED_STATUSES,
|
||||
OVERVIEW_IN_APPROVAL_STATUSES,
|
||||
OVERVIEW_NEEDS_ACTION_STATUSES,
|
||||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||
} from './overview.constants';
|
||||
} from "./overview.constants";
|
||||
import { Company } from "../companies/entities/company.entity";
|
||||
|
||||
export type OverviewBookingKpisRow = {
|
||||
totalActive: number;
|
||||
@@ -46,8 +46,8 @@ export class OverviewRepository {
|
||||
private readonly bookingRepository: Repository<Booking>,
|
||||
@InjectRepository(PaymentEntity)
|
||||
private readonly paymentRepository: Repository<PaymentEntity>,
|
||||
@InjectRepository(Customer)
|
||||
private readonly customerRepository: Repository<Customer>,
|
||||
@InjectRepository(Company)
|
||||
private readonly companyRepository: Repository<Company>,
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepository: Repository<Train>,
|
||||
@InjectRepository(Wagon)
|
||||
@@ -60,32 +60,32 @@ export class OverviewRepository {
|
||||
private readonly employeeRepository: Repository<Employee>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||
const row = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.createQueryBuilder("booking")
|
||||
.select(
|
||||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||||
'totalActive',
|
||||
"totalActive",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
|
||||
'needsAction',
|
||||
"needsAction",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
|
||||
'urgent',
|
||||
"urgent",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
|
||||
'inApproval',
|
||||
"inApproval",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
|
||||
'submittedToday',
|
||||
"submittedToday",
|
||||
)
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.setParameters({
|
||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||||
@@ -112,9 +112,9 @@ export class OverviewRepository {
|
||||
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
||||
await Promise.all([
|
||||
this.trainRepository
|
||||
.createQueryBuilder('train')
|
||||
.where('train.deleted_at IS NULL')
|
||||
.andWhere('train.status IN (:...statuses)', {
|
||||
.createQueryBuilder("train")
|
||||
.where("train.deleted_at IS NULL")
|
||||
.andWhere("train.status IN (:...statuses)", {
|
||||
statuses: [
|
||||
Freight.TrainStatus.InService,
|
||||
Freight.TrainStatus.Scheduled,
|
||||
@@ -122,39 +122,46 @@ export class OverviewRepository {
|
||||
})
|
||||
.getCount(),
|
||||
this.wagonRepository
|
||||
.createQueryBuilder('wagon')
|
||||
.where('wagon.deleted_at IS NULL')
|
||||
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
|
||||
.createQueryBuilder("wagon")
|
||||
.where("wagon.deleted_at IS NULL")
|
||||
.andWhere("wagon.status = :status", {
|
||||
status: Freight.WagonStatus.Available,
|
||||
})
|
||||
.getCount(),
|
||||
this.containerRepository
|
||||
.createQueryBuilder('container')
|
||||
.where('container.deleted_at IS NULL')
|
||||
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
|
||||
.createQueryBuilder("container")
|
||||
.where("container.deleted_at IS NULL")
|
||||
.andWhere("container.status = :status", { status: "IN_TRANSIT" })
|
||||
.getCount(),
|
||||
this.cargoRepository
|
||||
.createQueryBuilder('cargo')
|
||||
.where('cargo.deleted_at IS NULL')
|
||||
.andWhere('cargo.status IN (:...statuses)', {
|
||||
statuses: ['LOADED', 'IN_TRANSIT'],
|
||||
.createQueryBuilder("cargo")
|
||||
.where("cargo.deleted_at IS NULL")
|
||||
.andWhere("cargo.status IN (:...statuses)", {
|
||||
statuses: ["LOADED", "IN_TRANSIT"],
|
||||
})
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
|
||||
return {
|
||||
trainsActive,
|
||||
wagonsAvailable,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
};
|
||||
}
|
||||
|
||||
async getCustomerKpis(): Promise<{
|
||||
totalCustomers: number;
|
||||
newCustomersThisMonth: number;
|
||||
}> {
|
||||
const row = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select('COUNT(*)::int', 'totalCustomers')
|
||||
const row = await this.companyRepository
|
||||
.createQueryBuilder("customer")
|
||||
.select("COUNT(*)::int", "totalCustomers")
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
|
||||
'newCustomersThisMonth',
|
||||
"newCustomersThisMonth",
|
||||
)
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.where("customer.deleted_at IS NULL")
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
return {
|
||||
@@ -170,26 +177,26 @@ export class OverviewRepository {
|
||||
successfulPaymentsMtd: number;
|
||||
}> {
|
||||
const revenueRow = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.createQueryBuilder("payment")
|
||||
.select(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
'revenueMtdEtb',
|
||||
"revenueMtdEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
'revenueMtdUsd',
|
||||
"revenueMtdUsd",
|
||||
)
|
||||
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||
)
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
const pendingPayments = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.where('payment.status IN (:...statuses)', {
|
||||
statuses: ['action-required', 'processing'],
|
||||
.createQueryBuilder("payment")
|
||||
.where("payment.status IN (:...statuses)", {
|
||||
statuses: ["action-required", "processing"],
|
||||
})
|
||||
.getCount();
|
||||
|
||||
@@ -201,7 +208,10 @@ export class OverviewRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
|
||||
async getStaffKpis(): Promise<{
|
||||
activeEmployees: number;
|
||||
activeUsers: number;
|
||||
}> {
|
||||
const [activeEmployees, activeUsers] = await Promise.all([
|
||||
this.employeeRepository.count({
|
||||
where: { isCurrent: true },
|
||||
@@ -217,15 +227,17 @@ export class OverviewRepository {
|
||||
return { activeEmployees, activeUsers };
|
||||
}
|
||||
|
||||
async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
async getBookingTrend(
|
||||
days: number,
|
||||
): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.createQueryBuilder("booking")
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('booking.created_at::date')
|
||||
.orderBy('booking.created_at::date', 'ASC')
|
||||
.groupBy("booking.created_at::date")
|
||||
.orderBy("booking.created_at::date", "ASC")
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -236,11 +248,11 @@ export class OverviewRepository {
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.groupBy('booking.status')
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.groupBy("booking.status")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return Object.fromEntries(
|
||||
@@ -252,26 +264,26 @@ export class OverviewRepository {
|
||||
days: number,
|
||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.createQueryBuilder("payment")
|
||||
.select(
|
||||
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
|
||||
'date',
|
||||
"date",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
'amountEtb',
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
'amountUsd',
|
||||
"amountUsd",
|
||||
)
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
|
||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
||||
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -283,18 +295,18 @@ export class OverviewRepository {
|
||||
|
||||
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoin('booking.company', 'company')
|
||||
.select('booking.id', 'id')
|
||||
.addSelect('booking.reference', 'reference')
|
||||
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
|
||||
.addSelect('booking.status', 'status')
|
||||
.addSelect('booking.priority_score', 'priorityScore')
|
||||
.addSelect('booking.total_amount', 'totalAmount')
|
||||
.addSelect('booking.payment_currency', 'paymentCurrency')
|
||||
.addSelect('booking.created_at', 'createdAt')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.orderBy('booking.created_at', 'DESC')
|
||||
.createQueryBuilder("booking")
|
||||
.leftJoin("booking.company", "company")
|
||||
.select("booking.id", "id")
|
||||
.addSelect("booking.reference", "reference")
|
||||
.addSelect("COALESCE(company.name, '—')", "customerLabel")
|
||||
.addSelect("booking.status", "status")
|
||||
.addSelect("booking.priority_score", "priorityScore")
|
||||
.addSelect("booking.total_amount", "totalAmount")
|
||||
.addSelect("booking.payment_currency", "paymentCurrency")
|
||||
.addSelect("booking.created_at", "createdAt")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.orderBy("booking.created_at", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{
|
||||
id: string;
|
||||
@@ -319,15 +331,17 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
|
||||
async getBookingsByFreightType(): Promise<
|
||||
{ label: string; count: number }[]
|
||||
> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.freight_type', 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.freight_type", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('booking.freight_type')
|
||||
.orderBy('count', 'DESC')
|
||||
.groupBy("booking.freight_type")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -338,13 +352,13 @@ export class OverviewRepository {
|
||||
|
||||
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.payment_currency', 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.payment_currency", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('booking.payment_currency')
|
||||
.orderBy('count', 'DESC')
|
||||
.groupBy("booking.payment_currency")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -355,11 +369,11 @@ export class OverviewRepository {
|
||||
|
||||
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('payment.status')
|
||||
.orderBy('count', 'DESC')
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.groupBy("payment.status")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -372,20 +386,25 @@ export class OverviewRepository {
|
||||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
||||
> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.method', 'method')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.method", "method")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
|
||||
'amountEtb',
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||
'amountUsd',
|
||||
"amountUsd",
|
||||
)
|
||||
.groupBy('payment.method')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
|
||||
.groupBy("payment.method")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{
|
||||
method: string;
|
||||
count: string;
|
||||
amountEtb: string;
|
||||
amountUsd: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
method: row.method,
|
||||
@@ -395,16 +414,18 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
|
||||
async getRevenueByCurrency(): Promise<
|
||||
{ currency: string; amount: number }[]
|
||||
> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.currency", "currency")
|
||||
.addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||
)
|
||||
.groupBy('payment.currency')
|
||||
.groupBy("payment.currency")
|
||||
.getRawMany<{ currency: string; amount: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -413,20 +434,28 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.trainRepository, 'train');
|
||||
async getTrainStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.trainRepository, "train");
|
||||
}
|
||||
|
||||
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.wagonRepository, 'wagon');
|
||||
async getWagonStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.wagonRepository, "wagon");
|
||||
}
|
||||
|
||||
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.containerRepository, 'container');
|
||||
async getContainerStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.containerRepository, "container");
|
||||
}
|
||||
|
||||
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.cargoRepository, 'cargo');
|
||||
async getCargoStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.cargoRepository, "cargo");
|
||||
}
|
||||
|
||||
private async statusBreakdown(
|
||||
@@ -435,11 +464,11 @@ export class OverviewRepository {
|
||||
): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await repository
|
||||
.createQueryBuilder(alias)
|
||||
.select(`${alias}.status`, 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.select(`${alias}.status`, "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where(`${alias}.deleted_at IS NULL`)
|
||||
.groupBy(`${alias}.status`)
|
||||
.orderBy('count', 'DESC')
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -448,15 +477,19 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('customer.created_at::date')
|
||||
.orderBy('customer.created_at::date', 'ASC')
|
||||
async getCustomerGrowthTrend(
|
||||
days: number,
|
||||
): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.companyRepository
|
||||
.createQueryBuilder("customer")
|
||||
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("customer.deleted_at IS NULL")
|
||||
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||||
days,
|
||||
})
|
||||
.groupBy("customer.created_at::date")
|
||||
.orderBy("customer.created_at::date", "ASC")
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -466,13 +499,16 @@ export class OverviewRepository {
|
||||
}
|
||||
|
||||
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.groupBy('customer.customer_type')
|
||||
.orderBy('count', 'DESC')
|
||||
const rows = await this.companyRepository
|
||||
.createQueryBuilder("customer")
|
||||
.select(
|
||||
`COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
|
||||
"label",
|
||||
)
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("customer.deleted_at IS NULL")
|
||||
.groupBy("customer.type")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -481,16 +517,18 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
|
||||
async getTopCustomersByBookings(
|
||||
limit: number,
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoin('booking.company', 'company')
|
||||
.select(`COALESCE(company.name, 'Unknown')`, 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.createQueryBuilder("booking")
|
||||
.leftJoin("booking.company", "company")
|
||||
.select(`COALESCE(company.name, 'Unknown')`, "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('company.name')
|
||||
.orderBy('count', 'DESC')
|
||||
.groupBy("company.name")
|
||||
.orderBy("count", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
@@ -502,11 +540,11 @@ export class OverviewRepository {
|
||||
|
||||
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.select('user.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('user.status')
|
||||
.orderBy('count', 'DESC')
|
||||
.createQueryBuilder("user")
|
||||
.select("user.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.groupBy("user.status")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -515,15 +553,19 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
async getEmployeeGrowthTrend(
|
||||
days: number,
|
||||
): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.employeeRepository
|
||||
.createQueryBuilder('employee')
|
||||
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('employee.is_current = true')
|
||||
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('employee.created_at::date')
|
||||
.orderBy('employee.created_at::date', 'ASC')
|
||||
.createQueryBuilder("employee")
|
||||
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("employee.is_current = true")
|
||||
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||||
days,
|
||||
})
|
||||
.groupBy("employee.created_at::date")
|
||||
.orderBy("employee.created_at::date", "ASC")
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -538,16 +580,16 @@ export class OverviewRepository {
|
||||
where: { isActive: true, status: EUserStatus.ACCEPTED },
|
||||
}),
|
||||
this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.is_active = false OR user.status != :status', {
|
||||
.createQueryBuilder("user")
|
||||
.where("user.is_active = false OR user.status != :status", {
|
||||
status: EUserStatus.ACCEPTED,
|
||||
})
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return [
|
||||
{ label: 'Active', count: active },
|
||||
{ label: 'Inactive', count: inactive },
|
||||
{ label: "Active", count: active },
|
||||
{ label: "Inactive", count: inactive },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||
// process.env.PAYMENT_API_URL ??
|
||||
"https://paymentcallback.triaplc.com"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
|
||||
@@ -145,9 +145,7 @@ export class PaymentService {
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
console.log("bookingbooking",booking)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
|
||||
console.log("amountminor",amountMinor)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
@@ -159,8 +157,8 @@ export class PaymentService {
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
|
||||
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
|
||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||
});
|
||||
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
|
||||
@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
// A train can be rescheduled (with or without bookings) at any time UNLESS it
|
||||
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
|
||||
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
|
||||
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
||||
throw new BadRequestException('Cannot reschedule a dispatched train');
|
||||
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
|
||||
}
|
||||
if (schedule.status === TrainScheduleStatus.Arrived) {
|
||||
throw new BadRequestException('Cannot reschedule a train that has already arrived');
|
||||
}
|
||||
if (schedule.status === TrainScheduleStatus.Cancelled) {
|
||||
throw new BadRequestException('Cannot reschedule a cancelled train');
|
||||
}
|
||||
|
||||
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
||||
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
|
||||
}
|
||||
}
|
||||
|
||||
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||
bookingIds: dto.finalBookingIds,
|
||||
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||
});
|
||||
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||
// only call it when something is actually being (re)assigned — the new
|
||||
// departure date above is the meaningful change for an empty train. The
|
||||
// empty-train branch returns the same schedule-detail shape as the assign
|
||||
// path so callers get a consistent response.
|
||||
const assignResult = dto.finalBookingIds.length
|
||||
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||
bookingIds: dto.finalBookingIds,
|
||||
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||
})
|
||||
: {
|
||||
...(await this.trainSchedulingService.getContainerTrainScheduleById(
|
||||
scheduleId,
|
||||
)),
|
||||
warnings: [] as string[],
|
||||
deferredBookings: [] as unknown[],
|
||||
};
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent({
|
||||
trainScheduleId: scheduleId,
|
||||
|
||||
@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
|
||||
* key for day-level booking pools — it must match the day the portal calendar
|
||||
* renders, so always derive day keys through this (never `toISOString().slice`).
|
||||
*/
|
||||
export function eatDay(date: Date): string {
|
||||
const { year, month, day } = eatParts(date);
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||
function eatToUtc(
|
||||
year: number,
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
let bookingsRepository: {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findBatchPoolByRouteDay: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
getBookableSchedules: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
let notifier: {
|
||||
payNow: jest.Mock;
|
||||
secured: jest.Mock;
|
||||
expired: jest.Mock;
|
||||
unplaced: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const bookingRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
// WagonType.find() / global-rules find() fall back to defaults when empty.
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
notifier = {
|
||||
payNow: jest.fn(),
|
||||
secured: jest.fn(),
|
||||
expired: jest.fn(),
|
||||
unplaced: jest.fn(),
|
||||
};
|
||||
|
||||
service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||
});
|
||||
|
||||
describe('fillRouteDay — day-level distribution', () => {
|
||||
const originYardId = 'yard-origin';
|
||||
const destinationYardId = 'yard-dest';
|
||||
const day = '2026-06-20';
|
||||
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
|
||||
const trainA = 'train-a';
|
||||
const trainB = 'train-b';
|
||||
|
||||
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
|
||||
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
|
||||
|
||||
const commercial = (id: string, priority: number): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
isGovernment: false,
|
||||
priorityScore: priority,
|
||||
status: 'FULLY_EXECUTED',
|
||||
wagonsRequired: 1,
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainers: [],
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
// Two OPEN trains on the same route + day, train A earlier than train B.
|
||||
trainSchedulingService.getBookableSchedules.mockResolvedValue([
|
||||
{
|
||||
id: trainA,
|
||||
scheduleDate: '2026-06-20T06:00:00.000Z',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
{
|
||||
id: trainB,
|
||||
scheduleDate: '2026-06-20T09:00:00.000Z',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
maxWagons: 1,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
trainSetId: `set-${id}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||||
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
commercial('hi', 30),
|
||||
commercial('mid', 20),
|
||||
commercial('lo', 10),
|
||||
]);
|
||||
|
||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
// Both trains were processed.
|
||||
expect(touched).toEqual([trainA, trainB]);
|
||||
// Highest priority reserved on train A, next on train B (commercial → reserve).
|
||||
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||
expect(reservedOn).toEqual(['hi', 'mid']);
|
||||
// The third booking fits no train and is reported unplaced (and only it).
|
||||
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
|
||||
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
|
||||
});
|
||||
|
||||
it('reserves the chosen train id on each commercial booking', async () => {
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'hi',
|
||||
expect.objectContaining({
|
||||
trainScheduleId: trainA,
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -42,6 +42,14 @@ interface Capacity {
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
/** EAT calendar day, `yyyy-MM-dd`. */
|
||||
day: string;
|
||||
}
|
||||
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
for (const s of open) {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
await this.processRouteDay(group);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
|
||||
this.logger.warn(
|
||||
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const reserved = await this.dataSource
|
||||
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
|
||||
/**
|
||||
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
|
||||
* (contract sign, payment). Day-level pooling distributes across all of that
|
||||
* day's trains, so a single schedule id maps to its whole route-day group.
|
||||
*/
|
||||
enqueueScheduleProcessing(scheduleId: string): void {
|
||||
void this.processSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
|
||||
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(
|
||||
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
|
||||
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (!schedule?.scheduledDepartureDate) return;
|
||||
await this.processRouteDay({
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day: eatDay(schedule.scheduledDepartureDate),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level pipeline: distribute the (route, day) pool across all its trains,
|
||||
* then settle / reconcile / assign wagons per schedule (those steps stay
|
||||
* schedule-scoped — only the fill is day-level).
|
||||
*/
|
||||
async processRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
const scheduleIds = await this.fillRouteDay(
|
||||
group.originYardId,
|
||||
group.destinationYardId,
|
||||
group.day,
|
||||
);
|
||||
for (const scheduleId of scheduleIds) {
|
||||
await this.settleDueReservations(scheduleId);
|
||||
await this.reconcilePaidUnlinked(scheduleId);
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
||||
async processSchedule(scheduleId: string): Promise<void> {
|
||||
await this.fillSchedule(scheduleId);
|
||||
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
const groups = new Map<string, RouteDayGroup>();
|
||||
for (const s of open) {
|
||||
if (!s.scheduledDepartureDate) continue;
|
||||
const day = eatDay(s.scheduledDepartureDate);
|
||||
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
originYardId: s.originStationId,
|
||||
destinationYardId: s.destinationStationId,
|
||||
day,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
private groupLabel(group: RouteDayGroup): string {
|
||||
return `${group.originYardId}→${group.destinationYardId} on ${group.day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
||||
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
||||
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||
async runBatchFill(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
|
||||
for (const s of open) {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
await this.processRouteDay(group);
|
||||
} catch (err) {
|
||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
||||
this.logger.error(
|
||||
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking);
|
||||
await this.reserve(booking, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget = this.subtract(budget, need);
|
||||
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
|
||||
* priority, filling each train (earliest departure first) until it's full and
|
||||
* spilling overflow to the next. Government bookings that fit no train preempt
|
||||
* lower-priority commercial; bookings that fit no train at all stay pending and
|
||||
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
|
||||
* (or that had remaining pool work) so the caller can settle them per-schedule.
|
||||
*/
|
||||
async fillRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<string[]> {
|
||||
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
||||
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const scheduleIds = bookable
|
||||
.filter(
|
||||
(s) =>
|
||||
s.bookingWindowStatus === 'OPEN' &&
|
||||
s.scheduleDate != null &&
|
||||
eatDay(new Date(s.scheduleDate)) === day,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
|
||||
if (scheduleIds.length === 0) return [];
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
|
||||
// Live per-schedule budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
|
||||
continue;
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
|
||||
// First train (earliest departure) that fits this booking as-is.
|
||||
let target = trains.find((t) => this.fits(need, t.budget));
|
||||
|
||||
if (!target && booking.isGovernment) {
|
||||
// Government booking fits nowhere on its own — try to preempt commercial
|
||||
// on each train (earliest first) until one frees enough room.
|
||||
for (const t of trains) {
|
||||
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
|
||||
if (this.fits(need, t.budget)) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// Fits no train this day — stays in the pool, retried next batch.
|
||||
this.notifier.unplaced(booking, day);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(target.id, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget = this.subtract(target.budget, need);
|
||||
}
|
||||
|
||||
for (const t of trains) {
|
||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
|
||||
/** Reserve capacity for a commercial booking and open its pay window. */
|
||||
private async reserve(booking: Booking): Promise<void> {
|
||||
/**
|
||||
* Reserve capacity for a commercial booking on a specific train and open its
|
||||
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
|
||||
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
|
||||
* which is all keyed off `booking.trainScheduleId`, can find the train — with
|
||||
* day-level pooling the booking arrives here with `trainScheduleId` still null,
|
||||
* so the engine sets it as it picks the train.
|
||||
*/
|
||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
selectedForBatchAt: now,
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Expire an unpaid reservation and free its capacity. */
|
||||
/**
|
||||
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||
*/
|
||||
private async expire(booking: Booking): Promise<void> {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@ export class BookingNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-facing warning when a pooled booking fits no train on its chosen day.
|
||||
* It stays pending and is retried next batch; staff can add capacity or pin it
|
||||
* to a train manually. Mirrors {@link scheduleFull} — no customer notification.
|
||||
*/
|
||||
unplaced(b: Booking, day: string): void {
|
||||
this.logger.warn(
|
||||
`UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
|
||||
);
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableDaysQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("available-days")
|
||||
// No staff guard: customers hit this while creating a booking to find which
|
||||
// DAYS have a departure on their route. Day-level pooling — no capacity is
|
||||
// returned, only the list of bookable days.
|
||||
@ApiOperation({
|
||||
summary: "Distinct days with an OPEN same-route departure (day-level pool)",
|
||||
})
|
||||
getAvailableDays(@Query() query: AvailableDaysQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableDays(
|
||||
query.originYardId,
|
||||
query.destinationYardId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("container/eligible-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List eligible container bookings" })
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
} from './booking-batch.constants';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
|
||||
) {}
|
||||
|
||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||
// resolving the schedule's route + day and filtering on the day instead.
|
||||
let day: string | undefined;
|
||||
let originStationId = query.originStationId;
|
||||
let destinationStationId = query.destinationStationId;
|
||||
if (query.trainScheduleId) {
|
||||
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
|
||||
if (schedule?.scheduledDepartureDate) {
|
||||
day = eatDay(schedule.scheduledDepartureDate);
|
||||
originStationId = originStationId ?? schedule.originStationId;
|
||||
destinationStationId = destinationStationId ?? schedule.destinationStationId;
|
||||
}
|
||||
}
|
||||
|
||||
const bookings = await this.bookingsRepository.findEligibleForScheduling({
|
||||
freightType: query.freightType,
|
||||
originStationId: query.originStationId,
|
||||
destinationStationId: query.destinationStationId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
schedulingStatus: query.schedulingStatus,
|
||||
trainScheduleId: query.trainScheduleId,
|
||||
day,
|
||||
});
|
||||
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
||||
}
|
||||
@@ -721,27 +738,37 @@ export class TrainSchedulingService {
|
||||
: null;
|
||||
|
||||
if (route) {
|
||||
const origin = route.originYard;
|
||||
const destination = route.destinationYard;
|
||||
// `route.milestones` is the complete ordered corridor and already includes
|
||||
// the origin (first) and destination (last) yards — `route.originYardId`
|
||||
// and `route.destinationYardId` are derived from them. Use the milestones
|
||||
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
|
||||
const milestones = [...(route.milestones ?? [])].sort(
|
||||
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
|
||||
);
|
||||
|
||||
if (milestones.length > 0) {
|
||||
milestones.forEach((m, i) =>
|
||||
stations.push({
|
||||
sequenceNo: i,
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||
code: m.yard?.code ?? '',
|
||||
}),
|
||||
);
|
||||
return stations;
|
||||
}
|
||||
|
||||
// Route with no milestones recorded — fall back to its origin/destination.
|
||||
const origin = route.originYard;
|
||||
const destination = route.destinationYard;
|
||||
stations.push({
|
||||
sequenceNo: 0,
|
||||
yardId: route.originYardId,
|
||||
label: origin?.label ?? origin?.code ?? 'Origin',
|
||||
code: origin?.code ?? '',
|
||||
});
|
||||
milestones.forEach((m, i) =>
|
||||
stations.push({
|
||||
sequenceNo: i + 1,
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||
code: m.yard?.code ?? '',
|
||||
}),
|
||||
);
|
||||
stations.push({
|
||||
sequenceNo: milestones.length + 1,
|
||||
sequenceNo: 1,
|
||||
yardId: route.destinationYardId,
|
||||
label: destination?.label ?? destination?.code ?? 'Destination',
|
||||
code: destination?.code ?? '',
|
||||
@@ -775,8 +802,16 @@ export class TrainSchedulingService {
|
||||
|
||||
const stations = await this.buildScheduleStations(schedule);
|
||||
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||
|
||||
// Resolve each checkpoint's position by its yard against the canonical
|
||||
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
|
||||
// under an older station numbering still line up with the current stations.
|
||||
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
|
||||
const resolvedSeq = (e: TrainCheckpointEvent) =>
|
||||
seqByYard.get(e.yardId) ?? e.sequenceNo;
|
||||
|
||||
const currentSequenceNo = events.length
|
||||
? Math.max(...events.map((e) => e.sequenceNo))
|
||||
? Math.max(...events.map(resolvedSeq))
|
||||
: -1;
|
||||
|
||||
return {
|
||||
@@ -790,13 +825,19 @@ export class TrainSchedulingService {
|
||||
actualArrivalAt: schedule.actualArrivalAt
|
||||
? schedule.actualArrivalAt.toISOString()
|
||||
: null,
|
||||
scheduledDepartureAt: schedule.scheduledDepartureDate
|
||||
? schedule.scheduledDepartureDate.toISOString()
|
||||
: null,
|
||||
scheduledArrivalAt: schedule.scheduledArrivalDate
|
||||
? schedule.scheduledArrivalDate.toISOString()
|
||||
: null,
|
||||
origin: stations[0]?.label ?? null,
|
||||
destination: stations[stations.length - 1]?.label ?? null,
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints: events.map((e) => ({
|
||||
id: e.id,
|
||||
sequenceNo: e.sequenceNo,
|
||||
sequenceNo: resolvedSeq(e),
|
||||
yardId: e.yardId,
|
||||
label: e.yard?.label ?? e.yard?.code ?? null,
|
||||
kind: e.kind,
|
||||
@@ -1965,6 +2006,33 @@ export class TrainSchedulingService {
|
||||
return filteredSchedules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
|
||||
* departure on the route. Customers pick a DAY (not a train) — so this returns
|
||||
* only the day strings, no capacity, counts or train info.
|
||||
*/
|
||||
async getAvailableDays(
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): Promise<{ days: string[] }> {
|
||||
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
|
||||
}
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||
async existsOpenScheduleOnRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<boolean> {
|
||||
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
|
||||
return days.includes(day);
|
||||
}
|
||||
|
||||
private async mapScheduleDetail(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
|
||||
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
|
||||
|
||||
export class CreateVehicleDto {
|
||||
@IsString()
|
||||
plateNumber!: string;
|
||||
|
||||
@IsEnum(VehicleType)
|
||||
vehicleType!: VehicleType;
|
||||
|
||||
@IsString()
|
||||
manufacturer!: string;
|
||||
|
||||
@IsString()
|
||||
model!: string;
|
||||
|
||||
@IsNumber()
|
||||
year!: number;
|
||||
|
||||
@IsEnum(FuelType)
|
||||
fuelType!: FuelType;
|
||||
|
||||
@IsNumber()
|
||||
capacity!: number;
|
||||
|
||||
@IsEnum(VehicleStatus)
|
||||
status!: VehicleStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateVehicleDto } from './create-vehicle.dto';
|
||||
|
||||
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Entity, Column, Index } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
export enum VehicleType {
|
||||
TRUCK = 'TRUCK',
|
||||
VAN = 'VAN',
|
||||
CAR = 'CAR',
|
||||
BUS = 'BUS',
|
||||
TRAILER = 'TRAILER',
|
||||
TANKER = 'TANKER',
|
||||
FLATBED = 'FLATBED',
|
||||
}
|
||||
|
||||
export enum FuelType {
|
||||
PETROL = 'PETROL',
|
||||
DIESEL = 'DIESEL',
|
||||
ELECTRIC = 'ELECTRIC',
|
||||
HYBRID = 'HYBRID',
|
||||
}
|
||||
|
||||
export enum VehicleStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
MAINTENANCE = 'MAINTENANCE',
|
||||
RETIRED = 'RETIRED',
|
||||
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
|
||||
}
|
||||
|
||||
@Entity({ name: 'vehicles', schema: 'freight' })
|
||||
@Index(['plateNumber'])
|
||||
@Index(['registrationNumber'])
|
||||
@Index(['status'])
|
||||
@Index(['vehicleType'])
|
||||
@Index(['manufacturer'])
|
||||
export class Vehicle extends BaseEntity {
|
||||
@Column({ name: 'plate_number', unique: true })
|
||||
plateNumber!: string;
|
||||
|
||||
@Column({ name: 'registration_number', unique: true })
|
||||
registrationNumber!: string;
|
||||
|
||||
@Column({ name: 'vehicle_type', type: 'varchar' })
|
||||
vehicleType!: VehicleType;
|
||||
|
||||
@Column()
|
||||
manufacturer!: string;
|
||||
|
||||
@Column()
|
||||
model!: string;
|
||||
|
||||
@Column()
|
||||
year!: number;
|
||||
|
||||
@Column({ name: 'fuel_type', type: 'varchar' })
|
||||
fuelType!: FuelType;
|
||||
|
||||
@Column()
|
||||
capacity!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
|
||||
status!: VehicleStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
|
||||
@ApiTags('vehicles')
|
||||
@ApiBearerAuth()
|
||||
@Controller('vehicles')
|
||||
@FleetView()
|
||||
export class VehiclesController {
|
||||
constructor(private readonly vehiclesService: VehiclesService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new vehicle' })
|
||||
create(@Body() createVehicleDto: CreateVehicleDto) {
|
||||
return this.vehiclesService.create(createVehicleDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all vehicles with filters' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
|
||||
) {
|
||||
return this.vehiclesService.findAll({
|
||||
search,
|
||||
status: status as any,
|
||||
page: page ? parseInt(page) : undefined,
|
||||
limit: limit ? parseInt(limit) : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get vehicle by id' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.vehiclesService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a vehicle' })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateVehicleDto: UpdateVehicleDto,
|
||||
) {
|
||||
return this.vehiclesService.update(id, updateVehicleDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a vehicle' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.vehiclesService.remove(id);
|
||||
}
|
||||
}
|
||||
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { VehiclesController } from './vehicles.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Vehicle])],
|
||||
providers: [VehiclesService],
|
||||
controllers: [VehiclesController],
|
||||
exports: [VehiclesService],
|
||||
})
|
||||
export class VehiclesModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesRepository extends BaseRepository<Vehicle> {
|
||||
constructor(
|
||||
@InjectRepository(Vehicle)
|
||||
repository: Repository<Vehicle>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesService {
|
||||
constructor(
|
||||
@InjectRepository(Vehicle)
|
||||
private readonly vehicleRepo: Repository<Vehicle>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Vehicle with plate number ${dto.plateNumber} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
registrationNumber,
|
||||
});
|
||||
|
||||
return this.vehicleRepo.save(vehicle);
|
||||
}
|
||||
|
||||
async findAll(query: {
|
||||
search?: string;
|
||||
status?: VehicleStatus | string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
} = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
|
||||
const page = query.page || 1;
|
||||
const limit = query.limit || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (query.status) where.status = query.status;
|
||||
|
||||
let qb = this.vehicleRepo.createQueryBuilder('v');
|
||||
|
||||
if (query.search) {
|
||||
qb = qb.where(
|
||||
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
qb = qb.andWhere('v.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
|
||||
query.sortBy ?? '',
|
||||
)
|
||||
? query.sortBy
|
||||
: 'createdAt';
|
||||
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
|
||||
|
||||
const [data, total] = await qb
|
||||
.orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Vehicle> {
|
||||
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
|
||||
if (!vehicle) {
|
||||
throw new NotFoundException(`Vehicle ${id} not found`);
|
||||
}
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
|
||||
const vehicle = await this.findById(id);
|
||||
|
||||
if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Vehicle with plate number ${dto.plateNumber} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(vehicle, dto);
|
||||
return this.vehicleRepo.save(vehicle);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.vehicleRepo.softDelete(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user