mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
resolve merge conflict
This commit is contained in:
@@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
||||
import { CustomerTruckService } from './customer-truck.service';
|
||||
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
@@ -81,6 +83,60 @@ import {
|
||||
hasFreightPermission,
|
||||
} from "../../common/freight-permission.util";
|
||||
|
||||
interface MileVehicleSummary {
|
||||
plate: string | null;
|
||||
code: string | null;
|
||||
driverName: string | null;
|
||||
containerNumber: string | null;
|
||||
distanceKm: number | null;
|
||||
}
|
||||
|
||||
interface MileLegSummary {
|
||||
status: string;
|
||||
exactKm: number | null;
|
||||
remainingPayment: number | null;
|
||||
currency: string;
|
||||
invoiced: boolean;
|
||||
vehicles: MileVehicleSummary[];
|
||||
}
|
||||
|
||||
/** Trim a first/last-mile record down to a customer-safe operational summary. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function summarizeMileLeg(rec?: Record<string, any>): MileLegSummary | null {
|
||||
if (!rec) return null;
|
||||
const num = (v: unknown) => (v == null ? null : Number(v));
|
||||
const assignments: Array<Record<string, any>> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const currency =
|
||||
rec.vehicle?.currency ??
|
||||
assignments[0]?.vehicle?.currency ??
|
||||
rec.booking?.paymentCurrency ??
|
||||
'ETB';
|
||||
const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
|
||||
plate: a.vehicle?.plateNumber ?? null,
|
||||
code: a.vehicle?.code ?? null,
|
||||
driverName: a.vehicle?.assignedDriverName ?? null,
|
||||
containerNumber: a.containerNumber ?? null,
|
||||
distanceKm: num(a.distanceKm),
|
||||
}));
|
||||
if (!vehicles.length && rec.vehicle) {
|
||||
vehicles.push({
|
||||
plate: rec.vehicle.plateNumber ?? null,
|
||||
code: rec.vehicle.code ?? null,
|
||||
driverName: rec.vehicle.assignedDriverName ?? null,
|
||||
containerNumber: null,
|
||||
distanceKm: num(rec.exactKm),
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: rec.status ?? '',
|
||||
exactKm: num(rec.exactKm),
|
||||
remainingPayment: num(rec.remainingPayment),
|
||||
currency,
|
||||
invoiced: Boolean(rec.invoice),
|
||||
vehicles,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags("bookings")
|
||||
@Controller("bookings")
|
||||
@ApiBearerAuth()
|
||||
@@ -94,6 +150,8 @@ export class BookingsController {
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
private readonly customerTruckService: CustomerTruckService,
|
||||
private readonly containerReceiptService: ContainerReceiptService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -290,6 +348,33 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/mile-summary')
|
||||
@ApiOperation({
|
||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||
})
|
||||
async mileSummary(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Customers may only see their own booking's mile summary.
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
|
||||
const [first, last] = await Promise.all([
|
||||
this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
|
||||
this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
|
||||
]);
|
||||
return {
|
||||
firstMile: summarizeMileLeg(first.data[0]),
|
||||
lastMile: summarizeMileLeg(last.data[0]),
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/customer-truck-assignment')
|
||||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||||
async assignCustomerTruck(
|
||||
@@ -350,6 +435,21 @@ export class BookingsController {
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||
async updateCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Body() dto: AddCustomerTruckDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.updateTruck(id, assignmentId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||
async removeCustomerTruck(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
// import { BookingPaymentController } from './booking-payment.controller';
|
||||
@@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => LastMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
|
||||
@@ -1424,6 +1424,20 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||
// handovers are per delivering truck and signed by the receiver at the door,
|
||||
// so they never surface the portal "Approve delivery" action.
|
||||
const [pendingHandover] = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.booking_handovers
|
||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
(booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
|
||||
Boolean(pendingHandover);
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,21 +42,30 @@ export class CustomerTruckService {
|
||||
const booking = await this.loadBookingGuard(bookingId);
|
||||
this.assertSelfHaulPaid(booking);
|
||||
|
||||
const isExport = booking.tradeDirection === 'EXPORT';
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
|
||||
// EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are
|
||||
// not pre-specified — they are registered + weighed when the truck leaves.
|
||||
if (isExport) {
|
||||
if (requested.length < 1 || requested.length > 2) {
|
||||
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
|
||||
}
|
||||
} else if (requested.length > 2) {
|
||||
// Both import and export specify the containers each truck carries. Capacity
|
||||
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
|
||||
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
|
||||
// each container is assigned to exactly one truck.
|
||||
if (requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
|
||||
if (requested.length) {
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
// Never assign more trucks than the booking has containers.
|
||||
const existingTrucks = await this.dataSource
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.count({ where: { bookingId } });
|
||||
if (existingTrucks + 1 > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||||
);
|
||||
}
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
@@ -68,6 +77,13 @@ export class CustomerTruckService {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
// Size cap: a 40ft container fills the truck.
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -133,6 +149,76 @@ export class CustomerTruckService {
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a truck assignment — plate/driver/type and the containers it carries.
|
||||
* Allowed only until the truck has arrived (same guard as removal). Container
|
||||
* rules mirror {@link addTruck}: 1–2 of the booking's containers, none already
|
||||
* on another truck, and a 40ft container fills the truck (max 1).
|
||||
*/
|
||||
async updateTruck(
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
dto: AddCustomerTruckDto,
|
||||
): Promise<CustomerTruckAssignment[]> {
|
||||
const booking = await this.loadBookingGuard(bookingId);
|
||||
this.assertSelfHaulPaid(booking);
|
||||
|
||||
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||
if (!assignment || assignment.bookingId !== bookingId) {
|
||||
throw new NotFoundException('Truck assignment not found for this booking');
|
||||
}
|
||||
if (assignment.arrivedAt) {
|
||||
throw new ConflictException('Cannot edit a truck that has already arrived');
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (assignedElsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: dto.driverName.trim(),
|
||||
truckType: dto.truckType.trim(),
|
||||
});
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
requested.map((containerNumber) =>
|
||||
manager.getRepository(CustomerTruckContainer).create({
|
||||
assignmentId,
|
||||
bookingId,
|
||||
containerNumber,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an IMPORT self-haul truck leaving the port: the containers it
|
||||
* actually loaded (replacing any provisional list) and its weighed gross.
|
||||
@@ -226,6 +312,13 @@ export class CustomerTruckService {
|
||||
if (assignment.departedAt) {
|
||||
throw new ConflictException('This truck has already left — its load is locked');
|
||||
}
|
||||
// Containers can only be loaded after the truck has physically arrived at the
|
||||
// warehouse (arrival weighing recorded). Assignment alone is just planning.
|
||||
if (!assignment.arrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
|
||||
);
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (!requested.length) {
|
||||
@@ -244,30 +337,35 @@ export class CustomerTruckService {
|
||||
}
|
||||
}
|
||||
|
||||
const grossKg = await this.vgmKgForContainers(bookingId, requested);
|
||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
// Operator loading the truck: stamp loaded_at so these containers move to
|
||||
// the LOADED stage (customer assignment alone leaves loaded_at null).
|
||||
const loadedAt = new Date();
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
requested.map((containerNumber) =>
|
||||
manager.getRepository(CustomerTruckContainer).create({
|
||||
assignmentId,
|
||||
bookingId,
|
||||
containerNumber,
|
||||
loadedAt,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Provisional gross from the loaded containers' VGM — overridden by the
|
||||
// weighed gross on departure.
|
||||
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
|
||||
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
grossWeightKg: grossKg,
|
||||
grossWeightKg: grossTons,
|
||||
});
|
||||
});
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
||||
const [row]: Array<{ kg: string }> = await this.dataSource.query(
|
||||
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
|
||||
/** Summed VGM (tonnes) of the given containers — provisional truck gross. */
|
||||
private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
||||
const [row]: Array<{ tons: string }> = await this.dataSource.query(
|
||||
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
@@ -276,7 +374,7 @@ export class CustomerTruckService {
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return Number(row?.kg ?? 0);
|
||||
return Number(row?.tons ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,4 +497,20 @@ export class CustomerTruckService {
|
||||
);
|
||||
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((r) => (r.size ?? '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||
containerNumber!: string;
|
||||
|
||||
/**
|
||||
* When the container was actually loaded onto the truck by the operator.
|
||||
* Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
|
||||
* this to be set, so customer assignment alone does not mark a container loaded.
|
||||
*/
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
} from "./dto/response-company.dto";
|
||||
import { BusinessLicenseFile } from "./entities/company-profile.entity";
|
||||
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
@@ -43,6 +44,8 @@ import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
||||
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
|
||||
@@ -61,6 +64,25 @@ export class CompaniesController {
|
||||
private readonly filesService: FilesService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* License files are FileRecord-backed and previewed through `GET /api/files/:id`
|
||||
* (the client builds that URL from the returned `id`). Populate each profile
|
||||
* DTO's `licenseFiles` with its live/pending files in one batched lookup.
|
||||
*/
|
||||
private async populateLicenseFiles(
|
||||
companyId: string,
|
||||
profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[],
|
||||
): Promise<void> {
|
||||
if (profiles.length === 0) return;
|
||||
const byProfile = await this.companiesService.assembleLicenseFilesByProfile(
|
||||
companyId,
|
||||
profiles.map((p) => p.id),
|
||||
);
|
||||
for (const p of profiles) {
|
||||
p.licenseFiles = byProfile[p.id] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@Get("getInfo")
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
async getInfo(
|
||||
@@ -68,7 +90,10 @@ export class CompaniesController {
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company, review);
|
||||
}
|
||||
|
||||
@Get("profile")
|
||||
@@ -78,7 +103,42 @@ export class CompaniesController {
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new ProfileResponseDto(profile, company);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
const dto = new ProfileResponseDto(profile, company, review);
|
||||
await this.populateLicenseFiles(company.id, dto.companyProfiles);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Get("profile/change-request")
|
||||
@ApiOperation({
|
||||
summary: "Current user's open profile change request (pending/rejected)",
|
||||
})
|
||||
async getMyChangeRequest(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<ChangeRequestResponseDto | null> {
|
||||
const { company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
return review ? new ChangeRequestResponseDto(review) : null;
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/reapply")
|
||||
@ApiOperation({
|
||||
summary: "Resubmit a rejected operational role for approval (→ pending)",
|
||||
})
|
||||
async reapplyCompanyProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.reapplyCompanyProfile(
|
||||
user.id,
|
||||
profileId,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Get("dashboard")
|
||||
@@ -177,28 +237,72 @@ export class CompaniesController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload business-license document(s) for one of the current user's company profiles",
|
||||
"Add business-license document(s) to a profile. For an approved company " +
|
||||
"the upload is staged for backoffice review; during onboarding it goes live.",
|
||||
})
|
||||
async uploadProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
return this.companiesService.uploadProfileLicenseFiles(
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.addProfileLicenseFiles(
|
||||
user.id,
|
||||
profileId,
|
||||
files,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/license/:fileId/replace")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Replace a business-license file with a newly uploaded one (staged for " +
|
||||
"review on an approved company).",
|
||||
})
|
||||
async replaceProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const file = files?.[0];
|
||||
if (!file) {
|
||||
throw new BadRequestException("A replacement file is required");
|
||||
}
|
||||
return this.companiesService.replaceProfileLicenseFile(
|
||||
user.id,
|
||||
profileId,
|
||||
fileId,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("company-profiles/:profileId/license/:fileId")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove a business-license file (staged for review on an approved company).",
|
||||
})
|
||||
async removeProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.removeProfileLicenseFile(
|
||||
user.id,
|
||||
profileId,
|
||||
fileId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("company-profiles/:profileId/license")
|
||||
@ApiOperation({
|
||||
summary: "List business-license documents for a company profile",
|
||||
summary: "List business-license documents (with review state) for a profile",
|
||||
})
|
||||
async listProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||
}
|
||||
|
||||
@@ -306,7 +410,9 @@ export class CompaniesController {
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.findCompanyById(id);
|
||||
return new ResponseCompanyDto(company);
|
||||
const dto = new ResponseCompanyDto(company);
|
||||
await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@@ -354,26 +460,76 @@ export class CompaniesController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
||||
async uploadDocuments(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
) {
|
||||
return this.filesService.uploadMany(companyId, "companies", files);
|
||||
// Routed through the service so an approved company's uploads are staged for
|
||||
// review (and lock the customer), while onboarding uploads pass straight through.
|
||||
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
||||
}
|
||||
|
||||
@Patch("company-profiles/:profileId/status")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||
async updateCompanyProfileStatus(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||
profileId,
|
||||
dto.status,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Get(":companyId/change-requests")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "List a company's profile change requests" })
|
||||
async listChangeRequests(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<ChangeRequestResponseDto[]> {
|
||||
const requests = await this.companiesService.listChangeRequests(companyId);
|
||||
return requests.map((r) => new ChangeRequestResponseDto(r));
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/approve")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({
|
||||
summary: "Approve a pending profile change request (applies the changes)",
|
||||
})
|
||||
async approveChangeRequest(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ChangeRequestResponseDto> {
|
||||
const request = await this.companiesService.approveChangeRequest(
|
||||
id,
|
||||
user.id,
|
||||
);
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/reject")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({
|
||||
summary: "Reject a pending profile change request with a note",
|
||||
})
|
||||
async rejectChangeRequest(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectChangeRequestDto,
|
||||
): Promise<ChangeRequestResponseDto> {
|
||||
const request = await this.companiesService.rejectChangeRequest(
|
||||
id,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post(":companyId/profiles")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
|
||||
@@ -12,13 +12,21 @@ 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 { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||
TypeOrmModule.forFeature([
|
||||
Company,
|
||||
ExternalProfile,
|
||||
CompanyProfile,
|
||||
CompanyChangeRequest,
|
||||
Booking,
|
||||
]),
|
||||
HttpModule,
|
||||
FilesModule,
|
||||
FileUploadSettingsModule,
|
||||
@@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
CompaniesRepository,
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyChangeRequestRepository,
|
||||
CompanyDashboardRepository,
|
||||
ETradeService,
|
||||
],
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import {
|
||||
CompanyDashboardRepository,
|
||||
DashboardScope,
|
||||
} from "./company-dashboard.repository";
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
@@ -37,9 +38,21 @@ import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
ProfileType,
|
||||
ProfileStatus,
|
||||
} from "./entities/company-profile.entity";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||
const LICENSE_RESOURCE = "company_profiles";
|
||||
const LICENSE_CODE = "business_license";
|
||||
/** Code for a license file staged in an open change request (not yet live). */
|
||||
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
@@ -54,9 +67,9 @@ export class CompaniesService {
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly etradeService: ETradeService,
|
||||
@@ -334,28 +347,9 @@ export class CompaniesService {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||
for (const profile of company.companyProfiles) {
|
||||
profile.businessLicenseFiles = await this.signLicenseFiles(
|
||||
profile.businessLicenseFiles,
|
||||
);
|
||||
}
|
||||
return company;
|
||||
}
|
||||
|
||||
/**
|
||||
* Business-license files are stored as raw, unsigned MinIO URLs (see
|
||||
* `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
|
||||
* directly. Sign each one with a short-lived URL before it reaches a response.
|
||||
*/
|
||||
private async signLicenseFiles(
|
||||
files?: BusinessLicenseFile[] | null,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
if (!files?.length) return [];
|
||||
return Promise.all(
|
||||
files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||
* to the booking's company and be Active. Used for government bookings (staff
|
||||
@@ -574,12 +568,24 @@ export class CompaniesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
/** Keep only the keys that were actually provided (drop `undefined`). */
|
||||
private pickDefined(dto: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(dto)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
||||
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
||||
* PoA live there). Pure — the caller runs the async TIN-uniqueness check.
|
||||
*/
|
||||
private mapProfileDtoToCompanyUpdates(
|
||||
company: Company,
|
||||
dto: Partial<UpdateProfileDto>,
|
||||
): Record<string, any> {
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||
|
||||
@@ -593,21 +599,10 @@ export class CompaniesService {
|
||||
companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined)
|
||||
companyUpdates.address = dto.companyAddress;
|
||||
if (dto.tin !== undefined && dto.tin !== company.tin) {
|
||||
// Reject a TIN already taken by a different company (the user's own draft
|
||||
// placeholder is fine to overwrite).
|
||||
const owner = await this.companiesRepo.findByTin(dto.tin);
|
||||
if (owner && owner.id !== company.id) {
|
||||
throw new ConflictException(
|
||||
`This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
|
||||
);
|
||||
}
|
||||
if (dto.tin !== undefined && dto.tin !== company.tin)
|
||||
companyUpdates.tin = dto.tin;
|
||||
}
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) {
|
||||
companyUpdates.fanNumber = dto.fanNumber;
|
||||
}
|
||||
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
|
||||
|
||||
if (dto.contactPersonName !== undefined)
|
||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
@@ -629,8 +624,7 @@ export class CompaniesService {
|
||||
if (dto.poaPhone !== undefined)
|
||||
attrUpdates.poaPhone = normalizeE164(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;
|
||||
|
||||
if (dto.licenceNumber !== undefined)
|
||||
@@ -653,11 +647,219 @@ export class CompaniesService {
|
||||
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
||||
|
||||
companyUpdates.attributes = attrUpdates;
|
||||
return companyUpdates;
|
||||
}
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
/** Reject a TIN already registered to a *different* company. */
|
||||
private async assertTinAvailable(
|
||||
company: Company,
|
||||
tin: string | undefined,
|
||||
): Promise<void> {
|
||||
if (tin === undefined || tin === company.tin) return;
|
||||
const owner = await this.companiesRepo.findByTin(tin);
|
||||
if (owner && owner.id !== company.id) {
|
||||
throw new ConflictException(
|
||||
`This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The company's open (pending or last-rejected) profile change request. */
|
||||
async getOpenChangeRequestForCompany(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's profile.
|
||||
*
|
||||
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||
* as before. The company/role pending→approve gate already covers first-run.
|
||||
* - Company already `active` → do NOT touch the live Company. Stage the edit in
|
||||
* a pending change request (merging into any open one) so a backoffice
|
||||
* reviewer can approve (apply) or reject (with a note). This locks the
|
||||
* customer until the review resolves.
|
||||
*/
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
|
||||
const updated = await this.companiesRepo.update(
|
||||
company.id,
|
||||
companyUpdates,
|
||||
);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
// Approved company: stage the change for review, leaving the live row intact.
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const fields = this.pickDefined(dto);
|
||||
|
||||
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||
company.id,
|
||||
);
|
||||
const now = new Date();
|
||||
let request: CompanyChangeRequest;
|
||||
if (existing) {
|
||||
request =
|
||||
(await this.changeRequestRepo.update(existing.id, {
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...fields },
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
})) ?? existing;
|
||||
} else {
|
||||
request = await this.changeRequestRepo.create({
|
||||
companyId: company.id,
|
||||
snapshot: fields,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Live company is unchanged; surface the pending state for the settings page.
|
||||
return new ProfileResponseDto(profile, company, request);
|
||||
}
|
||||
|
||||
/** List a company's change requests, newest first (backoffice review). */
|
||||
async listChangeRequests(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest[]> {
|
||||
await this.findCompanyById(companyId);
|
||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending change request: apply its snapshot to the live Company and
|
||||
* mark the request approved. Any staged documents are already attached to the
|
||||
* company, so nothing else needs promoting.
|
||||
*/
|
||||
async approveChangeRequest(
|
||||
id: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const company = await this.companiesRepo.findById(request.companyId);
|
||||
if (!company)
|
||||
throw new NotFoundException(`Company ${request.companyId} not found`);
|
||||
|
||||
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
||||
await this.assertTinAvailable(company, snapshot.tin);
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||
await this.companiesRepo.update(company.id, companyUpdates);
|
||||
await this.applyLicenseChanges(request);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Approved,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
note: null,
|
||||
})) ?? request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload company documents. For an approved company this also opens/updates a
|
||||
* pending change request (recording the uploaded file ids) so the upload is
|
||||
* reviewed and the customer is locked until it clears — consistent with the
|
||||
* field-edit review. During onboarding (company not yet active) it's a plain
|
||||
* upload with no review.
|
||||
*/
|
||||
async uploadCompanyDocuments(
|
||||
companyId: string,
|
||||
files: Express.Multer.File[],
|
||||
submittedBy?: string,
|
||||
): Promise<FileRecord[]> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const uploaded = await this.filesService.uploadMany(
|
||||
companyId,
|
||||
"companies",
|
||||
files,
|
||||
);
|
||||
if (company.status === CompanyStatus.Active) {
|
||||
await this.stageDocumentChange(
|
||||
company.id,
|
||||
uploaded.map((f) => f.id),
|
||||
submittedBy,
|
||||
);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording staged document uploads. */
|
||||
private async stageDocumentChange(
|
||||
companyId: string,
|
||||
fileIds: string[],
|
||||
submittedBy?: string,
|
||||
): Promise<void> {
|
||||
if (fileIds.length === 0) return;
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.documentFileIds ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { documentFileIds: [...prev, ...fileIds] },
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { documentFileIds: fileIds },
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a pending change request with a note (customer amends & resubmits). */
|
||||
async rejectChangeRequest(
|
||||
id: string,
|
||||
note: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
);
|
||||
}
|
||||
await this.discardLicenseChanges(request);
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Rejected,
|
||||
// Staged license uploads were just discarded; drop their intents so an
|
||||
// amended resubmit never re-references deleted files.
|
||||
documents: { ...request.documents, licenseChanges: [] },
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
})) ?? request
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
@@ -713,6 +915,8 @@ export class CompaniesService {
|
||||
async setCompanyProfileStatus(
|
||||
profileId: string,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const existing = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!existing)
|
||||
@@ -727,6 +931,18 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Track the review outcome. Rejection keeps the note so the customer knows
|
||||
// why; approval clears it. Any decision stamps the reviewer + time.
|
||||
if (status === ProfileStatus.Rejected) {
|
||||
patch.reviewNote = note ?? null;
|
||||
} else if (status === ProfileStatus.Active) {
|
||||
patch.reviewNote = null;
|
||||
}
|
||||
if (status !== ProfileStatus.Pending) {
|
||||
patch.reviewedBy = reviewerId ?? null;
|
||||
patch.reviewedAt = new Date();
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
@@ -744,6 +960,41 @@ export class CompaniesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer reapplies for a rejected operational role (after fixing whatever the
|
||||
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
||||
* clear the rejection note so it re-enters the approval queue.
|
||||
*/
|
||||
async reapplyCompanyProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): 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 target = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!target || target.companyId !== companyId) {
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
}
|
||||
if (target.status !== ProfileStatus.Rejected) {
|
||||
throw new BadRequestException(
|
||||
"Only a rejected role can be resubmitted for approval",
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(profileId, {
|
||||
status: ProfileStatus.Pending,
|
||||
reviewNote: null,
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async createCompanyProfile(
|
||||
companyId: string,
|
||||
profileType?: ProfileType,
|
||||
@@ -833,12 +1084,12 @@ export class CompaniesService {
|
||||
);
|
||||
if (existing) continue;
|
||||
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
// Self-service role adds start Pending and carry no reference — a reference
|
||||
// is minted only when a backoffice reviewer approves the role.
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -870,13 +1121,14 @@ export class CompaniesService {
|
||||
|
||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!created) {
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved. The customer can select this mode but
|
||||
// can't book under it until it's cleared.
|
||||
created = await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
businessLicense: businessLicense ?? null,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -971,13 +1223,21 @@ export class CompaniesService {
|
||||
}));
|
||||
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||
|
||||
// 3. Per-operational-profile business licenses.
|
||||
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
|
||||
profileId: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
|
||||
}));
|
||||
// 3. Per-operational-profile business licenses (FileRecord-backed).
|
||||
const licenseProfiles = await Promise.all(
|
||||
(company.companyProfiles ?? []).map(async (p) => {
|
||||
const records = await this.filesService.findByResource(
|
||||
p.id,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return {
|
||||
profileId: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
const outstanding = [
|
||||
@@ -1094,61 +1354,321 @@ export class CompaniesService {
|
||||
return owned;
|
||||
}
|
||||
|
||||
// ─── Business-license files ────────────────────────────────────────────────
|
||||
//
|
||||
// License documents live in the FileRecord model (`freight.files`) with
|
||||
// `resource = "company_profiles"`, `resourceId = <profileId>`. Live files use
|
||||
// code `LICENSE_CODE`; files staged inside an open change request (add /
|
||||
// replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
|
||||
// Preview streams through `GET /api/files/:id` (server-side proxy) — the same
|
||||
// path regular documents use — so it never hits MinIO directly from the
|
||||
// browser (which fails on the internal bucket endpoint).
|
||||
|
||||
/**
|
||||
* Upload business-license document(s) and store them directly on the company
|
||||
* profile (multi-file). Bytes go to object storage; only metadata/URLs are
|
||||
* persisted on the profile — intentionally not via the FileRecord file model.
|
||||
* New files are appended to any already present. Returns the full list.
|
||||
* Upload business-license file(s) for one of the user's profiles. During
|
||||
* onboarding (company not yet Active) they go live immediately; for an Active
|
||||
* company they're staged under the pending code and recorded as `add` intents
|
||||
* on a pending change request for backoffice review. Returns the updated view.
|
||||
*/
|
||||
async uploadProfileLicenseFiles(
|
||||
async addProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||
|
||||
const uploaded: BusinessLicenseFile[] = [];
|
||||
for (const file of files) {
|
||||
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
|
||||
const url = await this.minioService.uploadFile(
|
||||
objectName,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
const uploaded = await Promise.all(
|
||||
files.map((file) =>
|
||||
this.filesService.upload({
|
||||
resourceId: profileId,
|
||||
resource: LICENSE_RESOURCE,
|
||||
code,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
uploaded.map((r) => ({
|
||||
profileId,
|
||||
op: "add" as const,
|
||||
fileId: r.id,
|
||||
fileName: r.name,
|
||||
})),
|
||||
userId,
|
||||
);
|
||||
uploaded.push({
|
||||
name: file.originalname,
|
||||
url,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
|
||||
await this.companyProfilesRepo.update(profileId, {
|
||||
businessLicenseFiles: next,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The business-license files stored on a single company profile. */
|
||||
async listProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
return profile.businessLicenseFiles ?? [];
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding documents stored on a company profile, fetched by profile id.
|
||||
* Internal helper (no ownership check) used when a booking reuses the active
|
||||
* profile's onboarding documents. Returns [] when the profile is unknown.
|
||||
* Remove a license file. A staged (pending) file is withdrawn outright
|
||||
* (soft-deleted, its `add` intent dropped). A live file on an Active company
|
||||
* is kept and recorded as a `remove` intent for review; during onboarding it
|
||||
* is deleted immediately.
|
||||
*/
|
||||
async removeProfileLicenseFile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
fileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const record = await this.filesService.findById(fileId);
|
||||
if (
|
||||
record.resource !== LICENSE_RESOURCE ||
|
||||
record.resourceId !== profileId
|
||||
) {
|
||||
throw new NotFoundException(`License file ${fileId} not found`);
|
||||
}
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
|
||||
if (record.code === LICENSE_PENDING_CODE) {
|
||||
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
||||
await this.filesService.remove(fileId);
|
||||
await this.withdrawLicenseIntent(company.id, fileId);
|
||||
} else if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
[{ profileId, op: "remove", fileId, fileName: record.name }],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a live license file with a freshly uploaded one — recorded as a
|
||||
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
||||
* atomically. During onboarding the swap is applied immediately.
|
||||
*/
|
||||
async replaceProfileLicenseFile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
fileId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const old = await this.filesService.findById(fileId);
|
||||
if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
|
||||
throw new NotFoundException(`License file ${fileId} not found`);
|
||||
}
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
|
||||
const created = await this.filesService.upload({
|
||||
resourceId: profileId,
|
||||
resource: LICENSE_RESOURCE,
|
||||
code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
|
||||
file,
|
||||
});
|
||||
|
||||
if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
[
|
||||
{ profileId, op: "remove", fileId, fileName: old.name },
|
||||
{ profileId, op: "add", fileId: created.id, fileName: created.name },
|
||||
],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/** License files for one profile, with each file's review status resolved. */
|
||||
async listProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
return this.getProfileLicenseView(profileId, profile.companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live license files for a profile, shaped for by-reference reuse (bookings /
|
||||
* contracts snapshot these). No ownership check — internal callers only.
|
||||
* Returns the raw stored URLs; pending (unapproved) files are excluded.
|
||||
*/
|
||||
async getProfileOnboardingFiles(
|
||||
profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||
return profile?.businessLicenseFiles ?? [];
|
||||
const records = await this.filesService.findByResource(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter((r) => r.code === LICENSE_CODE)
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the review-aware license view for a set of profiles in one pass
|
||||
* (single change-request lookup). Used to enrich company/profile responses.
|
||||
*/
|
||||
async assembleLicenseFilesByProfile(
|
||||
companyId: string,
|
||||
profileIds: string[],
|
||||
): Promise<Record<string, ProfileLicenseFileView[]>> {
|
||||
const pending =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const removeIds = new Set(
|
||||
(pending?.documents?.licenseChanges ?? [])
|
||||
.filter((c) => c.op === "remove")
|
||||
.map((c) => c.fileId),
|
||||
);
|
||||
const result: Record<string, ProfileLicenseFileView[]> = {};
|
||||
await Promise.all(
|
||||
profileIds.map(async (pid) => {
|
||||
result[pid] = await this.mapLicenseRecords(pid, removeIds);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Single-profile license view (fetches the company's pending request once). */
|
||||
private async getProfileLicenseView(
|
||||
profileId: string,
|
||||
companyId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const pending =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const removeIds = new Set(
|
||||
(pending?.documents?.licenseChanges ?? [])
|
||||
.filter((c) => c.op === "remove")
|
||||
.map((c) => c.fileId),
|
||||
);
|
||||
return this.mapLicenseRecords(profileId, removeIds);
|
||||
}
|
||||
|
||||
private async mapLicenseRecords(
|
||||
profileId: string,
|
||||
pendingRemoveIds: Set<string>,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const records = await this.filesService.findByResource(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter(
|
||||
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
|
||||
)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
status:
|
||||
r.code === LICENSE_PENDING_CODE
|
||||
? ("pending_add" as const)
|
||||
: pendingRemoveIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording license add/remove intents. */
|
||||
private async stageLicenseChange(
|
||||
companyId: string,
|
||||
changes: LicenseChangeIntent[],
|
||||
submittedBy?: string,
|
||||
): Promise<void> {
|
||||
if (changes.length === 0) return;
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.licenseChanges ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: {
|
||||
...existing.documents,
|
||||
licenseChanges: [...prev, ...changes],
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { licenseChanges: changes },
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a staged license intent (add or remove) referencing `fileId` from the
|
||||
* company's open request. If that empties the request entirely, delete it so
|
||||
* the customer's settings page unlocks.
|
||||
*/
|
||||
private async withdrawLicenseIntent(
|
||||
companyId: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (!existing) return;
|
||||
const remaining = (existing.documents?.licenseChanges ?? []).filter(
|
||||
(c) => c.fileId !== fileId,
|
||||
);
|
||||
const docs = existing.documents ?? {};
|
||||
const stillHasWork =
|
||||
remaining.length > 0 ||
|
||||
(docs.documentFileIds?.length ?? 0) > 0 ||
|
||||
Object.keys(existing.snapshot ?? {}).length > 0;
|
||||
|
||||
if (stillHasWork) {
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { ...docs, licenseChanges: remaining },
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.softDelete(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a request's staged license changes: promote adds, delete removes. */
|
||||
private async applyLicenseChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.licenseChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.setCode(change.fileId, LICENSE_CODE);
|
||||
} else {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Discard a rejected request's staged license uploads (adds only). */
|
||||
private async discardLicenseChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.licenseChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyChangeRequest)
|
||||
repo: Repository<CompanyChangeRequest>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's current pending request, if any. */
|
||||
async findPendingByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's latest "open" request — pending (locks the customer) or the
|
||||
* most recent rejected one (drives the reapply banner + prefill). Approved
|
||||
* requests are terminal and ignored here.
|
||||
*/
|
||||
async findLatestOpenByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
const pending = await this.findPendingByCompanyId(companyId);
|
||||
if (pending) return pending;
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Rejected },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByCompanyId(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
LicenseChangeIntent,
|
||||
} from "../entities/company-change-request.entity";
|
||||
|
||||
/**
|
||||
* A staged profile change request. Used both by the portal (to lock the settings
|
||||
* page, show the reviewer note, and prefill the proposed values) and by the
|
||||
* backoffice review screen (to render the proposed-vs-current diff).
|
||||
*/
|
||||
export class ChangeRequestResponseDto {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: ChangeRequestStatus;
|
||||
/** Proposed field values (Partial<UpdateProfileDto>) — the diff payload. */
|
||||
snapshot: Record<string, any>;
|
||||
documentFileIds: string[];
|
||||
/** Staged business-license add/remove intents attached to this request. */
|
||||
licenseChanges: LicenseChangeIntent[];
|
||||
note: string | null;
|
||||
submittedBy: string | null;
|
||||
submittedAt: Date | null;
|
||||
reviewedBy: string | null;
|
||||
reviewedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(req: CompanyChangeRequest) {
|
||||
this.id = req.id;
|
||||
this.companyId = req.companyId;
|
||||
this.status = req.status;
|
||||
this.snapshot = req.snapshot ?? {};
|
||||
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
||||
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
||||
this.note = req.note ?? null;
|
||||
this.submittedBy = req.submittedBy ?? null;
|
||||
this.submittedAt = req.submittedAt ?? null;
|
||||
this.reviewedBy = req.reviewedBy ?? null;
|
||||
this.reviewedAt = req.reviewedAt ?? null;
|
||||
this.createdAt = req.createdAt;
|
||||
this.updatedAt = req.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,43 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from '../entities/company-change-request.entity';
|
||||
import { ResponseCompanyDto } from './response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class CompanyInfoResponseDto {
|
||||
profile: ResponseExternalProfileDto;
|
||||
company: ResponseCompanyDto;
|
||||
/**
|
||||
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||
*/
|
||||
review: {
|
||||
status: 'pending' | 'rejected';
|
||||
note: string | null;
|
||||
} | null;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
constructor(
|
||||
profile: ExternalProfile,
|
||||
company: Company,
|
||||
changeRequest?: CompanyChangeRequest | null,
|
||||
) {
|
||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
|
||||
const open =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.review = open
|
||||
? {
|
||||
status: open.status as 'pending' | 'rejected',
|
||||
note: open.note ?? null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from '../entities/company-change-request.entity';
|
||||
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||
|
||||
export class ProfileResponseDto {
|
||||
@@ -48,7 +52,20 @@ export class ProfileResponseDto {
|
||||
|
||||
profileId: string;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
/**
|
||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
||||
*/
|
||||
reviewStatus: "pending" | "rejected" | null;
|
||||
reviewNote: string | null;
|
||||
pendingChanges: Record<string, any> | null;
|
||||
|
||||
constructor(
|
||||
profile: ExternalProfile,
|
||||
company: Company,
|
||||
changeRequest?: CompanyChangeRequest | null,
|
||||
) {
|
||||
this.companyId = company.id;
|
||||
this.companyName = company.name;
|
||||
this.companyType = company.type;
|
||||
@@ -92,5 +109,20 @@ export class ProfileResponseDto {
|
||||
this.poaEmail = attrs.poaEmail ?? null;
|
||||
this.poaLocation = attrs.poaLocation ?? null;
|
||||
this.poaAddress = attrs.poaAddress ?? null;
|
||||
|
||||
const openReview =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.reviewStatus =
|
||||
openReview?.status === ChangeRequestStatus.Pending
|
||||
? "pending"
|
||||
: openReview?.status === ChangeRequestStatus.Rejected
|
||||
? "rejected"
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class RejectChangeRequestDto {
|
||||
/** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(2000)
|
||||
note!: string;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
CompanyNationality,
|
||||
} from '../entities/company.entity';
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
} from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
@@ -18,9 +18,15 @@ export class ResponseCompanyProfileDto {
|
||||
status: string;
|
||||
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
|
||||
businessLicense?: string | null;
|
||||
/** Business-license documents stored on the profile (multi-file). */
|
||||
licenseFiles: BusinessLicenseFile[];
|
||||
/**
|
||||
* Business-license documents (FileRecord-backed) with review state. Left empty
|
||||
* by the constructor and populated asynchronously by the controller, since the
|
||||
* files and their pending-change status require DB lookups.
|
||||
*/
|
||||
licenseFiles: ProfileLicenseFileView[];
|
||||
attributes?: Record<string, any> | null;
|
||||
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||
reviewNote?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -31,8 +37,9 @@ export class ResponseCompanyProfileDto {
|
||||
this.reference = profile.reference ?? '';
|
||||
this.status = profile.status;
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||
this.licenseFiles = [];
|
||||
this.attributes = profile.attributes;
|
||||
this.reviewNote = profile.reviewNote ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { ProfileStatus } from "../entities/company-profile.entity";
|
||||
|
||||
export class UpdateCompanyProfileStatusDto {
|
||||
@ApiProperty({ enum: ProfileStatus })
|
||||
@IsIn(Object.values(ProfileStatus))
|
||||
status!: ProfileStatus;
|
||||
|
||||
/** Reviewer note — required in practice when rejecting so the customer knows why. */
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
/**
|
||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
||||
* settings page by an already-approved company are staged here (not written to
|
||||
* the live Company row) until a backoffice reviewer approves — at which point
|
||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
||||
* amend and resubmit.
|
||||
*/
|
||||
export enum ChangeRequestStatus {
|
||||
Pending = "pending",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected",
|
||||
}
|
||||
|
||||
/**
|
||||
* A single staged business-license change on one company profile, awaiting
|
||||
* review. `add` → a new file was uploaded under the pending code and becomes
|
||||
* live on approval; `remove` → an existing live file is deleted on approval.
|
||||
* A "replace" is recorded as a `remove` of the old file plus an `add` of the
|
||||
* new one. `fileId` is the FileRecord id the op targets.
|
||||
*/
|
||||
export interface LicenseChangeIntent {
|
||||
profileId: string;
|
||||
op: "add" | "remove";
|
||||
fileId: string;
|
||||
/** File name, snapshotted for the backoffice review screen. */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** File references staged alongside a change request (documents/licenses). */
|
||||
export interface ChangeRequestDocuments {
|
||||
/** FileRecord ids uploaded against the company while this request was open. */
|
||||
documentFileIds?: string[];
|
||||
/** Staged per-profile business-license add/remove intents. */
|
||||
licenseChanges?: LicenseChangeIntent[];
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_change_request" })
|
||||
@Index(["companyId"])
|
||||
@Index(["status"])
|
||||
export class CompanyChangeRequest extends BaseEntity {
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
/**
|
||||
* Proposed profile field values, shaped as `Partial<UpdateProfileDto>`. Covers
|
||||
* the Company / Contact / General Manager / Power-of-Attorney tabs (contact/GM/
|
||||
* PoA fields land in `Company.attributes` on approval).
|
||||
*/
|
||||
@Column({ name: "snapshot", type: "jsonb" })
|
||||
snapshot!: Record<string, any>;
|
||||
|
||||
/** Staged document/license file references (see {@link ChangeRequestDocuments}). */
|
||||
@Column({ name: "documents", type: "jsonb", nullable: true })
|
||||
documents?: ChangeRequestDocuments | null;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
default: ChangeRequestStatus.Pending,
|
||||
})
|
||||
status!: ChangeRequestStatus;
|
||||
|
||||
/** Backoffice reviewer's rejection note. */
|
||||
@Column({ name: "note", type: "text", nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: "submitted_by", type: "uuid", nullable: true })
|
||||
submittedBy?: string | null;
|
||||
|
||||
@Column({ name: "submitted_at", type: "timestamptz", nullable: true })
|
||||
submittedAt?: Date | null;
|
||||
|
||||
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||
reviewedBy?: string | null;
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
}
|
||||
@@ -13,11 +13,17 @@ export enum ProfileType {
|
||||
export enum ProfileStatus {
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
/** Reviewer declined the role; carries a note. Customer can reapply → Pending. */
|
||||
Rejected = "rejected",
|
||||
Suspended = "suspended",
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
/** A business-license document stored directly on the company profile. */
|
||||
/**
|
||||
* @deprecated Legacy inline shape. Business-license files now live in the
|
||||
* FileRecord model (`freight.files`, resource `company_profiles`). Kept only for
|
||||
* the by-reference reuse shape consumed by bookings/contracts snapshots.
|
||||
*/
|
||||
export interface BusinessLicenseFile {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -25,6 +31,19 @@ export interface BusinessLicenseFile {
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||
export interface ProfileLicenseFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
/**
|
||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||
* `pending_remove` — live but flagged for deletion on approval.
|
||||
*/
|
||||
status: "live" | "pending_add" | "pending_remove";
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_profiles" })
|
||||
@Index(["reference"], { unique: true })
|
||||
@Index(["type"])
|
||||
@@ -80,4 +99,14 @@ export class CompanyProfile extends BaseEntity {
|
||||
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
/** Reviewer's note when the role is Rejected (cleared on reapply). */
|
||||
@Column({ name: "review_note", type: "text", nullable: true })
|
||||
reviewNote?: string | null;
|
||||
|
||||
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||
reviewedBy?: string | null;
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -326,10 +326,20 @@ export class ContractsService {
|
||||
companyProfileId: string | null,
|
||||
): Promise<void> {
|
||||
if (!companyProfileId) return;
|
||||
const profile = await this.dataSource
|
||||
.getRepository(CompanyProfile)
|
||||
.findOne({ where: { id: companyProfileId } });
|
||||
const docs = profile?.businessLicenseFiles ?? [];
|
||||
// Business-license files are FileRecords (resource "company_profiles"); carry
|
||||
// the live ones by reference. Staged/pending uploads are excluded by code.
|
||||
const records = await this.filesService.findByResource(
|
||||
companyProfileId,
|
||||
'company_profiles',
|
||||
);
|
||||
const docs = records
|
||||
.filter((r) => r.code === 'business_license')
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
}));
|
||||
if (docs.length === 0) return;
|
||||
|
||||
const slug = (name: string) =>
|
||||
|
||||
@@ -9,14 +9,19 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** One physical container under a booking line — entered at booking time. */
|
||||
export class CreateContainerUnitDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||
@IsString()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
message: 'containerNumber must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -124,6 +124,15 @@ export class FilesService {
|
||||
await this.filesRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-slot a stored file under a new `code` (e.g. promote a staged
|
||||
* `business_license_pending` file to the live `business_license` code once a
|
||||
* change request is approved). Bytes and URL are untouched.
|
||||
*/
|
||||
async setCode(id: string, code: string): Promise<void> {
|
||||
await this.filesRepository.update(id, { code });
|
||||
}
|
||||
|
||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
@@ -11,14 +11,15 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { GpsTrackingService } from './gps-tracking.service';
|
||||
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
||||
|
||||
@ApiTags('gps-tracking')
|
||||
@ApiBearerAuth()
|
||||
@Controller('gps')
|
||||
@FleetView()
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
||||
export class GpsTrackingController {
|
||||
constructor(private readonly gps: GpsTrackingService) {}
|
||||
|
||||
@@ -44,21 +45,21 @@ export class GpsTrackingController {
|
||||
}
|
||||
|
||||
@Post('devices')
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||
@ApiOperation({ summary: 'Register a GPS tracker' })
|
||||
register(@Body() dto: RegisterDeviceDto) {
|
||||
return this.gps.registerDevice(dto);
|
||||
}
|
||||
|
||||
@Patch('devices/:id')
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
|
||||
return this.gps.updateDevice(id, dto);
|
||||
}
|
||||
|
||||
@Delete('devices/:id')
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||
@ApiOperation({ summary: 'Delete a GPS tracker' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.gps.removeDevice(id);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
/**
|
||||
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
|
||||
* company's phone/email and sends the message over both channels, swallowing
|
||||
* per-channel failures so a missing provider never breaks the caller's flow.
|
||||
*/
|
||||
export async function sendCompanyChannels(
|
||||
dataSource: DataSource,
|
||||
notifications: NotificationsService,
|
||||
companyId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||
await dataSource.query(
|
||||
`SELECT COALESCE(phone, etrade_phone) AS phone, email
|
||||
FROM freight.companies
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[companyId],
|
||||
);
|
||||
if (contact?.phone) {
|
||||
try {
|
||||
await notifications.directSend('sms', contact.phone, message);
|
||||
} catch {
|
||||
/* best-effort: SMS provider unavailable */
|
||||
}
|
||||
}
|
||||
if (contact?.email) {
|
||||
try {
|
||||
await notifications.directSend('email', contact.email, message);
|
||||
} catch {
|
||||
/* best-effort: email provider unavailable */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,24 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Train carrying the booking departed — dispatched origin → destination. */
|
||||
dispatched(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has been dispatched` +
|
||||
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'DISPATCHED');
|
||||
this.inApp(b, 'Shipment dispatched', msg);
|
||||
}
|
||||
|
||||
/** Train carrying the booking arrived at destination. */
|
||||
arrived(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has arrived` +
|
||||
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'ARRIVED');
|
||||
this.inApp(b, 'Shipment arrived', msg);
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('TrainSchedulingService', () => {
|
||||
{
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} as never, // bookingJourneyService
|
||||
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
computeFleetAvailability,
|
||||
@@ -281,10 +282,38 @@ export class TrainSchedulingService {
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly bookingNotifier: BookingNotifierService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||||
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||||
*/
|
||||
private async notifyScheduleBookings(
|
||||
schedule: TrainSchedule,
|
||||
event: 'dispatched' | 'arrived',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
|
||||
const destination =
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
|
||||
const bookings = await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(ids) },
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const b of bookings) {
|
||||
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||||
else this.bookingNotifier.arrived(b, origin, destination);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete customer-tracking clearance milestones for every booking on a
|
||||
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||||
@@ -1551,6 +1580,7 @@ export class TrainSchedulingService {
|
||||
{ originYardId: schedule.originStationId },
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
@@ -2556,6 +2586,7 @@ export class TrainSchedulingService {
|
||||
{ destinationYardId: schedule.destinationStationId },
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'arrived');
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||
|
||||
@@ -35,7 +35,7 @@ export class CreateWarehouseYardDto {
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@@ -35,7 +35,7 @@ export class CreateWarehouseZoneDto {
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
||||
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@ApiProperty()
|
||||
@@ -47,7 +47,7 @@ export class CreateWarehouseDto {
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_STATUSES)
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export class LoadInventoryDto {
|
||||
@IsUUID()
|
||||
wagonId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' })
|
||||
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Optional explicit storage location. When warehouse/yard/zone are all provided,
|
||||
* the item is stored there directly; otherwise store() falls back to the
|
||||
* allocation-rule / capacity-balanced auto pick.
|
||||
*/
|
||||
export class StoreInventoryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
STORED: ['RESERVED'],
|
||||
// Reserve is retired from the operator flow — a stored export item advances
|
||||
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
|
||||
STORED: ['RESERVED', 'READY_FOR_LOADING'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
|
||||
import { BookingHandover } from './entities/booking-handover.entity';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
|
||||
/**
|
||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||
@@ -13,7 +17,35 @@ import { BookingHandover } from './entities/booking-handover.entity';
|
||||
export class HandoverService {
|
||||
private readonly logger = new Logger(HandoverService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** Tell the customer a handover is ready and needs their signature. */
|
||||
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Handover — signature needed',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
list(bookingId: string): Promise<BookingHandover[]> {
|
||||
return this.dataSource.getRepository(BookingHandover).find({
|
||||
@@ -22,6 +54,32 @@ export class HandoverService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the customer to sign the booking's handover. Ensures a handover exists
|
||||
* (creates a booking-level self-haul one if none yet), then fires the
|
||||
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
|
||||
*/
|
||||
async requestSignature(
|
||||
bookingId: string,
|
||||
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
|
||||
const repo = this.dataSource.getRepository(BookingHandover);
|
||||
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
|
||||
|
||||
if (existing.length === 0) {
|
||||
// No handover yet (truck not arrived): create a booking-level one so the
|
||||
// customer has something to sign. ensureForArrivedTruck notifies on create.
|
||||
const created = await this.ensureForArrivedTruck(bookingId, {});
|
||||
return { notified: true, reference: created.reference, alreadySigned: false };
|
||||
}
|
||||
|
||||
const unsigned = existing.find((h) => !h.signedAt);
|
||||
if (!unsigned) {
|
||||
return { notified: false, reference: existing[0].reference, alreadySigned: true };
|
||||
}
|
||||
await this.notifySignNeeded(bookingId, unsigned.reference);
|
||||
return { notified: true, reference: unsigned.reference, alreadySigned: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
||||
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
||||
@@ -54,6 +112,7 @@ export class HandoverService {
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
||||
void this.notifySignNeeded(bookingId, reference);
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInspectionService {
|
||||
private readonly logger = new Logger(WarehouseInspectionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
||||
@@ -44,7 +53,7 @@ export class WarehouseInspectionService {
|
||||
expectedWeight: expected,
|
||||
actualWeight: actual,
|
||||
weightLoss,
|
||||
weightLossUnit: weightLoss !== null ? 'kg' : null,
|
||||
weightLossUnit: weightLoss !== null ? 't' : null,
|
||||
hasMissingItems: dto.hasMissingItems ?? false,
|
||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||
remarks: dto.remarks ?? null,
|
||||
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "companyId",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
} else if (!hasLastMile && !row.customerTruckAssignedAt) {
|
||||
// Self-haul import: goods are pickup-ready but no collection truck is
|
||||
// assigned yet — nudge the customer to assign one from the portal.
|
||||
void this.notifyTruckAssignmentNeeded(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
|
||||
private async notifyTruckAssignmentNeeded(row: {
|
||||
bookingId?: string | null;
|
||||
bookingReference?: string | null;
|
||||
companyId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!row.companyId || !row.bookingId) return;
|
||||
const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: row.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body,
|
||||
link: `/bookings/${row.bookingId}`,
|
||||
data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.store(id, performedBy);
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@@ -354,12 +355,34 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.list(bookingId);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/request-handover-signature')
|
||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.handoverService.requestSignature(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-items')
|
||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.containerItems(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-weights')
|
||||
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
||||
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
@@ -39,6 +40,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
import { HandoverService } from './handover.service';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||
@@ -356,12 +359,14 @@ export interface ImportUnloadedRow {
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -383,8 +388,41 @@ export class WarehouseInventoryService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly signatures: SignaturesService,
|
||||
private readonly handover: HandoverService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* When a self-haul booking (no EDR first/last mile) is received to the warehouse
|
||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||
*/
|
||||
private async notifyTruckAssignmentNeeded(booking: {
|
||||
companyId?: string | null;
|
||||
reference?: string | null;
|
||||
hasFirstMile?: boolean;
|
||||
hasLastMile?: boolean;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
}, bookingId: string): Promise<void> {
|
||||
if (!booking.companyId) return;
|
||||
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||
if (booking.customerTruckAssignedAt) return; // already assigned
|
||||
const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 6 — final terminal release / gate clearance.
|
||||
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||||
@@ -866,7 +904,10 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.company_id AS "companyId",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -1002,6 +1043,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
void this.notifyTruckAssignmentNeeded(booking, bookingId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1301,12 +1343,18 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
(b.customer_truck_assigned_at IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM freight.last_mile lm
|
||||
WHERE lm.booking_id = b.id
|
||||
AND lm.vehicle_id IS NOT NULL
|
||||
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||
inv.delivered_at AS "deliveredAt",
|
||||
inv.notes AS "notes",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry"
|
||||
FROM freight.warehouse_inventory inv
|
||||
@@ -2020,7 +2068,7 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
|
||||
description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
@@ -2104,13 +2152,30 @@ export class WarehouseInventoryService {
|
||||
|
||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||
|
||||
async store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
async store(
|
||||
id: string,
|
||||
performedBy?: string,
|
||||
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
|
||||
): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'STORED');
|
||||
|
||||
// Explicit location wins when the operator picked warehouse + yard + zone;
|
||||
// otherwise fall back to the allocation-rule / capacity-balanced auto pick.
|
||||
const manualLocation =
|
||||
chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
|
||||
? {
|
||||
warehouseId: chosen.warehouseId,
|
||||
yardId: chosen.yardId,
|
||||
zoneId: chosen.zoneId,
|
||||
path: undefined as string | undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
const criteria = await this.getInventoryAllocationCriteria(item);
|
||||
const ruleLocation = await this.allocation.resolveLocation(criteria);
|
||||
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
|
||||
const location =
|
||||
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||
|
||||
if (!location) {
|
||||
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
||||
@@ -2154,18 +2219,19 @@ export class WarehouseInventoryService {
|
||||
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
||||
}
|
||||
|
||||
const storedReason = manualLocation
|
||||
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
|
||||
: ruleLocation?.rule
|
||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'STORED',
|
||||
storedAt: new Date(),
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
notes: this.appendNote(
|
||||
locked.notes,
|
||||
ruleLocation?.rule
|
||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
|
||||
),
|
||||
notes: this.appendNote(locked.notes, storedReason),
|
||||
});
|
||||
|
||||
await this.activityLog.record(
|
||||
@@ -2173,9 +2239,7 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_STORED',
|
||||
inventoryId: id,
|
||||
warehouseId: location.warehouseId,
|
||||
description: ruleLocation?.rule
|
||||
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
|
||||
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
|
||||
description: storedReason.replace(/^Stored/, 'Inventory stored'),
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
@@ -2294,6 +2358,26 @@ export class WarehouseInventoryService {
|
||||
'Customer must sign the handover before the exit paper can be generated',
|
||||
);
|
||||
}
|
||||
|
||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||
// total VGM cargo weight of the containers selected as loaded on it.
|
||||
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||
const selected = dto.containerNumber
|
||||
.split(/[,;\n]+/)
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (selected.length) {
|
||||
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
|
||||
const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
|
||||
const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
|
||||
if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const releaseDate = isTruckLeaving
|
||||
@@ -2519,15 +2603,17 @@ export class WarehouseInventoryService {
|
||||
Array<{
|
||||
containerNumber: string;
|
||||
goods: string | null;
|
||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
grnNumber: string | null;
|
||||
truckAssignmentId: string | null;
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
loaded: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
@@ -2539,6 +2625,7 @@ export class WarehouseInventoryService {
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
loaded: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
@@ -2552,6 +2639,7 @@ export class WarehouseInventoryService {
|
||||
a.plate_number AS "truckPlate",
|
||||
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
||||
(a.departed_at IS NOT NULL) AS "truckLeft",
|
||||
(ctc.loaded_at IS NOT NULL) AS loaded,
|
||||
b.reference AS "bookingReference",
|
||||
b.contract_id AS "contractId",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||
@@ -2574,28 +2662,64 @@ export class WarehouseInventoryService {
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
// Booking-level gate: the per-truck exit paper is blocked until the handover
|
||||
// is fully signed, so the UI can disable "Exit Paper" with a clear reason.
|
||||
const handoverSigned = await this.handover.isFullySigned(bookingId);
|
||||
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
goods: r.goods,
|
||||
// A container the customer assigned to a truck is ASSIGNED (planned); it
|
||||
// only becomes LOADED once the operator loads it (loaded_at) on truck
|
||||
// leaving. Departed → LEFT, delivered → DELIVERED.
|
||||
stage: r.delivered
|
||||
? 'DELIVERED'
|
||||
: r.truckLeft
|
||||
? 'LEFT'
|
||||
: r.truckAssignmentId
|
||||
: r.loaded
|
||||
? 'LOADED'
|
||||
: r.grnNumber
|
||||
? 'GRN'
|
||||
: r.received
|
||||
? 'RECEIVED'
|
||||
: 'PENDING',
|
||||
: r.truckAssignmentId
|
||||
? 'ASSIGNED'
|
||||
: r.grnNumber
|
||||
? 'GRN'
|
||||
: r.received
|
||||
? 'RECEIVED'
|
||||
: 'PENDING',
|
||||
grnNumber: r.grnNumber,
|
||||
truckAssignmentId: r.truckAssignmentId,
|
||||
truckPlate: r.truckPlate,
|
||||
truckArrived: r.truckArrived,
|
||||
truckLeft: r.truckLeft,
|
||||
loaded: r.loaded,
|
||||
bookingReference: r.bookingReference,
|
||||
contractId: r.contractId,
|
||||
hasLastMile: r.hasLastMile,
|
||||
handoverSigned,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||
* container number. Drives the truck-leaving exit weighing: the selected
|
||||
* containers' total cargo weight must match (gross − tare).
|
||||
*/
|
||||
async bookingContainerWeights(
|
||||
bookingId: string,
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
|
||||
const rows: Array<{ containerNumber: string; weightTons: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber",
|
||||
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
||||
ORDER BY bcu.container_number`,
|
||||
[bookingId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
weightTons: Number(r.weightTons) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2677,7 +2801,7 @@ export class WarehouseInventoryService {
|
||||
['Pickup Truck Plate', data.plateNumber],
|
||||
['Driver', data.driverName],
|
||||
['Truck Type', data.truckType],
|
||||
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
|
||||
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`],
|
||||
['Gate-Out Time', gateOut],
|
||||
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
|
||||
];
|
||||
@@ -2901,6 +3025,21 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
|
||||
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!inv) {
|
||||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||
}
|
||||
return this.handoverDocument(inv.id);
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
@@ -3127,7 +3266,22 @@ export class WarehouseInventoryService {
|
||||
[item.bookingId],
|
||||
);
|
||||
} else {
|
||||
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
|
||||
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||||
// vehicle that carried this item's container so each truck gets its own
|
||||
// handover (falls back to a booking-level one when unresolvable).
|
||||
let truckPlate: string | null = null;
|
||||
if (item.containerId) {
|
||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||
FROM freight.last_mile_container_allocations lca
|
||||
JOIN freight.vehicles v ON v.id = lca.vehicle_id
|
||||
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[item.containerId],
|
||||
);
|
||||
truckPlate = veh?.plate ?? null;
|
||||
}
|
||||
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3608,8 +3762,8 @@ export class WarehouseInventoryService {
|
||||
['Booking Containers', data.bookingContainerSummary],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Received Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
||||
['Received Weight', `${data.weight.toLocaleString()} t`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
|
||||
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
@@ -3731,7 +3885,7 @@ export class WarehouseInventoryService {
|
||||
`${(data.truckPlateNumber && data.truckWeightKg
|
||||
? data.truckWeightKg
|
||||
: data.weight
|
||||
).toLocaleString()} kg`,
|
||||
).toLocaleString()} t`,
|
||||
],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
@@ -3894,8 +4048,8 @@ export class WarehouseInventoryService {
|
||||
['Booking Containers', data.bookingContainerSummary],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
||||
['Inventory Weight', `${data.weight.toLocaleString()} t`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
['Zone', data.zone],
|
||||
@@ -3968,8 +4122,8 @@ export class WarehouseInventoryService {
|
||||
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
||||
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
||||
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
|
||||
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
||||
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
|
||||
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} t`)}</td></tr>
|
||||
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="section-title">Handover Clause</div>
|
||||
@@ -4303,9 +4457,9 @@ export class WarehouseInventoryService {
|
||||
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||
`Tare Weight: ${tareWeight} kg`,
|
||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
|
||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
|
||||
`Tare Weight: ${tareWeight} t`,
|
||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
|
||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||
];
|
||||
|
||||
@@ -4357,7 +4511,7 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
|
||||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
|
||||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, '');
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
@@ -4420,9 +4574,9 @@ export class WarehouseInventoryService {
|
||||
truck?.driverName ? `Driver: ${truck.driverName}` : null,
|
||||
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||||
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
|
||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null,
|
||||
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
|
||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null,
|
||||
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||
@@ -4430,8 +4584,8 @@ export class WarehouseInventoryService {
|
||||
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
||||
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
||||
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null,
|
||||
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null,
|
||||
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Freight, NotificationAudience, NotificationType } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
@@ -135,6 +137,7 @@ export class WarehouseInvoiceService {
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) { }
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
@@ -968,6 +971,25 @@ export class WarehouseInvoiceService {
|
||||
message,
|
||||
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
|
||||
// In-app deep-link to pay the fee from the booking.
|
||||
if (invoice.customerId && invoice.bookingId) {
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: invoice.customerId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
title: "Warehouse fee due",
|
||||
body:
|
||||
`Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
|
||||
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
|
||||
link: `/bookings/${invoice.bookingId}`,
|
||||
data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
@@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -64,8 +64,8 @@ export class WarehousesService {
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
status: dto.status ?? 'ACTIVE',
|
||||
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
|
||||
});
|
||||
} catch (error) {
|
||||
this.mapDbError(error);
|
||||
|
||||
Reference in New Issue
Block a user