Merge branch 'dev' into freight/feat/invoice

This commit is contained in:
Nathnael
2026-06-29 10:58:12 +00:00
425 changed files with 46006 additions and 7705 deletions

View File

@@ -24,12 +24,6 @@ import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
/**
* Default ordering window (months) for a general contract activated on
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
* defined locally to avoid a circular module dependency on booking-orders.
*/
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { SignaturesService } from '../signatures/signatures.service';
@@ -240,23 +234,9 @@ export class BookingContractService {
includesCustoms,
);
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
} else if (isGeneralContract) {
// A general contract is NOT paid up front — each drawdown order is priced
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
// opens its ordering window; orders spawn their own priced child bookings.
const expiresAt = new Date(now);
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
updates.status = 'CONTRACT_ACTIVE';
updates.expiresAt = expiresAt;
} else {
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;

View File

@@ -121,9 +121,18 @@ export class BookingPricingService {
total += line.amount;
}
// First / last mile trucking — billed per the rate's unit (km / container /
// ton / flat), only for legs the booking actually carries.
const { lineItems: mileLines, usedRates: mileRates } =
await this.computeFirstLastMileLines(booking, evalInput);
for (const line of mileLines) {
lineItems.push(line);
total += line.amount;
}
const liveRates = await this.ratesService.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
@@ -424,6 +433,97 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/**
* First-mile (pick-up) and last-mile (delivery) trucking lines. Each leg is
* billed only when the booking carries that leg (an address is set) and a LIVE
* rate exists, scaled by the rate's own unit:
* PER_KM → contract-route road distance (km)
* PER_CONTAINER → total container count
* PER_TON → total bulk tonnage
* FLAT → once
* A leg whose rate value (or computed amount) is 0 contributes nothing.
*/
private async computeFirstLastMileLines(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
{
rateType: 'FIRST_MILE',
label: 'First mile (pick-up)',
active: Boolean(booking.firstMilePickupAddress),
},
{
rateType: 'LAST_MILE',
label: 'Last mile (delivery)',
active: Boolean(booking.lastMileDeliveryAddress),
},
];
if (!legs.some((l) => l.active)) {
return { lineItems: [], usedRates: [] };
}
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const containerCount = evalInput.containers.reduce(
(sum, c) => sum + Number(c.quantity || 0),
0,
);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const routeKm = await this.bookingsRepository.getContractRouteKm(booking.contractRouteId);
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
for (const leg of legs) {
if (!leg.active) continue;
const rate = liveRates.find(
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
);
if (!rate) continue;
const value = Number(rate.rateValue);
let quantity: number;
switch (rate.rateUnit) {
case 'PER_KM':
quantity = routeKm;
break;
case 'PER_CONTAINER':
quantity = containerCount;
break;
case 'PER_TON':
quantity = bulkTons;
break;
case 'FLAT':
default:
quantity = 1;
break;
}
const usdAmount = value * quantity;
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
if (!(usdAmount > 0)) continue;
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = value;
usedRatesMap.set(rate.id, rate);
lines.push({
code: leg.rateType,
description: leg.label,
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unit: rate.rateUnit,
quantity,
currency: paymentCurrency,
});
}
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/** Friendly container-type label for the per-unit card; degrades to "Container". */
private async containerTypeLabel(containerTypeId: string): Promise<string> {
try {

View File

@@ -6,7 +6,6 @@ import { BookingTransitionService } from './booking-transition.service';
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
@@ -79,18 +78,4 @@ describe('BookingTransitionService — operation review', () => {
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
);
});
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
amount: 1500,
});
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({
adjustedTotalAmount: 1500,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
}),
);
});
});

View File

@@ -10,7 +10,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from '../booking-orders/road.util';
import { isRoadService } from './road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -497,30 +497,6 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
/**
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
* that supersedes the computed total for the customer, who sees an
* "Adjusted by EDR" badge. Passing null clears the adjustment.
*/
async adjustPrice(
bookingId: string,
amount: number | null,
staffId: string,
reason?: string,
): Promise<Booking> {
await this.bookingsService.findById(bookingId);
if (amount != null && amount < 0) {
throw new BadRequestException('Adjusted amount cannot be negative');
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: amount,
adjustedByStaffId: amount == null ? null : staffId,
adjustedAt: amount == null ? null : new Date(),
adjustmentReason: amount == null ? null : (reason ?? null),
} as never);
return this.bookingsService.findById(bookingId);
}
// ── Document clearance gate (post counter-sign) ───────────────────────────
/**
@@ -881,17 +857,17 @@ export class BookingTransitionService {
/**
* Operations team reviews a pending operation request (capacity, documents,
* route). Three outcomes:
* route). Two outcomes:
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
* - ADJUST_PRICE → a new total is set; the customer must re-confirm it
* before the booking can enter the pool.
*
* The booking price is computed from the contract and is never adjusted here.
*/
async reviewOperationRequest(
bookingId: string,
decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
decision: 'ACCEPT' | 'REQUEST_CHANGES',
actorId: string,
options: { note?: string; amount?: number } = {},
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
@@ -914,48 +890,10 @@ export class BookingTransitionService {
return this.bookingsService.findById(bookingId);
}
if (decision === 'ADJUST_PRICE') {
if (options.amount == null || options.amount < 0) {
throw new BadRequestException(
'A non-negative adjusted amount is required to adjust the price',
);
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: options.amount,
adjustedByStaffId: actorId,
adjustedAt: new Date(),
adjustmentReason: options.note ?? null,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
} as never);
return this.bookingsService.findById(bookingId);
}
// ACCEPT — enter the batch holding pool.
return this.acceptOperationRequest(booking);
}
/**
* Customer re-confirms (or rejects) an operations price adjustment. Accepting
* pushes the booking into the pool; rejecting returns it to the customer as an
* operation change request so they can resubmit or cancel.
*/
async confirmOperationPrice(
bookingId: string,
accept: boolean,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']);
if (!accept) {
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED',
} as never);
return this.bookingsService.findById(bookingId);
}
return this.acceptOperationRequest(booking);
}
/**
* Move a reviewed operation request forward after Marketing accepts.
*

View File

@@ -43,7 +43,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
AdjustPriceDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
@@ -52,7 +51,6 @@ import {
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
ConfirmOperationPriceDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
@@ -404,24 +402,7 @@ export class BookingsController {
id,
dto.decision,
resolveAuthUserId(user),
{ note: dto.note, amount: dto.amount },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/confirm-price')
@ApiOperation({
summary:
'Customer confirms or rejects an operations price adjustment ' +
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
})
async confirmOperationPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ConfirmOperationPriceDto,
) {
const booking = await this.transitionService.confirmOperationPrice(
id,
dto.accept,
{ note: dto.note },
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -521,25 +502,6 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/adjust-price')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary: 'Staff adjust booking total price (override; null clears it)',
})
async adjustPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AdjustPriceDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.adjustPrice(
id,
dto.amount ?? null,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })

View File

@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
@@ -34,7 +35,6 @@ export interface BookingListFilterOptions {
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
@@ -164,6 +164,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
return Number(result?.total ?? 0);
}
/**
* The road billing distance (km) of a booking's contract route, used to price
* per-km first/last-mile trucking. Returns 0 when there is no route or no km
* recorded (rail-only lanes) so a PER_KM rate bills nothing.
*/
async getContractRouteKm(contractRouteId: string | null | undefined): Promise<number> {
if (!contractRouteId) return 0;
const route = await this.dataSource
.getRepository(ContractRoute)
.findOne({ where: { id: contractRouteId }, select: { id: true, km: true } });
return Number(route?.km ?? 0);
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides).
@@ -693,11 +706,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
freightType: options.freightType,
});
}
if (options.bookingType) {
qb.andWhere('booking.booking_type = :bookingType', {
bookingType: options.bookingType,
});
}
if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', {
createdFrom: options.createdFrom,

View File

@@ -3,6 +3,7 @@ import {
ConflictException,
ForbiddenException,
forwardRef,
GoneException,
Inject,
Injectable,
NotFoundException,
@@ -27,7 +28,6 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
@@ -283,6 +283,15 @@ export class BookingsService {
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
// Contractbooking separation: contracts are no longer created through the
// booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients
// must use POST /contracts (and create shipments via POST /contracts/:id/bookings).
if (dto.bookingType === 'GENERAL_CONTRACT') {
throw new GoneException(
'General contracts are no longer created here. Use POST /contracts instead.',
);
}
// let customerId = dto.customerId;
// if (!customerId) {
// if (!userId) {
@@ -295,7 +304,6 @@ export class BookingsService {
// }
const isGovernment = dto.isGovernment === true;
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
@@ -474,7 +482,6 @@ export class BookingsService {
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
firstMilePickupLat: dto.firstMilePickupLat ?? null,
@@ -501,7 +508,6 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
estimatedShipmentDate: dto.estimatedShipmentDate
? new Date(dto.estimatedShipmentDate)
@@ -528,27 +534,6 @@ export class BookingsService {
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
// Multi-route general contracts: persist the contracted routes (lanes). Routes
// carry NO quantity — the contract has a single shared pool (the cargo-step
// total / container quantities). Each drawdown order picks one lane for
// scheduling + road billing and draws from that shared pool. `quantity` on the
// route line is retained for legacy rows but is no longer meaningful (0).
if (isGeneralContract && dto.routes?.length) {
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
await routeRepo.save(
dto.routes.map((r) =>
routeRepo.create({
contractBookingId: booking.id,
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
containerTypeId: null,
quantity: 0,
km: r.km ?? null,
}),
),
);
}
if (files.length > 0) {
try {
await this.filesService.uploadMany(booking.id, 'bookings', files);
@@ -840,7 +825,6 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
@@ -1049,7 +1033,6 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,

View File

@@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): {
outputCode: string | null;
includesCustoms: boolean;
} {
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
// Customs applies when EITHER the service type bundles it OR the booking was
// created with customsClearingEnabled (copied from the contract). Contract
// bookings carry customsClearingEnabled even when the serviceType relation
// isn't loaded / has includesCustoms=false — without this the per-booking
// clearance grid would resolve empty.
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -1,10 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
@@ -70,22 +68,6 @@ export class RejectBookingDto {
reason?: string;
}
export class AdjustPriceDto {
@ApiPropertyOptional({
description:
'New total price. Omit or send null to clear a previous adjustment.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number | null;
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
@IsOptional()
@IsString()
reason?: string;
}
export class ReviewDocumentDto {
@ApiProperty({ description: 'The document fileKey being reviewed' })
@IsString()
@@ -117,12 +99,12 @@ export class OperationReviewDto {
@ApiProperty({
description:
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' +
'customer must re-confirm before it proceeds.',
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
'returns it to the customer with a note. The booking price is computed ' +
'from the contract and cannot be adjusted by staff.',
enum: ['ACCEPT', 'REQUEST_CHANGES'],
})
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
@IsIn(['ACCEPT', 'REQUEST_CHANGES'])
decision!: 'ACCEPT' | 'REQUEST_CHANGES';
@ApiPropertyOptional({
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
@@ -130,22 +112,4 @@ export class OperationReviewDto {
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({
description: 'New total price — required for ADJUST_PRICE.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
}
export class ConfirmOperationPriceDto {
@ApiProperty({
description:
'true to accept the operations price adjustment and proceed to the ' +
'batch pool; false to reject it (returns to operation changes requested).',
})
@IsBoolean()
accept!: boolean;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { BookingContainer } from './booking-container.entity';
/**
* One physical container under a booking_container line — its number, seal, and
* per-unit VGM. Entered at booking time (by the customer in Path A or by GL ET
* in Path B). See §5.10.
*/
@Entity({ schema: 'freight', name: 'booking_container_units' })
@Index(['bookingContainerId'])
export class BookingContainerUnit extends BaseEntity {
@Column({ name: 'booking_container_id', type: 'uuid' })
bookingContainerId!: string;
@ManyToOne(() => BookingContainer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_container_id' })
bookingContainer?: BookingContainer;
@Column({ name: 'container_number', type: 'varchar', length: 64 })
containerNumber!: string;
@Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true })
sealNumber?: string | null;
@Column({ name: 'vgm_tons', type: 'numeric', precision: 10, scale: 3 })
vgmTons!: number;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;
}

View File

@@ -25,9 +25,21 @@ export class BookingContainer extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
containerNumber?: string | null;
/** Contract container size this line covers (20ft | 40ft). Null for legacy rows. */
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
containerSize?: string | null;
@Column({ name: 'quantity', type: 'smallint' })
quantity!: number;
/** How many units of this line are hazardous (≤ quantity). */
@Column({ name: 'hazardous_quantity', type: 'smallint', default: 0 })
hazardousQuantity!: number;
/** How many units of this line are refrigerated (≤ quantity). */
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
reeferQuantity!: number;
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
vgmPerUnitTons!: number;

View File

@@ -147,13 +147,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
/**
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
* umbrella contract that is signed/paid once and then drawn down by many
* orders (each order spawns its own ONE_TIME child booking).
*/
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
bookingType!: string;
/** The contract this shipment booking was created under (contractbooking split). */
@Column({ name: 'contract_id', type: 'uuid', nullable: true })
contractId?: string | null;
/** The contract route (lane) this shipment uses. */
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
contractRouteId?: string | null;
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
contractKind?: string | null;
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
createdByRole?: string | null;
@Column({ name: 'created_by_user_id', type: 'uuid', nullable: true })
createdByUserId?: string | null;
/**
* Nullable: general contracts have no shipment date at creation — the date is
@@ -223,13 +234,6 @@ export class Booking extends BaseEntity {
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
contractType!: string;
@Column({ name: 'previous_contract_id', type: 'uuid', nullable: true })
previousContractId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'previous_contract_id' })
previousContract?: Booking | null;
@Column({ name: 'service_type_id', type: 'uuid' })
serviceTypeId!: string;
@@ -425,6 +429,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
selectedForBatchAt?: Date | null;
// ── Global Logistics station routing (GL Import/Export US-02) ──────────────
/** Origin-station yard the shipment is routed to for GL handling. */
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
glStationYardId?: string | null;
/** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null;
@Column({ name: 'gl_assigned_at', type: 'timestamptz', nullable: true })
glAssignedAt?: Date | null;
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];

View File

@@ -0,0 +1,35 @@
import { ServiceType } from '../rule-engine/entities/service-type.entity';
/**
* Road (truck) services are distinguished by their ServiceType.code. Rail
* services are seeded as RAIL_* and go through the train batch pool; a road
* service (code starting ROAD_ or TRUCK_, or exactly ROAD/TRUCK) instead bills
* by distance and dispatches a truck. Prefix-matching keeps this resilient to
* the exact seeded code (e.g. ROAD_CONTAINER, TRUCK_FORWARDING).
*/
export function isRoadService(
serviceType?: Pick<ServiceType, 'code'> | null,
): boolean {
const code = serviceType?.code?.toUpperCase() ?? '';
return (
code === 'ROAD' ||
code === 'TRUCK' ||
code.startsWith('ROAD_') ||
code.startsWith('TRUCK_')
);
}
/**
* Road freight charge for an order: distance (km, from the route line) × the
* per-km rate. Returns 0 when either input is missing so callers can add it to
* a total without guarding.
*/
export function roadKmPrice(
km: number | null | undefined,
perKmRate: number | null | undefined,
): number {
const distance = Number(km ?? 0);
const rate = Number(perKmRate ?? 0);
if (!(distance > 0) || !(rate > 0)) return 0;
return distance * rate;
}