fix schule issue and contianer type issue

This commit is contained in:
Marshal
2026-07-17 09:18:26 +00:00
62 changed files with 3205 additions and 495 deletions

View File

@@ -0,0 +1,62 @@
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { AccountService } from "./account.service";
import {
SendContactOtpDto,
UpdateAccountNameDto,
UpdateContactDto,
} from "./dto/account.dto";
/**
* The caller's own account record. Everything here is scoped to the JWT's user
* id — there is no `:id` parameter to tamper with, so these routes need no
* permission key beyond being authenticated.
*/
@ApiTags("auth")
@Controller("me")
@ApiBearerAuth()
@UseGuards(JwtGuard)
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Post("contact/otp")
@ApiOperation({
summary: "Send a verification code to a new email/phone before changing it",
description:
"The code goes to the NEW value supplied here, proving the caller controls " +
"it. Returns the target masked — an unverified caller never gets it back in full.",
})
sendContactOtp(
@CurrentUser() user: TCurrentUser,
@Body() dto: SendContactOtpDto,
): Promise<{ sentTo: string }> {
return this.accountService.sendContactOtp(user.id, dto);
}
@Patch("contact")
@ApiOperation({
summary: "Change the account's email or phone, gated by a verification code",
description:
"Verifies the code and writes the new value in one call, so the API never " +
"has to take a client's word that verification happened.",
})
updateContact(
@CurrentUser() user: TCurrentUser,
@Body() dto: UpdateContactDto,
): Promise<{ success: true; value: string }> {
return this.accountService.updateContact(user.id, dto);
}
@Patch("name")
@ApiOperation({ summary: "Change the account's display name" })
updateName(
@CurrentUser() user: TCurrentUser,
@Body() dto: UpdateAccountNameDto,
): Promise<{ success: true }> {
return this.accountService.updateName(user.id, dto);
}
}

View File

@@ -0,0 +1,226 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, EntityManager, Repository } from "typeorm";
import { isValidPhoneNumber } from "libphonenumber-js";
import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum";
import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type";
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { OtpService, OtpTarget } from "../otp/otp.service";
import {
ContactChannel,
SendContactOtpDto,
UpdateAccountNameDto,
UpdateContactDto,
} from "./dto/account.dto";
import { maskOtpTarget } from "./mask-target.util";
/** How long a contact-change code stays valid before it must be re-requested. */
const CONTACT_OTP_TTL_MS = 10 * 60 * 1000;
/** Postgres unique-violation SQLSTATE. */
const PG_UNIQUE_VIOLATION = "23505";
/**
* Self-serve management of the caller's own IAM user record.
*
* IAM ships `PATCH /api/auth/update-profile`, but it takes email + username +
* phone + name all at once (every field `@IsNotEmpty`) and performs no
* verification — it will move an account's phone to any number the caller
* types. These routes exist so a contact change is *proven*: the code goes to
* the NEW address and the write only lands once it comes back.
*/
@Injectable()
export class AccountService {
private readonly logger = new Logger(AccountService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly otpService: OtpService,
) {}
/**
* Send a code to the address the caller wants to move TO. Sending to the new
* value (rather than the one on file) is the whole point — it proves control
* of the destination before anything is written.
*/
async sendContactOtp(
userId: string,
dto: SendContactOtpDto,
): Promise<{ sentTo: string }> {
const value = this.normalize(dto.channel, dto.value);
await this.assertNotTaken(dto.channel, value, userId);
const target = this.targetFor(dto.channel, value);
await this.otpService.sendOtp(target);
return { sentTo: maskOtpTarget(target) };
}
/**
* Verify the code, then write the new contact value. The verify and the write
* are one call: the API never has to trust that a client "already verified"
* — unlike the signup flow, where the OTP is client-orchestrated and
* `POST /api/otp/verify` is a separate public route the client may simply skip.
*/
async updateContact(
userId: string,
dto: UpdateContactDto,
): Promise<{ success: true; value: string }> {
const value = this.normalize(dto.channel, dto.value);
await this.assertNotTaken(dto.channel, value, userId);
await this.otpService.verifyOtpForAction(
this.targetFor(dto.channel, value),
dto.otp,
CONTACT_OTP_TTL_MS,
);
const isEmail = dto.channel === ContactChannel.Email;
const userPatch = isEmail
? { email: value }
: {
phoneNumber: value,
// The number just passed an OTP, which is exactly what IAM's own
// phone-verification flag means. Set it here so the freight app stops
// needing its own parallel "verified phone" bookkeeping.
isPhoneNumberVerified: true,
verifiedBy: EUserVerifiedBy.PHONE_NUMBER,
};
const sessionPatch: Partial<TCurrentTokenUser> = isEmail
? { email: value }
: { phoneNumber: value, isPhoneNumberVerified: true };
try {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(User).update({ id: userId }, userPatch);
await this.refreshSessions(manager, userId, sessionPatch);
});
} catch (error) {
throw this.asConflict(error, dto.channel);
}
this.logger.log(`Account ${dto.channel} updated for user ${userId}`);
return { success: true, value };
}
/** Rename the account. No OTP — a name change proves nothing and grants nothing. */
async updateName(
userId: string,
dto: UpdateAccountNameDto,
): Promise<{ success: true }> {
const en = dto.name.en?.trim();
const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) };
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(User).update({ id: userId }, { name });
// IAM mirrors the name onto the employee row. Portal customers are
// `individual` users with no employee row at all, so this is a no-op for
// them — hence an unconditional update() rather than a lookup-then-write.
await manager.getRepository(Employee).update({ userId }, { name });
await this.refreshSessions(manager, userId, { name });
});
return { success: true };
}
/**
* `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only
* when a session is created at login. Without patching it here, a saved change
* stays invisible to /me (and to anything reading the token's claims) until the
* user logs out and back in, which reads as "my edit didn't save".
*/
private async refreshSessions(
manager: EntityManager,
userId: string,
patch: Partial<TCurrentTokenUser>,
): Promise<void> {
const repo = manager.getRepository(Session);
const sessions = await repo.find({ where: { userId } });
await Promise.all(
sessions.map((session) =>
repo.update(
{ id: session.id },
{ userInfo: { ...session.userInfo, ...patch } },
),
),
);
}
/** Canonicalise for the channel and reject anything malformed up front. */
private normalize(channel: ContactChannel, value: string): string {
const raw = value.trim();
if (channel === ContactChannel.Email) {
const email = raw.toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new BadRequestException("A valid email address is required");
}
return email;
}
if (!isValidPhoneNumber(raw)) {
throw new BadRequestException(
"A valid international phone number is required (E.164, e.g. +251911223344)",
);
}
// Store the same canonical form the OTP is keyed by, so the code sent here
// is findable on verify regardless of how the number was typed.
return normalizeE164(raw) as string;
}
private targetFor(channel: ContactChannel, value: string): OtpTarget {
return channel === ContactChannel.Email ? { email: value } : { phone: value };
}
/**
* `iam.users.email` and `.phone_number` are each independently UNIQUE, so a
* collision would otherwise surface as a raw 500 at write time. This is a
* courtesy check, not the guard — it races, so {@link asConflict} still has to
* catch the violation.
*/
private async assertNotTaken(
channel: ContactChannel,
value: string,
userId: string,
): Promise<void> {
const existing = await this.userRepository.findOne({
where:
channel === ContactChannel.Email
? { email: value }
: { phoneNumber: value },
select: { id: true },
});
if (existing && existing.id !== userId) {
throw this.takenError(channel);
}
}
private asConflict(error: unknown, channel: ContactChannel): Error {
const code = (error as { code?: string } | null)?.code;
if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel);
return error as Error;
}
private takenError(channel: ContactChannel): ConflictException {
return new ConflictException(
channel === ContactChannel.Email
? "That email address is already registered to another account"
: "That phone number is already registered to another account",
);
}
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
/** The contact channel being changed on the caller's own account. */
export enum ContactChannel {
Email = "email",
Phone = "phone",
}
export class SendContactOtpDto {
@ApiProperty({ enum: ContactChannel })
@IsEnum(ContactChannel)
channel!: ContactChannel;
@ApiProperty({
description:
"The NEW email or phone to verify. The code is sent here, not to the " +
"address currently on the account — that is what proves the caller " +
"controls the number/inbox they are moving to.",
example: "+251911223344",
})
@IsString()
@IsNotEmpty()
value!: string;
}
export class UpdateContactDto extends SendContactOtpDto {
@ApiProperty({ description: "The 6-digit code sent to the new value" })
@IsString()
@IsNotEmpty()
otp!: string;
}
export class AccountNameDto {
@ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" })
@IsString()
@IsNotEmpty()
am!: string;
@ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" })
@IsOptional()
@IsString()
en?: string;
}
export class UpdateAccountNameDto {
@ApiProperty({ type: AccountNameDto })
@IsObject()
@ValidateNested()
@Type(() => AccountNameDto)
name!: AccountNameDto;
}

View File

@@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user
import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
import { maskOtpTarget } from "./mask-target.util";
/**
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
@@ -158,12 +159,6 @@ export class ForgotPasswordService {
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
maskTarget(target: OtpTarget): string {
if (target.email) {
const [local, domain] = target.email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
const phone = target.phone ?? "";
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
return maskOtpTarget(target);
}
}

View File

@@ -1,11 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
import { ExternalProfile } from '../companies/entities/external-profile.entity';
import { OtpModule } from '../otp/otp.module';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerResetController } from './customer-reset.controller';
@@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service';
@Module({
imports: [
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
TypeOrmModule.forFeature([
User,
UserVerification,
ExternalProfile,
Session,
Employee,
]),
OtpModule,
],
controllers: [
FreightMeController,
AccountController,
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
],
providers: [
FreightMeService,
AccountService,
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,

View File

@@ -0,0 +1,16 @@
import { OtpTarget } from "../otp/otp.service";
/**
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
* a caller who has not yet proven possession of the channel.
*/
export function maskOtpTarget(target: OtpTarget): string {
if (target.email) {
const [local, domain] = target.email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
const phone = target.phone ?? "";
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
}

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
NotificationAudience,
NotificationType,
@@ -8,6 +10,7 @@ import {
import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
/**
* Customer + staff notifications for the booking lifecycle: review, clearance
@@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
private ref(b: Booking): string {
@@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const phone = b.companyId
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
: null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {

View File

@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@Injectable()
export class CompaniesRepository extends BaseRepository<Company> {
/**
* A company still being filled in by its owner in the portal wizard: it was
* self-registered (so it has an external profile) and nobody has submitted
* onboarding yet. The row exists from the wizard's first click, carrying a
* placeholder name + TIN, so it must not be offered up for review.
* Staff-created companies have no external profiles and are never drafts.
*/
private static readonly DRAFT_SQL = `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
constructor(
@InjectRepository(Company)
repo: Repository<Company>,
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const {
page = 1,
pageSize = 20,
search,
type,
kind,
status,
onboardingCompleted,
} = query;
const qb = this.repository
.createQueryBuilder('company')
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
// External profiles carry onboardingCompleted, which the backoffice list
// uses to flag customers still mid-onboarding (not yet reviewable).
.leftJoinAndSelect('company.profiles', 'profiles')
.where('company.deleted_at IS NULL');
if (type) {
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.status = :status', { status });
}
if (onboardingCompleted !== undefined) {
qb.andWhere(
onboardingCompleted
? `NOT ${CompaniesRepository.DRAFT_SQL}`
: CompaniesRepository.DRAFT_SQL,
);
}
if (search) {
const term = `%${search.trim()}%`;
qb.andWhere(
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
}
async getStats(): Promise<CompanyStatsResponseDto> {
const rows: { status: string; count: string }[] = await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.getRawMany();
// Drafts are counted separately rather than under `pending`: they carry
// status=pending from creation, which would otherwise inflate the review
// queue's KPI with customers who haven't submitted anything yet.
const rows: { status: string; is_draft: boolean; count: string }[] =
await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.addGroupBy(CompaniesRepository.DRAFT_SQL)
.getRawMany();
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const map = new Map<string, number>();
let onboarding = 0;
let total = 0;
for (const row of rows) {
const count = parseInt(row.count, 10);
total += count;
if (row.is_draft) onboarding += count;
else map.set(row.status, (map.get(row.status) ?? 0) + count);
}
return {
total,
active: map.get('active') ?? 0,
pending: map.get('pending') ?? 0,
onboarding,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
};

View File

@@ -372,6 +372,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);
// External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id);
return company;
}
@@ -962,6 +965,28 @@ export class CompaniesService {
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an
// application that doesn't exist yet. Staff-created companies have no
// external profiles and are exempt.
//
// Only the review decision itself is gated (a profile still awaiting one:
// Pending, or Rejected and awaiting re-approval). Profiles already in
// service stay managable so staff can suspend/blacklist them — including to
// undo an approval granted before this guard existed.
const awaitingReview =
existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected;
if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
);
}
}
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import {
NotificationAudience,
NotificationPriority,
@@ -8,6 +10,7 @@ import {
import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
/** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
@@ -28,11 +31,13 @@ export class CompanyNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> {
const phone = company.contactPersonPhone ?? company.phone ?? null;
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
const email = company.email ?? company.generalManagerEmail ?? null;
if (phone) {

View File

@@ -1,7 +1,10 @@
export class CompanyStatsResponseDto {
total!: number;
active!: number;
/** Submitted applications awaiting review. Excludes drafts. */
pending!: number;
/** Self-registered companies still working through the onboarding wizard. */
onboarding!: number;
suspended!: number;
blacklisted!: number;
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
@@ -37,4 +37,14 @@ export class ListCompaniesQueryDto {
@IsOptional()
@IsIn(Object.values(CompanyStatus))
status?: CompanyStatus;
@ApiPropertyOptional({
description:
"Filter by onboarding submission. `true` = reviewable applications; " +
"`false` = drafts still in the portal wizard. Omit for both.",
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
@IsBoolean()
onboardingCompleted?: boolean;
}

View File

@@ -62,6 +62,13 @@ export class ResponseCompanyDto {
attributes?: Record<string, any> | null;
profiles?: ResponseExternalProfileDto[];
companyProfiles?: ResponseCompanyProfileDto[];
/**
* Whether the owning portal user has submitted the onboarding wizard.
* Approval decisions are blocked while this is false. Staff-created
* companies (no external profiles) count as completed. Undefined when the
* external profiles weren't loaded.
*/
onboardingCompleted?: boolean;
createdAt: Date;
updatedAt: Date;
@@ -84,6 +91,10 @@ export class ResponseCompanyDto {
this.companyProfiles = company.companyProfiles?.map(
(p) => new ResponseCompanyProfileDto(p),
);
this.onboardingCompleted = company.profiles
? company.profiles.length === 0 ||
company.profiles.some((p) => p.onboardingCompleted)
: undefined;
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
NotificationAudience,
NotificationType,
@@ -8,6 +10,7 @@ import {
import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
/**
* Customer + staff notifications for the contract lifecycle. Every customer
@@ -24,6 +27,8 @@ export class ContractNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
private ref(c: Contract): string {
@@ -37,7 +42,9 @@ export class ContractNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(c)}`);
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
const phone = c.companyId
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId)
: null;
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
if (phone) {

View File

@@ -4,6 +4,8 @@ import {
Injectable,
Logger,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
@@ -102,8 +104,41 @@ export class ContractTransitionService {
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly clearanceFeeService: ClearanceFeeService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/**
* The phone the signing OTP is sent to and verified against: the signer's own
* IAM account number.
*
* H12(b): resolved server-side from the authenticated user id, never from the
* request body — a caller-supplied number would let an attacker point the code
* at their own phone. Ownership is already gated separately by
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
* signature to the *person* signing rather than to a company landline that may
* be shared, stale, or imported from eTrade.
*/
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
if (!signerUserId) {
// Unreachable in practice (the ownership gate rejects a missing user
// first), but never fall back to another number if it ever changes.
throw new BadRequestException('Authentication required to sign');
}
const rows: Array<{ phone_number: string | null }> =
await this.dataSource.query(
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
[signerUserId],
);
const phone = rows[0]?.phone_number?.trim();
if (!phone) {
throw new BadRequestException(
'Your account has no registered phone number. Add one in Settings → Account before signing.',
);
}
return phone;
}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
async submit(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
@@ -804,10 +839,10 @@ export class ContractTransitionService {
}
/**
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
* the same number {@link sign} verifies against. The client never picks the
* number (that is the H12(b) trust property): it only asks us to send, and we
* resolve the phone from the contract. Returns a masked hint so the UI can
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
* same number {@link sign} verifies against. The client never picks the number
* (that is the H12(b) trust property): it only asks us to send, and we resolve
* the phone from the authenticated user id. Returns a masked hint so the UI can
* say where the code went without exposing the full number.
*/
async sendSigningOtp(
@@ -823,14 +858,9 @@ export class ContractTransitionService {
);
assertContractStatus(contract, ['CONTRACT_READY']);
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to send the signing OTP to',
);
}
await this.otpService.sendOtp({ phone: companyPhone });
return { sentTo: maskPhone(companyPhone) };
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
await this.otpService.sendOtp({ phone: signerPhone });
return { sentTo: maskPhone(signerPhone) };
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
@@ -856,20 +886,18 @@ export class ContractTransitionService {
throw new BadRequestException('Customer has already signed this contract');
}
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
// registered phone — never the caller-supplied dto.otpPhone, which an
// attacker could point at their own phone to sign someone else's
// contract. The OTP is issued to the company's registered number.
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to verify the signing OTP against',
);
}
// signature is applied. H12(b): verify against the SIGNER's own registered
// phone, resolved server-side from the authenticated user id — never a
// caller-supplied number, which an attacker could point at their own
// phone. Ownership is already asserted above, so this proves the specific
// person holding the account is present, not merely that someone reached a
// shared company line. Must resolve identically to sendSigningOtp, or send
// and verify would target different numbers.
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
if (!dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -28,17 +28,13 @@ export class SignContractDto {
consentText?: string;
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
// SMS'd to the signer's phone, verified server-side before the signature is
// applied. `otpPhone` is the number the code was sent to (the signed-in
// customer's registered phone).
// SMS'd to the signer's registered phone, verified server-side before the
// signature is applied. The number itself is deliberately NOT part of this
// DTO — the server resolves it from the authenticated user id, so a caller
// cannot redirect the challenge to a phone they control.
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
@IsOptional()
@IsString()
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
otp?: string;
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
@IsOptional()
@IsString()
otpPhone?: string;
}

View File

@@ -1,6 +1,10 @@
import { DataSource } from 'typeorm';
import { NotificationsService } from './notifications.service';
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from './resolve-company-phone.util';
/**
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
@@ -15,9 +19,10 @@ export async function sendCompanyChannels(
): 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`,
`SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email
FROM freight.companies co
${primaryContactUserJoin('co')}
WHERE co.id = $1 AND co.deleted_at IS NULL`,
[companyId],
);
if (contact?.phone) {

View File

@@ -0,0 +1,60 @@
import { DataSource, EntityManager } from "typeorm";
/**
* Where a customer-facing SMS actually goes.
*
* The person who signs up, logs in, and receives OTPs is an IAM user, and
* `iam.users.phone_number` is the number they control and can change themselves
* (see the account settings flow). A company's own `phone` is business contact
* data — often a landline, a shared desk, or a stale eTrade import — so it is
* the fallback, not the source.
*
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
* path stores that value in the `attributes` jsonb and has never populated the
* column, so every reader of it was silently falling through to `phone` anyway.
*/
/**
* LEFT JOIN a company alias to its primary contact's IAM user, exposing
* `pc.phone_number`.
*
* LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a
* company having two `is_primary_contact` rows, and a plain join would then
* duplicate the company row — which in a fan-out query means sending the same
* customer the same SMS twice.
*
* `alias` is always a code-controlled literal, never caller input.
*/
export function primaryContactUserJoin(alias: string): string {
return `
LEFT JOIN LATERAL (
SELECT u.phone_number
FROM freight.external_profiles ep
JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true
WHERE ep.company_id = ${alias}.id
AND ep.is_primary_contact = true
AND ep.deleted_at IS NULL
ORDER BY ep.created_at
LIMIT 1
) pc ON true`;
}
/** SQL expression for the company's SMS number, given the joined `pc` alias. */
export function companyNotifyPhoneExpr(alias: string): string {
return `COALESCE(pc.phone_number, ${alias}.phone)`;
}
/** The SMS number for one company, or null when neither source has one. */
export async function resolveCompanyNotifyPhone(
db: DataSource | EntityManager,
companyId: string,
): Promise<string | null> {
const rows: Array<{ phone: string | null }> = await db.query(
`SELECT ${companyNotifyPhoneExpr("co")} AS phone
FROM freight.companies co
${primaryContactUserJoin("co")}
WHERE co.id = $1 AND co.deleted_at IS NULL`,
[companyId],
);
return rows[0]?.phone ?? null;
}

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
NotificationAudience,
NotificationPriority,
@@ -9,6 +11,7 @@ import {
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@@ -20,6 +23,8 @@ export class BookingNotifierService {
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly trainSchedules: TrainSchedulesRepository,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/**
@@ -60,7 +65,9 @@ export class BookingNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const phone = b.companyId
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
: null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {

View File

@@ -13,6 +13,10 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
@@ -527,7 +531,7 @@ export class BookingWindowService implements OnModuleInit {
}> = await this.dataSource.query(
`SELECT DISTINCT
c.company_id,
COALESCE(co.contact_person_phone, co.phone) AS phone,
${companyNotifyPhoneExpr('co')} AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
@@ -535,6 +539,7 @@ export class BookingWindowService implements OnModuleInit {
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
${primaryContactUserJoin('co')}
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,

View File

@@ -21,6 +21,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import {
DataSource,
EntityManager,
@@ -2037,6 +2038,58 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/**
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
* loaded onto the wagons allocated to the booking, so anything still in the
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
* booking has warehouse inventory that never made it onto a wagon (received /
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
* or drop the booking's wagon allocation so it rides a later train.
*
* Import/domestic are untouched: their cargo isn't loaded out of an origin
* warehouse, so warehouse inventory says nothing about what's aboard.
*
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
* wagon before the goods arrive is normal planning; they simply aren't aboard.
*/
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
await this.dataSource.query(
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
[scheduleId],
);
if (!route) return;
const direction = deriveTradeDirection(
{ country: route.originCountry },
{ country: route.destinationCountry },
);
if (direction !== 'EXPORT') return;
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
`WITH ${SCHEDULE_BOOKINGS_CTE}
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
FROM sched_bookings sb
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = $1
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
[scheduleId],
);
if (rows.length) {
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
throw new BadRequestException(
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
);
}
}
async dispatchSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -2046,6 +2099,8 @@ export class TrainSchedulingService {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
await this.assertImportDjiboutiMayDepart(schedule);
// Export only: don't leave received cargo behind in the warehouse.
await this.assertAllocatedCargoLoaded(scheduleId);
// A locomotive may sit on many future schedules, but it can only pull one train
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);

View File

@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Company } from '../companies/entities/company.entity';
@@ -13,6 +14,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
@@ -1027,6 +1032,8 @@ export class WarehouseInventoryService {
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
continue;
}
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads
// onto a train without one. Import GRN handling is left untouched.
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
@@ -1036,6 +1043,9 @@ export class WarehouseInventoryService {
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
...(booking.tradeDirection === 'EXPORT'
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
: {}),
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
});
result.processedCount += 1;
@@ -1056,6 +1066,14 @@ export class WarehouseInventoryService {
/** Unload a single arrived booking into a chosen (or default) location. */
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
// a train without one. Import GRN handling is left untouched.
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const isExport = bookingRow?.tradeDirection === 'EXPORT';
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1076,6 +1094,10 @@ export class WarehouseInventoryService {
zoneId: location.zoneId,
status: 'RECEIVED',
arrivedAt,
// Export only, and keep an already-issued GRN rather than reissuing.
...(isExport && !existing[0].grnNumber
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1090,6 +1112,9 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
...(isExport
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
@@ -1145,7 +1170,7 @@ export class WarehouseInventoryService {
b.company_id AS "customerId",
company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
bcu.seal_numbers AS "sealNumbers",
bc.container_quantity AS "containerQuantity",
@@ -1183,6 +1208,7 @@ export class WarehouseInventoryService {
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
@@ -1288,7 +1314,7 @@ export class WarehouseInventoryService {
b.cargo_total_weight_vgm AS "weight",
company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
bc.container_numbers AS "containerNumber",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
@@ -1317,6 +1343,7 @@ export class WarehouseInventoryService {
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
@@ -1536,11 +1563,19 @@ export class WarehouseInventoryService {
// their already-allocated wagons. Reuses the single-item load() machinery.
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
/**
* Export flow this queue serves: booked -> paid -> received at the warehouse
* (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
* booking. Which bookings ride a train comes from the shared CTE.
*/
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
async loadableTrains(): Promise<LoadableTrainRow[]> {
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
@@ -1548,15 +1583,15 @@ export class WarehouseInventoryService {
dy.country AS "destinationCountry",
ts.status AS "status",
ts.scheduled_departure_date AS "departureTime",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
(SELECT count(*) FROM sched_bookings sb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = ts.id
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM sched_bookings sb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = ts.id
AND inv.status = 'LOADED') AS "loadedCount"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
@@ -1564,11 +1599,11 @@ export class WarehouseInventoryService {
WHERE ts.deleted_at IS NULL
AND ts.status = ANY($1)
AND EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb2
SELECT 1 FROM sched_bookings sb2
JOIN freight.warehouse_inventory inv2
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
WHERE sb2.schedule_id = ts.id
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
@@ -1593,22 +1628,28 @@ export class WarehouseInventoryService {
*/
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
`SELECT inv.id AS "id",
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
SELECT inv.id AS "id",
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
-- receive() stamps the GRN onto the row and mirrors it into the
-- note; prefer the column and fall back for legacy/seeded rows.
COALESCE(
inv.grn_number,
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
wl.wagon_number AS "wagonNumber",
wl.sequence_no AS "sequenceNo"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
FROM sched_bookings sb
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
@@ -1625,15 +1666,19 @@ export class WarehouseInventoryService {
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1
) wl ON true
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
WHERE sb.schedule_id = $1
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
[scheduleId],
);
return rows.map((r) => ({
...r,
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
// Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
// The row only exists once the goods were received, so requiring a GRN and
// an allocated wagon completes the chain.
loadable:
r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
}));
}
@@ -1686,6 +1731,9 @@ export class WarehouseInventoryService {
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
// Export: the GRN is raised when the goods arrive at the warehouse, and
// nothing rides a train without one.
if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {
@@ -4946,7 +4994,7 @@ export class WarehouseInventoryService {
`SELECT b.reference AS "reference",
company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
b.cargo_total_weight_vgm AS "weight",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
bc.container_numbers AS "containerNumber",
@@ -4963,6 +5011,7 @@ export class WarehouseInventoryService {
v.vehicle_type AS "firstMileTruckType"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,

View File

@@ -25,6 +25,10 @@ import {
InvoiceDocumentService,
} from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service";
import {
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from "../notifications/resolve-company-phone.util";
import { WarehouseFeeService } from "./warehouse-fee.service";
import {
WarehouseFeeInvoiceView,
@@ -879,7 +883,7 @@ export class WarehouseInvoiceService {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
last_vehicle.assigned_driver_name,
@@ -892,6 +896,7 @@ export class WarehouseInvoiceService {
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id