Merge pull request #616 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-11 09:42:56 +03:00
committed by GitHub
57 changed files with 2711 additions and 345 deletions

View File

@@ -3,6 +3,10 @@ import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
bullets: string[];
}
@@ -16,10 +20,26 @@ export interface RenderedArticle {
}
/**
* Parse a template article body into clauses. Format: one clause per line;
* lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single clause without bullets renders as a plain
* paragraph rather than a numbered list of one.
* Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." …
* The token's segment count sets the clause depth; its digits are ignored —
* numbering is recomputed sequentially so stale numbers self-heal.
* A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x"
* is clause ten); multi-segment tokens ("1.1") may omit it. A token may also
* end the line — that is an empty clause still being typed in the editor.
*/
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
const MAX_CLAUSE_DEPTH = 6;
/**
* Parse a template article body into clauses. Format: one clause per line.
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
* sub-clause at that depth — the typed digits are stripped and renumbered
* sequentially, so editing order never leaves stale numbers in the document.
* Lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single un-numbered clause without bullets renders
* as a plain paragraph rather than a numbered list of one.
*/
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
const lines = (body ?? '')
@@ -28,20 +48,44 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
.filter((line) => line.length > 0);
const clauses: RenderedClause[] = [];
// counters[i] = current number at depth i+1; truncated when a shallower
// clause arrives so deeper numbering restarts at 1.
const counters: number[] = [];
let sawNumberToken = false;
for (const line of lines) {
if (line.startsWith('- ')) {
const bullet = line.slice(2).trim();
if (clauses.length === 0) {
clauses.push({ text: bullet, bullets: [] });
counters.splice(0, counters.length, 1);
clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] });
} else {
clauses[clauses.length - 1].bullets.push(bullet);
}
} else {
clauses.push({ text: line, bullets: [] });
continue;
}
const match = CLAUSE_NUMBER_RE.exec(line);
const token = match ? (match[1] ?? match[2]) : null;
let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1;
// A sub-clause can only sit directly under an existing parent — "1.1.1"
// typed as the first line clamps to whatever level is actually open.
depth = Math.min(depth, counters.length + 1);
if (match) sawNumberToken = true;
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
clauses.push({
text: match ? line.slice(match[0].length).trim() : line,
number: counters.slice(0, depth).join('.'),
depth,
bullets: [],
});
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };

View File

@@ -26,6 +26,40 @@ describe('parseArticleBody', () => {
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
expect(parsed.clauses).toEqual([]);
});
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
const parsed = parseArticleBody(
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
);
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
['1', 1, 'Scope'],
['1.1', 2, 'Rail transport'],
['1.1.1', 3, 'Wagon supply'],
['2', 1, 'Payment'],
]);
});
it('clamps a sub-clause with no open parent to the next available level', () => {
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
['1', 1],
['2', 1],
]);
});
it('leaves prose that merely starts with a number un-tokenized', () => {
const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.');
expect(parsed.clauses.map((c) => c.text)).toEqual([
'10 tons is the minimum load.',
'Payment in advance.',
]);
});
it('keeps a single explicitly numbered line as a clause, not a paragraph', () => {
const parsed = parseArticleBody('1. Only clause.');
expect(parsed.paragraph).toBeUndefined();
expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]);
});
});
describe('interpolateTemplateText', () => {

View File

@@ -6,7 +6,8 @@
{{else}}
<ol class="clauses">
{{#each clauses}}
<li>
<li class="clause depth-{{depth}}">
<span class="clause-no">{{number}}.</span>
{{text}}
{{#if bullets.length}}
<ul class="clause-bullets">

View File

@@ -282,28 +282,27 @@
.article-name { color: #0e5b45; }
.article-paragraph { margin: 4px 0 0; }
ol.clauses {
counter-reset: clause;
list-style: none;
margin: 6px 0 0;
padding-left: 0;
}
ol.clauses > li {
counter-increment: clause;
ol.clauses > li.clause {
margin-bottom: 6px;
padding-left: 24px;
position: relative;
text-align: justify;
}
ol.clauses > li::before {
ol.clauses .clause-no {
color: #0e5b45;
content: counter(clause) ".";
font-family: Arial, sans-serif;
font-size: 9.5pt;
font-weight: 700;
left: 0;
position: absolute;
top: 1px;
margin-right: 6px;
}
/* Sub-clause indentation: each outline level steps in. */
ol.clauses > li.depth-2 { padding-left: 20px; }
ol.clauses > li.depth-3 { padding-left: 40px; }
ol.clauses > li.depth-4 { padding-left: 60px; }
ol.clauses > li.depth-5 { padding-left: 80px; }
ol.clauses > li.depth-6 { padding-left: 100px; }
ul.clause-bullets {
margin: 5px 0 2px;
padding-left: 16px;

View File

@@ -14,9 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
serviceType: { includesCustoms: false }, // no output set → only the input gate
};
// Input set has two required docs.
// Input set has two required docs. Non-customs bookings resolve to the
// ONE_TIME self-clearance document set.
const inputSetting = {
code: 'clearance_import_container_without_customs',
code: 'contract_clearance_selfclear_import_container',
fields: [
{ fileKey: 'commercial_invoice', isRequired: true },
{ fileKey: 'packing_list', isRequired: true },
@@ -198,7 +199,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
*/
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
const inputSetting = {
code: 'clearance_import_container_without_customs',
code: 'contract_clearance_selfclear_import_container',
fields: [
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },

View File

@@ -988,6 +988,15 @@ export class BookingTransitionService {
"OPERATION_CHANGES_REQUESTED",
]);
// A bare initiated instance (clearance-first flow) carries no cargo or
// price — it must go through the contract completion endpoint, which
// persists cargo, prices, invoices and only then lands here itself.
if (booking.contractId && !(Number(booking.totalAmount) > 0)) {
throw new BadRequestException(
"This booking must be completed (cargo and shipment day) before requesting operation.",
);
}
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");

View File

@@ -7,6 +7,7 @@ function mockQueryBuilder() {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
@@ -15,6 +16,8 @@ function mockQueryBuilder() {
take: jest.fn().mockReturnThis(),
getMany: jest.fn(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
getCount: jest.fn().mockResolvedValue(0),
getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }),
};
return qb;
}

View File

@@ -8,8 +8,10 @@ describe('clearance.util — clearanceSettingCode', () => {
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
'clearance_import_container_with_customs',
);
// Non-customs bookings self-clear with the same document set a ONE_TIME
// self-clear contract uses.
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
'clearance_import_container_without_customs',
'contract_clearance_selfclear_import_container',
);
});
@@ -18,7 +20,7 @@ describe('clearance.util — clearanceSettingCode', () => {
'clearance_export_bulk_with_customs',
);
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
'clearance_export_bulk_without_customs',
'contract_clearance_selfclear_export_bulk',
);
});

View File

@@ -29,8 +29,14 @@ export function clearanceSettingCode(
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
const customs = includesCustoms ? 'with_customs' : 'without_customs';
return `clearance_${op}_${freight}_${customs}`;
// Non-customs (Path A) bookings self-clear: the customer proves his own
// clearance with the SAME smaller document set a ONE_TIME self-clear
// contract uses (customs declaration, release permit, …) — not the
// GL-oriented booking sets.
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
}
return `clearance_${op}_${freight}_with_customs`;
}
/** The GL-output (customs output) setting code, keyed on op + freight. */

View File

@@ -113,6 +113,17 @@ export class BookingRequestService {
},
};
// Clearance-first flow: the request immediately initiates a BARE booking
// instance (no cargo, no date, no price) that enters per-booking phased
// customs clearance. GL no longer screens the request up front — it
// reviews the documents in the clearance queue and completes the booking
// (container numbers, VGM, shipment day) once clearance is ready. The
// instance is created first so a failure leaves no half-linked request.
const booking = await this.contractBookingService.initiateForShipmentRequest(
contract,
{ contractRouteId: dto.contractRouteId, userId },
);
const reference = await this.generateReference();
const request = await this.repo.create({
reference,
@@ -120,7 +131,8 @@ export class BookingRequestService {
requestedByUserId: userId ?? null,
contractRouteId: dto.contractRouteId ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
status: 'PENDING',
status: 'ACCEPTED',
createdBookingId: booking.id,
requestedLines,
notes: dto.notes ?? null,
} as never);

View File

@@ -58,6 +58,31 @@ export class ClearanceMilestoneService {
await this.seed(postBooking, { bookingId });
}
/**
* Seed whichever pre/post-booking milestones the booking is still missing,
* keyed by milestoneCode. Plain seeding is a blind insert, so paths that can
* run more than once (completing an initiated instance whose pre-booking
* milestones were seeded at initiation, or a consolidation pairing replay)
* must go through this instead — a duplicate timeline breaks the phase
* derivation.
*/
async ensureBookingMilestones(
bookingId: string,
tradeDirection: string,
): Promise<void> {
const existing = await this.repo.find({ where: { bookingId } });
const have = new Set(existing.map((m) => m.milestoneCode));
const { preBooking, postBooking } = splitMilestones(tradeDirection);
await this.seed(
preBooking.filter((d) => !have.has(d.code)),
{ bookingId },
);
await this.seed(
postBooking.filter((d) => !have.has(d.code)),
{ bookingId },
);
}
private async seed(
defs: MilestoneDef[],
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },

View File

@@ -36,6 +36,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
const milestoneService = {
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
ensureBookingMilestones: jest.fn().mockResolvedValue(undefined),
...overrides.milestoneService,
};
const contractsRepository = {
@@ -144,9 +145,13 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
// GENERAL customs → per-booking pre + post milestones.
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
// GENERAL customs → per-booking milestones, via the idempotent ensure so a
// pairing replay (or an initiated instance's pre-seeded timeline) never
// duplicates rows.
expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith(
'b-1',
'EXPORT',
);
});
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
@@ -192,6 +193,11 @@ export class ContractBookingService {
// their only chance to hard-block an unbalanceable set. Entry order is
// irrelevant (the check sorts by weight before pairing).
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(dto, {
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
});
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
@@ -426,17 +432,100 @@ export class ContractBookingService {
}
/**
* Complete a bare initiated booking after Operations finalized its per-booking
* clearance (CLEARANCE_READY) or returned it for changes
* Initiate a BARE booking instance for a GENERAL + customs shipment request
* (Path B, clearance-first). Called by BookingRequestService.submit AFTER it
* validated the contract (general customs, active, capacity) — the request
* itself carries the quantities; the instance carries none. Pre-booking
* customs milestones are seeded immediately so the instance enters the same
* phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking.
* GL completes the booking (cargo + day) via {@link completeUnderContract}
* once the clearance reaches CLEARANCE_READY.
*/
async initiateForShipmentRequest(
contract: Contract,
opts: { contractRouteId?: string; userId?: string | null },
): Promise<Booking> {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
if (!generalCustoms) {
throw new BadRequestException(
'Shipment-request initiation applies only to general customs contracts.',
);
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
const route = await this.resolveRoute(contract, opts.contractRouteId);
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole: 'CUSTOMER',
createdByUserId: opts.userId ?? null,
scheduledDate: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: this.resolveCargoTypeId(contract, {}),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: 0,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never),
);
// Pre-booking phase only — the post-booking milestones (loading, transit)
// are seeded when GL completes the booking, mirroring the ONE_TIME flow
// where GL's booking creation seeds them.
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
}
/**
* Complete a bare initiated booking after its per-booking clearance is
* finalized (CLEARANCE_READY) or operations returned it for changes
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
* {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
* window + open-departure checks, pricing, consolidation and invoicing all run
* here — the same gates a one-time shipment passes at creation.
*
* Actor rules mirror {@link assertGate}: a customs (Path B) instance is
* completed by GL Ethiopia only; a non-customs (Path A) instance by the
* customer (or staff).
*/
async completeUnderContract(
contractId: string,
bookingId: string,
dto: CreateBookingUnderContractDto,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
@@ -450,6 +539,18 @@ export class ContractBookingService {
'Clearance must be finalized before the booking can be completed.',
);
}
// Path B: only GL Ethiopia completes a customs instance — the customer
// never enters shipment data on a customs contract.
if (contract.customsClearingEnabled) {
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
if (!isGlActor) {
throw new ForbiddenException(
'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.',
);
}
}
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
@@ -457,6 +558,15 @@ export class ContractBookingService {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// Completion is booking time: the route's booking window must be open —
// the same config-driven gate a direct one-time booking passes at create.
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
scheduledDate: dto.scheduledDate,
direction: contract.tradeDirection ?? null,
});
const freightType = contract.freightType;
const hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
@@ -471,6 +581,15 @@ export class ContractBookingService {
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(
dto,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
await this.persistContainers(booking.id, contract, dto);
}
await this.bookingsRepository.update(booking.id, {
@@ -541,9 +660,18 @@ export class ContractBookingService {
}
}
// Invoice the now-priced booking (idempotent, non-blocking).
await this.finalizeContractBooking(booking.id, contract, false);
// Invoice the now-priced booking and, for a customs instance, seed the
// post-booking milestones (pre-booking ones exist since initiation —
// ensure* fills only what is missing). Idempotent, non-blocking.
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
// numbers must be free on the newly chosen train day too.
await this.assertPersistedContainersAvailable(booking, dto.scheduledDate);
}
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
@@ -627,12 +755,11 @@ export class ContractBookingService {
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never);
} else if (generalCustoms) {
// Per-booking clearance: seed full milestone timeline on the booking.
await this.milestoneService.seedPreBookingMilestonesOnBooking(
bookingId,
contract.tradeDirection,
);
await this.milestoneService.seedPostBookingMilestones(
// Per-booking clearance: seed the full milestone timeline on the booking.
// ensure* skips codes that already exist — an initiated instance carries
// its pre-booking milestones from initiation, and a consolidation pairing
// replay must not duplicate the timeline.
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
);
@@ -1236,6 +1363,131 @@ export class ContractBookingService {
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
/**
* A physical container rides one train only. Reject the submission when a
* container number is entered twice in the same booking (the portal checks
* this client-side, the API must not trust it) or already sits on another
* customer's active booking for the same train — same shipment day AND same
* route (origin/destination yards).
*/
private async assertContainerNumbersAvailable(
dto: CreateBookingUnderContractDto,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const numbers = (dto.containers ?? []).flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
if (!numbers.length) return;
const seen = new Set<string>();
const withinBooking = new Set<string>();
for (const n of numbers) {
if (seen.has(n)) withinBooking.add(n);
seen.add(n);
}
if (withinBooking.size) {
throw new BadRequestException(
`Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`,
);
}
// Intercity bookings have no shipment day yet — nothing to clash with.
if (!dto.scheduledDate) return;
await this.assertNumbersFreeOnTrain(
numbers,
dto.scheduledDate,
route,
excludeBookingId,
);
}
/**
* Same train guard for a booking whose containers are already persisted
* (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its
* stored numbers must be free on the newly chosen day for its route.
*/
private async assertPersistedContainersAvailable(
booking: Booking,
scheduledDate: string,
): Promise<void> {
const rows: Array<{ containerNumber: string }> = await this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.select('unit.container_number', 'containerNumber')
.where('line.booking_id = :bookingId', { bookingId: booking.id })
.getRawMany();
const numbers = rows.map((r) => r.containerNumber).filter(Boolean);
if (!numbers.length) return;
await this.assertNumbersFreeOnTrain(
numbers,
scheduledDate,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
}
/**
* Reject when any of `numbers` sits on another active booking of the same
* train — same day and same route. Bookings without route yards (legacy
* rows) are matched on the day alone rather than let through.
*/
private async assertNumbersFreeOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const qb = this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.innerJoin(Booking, 'b', 'b.id = line.booking_id')
.select('unit.container_number', 'containerNumber')
.addSelect('b.reference', 'reference')
.where('unit.container_number IN (:...numbers)', { numbers })
.andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.andWhere('b.deleted_at IS NULL');
if (route.originYardId && route.destinationYardId) {
// Same train = same day + same corridor. A clashing booking whose yards
// were never denormalized still blocks (NULL yards match any route).
qb.andWhere(
'(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)',
{ originYardId: route.originYardId },
).andWhere(
'(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)',
{ destinationYardId: route.destinationYardId },
);
}
if (excludeBookingId) {
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
}
const clashes: Array<{ containerNumber: string; reference: string }> =
await qb.getRawMany();
if (clashes.length) {
const detail = [
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
]
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
}
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {

View File

@@ -826,8 +826,16 @@ export class ContractsController {
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.contractBookingService.completeUnderContract(id, bookingId, dto);
// Customs (Path B) instances may only be completed by GL Ethiopia — the
// service checks the actor's contracts:create_booking permission.
return this.contractBookingService.completeUnderContract(
id,
bookingId,
dto,
user,
);
}
@Post(':id/validate-shipment')

View File

@@ -700,6 +700,16 @@ describe('BookingBatchService — PAID reconcile', () => {
bookingsRepository.findBatchPoolByCorridorDay
.mockResolvedValueOnce([waiting])
.mockResolvedValue([]);
// expire()'s paid-guard and reserve()'s idempotency guard both re-read the
// booking fresh — answer with the matching row, not the paidBooking default
// (which would make the guard rescue-allocate the lapsed reservation).
const byId: Record<string, Booking> = { lapsed, waiting };
dataSource
.getRepository()
.findOne.mockImplementation(
async (opts: { where?: { id?: string } }) =>
byId[opts?.where?.id ?? ''] ?? null,
);
await service.settleDueReservations(trainId);
@@ -725,6 +735,14 @@ describe('BookingBatchService — PAID reconcile', () => {
return Promise.resolve(reads === 1 ? [lapsed] : []);
});
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
// expire()'s paid-guard re-reads the booking fresh — answer with the
// (unpaid) lapsed row, not the paidBooking default.
dataSource
.getRepository()
.findOne.mockImplementation(
async (opts: { where?: { id?: string } }) =>
opts?.where?.id === 'lapsed' ? lapsed : null,
);
await Promise.all([
service.settleDueReservations(trainId),
@@ -733,6 +751,37 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(notifier.expired).toHaveBeenCalledTimes(1);
});
it('never expires a reservation whose payment landed — allocates it instead', async () => {
const latePaid = booking('late-paid', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([latePaid])
.mockResolvedValue([]);
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
// The payment webhook flipped paymentStatus between the settle's list
// read and expire()'s fresh re-read — the deadline had already passed.
dataSource
.getRepository()
.findOne.mockImplementation(
async (opts: { where?: { id?: string } }) =>
opts?.where?.id === 'late-paid'
? { ...latePaid, paymentStatus: 'PAID' }
: null,
);
await service.settleDueReservations(trainId);
// Money was taken → the booking boards. Never expired.
expect(notifier.expired).not.toHaveBeenCalled();
expect(notifier.secured).toHaveBeenCalledTimes(1);
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
[{ trainScheduleId: trainId, bookingId: 'late-paid' }],
expect.anything(),
);
});
});
});

View File

@@ -428,7 +428,19 @@ export class BookingBatchService implements OnModuleInit {
where: { id: bookingId },
relations: { company: true },
});
if (!booking?.trainScheduleId) return;
if (!booking) return;
if (!booking.trainScheduleId) {
// A paid booking with no train is money taken and nothing boarding —
// scream so staff pin it to a schedule manually (batch board / assign).
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
this.logger.error(
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
`its reservation was likely expired before the payment landed. ` +
`Assign it to a schedule manually from the batch board.`,
);
}
return;
}
const isBatchPaid =
booking.status === "SELECTED_FOR_BATCH" ||
@@ -2124,8 +2136,38 @@ export class BookingBatchService implements OnModuleInit {
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train
* it failed to pay for — it's back in the day pool for staff to act on.
* `reason` picks the customer message: 'payment' (pay window lapsed) or
* 'no-capacity' (no train on the chosen day could take the booking).
*
* PAID GUARD: a booking whose payment has landed is never expired — money was
* taken, so it boards, even when the webhook arrived after the deadline or the
* settle read a stale row. It allocates onto the train it was selected for; if
* the wagon planner then finds no physical wagon, the booking stays linked and
* staff assign wagons manually. Consolidated bookings are exempt from the
* rescue: the shared wagon is both-or-neither, and settleReserved owns that
* pair decision.
*/
private async expire(booking: Booking): Promise<void> {
private async expire(
booking: Booking,
reason: "payment" | "no-capacity" = "payment",
): Promise<void> {
if (!booking.consolidationPartnerId) {
const fresh = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: booking.id }, relations: { company: true } });
const paid =
fresh != null &&
(fresh.paymentStatus === "PAID" || fresh.status === "PAID");
const paidScheduleId = fresh?.trainScheduleId ?? booking.trainScheduleId;
if (paid && paidScheduleId) {
this.logger.log(
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
`landed; allocating on schedule ${paidScheduleId} instead`,
);
await this.allocate(paidScheduleId, fresh, "paid");
return;
}
}
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
@@ -2146,13 +2188,84 @@ export class BookingBatchService implements OnModuleInit {
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
this.notifier.expired(booking);
if (reason === "no-capacity") {
this.notifier.expiredNoCapacity(booking);
} else {
this.notifier.expired(booking);
}
this.logger.log(
`[BATCH] EXPIRED ${booking.reference} payment window passed; freed its ` +
`wagons back to the pool for top-up`,
`[BATCH] EXPIRED ${booking.reference}` +
(reason === "no-capacity"
? "no train on its day had capacity left"
: "payment window passed; freed its wagons back to the pool for top-up"),
);
}
/**
* End-of-day sweep: once a schedule's window cycle concludes and NO other
* train on the same route-day can still run a cycle, the waiting pool for
* that day is dead — a FULLY_EXECUTED booking left in it would wait forever.
* Expire every leftover commercial booking and tell the customers to rebook
* another day. Government bookings are never auto-expired (they preempt).
* Returns how many bookings were expired.
*/
async expireLeftoverDayPool(scheduleId: string): Promise<number> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule?.scheduledDepartureDate) return 0;
const day = eatDay(schedule.scheduledDepartureDate);
const group: RouteDayGroup = {
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day,
};
// Another train on this route-day that can still take bookings keeps the
// pool alive — when IT concludes, its own sweep runs this check again.
const siblings = await this.trainSchedulesRepository.findAll({
where: [
{
originStationId: group.originYardId,
destinationStationId: group.destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
originStationId: group.originYardId,
destinationStationId: group.destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
});
const anotherTrainStillOpen = siblings.some(
(s) =>
s.id !== schedule.id &&
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.windowPhase !== "DONE" &&
s.bookingWindowStatus !== "FULL",
);
if (anotherTrainStillOpen) return 0;
const corridorYards = await this.corridorYardsForRouteDay(group);
const pool = corridorYards.length
? await this.bookingsRepository.findBatchPoolByCorridorDay(corridorYards, day)
: await this.bookingsRepository.findBatchPoolByRouteDay(
group.originYardId,
group.destinationYardId,
day,
);
const leftovers = pool.filter((b) => !b.isGovernment);
for (const booking of leftovers) {
await this.expire(booking, "no-capacity");
}
if (leftovers.length) {
this.logger.log(
`[BATCH] ${this.groupLabel(group)}: no train left with capacity — ` +
`expired ${leftovers.length} waiting booking(s)`,
);
}
return leftovers.length;
}
/**
* Union of stop yards across the day's fillable schedules on this corridor —
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings

View File

@@ -14,6 +14,7 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
@@ -139,7 +140,7 @@ export class BookingJourneyService {
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.innerJoin(
'freight.train_schedule_bookings',
TrainScheduleBooking,
'tsb',
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
{ scheduleId },
@@ -218,8 +219,10 @@ export class BookingJourneyService {
const bookings = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
// Entity-class join: a raw 'freight.table' string is parsed by TypeORM as
// an alias.property path ("freight" alias was not found) — runtime 500.
.innerJoin(
'freight.train_schedule_bookings',
TrainScheduleBooking,
'tsb',
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
{ scheduleId },
@@ -350,7 +353,7 @@ export class BookingJourneyService {
.createQueryBuilder('alloc')
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
.innerJoin(
'freight.train_schedules',
TrainSchedule,
'schedule',
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
{ scheduleId },

View File

@@ -141,6 +141,22 @@ export class BookingNotifierService {
this.inApp(b, 'Payment window expired', msg);
}
/**
* Every train on the booking's chosen day filled up (or no further train runs)
* before the waiting list reached this booking — it expired unplaced. HIGH so
* the customer hears about it by email/SMS and rebooks another day.
*/
expiredNoCapacity(b: Booking): void {
const msg =
`Booking ${b.reference ?? b.id} could not be placed: every train for your selected day ` +
`is full and no other train is scheduled that day. The booking has expired — ` +
`please rebook for another day. No re-approval is needed.`;
void this.notifyContact(b, msg, 'EXPIRED (NO CAPACITY)');
this.inApp(b, 'No capacity — booking expired', msg, {
priority: NotificationPriority.HIGH,
});
}
scheduleFull(b: Booking): void {
this.logger.warn(
`SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`,

View File

@@ -19,6 +19,7 @@ describe('BookingWindowService — window state machine', () => {
isScheduleFull: jest.Mock;
hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock;
expireLeftoverDayPool: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -73,6 +74,7 @@ describe('BookingWindowService — window state machine', () => {
// No reservation is mid-pay-window by default, so the cycle concludes.
hasLiveReservations: jest.fn().mockResolvedValue(false),
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
@@ -186,6 +188,8 @@ describe('BookingWindowService — window state machine', () => {
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
expect(s.windowPhase).toBe('DONE');
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
// The day's leftover waiting list is swept once this train is done.
expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId);
});
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
@@ -210,6 +214,8 @@ describe('BookingWindowService — window state machine', () => {
});
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
expect(s.windowPhase).toBe('DONE');
// No further train can run for this day → leftover waiting list is swept.
expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId);
});
it('no transition fires before its deadline (idempotent tick)', async () => {

View File

@@ -357,6 +357,10 @@ export class BookingWindowService implements OnModuleInit {
this.logger.log(
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
);
// This train is done. If no other train on the route-day can still take
// the waiting list, those bookings have nowhere to go — expire + notify
// them now instead of leaving them FULLY_EXECUTED forever.
await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
return;
}
@@ -390,6 +394,9 @@ export class BookingWindowService implements OnModuleInit {
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
`departure — window DONE`,
);
// No further cycle on this train. Same sweep as the FULL branch: if no
// sibling train can still take the day's waiting list, expire + notify.
await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
return;
}

View File

@@ -0,0 +1,12 @@
import { WagonStatus } from '@edr/types';
import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator';
export class BulkSetWagonStatusDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonIds!: string[];
@IsEnum(WagonStatus)
status!: WagonStatus;
}

View File

@@ -0,0 +1,11 @@
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class BulkTransferWagonsDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonIds!: string[];
@IsUUID()
toYardId!: string;
}

View File

@@ -10,12 +10,16 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { WagonsService } from './wagons.service';
@ApiTags('wagons')
@@ -78,6 +82,20 @@ export class WagonsController {
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.unassignFromTrain(id);
}
@Post('bulk-transfer')
@FleetManage()
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
return this.wagonsService.bulkTransfer(dto, user?.id);
}
@Post('bulk-status')
@FleetManage()
@ApiOperation({ summary: 'Set the status of multiple wagons' })
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
return this.wagonsService.bulkSetStatus(dto);
}
}
// Separate controller for trainspecific reorder (registered in module)

View File

@@ -1,15 +1,18 @@
import { WagonMovementKind, WagonStatus } from '@edr/types';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@Injectable()
export class WagonsService {
@@ -168,6 +171,101 @@ export class WagonsService {
return this.wagonRepo.save(wagon);
}
/**
* Relocate many wagons to one destination yard in a single transaction. Each
* wagon whose yard actually changes gets a `wagon_movements` ledger row (kind
* `Manual`) so the yard history stays auditable — mirrors the single-wagon
* `update` path. Wagons already in the destination yard are skipped.
*/
async bulkTransfer(
dto: BulkTransferWagonsDto,
userId?: string | null,
): Promise<{ moved: number }> {
const { wagonIds, toYardId } = dto;
if (!wagonIds.length) return { moved: 0 };
const yard = await this.dataSource
.getRepository(Yard)
.findOne({ where: { id: toYardId } });
if (!yard) throw new NotFoundException('Destination yard not found');
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const wagons = await queryRunner.manager.find(Wagon, {
where: { id: In(wagonIds) },
});
if (wagons.length !== wagonIds.length) {
throw new NotFoundException('One or more wagons not found');
}
let moved = 0;
for (const wagon of wagons) {
const previousYardId = wagon.currentYardId ?? null;
if (previousYardId === toYardId) continue;
wagon.currentYardId = toYardId;
// Drop the eager relation so the scalar FK wins on save (see `update`).
wagon.currentYard = null;
await queryRunner.manager.save(Wagon, wagon);
await queryRunner.manager.save(
queryRunner.manager.create(WagonMovement, {
wagonId: wagon.id,
fromYardId: previousYardId,
toYardId,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
occurredAt: new Date(),
}),
);
moved++;
}
await queryRunner.commitTransaction();
return { moved };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
/**
* Set the same status on many wagons in one transaction (e.g. flip a batch
* from Available to Assigned in the yard workspace). Only the `status` column
* is touched — train assignment is managed through the assign/unassign flow.
*/
async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> {
const { wagonIds, status } = dto;
if (!wagonIds.length) return { updated: 0 };
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const wagons = await queryRunner.manager.find(Wagon, {
where: { id: In(wagonIds) },
});
if (wagons.length !== wagonIds.length) {
throw new NotFoundException('One or more wagons not found');
}
for (const wagon of wagons) {
wagon.status = status;
}
await queryRunner.manager.save(Wagon, wagons);
await queryRunner.commitTransaction();
return { updated: wagons.length };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();

View File

@@ -2,7 +2,11 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Company } from '../companies/entities/company.entity';
import { Container } from '../container-management/entities/container.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
@@ -3655,23 +3659,25 @@ export class WarehouseInventoryService {
.leftJoinAndSelect('inv.warehouse', 'warehouse')
.leftJoinAndSelect('inv.yard', 'yard')
.leftJoinAndSelect('inv.zone', 'zone')
.leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
.leftJoin('freight.companies', 'company', 'company.id = booking.company_id')
// Entity-class joins: TypeORM parses a raw 'freight.table' string as an
// alias.property path ("freight" alias was not found) — runtime 500.
.leftJoin(Booking, 'booking', 'booking.id = inv.booking_id')
.leftJoin(Company, 'company', 'company.id = booking.company_id')
.leftJoin(
'freight.containers',
Container,
'container',
`((inv.container_id IS NOT NULL AND container.id = inv.container_id)
OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id))
AND container.deleted_at IS NULL`,
)
.leftJoin(
'freight.cargoes',
Cargo,
'cargo',
`((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id))
AND cargo.deleted_at IS NULL`,
)
.leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
.leftJoin(CargoType, 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
.addSelect('booking.reference', 'b_reference')
.addSelect('company.name', 'c_name')
.addSelect('container.container_number', 'ct_number')

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { Route } from '../routes/entities/route.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
/**
@@ -50,9 +52,11 @@ export class WarehouseSchedulingAdapterService {
.leftJoinAndSelect('inv.warehouse', 'warehouse')
.leftJoinAndSelect('inv.yard', 'yard')
.leftJoinAndSelect('inv.zone', 'zone')
.innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
// Entity-class joins: TypeORM parses a raw 'freight.table' string as an
// alias.property path ("freight" alias was not found) — runtime 500.
.innerJoin(Booking, 'booking', 'booking.id = inv.booking_id')
.innerJoin(
'freight.routes',
Route,
'route',
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
{ routeId },