mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 01:13:26 +00:00
- Updated ContractRouteLineView to clarify that routes carry no quantity and are pure origin-destination lanes. - Modified GeneralContractService to reflect changes in route handling, removing quantity-related logic. - Adjusted BookingsService to persist contracted routes without quantities, aligning with the new contract structure. - Revised CreateBookingDto and CreateContractRouteDto to remove quantity fields, emphasizing shared pool usage. - Added ContractOrdersPanel component to display drawdown orders and their associated pool. - Implemented useContractOrders and useContractPool hooks for fetching order and pool data. - Created booking-orders.service.ts to manage API interactions for drawdown orders and pool data. - Updated BookingRequestDetailPage and PlaceOrderDialog to accommodate new order handling logic.
202 lines
7.5 KiB
TypeScript
202 lines
7.5 KiB
TypeScript
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
|
|
import { DataSource } from 'typeorm';
|
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
|
import { Booking } from '../bookings/entities/booking.entity';
|
|
import { BookingOrder } from './entities/booking-order.entity';
|
|
import { ContractRouteLine } from './entities/contract-route-line.entity';
|
|
import {
|
|
ContractQuantityLineView,
|
|
ContractRouteLineView,
|
|
} from './dto/contract-view.dto';
|
|
|
|
/** Setting code holding the global ordering window (in months) for general contracts. */
|
|
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
|
|
/** Fallback when the setting is missing or unparseable. */
|
|
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
|
|
|
/**
|
|
* Owns general-contract concerns that sit alongside the generic booking flow:
|
|
* the configurable ordering period, post-payment activation, and computing the
|
|
* remaining drawdown pool per contract.
|
|
*/
|
|
@Injectable()
|
|
export class GeneralContractService {
|
|
private readonly logger = new Logger(GeneralContractService.name);
|
|
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly dropdownSettings: DropdownSettingsService,
|
|
) {}
|
|
|
|
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
|
|
return booking.bookingType === BookingType.GeneralContract;
|
|
}
|
|
|
|
/** The configured ordering window in months (defaults to 3). */
|
|
async getPeriodMonths(): Promise<number> {
|
|
try {
|
|
const setting = await this.dropdownSettings.getByCode(
|
|
CONTRACT_PERIOD_SETTING_CODE,
|
|
);
|
|
const raw = setting.children?.[0]?.value;
|
|
const months = Number(raw);
|
|
if (Number.isFinite(months) && months > 0) return months;
|
|
} catch {
|
|
// Setting not seeded yet — fall back to the default.
|
|
}
|
|
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
|
}
|
|
|
|
/**
|
|
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
|
|
* entering the train queue like a one-time booking) and stamp the ordering
|
|
* window. Idempotent.
|
|
*/
|
|
async activateAfterPayment(bookingId: string): Promise<void> {
|
|
const repo = this.dataSource.getRepository(Booking);
|
|
const booking = await repo.findOne({ where: { id: bookingId } });
|
|
if (!booking || !this.isGeneralContract(booking)) return;
|
|
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
|
|
return;
|
|
}
|
|
|
|
const months = await this.getPeriodMonths();
|
|
const expiresAt = new Date();
|
|
expiresAt.setMonth(expiresAt.getMonth() + months);
|
|
|
|
await repo.update(bookingId, {
|
|
status: 'CONTRACT_ACTIVE',
|
|
paymentStatus: 'PAID',
|
|
expiresAt,
|
|
});
|
|
this.logger.log(
|
|
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
|
|
* per container type for CONTAINER contracts, or a single total line for
|
|
* BULK/BREAK_BULK (keyed on a null container type).
|
|
*/
|
|
async getQuantityLines(
|
|
contractBookingId: string,
|
|
): Promise<ContractQuantityLineView[]> {
|
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
|
where: { id: contractBookingId },
|
|
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
|
});
|
|
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
|
|
|
|
const ordered = await this.orderedByContainerType(contractBookingId);
|
|
|
|
if (booking.freightType === 'CONTAINER') {
|
|
return (booking.bookingContainers ?? []).map((c) => {
|
|
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
|
|
const contracted = Number(c.quantity);
|
|
return {
|
|
containerTypeId: c.containerTypeId ?? null,
|
|
containerTypeName: c.containerType?.label ?? null,
|
|
unitOfMeasure: null,
|
|
contractedQuantity: contracted,
|
|
orderedQuantity: orderedQty,
|
|
remainingQuantity: Math.max(0, contracted - orderedQty),
|
|
};
|
|
});
|
|
}
|
|
|
|
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
|
|
const orderedQty = ordered.get('') ?? 0;
|
|
const contracted = Number(booking.cargoTotalWeightVgm);
|
|
const uom: CargoUnitOfMeasure | null =
|
|
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
|
|
CargoUnitOfMeasure.PerTon;
|
|
return [
|
|
{
|
|
containerTypeId: null,
|
|
containerTypeName: null,
|
|
unitOfMeasure: uom,
|
|
contractedQuantity: contracted,
|
|
orderedQuantity: orderedQty,
|
|
remainingQuantity: Math.max(0, contracted - orderedQty),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The contracted routes (lanes) of a multi-route general contract — pure
|
|
* origin→destination pairs the contract covers. Routes carry NO quantity; the
|
|
* contract draws from a single shared pool ({@link getQuantityLines}). An order
|
|
* picks one lane (for scheduling + road billing) and draws from that pool.
|
|
* Returns [] for single-route contracts (no route lines) — callers then use the
|
|
* contract's own origin/destination.
|
|
*/
|
|
async getRouteLines(
|
|
contractBookingId: string,
|
|
): Promise<ContractRouteLineView[]> {
|
|
const routeLines = await this.dataSource
|
|
.getRepository(ContractRouteLine)
|
|
.find({
|
|
where: { contractBookingId },
|
|
relations: {
|
|
originYard: true,
|
|
destinationYard: true,
|
|
},
|
|
order: { createdAt: 'ASC' },
|
|
});
|
|
|
|
return routeLines.map((rl) => ({
|
|
routeLineId: rl.id,
|
|
originYardId: rl.originYardId,
|
|
originYardName: rl.originYard?.label ?? null,
|
|
destinationYardId: rl.destinationYardId,
|
|
destinationYardName: rl.destinationYard?.label ?? null,
|
|
km: rl.km != null ? Number(rl.km) : null,
|
|
}));
|
|
}
|
|
|
|
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
|
private async orderedByContainerType(
|
|
contractBookingId: string,
|
|
): Promise<Map<string, number>> {
|
|
const rows = await this.dataSource
|
|
.getRepository(BookingOrder)
|
|
.createQueryBuilder('o')
|
|
.innerJoin('o.lines', 'line')
|
|
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
|
|
.addSelect('SUM(line.quantity)', 'total')
|
|
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
|
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
|
.setParameter('empty', '')
|
|
.groupBy('key')
|
|
.getRawMany<{ key: string; total: string }>();
|
|
|
|
const map = new Map<string, number>();
|
|
for (const row of rows) map.set(row.key ?? '', Number(row.total));
|
|
return map;
|
|
}
|
|
|
|
/** Convenience: how many units remain for a given container type ('' = bulk). */
|
|
async remainingFor(
|
|
contractBookingId: string,
|
|
containerTypeKey: string,
|
|
): Promise<number> {
|
|
const lines = await this.getQuantityLines(contractBookingId);
|
|
const line = lines.find(
|
|
(l) => (l.containerTypeId ?? '') === containerTypeKey,
|
|
);
|
|
return line?.remainingQuantity ?? 0;
|
|
}
|
|
|
|
/**
|
|
* True once the contract's shared pool is fully drawn down. Routes are pure
|
|
* lanes with no quantity, so exhaustion is purely a function of the shared
|
|
* per-container-type (or bulk) pool, regardless of how many routes exist.
|
|
*/
|
|
async isExhausted(contractBookingId: string): Promise<boolean> {
|
|
const lines = await this.getQuantityLines(contractBookingId);
|
|
return lines.every((l) => l.remainingQuantity <= 0);
|
|
}
|
|
}
|