mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 16:00:56 +00:00
Merge pull request #1329 from Tria-plc/freight_feature/usermanagement
feat(freight): offer built-train wagons per boarding yard on multi-ya…
This commit is contained in:
@@ -81,6 +81,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
});
|
||||
|
||||
@@ -163,6 +164,7 @@ describe("BillingService.issueMemo", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, manager, savedLines };
|
||||
}
|
||||
@@ -297,6 +299,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -352,6 +355,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -397,6 +401,7 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -510,6 +515,7 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -627,6 +633,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -700,6 +707,7 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -791,6 +799,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -874,6 +883,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -943,6 +953,7 @@ describe("BillingService.document", () => {
|
||||
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||
: undefined,
|
||||
} as never, // config
|
||||
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||
);
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
||||
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
@@ -201,6 +202,7 @@ export class BillingService {
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -370,20 +372,24 @@ export class BillingService {
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
|
||||
// Only currencies whose manual-payment channel is switched on are listed:
|
||||
// a row Finance cannot act on is noise, and the confirm endpoint would
|
||||
// refuse it anyway. All off → nothing to work.
|
||||
const enabled = await this.manualPaymentSettings.enabledCurrencies();
|
||||
if (!enabled.length) return { items: [], total: 0 };
|
||||
const currencies = filter.currency
|
||||
? enabled.filter((c) => c === filter.currency)
|
||||
: enabled;
|
||||
if (!currencies.length) return { items: [], total: 0 };
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
|
||||
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.currency) {
|
||||
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
||||
currency: filter.currency,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
@@ -465,7 +471,8 @@ export class BillingService {
|
||||
|
||||
/**
|
||||
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
|
||||
* or counter payment: stores the slip against the invoice and settles the
|
||||
* or counter payment. Refused when that currency's manual-payment channel is
|
||||
* switched off in settings. Stores the slip against the invoice and settles the
|
||||
* FULL outstanding balance through
|
||||
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||
@@ -485,6 +492,13 @@ export class BillingService {
|
||||
): Promise<Invoice> {
|
||||
const invoice = await this.invoices.findById(invoiceId);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
// The channel is a setting, not a role: even a permitted user cannot
|
||||
// settle by hand in a currency whose channel is switched off.
|
||||
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
|
||||
throw new BadRequestException(
|
||||
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
|
||||
);
|
||||
}
|
||||
if (!file) {
|
||||
throw new BadRequestException("The bank payment slip file is required.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional } from "class-validator";
|
||||
|
||||
/**
|
||||
* Partial update: the UI flips one currency at a time, so an omitted field
|
||||
* leaves that currency's channel exactly as it was.
|
||||
*/
|
||||
export class UpdateManualPaymentSettingDto {
|
||||
@ApiPropertyOptional({ description: "Allow manual settlement of ETB invoices" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
etbEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Allow manual settlement of USD invoices" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
usdEnabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
/**
|
||||
* Single-row table controlling whether Finance may settle invoices by hand
|
||||
* (bank transfer / counter payment) instead of the customer paying online.
|
||||
*
|
||||
* Per currency on purpose: the two channels are operationally different — USD
|
||||
* bookings have always been bank-transfer-only, while ETB normally goes
|
||||
* through the gateway and manual settlement is the exception. Switching one
|
||||
* off must not switch off the other.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "manual_payment_settings" })
|
||||
export class ManualPaymentSetting extends BaseEntity {
|
||||
/** Manual settlement allowed for ETB invoices. */
|
||||
@Column({ name: "etb_enabled", type: "boolean", default: false })
|
||||
etbEnabled!: boolean;
|
||||
|
||||
/** Manual settlement allowed for USD invoices. */
|
||||
@Column({ name: "usd_enabled", type: "boolean", default: true })
|
||||
usdEnabled!: boolean;
|
||||
|
||||
/** IAM user id of the last operator to change either toggle. */
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Body, Controller, Get, Patch } from "@nestjs/common";
|
||||
import { ApiBearerAuth, 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 { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { UpdateManualPaymentSettingDto } from "./dto/update-manual-payment-setting.dto";
|
||||
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
|
||||
|
||||
@ApiTags("payment-settings")
|
||||
@ApiBearerAuth()
|
||||
@Controller("payment-settings/manual")
|
||||
export class ManualPaymentSettingsController {
|
||||
constructor(private readonly service: ManualPaymentSettingsService) {}
|
||||
|
||||
/**
|
||||
* Read is gated on `manual_payment:view`, which Finance also holds — the
|
||||
* Manual Payments worklist reads this to know which currency tabs to offer.
|
||||
*/
|
||||
@Get()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.settings.manualPayment.view,
|
||||
FREIGHT_PERMS.admin,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary: "Whether manual (offline) invoice settlement is enabled, per currency",
|
||||
})
|
||||
get() {
|
||||
return this.service.get();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.settings.manualPayment.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary: "Enable or disable manual invoice settlement for ETB and/or USD",
|
||||
})
|
||||
update(
|
||||
@Body() dto: UpdateManualPaymentSettingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.update(dto, user?.id ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
||||
|
||||
/** The two currencies an invoice can be settled by hand in. */
|
||||
export type ManualPaymentCurrency = "ETB" | "USD";
|
||||
|
||||
/**
|
||||
* Owns the single `manual_payment_settings` row: whether Finance may settle
|
||||
* invoices by hand, per currency.
|
||||
*
|
||||
* Defaults mirror how the platform behaved before the toggles existed — USD
|
||||
* has always been bank-transfer-only so it starts ON; ETB manual settlement is
|
||||
* the new capability and starts OFF, so enabling it is a deliberate act.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ManualPaymentSettingsService {
|
||||
private readonly logger = new Logger(ManualPaymentSettingsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ManualPaymentSetting)
|
||||
private readonly repository: Repository<ManualPaymentSetting>,
|
||||
) {}
|
||||
|
||||
/** The settings row, created at the defaults on first access. */
|
||||
async get(): Promise<ManualPaymentSetting> {
|
||||
const existing = await this.repository.findOne({ where: {} });
|
||||
if (existing) return existing;
|
||||
|
||||
return this.repository.save(
|
||||
this.repository.create({ etbEnabled: false, usdEnabled: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Currencies manual settlement is currently allowed for. */
|
||||
async enabledCurrencies(): Promise<ManualPaymentCurrency[]> {
|
||||
const setting = await this.get();
|
||||
const enabled: ManualPaymentCurrency[] = [];
|
||||
if (setting.etbEnabled) enabled.push("ETB");
|
||||
if (setting.usdEnabled) enabled.push("USD");
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Whether one currency may be settled by hand right now. */
|
||||
async isEnabled(currency: string | null | undefined): Promise<boolean> {
|
||||
const upper = currency?.toUpperCase();
|
||||
if (upper !== "ETB" && upper !== "USD") return false;
|
||||
const setting = await this.get();
|
||||
return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled;
|
||||
}
|
||||
|
||||
/** Flip either toggle; an omitted field leaves that currency unchanged. */
|
||||
async update(
|
||||
patch: { etbEnabled?: boolean; usdEnabled?: boolean },
|
||||
updatedById?: string | null,
|
||||
): Promise<ManualPaymentSetting> {
|
||||
const current = await this.get();
|
||||
await this.repository.update(current.id, {
|
||||
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
||||
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
const updated = await this.get();
|
||||
this.logger.warn(
|
||||
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
||||
import { ManualPaymentSettingsController } from "./manual-payment-settings.controller";
|
||||
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
|
||||
|
||||
/**
|
||||
* Global so billing can inject {@link ManualPaymentSettingsService} to gate
|
||||
* the manual-settlement worklist and confirmation endpoint without importing
|
||||
* this module (and without a cycle, since this module needs nothing back).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ManualPaymentSetting])],
|
||||
controllers: [ManualPaymentSettingsController],
|
||||
providers: [ManualPaymentSettingsService],
|
||||
exports: [ManualPaymentSettingsService],
|
||||
})
|
||||
export class PaymentSettingsModule {}
|
||||
@@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const ledger = new WagonStockLedger(
|
||||
stock.remainingByTypeId,
|
||||
Math.max(1, budget.stops.length - 1),
|
||||
stock.byYardId,
|
||||
budget.stops,
|
||||
);
|
||||
// On a multi-yard consist the pool that matters is the one standing at
|
||||
// the booking's own boarding yard — a type carried only in Mojo must not
|
||||
// be advertised to a customer boarding at Dire.
|
||||
const carriedAtBoardYard = (wagonTypeId: string): number => {
|
||||
const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null;
|
||||
if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0;
|
||||
return stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||
};
|
||||
const byWagonType = allowed
|
||||
.filter(
|
||||
({ wagonTypeId }) =>
|
||||
stock.mode !== 'TRAIN' ||
|
||||
!wagonTypeId ||
|
||||
(stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
|
||||
stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0,
|
||||
)
|
||||
.map(({ wagonTypeId, dims }) => {
|
||||
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
|
||||
@@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return new WagonStockLedger(
|
||||
stock.remainingByTypeId,
|
||||
Math.max(1, budget.stops.length - 1),
|
||||
stock.byYardId,
|
||||
budget.stops,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1589,6 +1589,7 @@ export class TrainSchedulingService {
|
||||
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
||||
);
|
||||
}
|
||||
await this.assertRouteCoversWagonYards(builtTrain, route);
|
||||
const conflict = await this.findTrainRouteDayConflict(
|
||||
builtTrain.id,
|
||||
route.id,
|
||||
@@ -4274,12 +4275,26 @@ export class TrainSchedulingService {
|
||||
.getRepository(Locomotive)
|
||||
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
|
||||
}
|
||||
// Only wagons the train has actually COLLECTED move with it. On a
|
||||
// consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo
|
||||
// moves the Dire wagons — the ones already aboard — and picks up the
|
||||
// Mojo ones standing here. Wagons waiting at yards further down the
|
||||
// line stay where they are until the train physically gets to them.
|
||||
const passedYardIds = stations
|
||||
.filter((s) => s.sequenceNo <= dto.sequenceNo)
|
||||
.map((s) => s.yardId);
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ currentTrainScheduleId: scheduleId },
|
||||
{ currentYardId: station.yardId },
|
||||
);
|
||||
.createQueryBuilder()
|
||||
.update(Wagon)
|
||||
.set({ currentYardId: station.yardId })
|
||||
.where('current_train_schedule_id = :scheduleId', { scheduleId })
|
||||
// A yard-less wagon has no "waiting further down the line" position
|
||||
// to protect, so it rides along as it always did.
|
||||
.andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', {
|
||||
passedYardIds,
|
||||
})
|
||||
.execute();
|
||||
if (schedule.trainSet?.trainId) {
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
@@ -5286,12 +5301,26 @@ export class TrainSchedulingService {
|
||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||
const counts = new Map<string, { code: string; available: number }>();
|
||||
|
||||
// A built consist spread across several yards can only offer, at each yard,
|
||||
// the wagons standing there. A single-yard consist keeps the original
|
||||
// behaviour: the whole train counts wherever it currently sits.
|
||||
const consistYards = builtTrainId
|
||||
? new Set(
|
||||
wagons
|
||||
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
|
||||
.map((w) => w.currentYardId as string),
|
||||
)
|
||||
: new Set<string>();
|
||||
const consistIsSplit = consistYards.size > 1;
|
||||
|
||||
for (const wagon of wagons) {
|
||||
// Train-bound schedule: the built train's own consist IS the fleet — only
|
||||
// its wagons count (wherever they currently sit; they travel with the
|
||||
// train), and loose yard wagons never do.
|
||||
// its wagons count, and loose yard wagons never do. A single-yard consist
|
||||
// counts wherever it sits (it travels with the train); a split consist is
|
||||
// counted at the yard each wagon actually stands in.
|
||||
if (builtTrainId) {
|
||||
if (wagon.trainId !== builtTrainId) continue;
|
||||
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
|
||||
} else {
|
||||
// Schedule-scoped availability: pins held by OTHER schedules never
|
||||
// consume a wagon here — the same physical wagon may serve the July 17
|
||||
@@ -5651,12 +5680,23 @@ export class TrainSchedulingService {
|
||||
// consist views draw the schedule exactly like the train builder; a schedule
|
||||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
||||
const consistYards = new Set(
|
||||
wagons
|
||||
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
|
||||
.map((w) => w.currentYardId as string),
|
||||
);
|
||||
// Split consist: a slot boarding at a given yard must take a wagon that
|
||||
// physically stands there — the train cannot load a Mojo wagon at Dire.
|
||||
// A single-yard consist ignores this (the whole train is at one place).
|
||||
const requiredYardId =
|
||||
consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null;
|
||||
const candidates = wagons
|
||||
.filter(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
spanFree(w.id),
|
||||
spanFree(w.id) &&
|
||||
(!requiredYardId || w.currentYardId === requiredYardId),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||||
@@ -5825,14 +5865,28 @@ export class TrainSchedulingService {
|
||||
});
|
||||
const remainingByTypeId = new Map<string, number>();
|
||||
const codesByTypeId = new Map<string, string>();
|
||||
const byYardId = new Map<string, Map<string, number>>();
|
||||
for (const wagon of wagons) {
|
||||
remainingByTypeId.set(
|
||||
wagon.wagonTypeId,
|
||||
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
|
||||
);
|
||||
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
|
||||
if (wagon.currentYardId) {
|
||||
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
|
||||
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
|
||||
byYardId.set(wagon.currentYardId, perType);
|
||||
}
|
||||
}
|
||||
return { mode: 'TRAIN', remainingByTypeId, codesByTypeId };
|
||||
// Single-yard consist (the overwhelming majority): the whole train is
|
||||
// offered at every boarding yard exactly as before — the per-yard split is
|
||||
// only meaningful once the consist is genuinely spread across yards.
|
||||
return {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId,
|
||||
codesByTypeId,
|
||||
...(byYardId.size > 1 ? { byYardId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6160,6 +6214,45 @@ export class TrainSchedulingService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* A built train's wagons may stand in several yards. The route must pass
|
||||
* through every one of them as origin or an intermediate stop — never only
|
||||
* as the final destination (the train has to pick the wagons up en route).
|
||||
*/
|
||||
private async assertRouteCoversWagonYards(train: Train, route: Route) {
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: train.id },
|
||||
select: { id: true, currentYardId: true },
|
||||
});
|
||||
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
|
||||
if (!wagonYards.length) return;
|
||||
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } });
|
||||
const stops = milestones.length >= 2
|
||||
? milestones.map((m) => m.yardId)
|
||||
: [route.originYardId, route.destinationYardId];
|
||||
// Every stop except the last one is a pickup point.
|
||||
const pickupYards = new Set(stops.slice(0, -1));
|
||||
|
||||
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
|
||||
if (!uncovered.length) return;
|
||||
|
||||
const labels = await this.yardLabelMap(uncovered);
|
||||
const destination = stops[stops.length - 1];
|
||||
const detail = uncovered
|
||||
.map((y) =>
|
||||
y === destination
|
||||
? `${labels.get(y) ?? y} (only as the destination)`
|
||||
: `${labels.get(y) ?? y} (not on route)`,
|
||||
)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async getSchedulableRoute(routeId: string) {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id: routeId },
|
||||
|
||||
@@ -43,6 +43,16 @@ export type WagonStock = {
|
||||
remainingByTypeId: Map<string, number>;
|
||||
/** Wagon-type code per id, for human-readable shortfall messages. */
|
||||
codesByTypeId: Map<string, string>;
|
||||
/**
|
||||
* Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons
|
||||
* standing at that yard. A train whose wagons are split across yards can
|
||||
* only offer, at each boarding yard, the wagons physically standing there —
|
||||
* a wagon waiting in Mojo is not bookable from Dire, and one picked up at
|
||||
* Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits
|
||||
* in one yard, which keeps single-yard trains on the original whole-train
|
||||
* math.
|
||||
*/
|
||||
byYardId?: Map<string, Map<string, number>>;
|
||||
};
|
||||
|
||||
export type FlexPlanResult = {
|
||||
|
||||
@@ -68,3 +68,94 @@ describe('WagonStockLedger', () => {
|
||||
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WagonStockLedger — multi-yard consist', () => {
|
||||
// The reported case: a built train of 53 wagons, 20 standing in Dire and 33
|
||||
// in Mojo. Each yard may only sell the wagons physically standing there.
|
||||
const DIRE = 'yard-dire';
|
||||
const MOJO = 'yard-mojo';
|
||||
const ADDIS = 'yard-addis';
|
||||
const STOPS = [DIRE, MOJO, ADDIS];
|
||||
const EDGES = STOPS.length - 1;
|
||||
const splitStock = () =>
|
||||
new Map([
|
||||
[DIRE, new Map([['nw5', 20]])],
|
||||
[MOJO, new Map([['nw5', 33]])],
|
||||
]);
|
||||
// Legs along Dire → Mojo → Addis.
|
||||
const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 };
|
||||
const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 };
|
||||
|
||||
const splitLedger = () =>
|
||||
new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS);
|
||||
|
||||
it('offers each yard only the wagons standing there', () => {
|
||||
const ledger = splitLedger();
|
||||
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20);
|
||||
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
|
||||
});
|
||||
|
||||
it('keeps the yards independent — Dire bookings never eat Mojo stock', () => {
|
||||
const ledger = splitLedger();
|
||||
// A Dire booking rides the whole corridor, occupying the Mojo→Addis edge…
|
||||
expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20);
|
||||
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0);
|
||||
// …but those are Dire's steel, so Mojo still has its own 33 to sell.
|
||||
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
|
||||
expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33);
|
||||
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0);
|
||||
});
|
||||
|
||||
it('never lends a free Dire wagon to a Mojo customer', () => {
|
||||
const ledger = splitLedger();
|
||||
// Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty.
|
||||
expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5);
|
||||
// Mojo is still capped at its own 33 — the 15 empty Dire wagons are not
|
||||
// offered here, exactly as the operator requires.
|
||||
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33);
|
||||
expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33);
|
||||
});
|
||||
|
||||
it('offers nothing at the destination — there is nothing to pick up there', () => {
|
||||
const ledger = splitLedger();
|
||||
// A leg boarding at the last stop has no pool of its own.
|
||||
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0);
|
||||
});
|
||||
|
||||
it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => {
|
||||
const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [
|
||||
'yard-add',
|
||||
'yard-dire',
|
||||
'yard-indode',
|
||||
'yard-mojo',
|
||||
'yard-djibouti',
|
||||
];
|
||||
const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI];
|
||||
const ledger = new WagonStockLedger(
|
||||
new Map([['nw5', 53]]),
|
||||
stops.length - 1,
|
||||
new Map([
|
||||
[DIRE_2, new Map([['nw5', 20]])],
|
||||
[MOJO_2, new Map([['nw5', 33]])],
|
||||
]),
|
||||
stops,
|
||||
);
|
||||
const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 });
|
||||
// Addis: the train starts empty — nothing to sell.
|
||||
expect(ledger.availableFor(['nw5'], to(0))).toBe(0);
|
||||
// Dire: the 20 wagons waiting there.
|
||||
expect(ledger.availableFor(['nw5'], to(1))).toBe(20);
|
||||
// Indode: the same 20 wagons, which have moved with the train.
|
||||
expect(ledger.availableFor(['nw5'], to(2))).toBe(0);
|
||||
// Mojo: its own 33 only.
|
||||
expect(ledger.availableFor(['nw5'], to(3))).toBe(33);
|
||||
});
|
||||
|
||||
it('single-yard consist keeps the original whole-train behaviour', () => {
|
||||
// No byYardId (the train is not split) — every leg sees the whole train,
|
||||
// exactly as before this feature.
|
||||
const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES);
|
||||
expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53);
|
||||
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util';
|
||||
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
|
||||
*/
|
||||
export class WagonStockLedger {
|
||||
/**
|
||||
* Usage rows keyed by pool. A single-yard train has one pool (''), so this is
|
||||
* exactly the original per-type accounting. A multi-yard consist keys by
|
||||
* boarding yard as well, because the Dire wagons and the Mojo wagons are
|
||||
* disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the
|
||||
* Mojo→Addis edge, but they must not shrink what Mojo itself can offer.
|
||||
*/
|
||||
private readonly usedPerEdge = new Map<string, number[]>();
|
||||
|
||||
constructor(
|
||||
private readonly remainingByTypeId: Map<string, number>,
|
||||
private readonly edgeCount: number,
|
||||
/**
|
||||
* Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons
|
||||
* standing at each yard. When present, a leg is served ONLY by the wagons
|
||||
* standing at the yard it boards from — a Dire→Addis booking on a train
|
||||
* whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis
|
||||
* booking sees 33, never the Dire wagons that ride past empty.
|
||||
*/
|
||||
private readonly byYardId?: Map<string, Map<string, number>>,
|
||||
/** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */
|
||||
private readonly stops: readonly string[] = [],
|
||||
) {}
|
||||
|
||||
/** The yard a leg boards from, or '' when the train is not split across yards. */
|
||||
private poolYardOf(leg: CorridorLeg): string {
|
||||
if (!this.byYardId) return '';
|
||||
return this.stops[leg.fromEdge] ?? '';
|
||||
}
|
||||
|
||||
/** Usage-row key: one row per (pool, wagon type). */
|
||||
private rowKey(wagonTypeId: string, leg: CorridorLeg): string {
|
||||
const pool = this.poolYardOf(leg);
|
||||
return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
|
||||
}
|
||||
|
||||
/** Wagons of one type offered at the yard a leg boards from. */
|
||||
private totalForType(wagonTypeId: string, leg: CorridorLeg): number {
|
||||
const pool = this.poolYardOf(leg);
|
||||
if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||
return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0;
|
||||
}
|
||||
|
||||
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
|
||||
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
|
||||
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||
const row = this.usedPerEdge.get(wagonTypeId);
|
||||
const total = this.totalForType(wagonTypeId, leg);
|
||||
const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg));
|
||||
if (!row) return total;
|
||||
let busiest = 0;
|
||||
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
|
||||
@@ -69,10 +105,11 @@ export class WagonStockLedger {
|
||||
if (!deepest) break;
|
||||
|
||||
const take = Math.min(outstanding, deepest.free);
|
||||
let row = this.usedPerEdge.get(deepest.id);
|
||||
const key = this.rowKey(deepest.id, leg);
|
||||
let row = this.usedPerEdge.get(key);
|
||||
if (!row) {
|
||||
row = new Array<number>(this.edgeCount).fill(0);
|
||||
this.usedPerEdge.set(deepest.id, row);
|
||||
this.usedPerEdge.set(key, row);
|
||||
}
|
||||
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
|
||||
row[edge] = (row[edge] ?? 0) + take;
|
||||
|
||||
@@ -44,7 +44,7 @@ export class BuildTrainDto {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Wagons to attach at build time, in consist order (must sit in the same yard)',
|
||||
description: 'Wagons to attach at build time, in consist order (any yard)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -283,6 +283,10 @@ export class TrainBuilderService {
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
sequenceNumber: wagon.sequenceNumber,
|
||||
status: wagon.status,
|
||||
currentYardId: wagon.currentYardId ?? null,
|
||||
currentYard: wagon.currentYard
|
||||
? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label }
|
||||
: null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
@@ -326,6 +330,27 @@ export class TrainBuilderService {
|
||||
: null,
|
||||
locomotives,
|
||||
wagons,
|
||||
// Where the consist physically stands. A train built from several yards
|
||||
// only picks a yard's wagons up when it reaches that yard, and a customer
|
||||
// boarding there can only book the wagons standing there — the schedule
|
||||
// route must therefore cover every one of these yards before its
|
||||
// destination.
|
||||
wagonYards: [
|
||||
...wagons
|
||||
.reduce((acc, wagon) => {
|
||||
const id = wagon.currentYardId ?? 'UNASSIGNED';
|
||||
const entry = acc.get(id) ?? {
|
||||
yardId: wagon.currentYardId ?? null,
|
||||
code: wagon.currentYard?.code ?? null,
|
||||
label: wagon.currentYard?.label ?? null,
|
||||
wagonCount: 0,
|
||||
};
|
||||
entry.wagonCount += 1;
|
||||
acc.set(id, entry);
|
||||
return acc;
|
||||
}, new Map<string, { yardId: string | null; code: string | null; label: string | null; wagonCount: number }>())
|
||||
.values(),
|
||||
].sort((a, b) => b.wagonCount - a.wagonCount),
|
||||
totals: {
|
||||
wagonCount: wagons.length,
|
||||
totalTareTons,
|
||||
@@ -428,10 +453,13 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit: every
|
||||
* coupled locomotive and wagon follows to the new yard (so their current
|
||||
* yards always match the train's), and each wagon gets a movement-ledger row.
|
||||
* Blocked while the train is out on a dispatched run.
|
||||
* Relocate the train to another yard. The locomotives always follow. Of the
|
||||
* wagons, only those standing WITH the train move: on a consist spread
|
||||
* across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo
|
||||
* relocates the 20 it is actually pulling and leaves the Mojo wagons where
|
||||
* they stand — the train collects those by arriving, not by this call.
|
||||
* Each moved wagon gets a movement-ledger row. Blocked while the train is
|
||||
* out on a dispatched run.
|
||||
*/
|
||||
async setYard(id: string, currentYardId: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -439,6 +467,7 @@ export class TrainBuilderService {
|
||||
if (train.currentYardId === currentYardId) return;
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
||||
const previousYardId = train.currentYardId ?? null;
|
||||
|
||||
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
|
||||
|
||||
@@ -454,7 +483,17 @@ export class TrainBuilderService {
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
|
||||
const allWagons = await manager
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { trainId: train.id } });
|
||||
// Wagons travelling with the train = those at the yard it is leaving.
|
||||
// A yard-less wagon has no standing position of its own, so it follows.
|
||||
const wagons = allWagons.filter(
|
||||
(wagon) =>
|
||||
wagon.currentYardId == null ||
|
||||
previousYardId == null ||
|
||||
wagon.currentYardId === previousYardId,
|
||||
);
|
||||
const now = new Date();
|
||||
for (const wagon of wagons) {
|
||||
if (wagon.currentYardId === yard.id) continue;
|
||||
@@ -475,7 +514,7 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Append AVAILABLE wagons from the train's own yard to the consist. */
|
||||
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
@@ -1037,11 +1076,8 @@ export class TrainBuilderService {
|
||||
if (wagon.status !== WagonStatus.Available) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
|
||||
}
|
||||
if (wagon.currentYardId !== train.currentYardId) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
||||
);
|
||||
}
|
||||
// Wagons may sit in any yard — the schedule's route must pass through
|
||||
// every wagon yard before its destination (checked at scheduling time).
|
||||
toAttach.push(wagon);
|
||||
}
|
||||
if (!toAttach.length) return [];
|
||||
|
||||
@@ -342,8 +342,8 @@ export class WagonsService {
|
||||
|
||||
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
// Mirror train-builder attachWagons: only a truly free, available wagon in
|
||||
// the train's own yard can be coupled, and never onto a dispatched train.
|
||||
// Mirror train-builder attachWagons: only a truly free, available wagon
|
||||
// (any yard) can be coupled, and never onto a dispatched train.
|
||||
if (wagon.trainId != null) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
|
||||
}
|
||||
@@ -358,11 +358,6 @@ export class WagonsService {
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
if (wagon.currentYardId !== train.currentYardId) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxSeq = await this.wagonRepo
|
||||
.createQueryBuilder('w')
|
||||
|
||||
Reference in New Issue
Block a user