mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
feat: implement shipping line bookings management
- Add ShippingLineBookingsPage for listing and managing shipping line bookings. - Create ShippingLineDocumentsModal for document uploads related to bookings. - Introduce ShippingLineInitiateModal for initiating new shipping line bookings. - Implement booking document state management with booking-doc-state utility. - Add shipping line bookings service for API interactions. - Update index to export new components and services. - Enhance types for freight to include shipping line credits.
This commit is contained in:
@@ -165,7 +165,7 @@ export class BookingPricingService {
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
|
||||
|
||||
@@ -414,6 +414,9 @@ export class BookingPricingService {
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
// A shipping line's own booking prices off that line's negotiated rates
|
||||
// instead of the standard customer ones (see RuleEngineService.ratesForOwner).
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
totalWagons,
|
||||
@@ -428,6 +431,22 @@ export class BookingPricingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVE rates this booking may price off.
|
||||
*
|
||||
* A shipping-line booking sees only its own line's rates; a customer booking
|
||||
* only the standard ones. Line rates override rather than stack, and the
|
||||
* standard rate is not a fallback — a lane the line has no rate for falls
|
||||
* through to the existing "no rate configured" hard block, which is the
|
||||
* intended outcome rather than silently billing the customer price.
|
||||
*/
|
||||
private async liveRatesForBooking(booking: Booking): Promise<Rate[]> {
|
||||
const rates = await this.ratesService.findLiveRates();
|
||||
return booking.shippingLineCompanyId
|
||||
? rates.filter((r) => r.shippingLineCompanyId === booking.shippingLineCompanyId)
|
||||
: rates.filter((r) => !r.shippingLineCompanyId);
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
@@ -512,7 +531,7 @@ export class BookingPricingService {
|
||||
warnings: string[];
|
||||
blocked: string[];
|
||||
}> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
@@ -713,7 +732,7 @@ export class BookingPricingService {
|
||||
return { lineItems: [], usedRates: [] };
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const liveRates = await this.liveRatesForBooking(booking);
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
@@ -16,6 +16,16 @@ type Freight = 'container' | 'bulk';
|
||||
*/
|
||||
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
||||
|
||||
/**
|
||||
* The document set a shipping line uploads on a booking it initiated.
|
||||
*
|
||||
* Shipping lines book without a contract, so none of the trade-direction /
|
||||
* freight / customs matrix below applies to them — this one admin-configured
|
||||
* set is what Operations reviews before the booking may be completed.
|
||||
*/
|
||||
export const SHIPPING_LINE_DOCUMENTS_SETTING_CODE =
|
||||
'shipping_line_booking_documents';
|
||||
|
||||
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||
function operationFor(tradeDirection: string): Op | null {
|
||||
if (tradeDirection === 'IMPORT') return 'import';
|
||||
@@ -67,6 +77,20 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
outputCode: string | null;
|
||||
includesCustoms: boolean;
|
||||
} {
|
||||
// Shipping-line bookings resolve to their own single set and never reach the
|
||||
// matrix below: they have no contract, and their trade direction / freight
|
||||
// type are placeholders until the booking is completed, so the customer codes
|
||||
// would resolve to a set that was never meant for them. Keyed off the owner
|
||||
// column, which is NULL on every customer booking — so no customer booking
|
||||
// can take this branch.
|
||||
if (booking.shippingLineCompanyId) {
|
||||
return {
|
||||
inputCode: SHIPPING_LINE_DOCUMENTS_SETTING_CODE,
|
||||
outputCode: null,
|
||||
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
|
||||
|
||||
@@ -108,15 +108,34 @@ export class Booking extends BaseEntity {
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
// 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' })
|
||||
// Every CUSTOMER booking is billed to a company — government bookings bill to
|
||||
// a seeded government company (companies.kind = 'government'). NULL only on a
|
||||
// shipping-line booking, owned by `shippingLineCompanyId` instead; a DB CHECK
|
||||
// enforces that exactly one of the two is set.
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
/**
|
||||
* The shipping-line ACCOUNT that owns this booking, when it is not a
|
||||
* customer's. Shipping lines book without a contract and are not `companies`
|
||||
* rows (no TIN, licence or operational profiles), so they get their own owner
|
||||
* column rather than a synthetic company. NULL on every customer booking.
|
||||
*
|
||||
* Deliberately NOT `shippingLineId` above: that is cargo metadata naming the
|
||||
* carrier line that moves the goods (`freight.shipping_lines`, reference data
|
||||
* set on customer bookings too). This points at `shipping_line_companies` —
|
||||
* the portal account — and the two are unrelated.
|
||||
*
|
||||
* No relation is declared: `ShippingLineCompany` lives in its own module and
|
||||
* the column is read by id, matching how the migration leaves it FK-free.
|
||||
*/
|
||||
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
|
||||
shippingLineCompanyId?: string | null;
|
||||
|
||||
/**
|
||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||
@@ -125,7 +144,9 @@ export class Booking extends BaseEntity {
|
||||
* commercial bookings resolve it from trade direction / active mode;
|
||||
* government bookings carry the explicitly-picked government profile.
|
||||
*/
|
||||
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||
// NULL only on a shipping-line booking — shipping lines have no operational
|
||||
// profiles. Always set on a customer booking, as before.
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@@ -259,7 +280,7 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||
contractType!: string;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
@Column({ name: 'service_type_id', type: 'uuid', nullable: true })
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ManyToOne(() => ServiceType)
|
||||
@@ -337,14 +358,14 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||
equipmentReturn!: string;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@@ -354,7 +375,7 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: string;
|
||||
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
|
||||
freightType!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
|
||||
Reference in New Issue
Block a user