mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Add DTOs for creating booking orders and viewing contract quantities. - Create entities for booking orders and booking order lines. - Implement service for managing general contract operations, including activation after payment and retrieving quantity lines. - Develop UI components for contract detail and list pages, including order placement dialog. - Integrate API service for booking orders, enabling listing and creating orders against contracts. - Enhance contract status display and quantity pool visualization in the UI.
162 lines
6.1 KiB
TypeScript
162 lines
6.1 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 { ContractQuantityLineView } 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),
|
|
},
|
|
];
|
|
}
|
|
|
|
/** 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 every contracted line is fully drawn down. */
|
|
async isExhausted(contractBookingId: string): Promise<boolean> {
|
|
const lines = await this.getQuantityLines(contractBookingId);
|
|
return lines.every((l) => l.remainingQuantity <= 0);
|
|
}
|
|
}
|