feat: add government support to the companies. add seeding and constrain on booking

This commit is contained in:
Nathnael
2026-06-27 09:08:15 +00:00
parent 55c42058d0
commit 98e34593c4
14 changed files with 495 additions and 23 deletions

View File

@@ -312,8 +312,9 @@ export class BookingOrdersService {
const child = manager.create(Booking, {
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
// Drawdown orders inherit the contract's company + profile (both required).
companyId: contract.companyId,
companyProfileId: contract.companyProfileId,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
contractType: contract.contractType,

View File

@@ -11,7 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
@@ -299,10 +299,23 @@ export class BookingsService {
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
if (!dto.governmentInstitution?.trim()) {
throw new BadRequestException('governmentInstitution is required for government bookings');
// Government bookings bill to a real seeded government company + an
// explicitly-chosen importer/exporter profile (no more null company +
// free-text institution).
if (!dto.companyId) {
throw new BadRequestException('A government company is required for government bookings');
}
companyId = dto.companyId ?? null;
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
if (govCompany.kind !== CompanyKind.Government) {
throw new BadRequestException('Selected company is not a government entity');
}
if (govCompany.status !== CompanyStatus.Active) {
throw new BadRequestException('Selected government company is not active');
}
if (!dto.companyProfileId) {
throw new BadRequestException('A government company profile is required for government bookings');
}
companyId = govCompany.id;
} else if (!companyId) {
if (!userId) {
throw new BadRequestException(
@@ -375,7 +388,16 @@ export class BookingsService {
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
if (dto.companyProfileId && companyId) {
// Explicit profile pin (government booking, or staff booking on behalf):
// must belong to the chosen company and be active.
const profile =
await this.companiesService.getActiveCompanyProfileForBooking(
companyId,
dto.companyProfileId,
);
companyProfileId = profile.id;
} else if (companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
@@ -405,6 +427,16 @@ export class BookingsService {
}
}
// Every booking must link to a company and a company profile.
if (!companyId) {
throw new BadRequestException('A company is required to create a booking');
}
if (!companyProfileId) {
throw new BadRequestException(
'A company profile is required to create a booking — none could be resolved for this company',
);
}
const needsConsolidation =
dto.freightType === 'CONTAINER'
? await this.needsConsolidation(containers)
@@ -435,10 +467,10 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
companyId,
companyProfileId,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
governmentInstitution: dto.governmentInstitution?.trim() || null,
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,

View File

@@ -14,7 +14,6 @@ import {
Max,
MaxLength,
Min,
MinLength,
Validate,
ValidateIf,
ValidateNested,
@@ -104,19 +103,31 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true)
isGovernment?: boolean;
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
@ValidateIf((o) => o.isGovernment === true)
/** @deprecated Government bookings now bill to a real government company. */
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
@IsOptional()
@IsString()
@MinLength(2)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
governmentInstitution?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@ValidateIf((o) => o.isGovernment !== true)
@ApiPropertyOptional({
format: 'uuid',
description:
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
})
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()

View File

@@ -105,8 +105,10 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
// Every booking is billed to a company — government bookings bill to a seeded
// government company (companies.kind = 'government'). Enforced NOT NULL.
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
@@ -116,11 +118,12 @@ export class Booking extends BaseEntity {
* The operational profile (importer/exporter/forwarder) this booking belongs
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
* legacy/government/staff-created bookings.
* Customer portal lists and dashboard KPIs are scoped by this. Required:
* commercial bookings resolve it from trade direction / active mode;
* government bookings carry the explicitly-picked government profile.
*/
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId?: string | null;
@Column({ name: 'company_profile_id', type: 'uuid' })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile, { nullable: true })
@JoinColumn({ name: 'company_profile_id' })

View File

@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, status } = query;
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const qb = this.repository
.createQueryBuilder('company')
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.type = :type', { type });
}
if (kind) {
qb.andWhere('company.kind = :kind', { kind });
}
if (status) {
qb.andWhere('company.status = :status', { status });
}

View File

@@ -337,6 +337,29 @@ export class CompaniesService {
return company;
}
/**
* 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
* pick the profile) and any staff booking that pins a profile directly.
*/
async getActiveCompanyProfileForBooking(
companyId: string,
profileId: string,
): Promise<CompanyProfile> {
const profile = await this.companyProfilesRepo.findById(profileId);
if (!profile || profile.companyId !== companyId) {
throw new BadRequestException(
"Selected company profile does not belong to the chosen company",
);
}
if (profile.status !== ProfileStatus.Active) {
throw new BadRequestException(
"Selected company profile is not active",
);
}
return profile;
}
async getCompanyInfoByUserId(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {

View File

@@ -1,7 +1,7 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyStatus, CompanyType } from "../entities/company.entity";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyType))
type?: CompanyType;
@ApiPropertyOptional({ enum: CompanyKind })
@IsOptional()
@IsIn(Object.values(CompanyKind))
kind?: CompanyKind;
@ApiPropertyOptional({ enum: CompanyStatus })
@IsOptional()
@IsIn(Object.values(CompanyStatus))

View File

@@ -10,6 +10,16 @@ export enum CompanyType {
Transporter = "transporter",
}
/**
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
* of carrying a null company + free-text institution.
*/
export enum CompanyKind {
Commercial = "commercial",
Government = "government",
}
export enum CompanyStatus {
Active = "active",
Pending = "pending",
@@ -25,6 +35,7 @@ export enum CompanyNationality {
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@Index(["kind"])
export class Company extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
type!: CompanyType;
/** Commercial customer vs. the seeded government entity. */
@Column({
name: "kind",
type: "varchar",
length: 20,
default: CompanyKind.Commercial,
enum: CompanyKind,
})
kind!: CompanyKind;
@Column({
name: "status",
type: "varchar",