diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 9edf388b9..b24cf6c83 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -32,7 +32,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -84,13 +85,15 @@ "@types/node": "^20.14.0", "@types/pg": "^8.6.7", "@types/supertest": "^6.0.2", + "@types/vorpal": "^1.12.8", "jest": "^29.7.0", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vorpal": "^1.12.0" }, "jest": { "moduleFileExtensions": [ diff --git a/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts new file mode 100644 index 000000000..e4b353b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add the `EXPIRED` invoice status. An invoice expires when its source's pay + * window closes before settlement (e.g. a booking whose `paymentDeadline` + * lapses) — driven event-style from the domain via `BillingService.expirePayable`, + * which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out + * of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and + * `OVERDUE` (still payable). + * + * Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and + * not referenced in this same transaction, so it is PG 12+ safe. + */ +export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface { + name = "AddExpiredInvoiceStatus1830000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`, + ); + } + + public async down(): Promise { + // Postgres cannot drop individual enum values; EXPIRED is left on + // freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index e954b7e1b..9a441ab65 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; +import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { FreightAdmin } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; @@ -21,4 +22,26 @@ export class BillingController { findById(@Param("id", ParseUUIDPipe) id: string) { return this.billingService.findById(id); } + + @Get("invoices/:id/document") + @ApiOperation({ summary: "Download the sealed invoice PDF" }) + async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.document(id); + sendPdf(res, filename, buffer); + } + + @Get("invoices/:id/receipt") + @ApiOperation({ summary: "Download the sealed payment receipt PDF" }) + async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.receipt(id); + sendPdf(res, filename, buffer); + } +} + +/** Stream a generated PDF as a file download. */ +export function sendPdf(res: Response, filename: string, buffer: Buffer): void { + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + res.send(buffer); } diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index f1b58ad5e..443f511ee 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -151,16 +151,22 @@ export class BillingService { /** Sealed PDF invoice for any source, rendered by the shared document service. */ async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE")); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); } /** Sealed PDF receipt; available once any payment has been recorded. */ async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); if (Number(invoice.paidAmount) <= 0) { - throw new BadRequestException("A receipt is available only after payment is recorded."); + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); } - return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT")); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); } /** Map a global invoice (+ lines) onto the source-agnostic document model. */ @@ -177,7 +183,11 @@ export class BillingService { if (Number(invoice.taxAmount) > 0) { totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); } - totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true }); + totals.push({ + label: "Total", + amount: Number(invoice.totalAmount), + grand: true, + }); totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); @@ -193,8 +203,18 @@ export class BillingService { { label: "Type", value: invoice.type }, { label: "Reference", value: invoice.sourceId }, { label: "Currency", value: invoice.currency }, - { label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null }, - { label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, ], categoryHeader: "Charge type", lines: invoice.lines.map((l) => ({ @@ -221,19 +241,33 @@ export class BillingService { } } - /** Every invoice billed to a company, newest first, with billing relations. */ - findByCompany(companyId: string): Promise { + /** + * Every invoice billed to a company, newest first, with billing relations. + * Optionally narrow to a single source record (e.g. a booking's invoices) via + * `{ source, sourceId }`. + */ + findByCompany( + companyId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { return this.invoices.findAll({ - where: { companyId }, + where: { + companyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, relations: { company: true, companyProfile: true }, order: { createdAt: "DESC" }, }); } /** Invoices for the signed-in customer; empty when they have no company. */ - async findForUser(userId: string): Promise { + async findForUser( + userId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { const companyId = await this.resolveCompanyId(userId); - return companyId ? this.findByCompany(companyId) : []; + return companyId ? this.findByCompany(companyId, filter) : []; } /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ @@ -267,11 +301,32 @@ export class BillingService { ); } + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ + async documentForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.document(id); + } + + /** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */ + async receiptForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.receipt(id); + } + // ── Generation ─────────────────────────────────────────────────────────────── /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ private nextInvoiceNumber(mg: EntityManager): Promise { - return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" }); + return nextDailyInvoiceNumber(mg, { + table: "freight.invoices", + code: "INV", + }); } /** @@ -319,8 +374,7 @@ export class BillingService { input.subtotalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); const taxAmount = input.taxAmount ?? 0; - const totalAmount = - input.totalAmount ?? round2(subtotalAmount + taxAmount); + const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount); const dueAt = input.dueAt ?? @@ -406,7 +460,9 @@ export class BillingService { manager?: EntityManager, ): Promise { if (!(input.amount > 0)) { - throw new BadRequestException("Payment amount must be greater than zero."); + throw new BadRequestException( + "Payment amount must be greater than zero.", + ); } const mg = manager ?? this.dataSource.manager; @@ -441,17 +497,13 @@ export class BillingService { }; const payments = [...(invoice.payments ?? []), entry]; - await mg.update( - Invoice, - { id: invoice.id }, - { - paidAmount, - balanceAmount, - status, - payments, - paidAt: fullyPaid ? at : invoice.paidAt ?? null, - } as never, - ); + await mg.update(Invoice, { id: invoice.id }, { + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), + } as never); const updated = { ...invoice, @@ -459,7 +511,7 @@ export class BillingService { balanceAmount, status, payments, - paidAt: fullyPaid ? at : invoice.paidAt ?? null, + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), } as Invoice; if (fullyPaid) this.emitInvoiceEvent("paid", updated); @@ -628,6 +680,72 @@ export class BillingService { return this.markInvoiceAsRefunded(invoice.id, mg); } + /** + * Expire a source's currently-open invoice (its pay window closed before + * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice + * and transitions it to EXPIRED — a terminal, non-payable status (kept out of + * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice + * (already paid/cancelled/expired). + * + * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in + * the batch engine) to enlist in its DB transaction. + */ + async expirePayable( + source: Freight.InvoiceSource, + sourceId: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.transition( + invoice.id, + Freight.InvoiceStatus.Expired, + "expired", + {}, + mg, + ); + } + + /** + * Sync a source's open invoice `dueAt` to its real pay-window deadline. The + * booking invoice is generated before the pay window opens (at booking + * creation/approval), so its printed due date is refreshed when the batch engine + * sets `paymentDeadline`. No-op when the source has no open invoice. + */ + async syncPayableDueDate( + source: Freight.InvoiceSource, + sourceId: string, + dueAt: Date, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + await mg.update(Invoice, { id: invoice.id }, { dueAt }); + } + + async updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + await mg.update(Invoice, { id: invoice.id }, { status }); + } + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** @@ -654,7 +772,9 @@ export class BillingService { ): Promise { const invoice = await this.findPayable(source, sourceId); if (!invoice) { - throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + throw new NotFoundException( + `No open invoice to charge for ${source}:${sourceId}`, + ); } const result = await this.payment.initiate({ diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 5a007c320..94e917754 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -5,14 +5,18 @@ import { Param, ParseUUIDPipe, Post, + Query, + Res, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { CurrentUser } from "@edr/api-common"; import { type AuthUserPayload, resolveAuthUserId, } from "../../common/resolve-auth-user-id"; +import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; import { PayInvoiceDto } from "./dto/pay-invoice.dto"; @@ -29,8 +33,15 @@ export class PortalBillingController { @Get("my-invoices") @ApiOperation({ summary: "List the signed-in customer's invoices" }) - findMine(@CurrentUser() user: AuthUserPayload) { - return this.billingService.findForUser(resolveAuthUserId(user)); + findMine( + @CurrentUser() user: AuthUserPayload, + @Query("source") source?: string, + @Query("sourceId") sourceId?: string, + ) { + return this.billingService.findForUser(resolveAuthUserId(user), { + source, + sourceId, + }); } @Get("my-invoices/:id") @@ -42,6 +53,34 @@ export class PortalBillingController { return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); } + @Get("my-invoices/:id/document") + @ApiOperation({ summary: "Download one of the customer's invoice PDFs" }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.documentForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + + @Get("my-invoices/:id/receipt") + @ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" }) + async receipt( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.receiptForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + @Post("my-invoices/:id/pay") @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) pay( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 47338f196..c70133ee7 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -1,20 +1,20 @@ -import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; -import { DataSource } from 'typeorm'; +import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource } from "typeorm"; import { BillingService, GenerateInvoiceInput, InvoiceEventPayload, InvoiceLineInput, -} from '../billing/billing.service'; -import { Invoice } from '../billing/entities/invoice.entity'; -import { FirstMileService } from '../first-mile/first-mile.service'; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; -import { PriceLineItemDto } from './dto/generate-price-response.dto'; -import { BookingsRepository } from './bookings.repository'; -import { Booking } from './entities/booking.entity'; +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { PriceLineItemDto } from "./dto/generate-price-response.dto"; +import { BookingsRepository } from "./bookings.repository"; +import { Booking } from "./entities/booking.entity"; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ interface StoredPricingBreakdown { @@ -23,6 +23,12 @@ interface StoredPricingBreakdown { currency?: string; } +export interface InvoiceOptions { + dueDate?: Date; + invoiceType?: string; + invoiceStatus?: Freight.InvoiceStatus; +} + /** Round to 2 decimals, avoiding binary float drift. */ const round2 = (n: number): number => Math.round(n * 100) / 100; @@ -56,11 +62,14 @@ export class BookingInvoiceService { * bill (e.g. government bookings whose `companyId` is null, which the invoices * FK requires), or no priced amount. */ - async ensureInvoiceForBooking(booking: Booking): Promise { + async ensureInvoiceForBooking( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Booking, booking.id, - Freight.InvoiceType.Prepaid, + "PREPAID", ); if (existing) return existing; @@ -68,16 +77,9 @@ export class BookingInvoiceService { this.logger.warn( `Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, ); - return null; } - const input = this.buildInput(booking); - if (!input) { - this.logger.warn( - `Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, - ); - return null; - } + const input = this.buildInput(booking, invoiceOptions); return this.billing.generateInvoice(input); } @@ -87,10 +89,10 @@ export class BookingInvoiceService { * reactions live here (not in the payment process): each invoice type advances * the booking its own way. Only PREPAID exists today. */ - @OnEvent('booking.invoice.paid') + @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { switch (payload.type) { - case Freight.InvoiceType.Prepaid: + case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); break; default: @@ -100,6 +102,8 @@ export class BookingInvoiceService { } } + updateStatus = this.billing.updateStatus; + /** * Advance a booking once its prepaid invoice settles — the domain side-effect * of payment, relocated out of the payment service: the booking becomes PAID @@ -114,16 +118,18 @@ export class BookingInvoiceService { private async advanceBookingOnPayment(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { - this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`); + this.logger.warn( + `Cannot advance unknown booking ${bookingId} on payment.`, + ); return; } - if (booking.paymentStatus === 'PAID') return; + if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( Booking, { id: bookingId }, - { paymentStatus: 'PAID', status: 'PAID' }, + { paymentStatus: "PAID", status: "PAID" }, ); await this.firstMile.acceptBooking(bookingId); }); @@ -138,9 +144,13 @@ export class BookingInvoiceService { } /** Map a booking's pricing snapshot into a generic invoice request. */ - private buildInput(booking: Booking): GenerateInvoiceInput | null { - const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; - const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB'; + private buildInput( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): GenerateInvoiceInput { + const breakdown = (booking.pricingBreakdown ?? + {}) as StoredPricingBreakdown; + const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB"; const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({ chargeType: l.code, @@ -155,10 +165,10 @@ export class BookingInvoiceService { // Fall back to a single freight line when no breakdown was snapshotted. if (lines.length === 0) { const amount = Number(booking.totalAmount); - if (!Number.isFinite(amount) || amount <= 0) return null; + if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price"); lines.push({ - chargeType: 'FREIGHT', - description: 'Rail freight', + chargeType: "FREIGHT", + description: "Rail freight", quantity: 1, unitRate: amount, amount, @@ -166,7 +176,9 @@ export class BookingInvoiceService { }); } - const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0)); + const subtotal = round2( + lines.reduce((sum, l) => sum + Number(l.amount), 0), + ); let totalAmount = subtotal; // Honor a staff price override: bill the adjusted total, recording the delta @@ -176,8 +188,8 @@ export class BookingInvoiceService { const delta = round2(Number(adjusted) - subtotal); if (delta !== 0) { lines.push({ - chargeType: 'ADJUSTMENT', - description: 'Staff price adjustment', + chargeType: "ADJUSTMENT", + description: "Staff price adjustment", quantity: 1, unitRate: delta, amount: delta, @@ -190,12 +202,14 @@ export class BookingInvoiceService { return { source: Freight.InvoiceSource.Booking, sourceId: booking.id, - type: Freight.InvoiceType.Prepaid, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency, lines, totalAmount, + dueAt: invoiceOptions.dueDate, + type: invoiceOptions.invoiceType ?? "PREPAID", + status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft, }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2ebceeabc..90ccd0118 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -4,53 +4,56 @@ import { Inject, Injectable, Logger, -} from '@nestjs/common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +} from "@nestjs/common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; -import { eatDay } from '../train-scheduling/batch-window.util'; -import { isRoadService } from './road.util'; -import { RuleEngineService } from '../rule-engine/rule-engine.service'; -import { FilesService } from '../files/files.service'; -import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; -import { BookingContractService } from './booking-contract.service'; -import { BookingInvoiceService } from './booking-invoice.service'; -import { BookingPricingService } from './booking-pricing.service'; -import { BookingsRepository } from './bookings.repository'; -import { assertBookingStatus } from './booking-status.util'; -import { clearanceCodesForBooking } from './clearance.util'; -import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; -import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; -import { PriceLineItemDto } from './dto/generate-price-response.dto'; -import { Booking } from './entities/booking.entity'; -import { BookingsService } from './bookings.service'; +import { assertCanApproveBookingStep } from "../../common/freight-permission.util"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { eatDay } from "../train-scheduling/batch-window.util"; +import { isRoadService } from "./road.util"; +import { RuleEngineService } from "../rule-engine/rule-engine.service"; +import { FilesService } from "../files/files.service"; +import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; +import { BookingContractService } from "./booking-contract.service"; +import { BookingPricingService } from "./booking-pricing.service"; +import { BookingsRepository } from "./bookings.repository"; +import { assertBookingStatus } from "./booking-status.util"; +import { clearanceCodesForBooking } from "./clearance.util"; +import { + computeNextStep, + type BookingNextStep, +} from "./booking-next-step.util"; +import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; +import { PriceLineItemDto } from "./dto/generate-price-response.dto"; +import { Booking } from "./entities/booking.entity"; +import { BookingsService } from "./bookings.service"; +import { BookingInvoiceService } from "./booking-invoice.service"; +import { Freight } from "@edr/types"; @Injectable() export class BookingTransitionService { private readonly logger = new Logger(BookingTransitionService.name); - constructor( private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, private readonly contractService: BookingContractService, - private readonly invoiceService: BookingInvoiceService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, - ) {} + private readonly invoiceService: BookingInvoiceService, + ) { } async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); if (Number(booking.totalAmount) <= 0) { throw new BadRequestException( - 'Generate a price before submitting (POST /bookings/:id/generate-price)', + "Generate a price before submitting (POST /bookings/:id/generate-price)", ); } @@ -69,7 +72,8 @@ export class BookingTransitionService { totalAmount?: number; } | null; const unchanged = this.pricingService.pricesMatch(stored, computed); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); if (unchanged) { await this.pricingService.createPricingSnapshots( @@ -79,7 +83,7 @@ export class BookingTransitionService { ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, } as never); @@ -109,7 +113,7 @@ export class BookingTransitionService { currency: computed.currency, generatedAt: new Date().toISOString(), }, - status: 'PRICE_CHANGED_PENDING_CONFIRM', + status: "PRICE_CHANGED_PENDING_CONFIRM", } as never); const updatedBooking = await this.bookingsService.findById(bookingId); @@ -121,16 +125,17 @@ export class BookingTransitionService { totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, - message: 'Price has changed since preview. Confirm to submit with the updated price.', + message: + "Price has changed since preview. Confirm to submit with the updated price.", }; } async confirmSubmit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]); if (Number(booking.totalAmount) <= 0) { - throw new BadRequestException('No price to confirm'); + throw new BadRequestException("No price to confirm"); } const computed = await this.pricingService.computePriceForBooking(booking); @@ -149,9 +154,10 @@ export class BookingTransitionService { computed.appliedModifiers, ); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, totalAmount: computed.totalAmount, pricingBreakdown: { @@ -173,7 +179,7 @@ export class BookingTransitionService { totalAmount: Number(finalBooking.totalAmount), currency: finalBooking.paymentCurrency, lineItems: computed.lineItems, - message: 'Booking submitted with confirmed price.', + message: "Booking submitted with confirmed price.", }; } @@ -183,17 +189,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); await this.bookingsRepository.createReviewNote( bookingId, note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CHANGES_REQUESTED', + status: "CHANGES_REQUESTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -203,7 +209,7 @@ export class BookingTransitionService { if ((booking.approvalSteps?.length ?? 0) > 0) return; await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); } @@ -217,14 +223,14 @@ export class BookingTransitionService { // Only SUBMITTED bookings are acceptable. A booking that still needs // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and // is therefore never offered for accept until a partner moves it to SUBMITTED. - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); // The backoffice must define how long the accepted contract stays valid. // Without a window the contract has no end date and cannot be relied on, so // accept is blocked until a positive number of days is supplied. if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( - 'A contract validity (in days) is required to accept this booking.', + "A contract validity (in days) is required to accept this booking.", ); } @@ -234,12 +240,12 @@ export class BookingTransitionService { validUntil.setDate(validUntil.getDate() + validityDays); await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); const updated = await this.bookingsRepository.update(bookingId, { - status: 'PENDING_APPROVAL', + status: "PENDING_APPROVAL", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, @@ -255,17 +261,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -283,8 +289,8 @@ export class BookingTransitionService { let booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'PENDING_APPROVAL', - 'APPROVED_PENDING_SIGNATURE', + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", ]); if ((booking.approvalSteps?.length ?? 0) === 0) { @@ -296,14 +302,17 @@ export class BookingTransitionService { bookingId, stepId, ); - if (!step || step.status !== 'PENDING') { - throw new BadRequestException('Approval step not found or already actioned'); + if (!step || step.status !== "PENDING") { + throw new BadRequestException( + "Approval step not found or already actioned", + ); } - const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + const next = + await this.bookingsRepository.findNextPendingApprovalStep(bookingId); if (!next || next.id !== step.id) { throw new BadRequestException( - 'Approval steps must be completed in order', + "Approval steps must be completed in order", ); } @@ -315,29 +324,36 @@ export class BookingTransitionService { const blocksRole = step.blocksRole; if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + throw new BadRequestException( + `Role ${requiredRole} is blocked for this step`, + ); } - await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + "APPROVED", + ); const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { - updates.status = 'APPROVED_PENDING_SIGNATURE'; + if (requiredRole === "LINE_STAFF") { + updates.status = "APPROVED_PENDING_SIGNATURE"; updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (requiredRole === "DIRECTOR") { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (requiredRole === "CEO") { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } - const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + const allDone = + await this.bookingsRepository.allApprovalStepsComplete(bookingId); if (allDone) { - updates.status = 'APPROVED'; + updates.status = "APPROVED"; } if (Object.keys(updates).length > 0) { @@ -359,90 +375,64 @@ export class BookingTransitionService { reason: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + assertBookingStatus(booking, [ + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", + ]); const step = await this.bookingsRepository.findApprovalStepById( bookingId, stepId, ); - if (!step) throw new BadRequestException('Approval step not found'); + if (!step) throw new BadRequestException("Approval step not found"); await this.bookingsRepository.completeApprovalStep( step.id, actorId, - 'REJECTED', + "REJECTED", reason, ); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } async customerSign(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CONTRACT_READY']); + assertBookingStatus(booking, ["CONTRACT_READY"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SIGNED_CUSTOMER', + status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } as never); return this.bookingsService.findById(updated!.id); } - async marketingApprove(bookingId: string, actorId: string): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SIGNED_CUSTOMER']); - - const updated = await this.bookingsRepository.update(bookingId, { - status: 'FULLY_EXECUTED', - fullyExecutedAt: new Date(), - marketingApprovedById: actorId, - marketingApprovedAt: new Date(), - lockedAt: new Date(), - } as never); - - const executed = await this.bookingsService.findById(updated!.id); - - // Billable state reached — generate the invoice payment will settle. - // Non-blocking: a billing hiccup must not undo the execution. - await this.invoiceService - .ensureInvoiceForBooking(executed) - .catch((err) => - this.logger.error( - `Failed to generate invoice for booking ${executed.reference}: ${ - err instanceof Error ? err.message : String(err) - }`, - ), - ); - - return executed; - } - async startTransit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PAID']); + assertBookingStatus(booking, ["PAID"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'IN_TRANSIT', + status: "IN_TRANSIT", } as never); return this.bookingsService.findById(updated!.id); } async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['IN_TRANSIT']); + assertBookingStatus(booking, ["IN_TRANSIT"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'COMPLETED', + status: "COMPLETED", endDate: new Date(), } as never); return this.bookingsService.findById(updated!.id); @@ -451,22 +441,22 @@ export class BookingTransitionService { async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'CHANGES_REQUESTED', - 'PENDING_APPROVAL', - 'CONTRACT_READY', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "CHANGES_REQUESTED", + "PENDING_APPROVAL", + "CONTRACT_READY", ]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CANCELLED', + status: "CANCELLED", } as never); return this.bookingsService.findById(updated!.id); } @@ -479,20 +469,20 @@ export class BookingTransitionService { async reject(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'PENDING_CONSOLIDATION', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "PENDING_CONSOLIDATION", ]); await this.bookingsRepository.createReviewNote( bookingId, - reason?.trim() || 'Customer rejected the price estimate.', - 'REJECTION', + reason?.trim() || "Customer rejected the price estimate.", + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -513,10 +503,10 @@ export class BookingTransitionService { fileKey: string; label: string; required: boolean; - uploadedBy: 'customer' | 'gl'; + uploadedBy: "customer" | "gl"; settingCode: string; file: { id: string; name: string; url: string } | null; - reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null; note: string | null; }>; allApproved: boolean; @@ -525,20 +515,21 @@ export class BookingTransitionService { const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const files = await this.filesService.findByResource(bookingId, "bookings"); const fileByCode = new Map(files.map((f) => [f.code, f])); - const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviews = + await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map( reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), ); const documents: Awaited< - ReturnType - >['documents'] = []; + ReturnType + >["documents"] = []; const pushSetting = async ( code: string | null, - uploadedBy: 'customer' | 'gl', + uploadedBy: "customer" | "gl", ) => { if (!code) return; let setting; @@ -556,28 +547,26 @@ export class BookingTransitionService { required: field.isRequired, uploadedBy, settingCode: code, - file: file - ? { id: file.id, name: file.name, url: file.url } - : null, + file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } }; - await pushSetting(inputCode, 'customer'); - await pushSetting(outputCode, 'gl'); + await pushSetting(inputCode, "customer"); + await pushSetting(outputCode, "gl"); // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. for (const f of files) { - if (!f.code?.startsWith('custom_')) continue; + if (!f.code?.startsWith("custom_")) continue; const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, label: f.name, required: false, - uploadedBy: 'customer', - settingCode: 'custom', + uploadedBy: "customer", + settingCode: "custom", file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, @@ -611,13 +600,15 @@ export class BookingTransitionService { } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return true; - const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + const reviews = await this.bookingsRepository.findDocumentReviews( + booking.id, + ); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && - r.status === 'APPROVED', + r.status === "APPROVED", ), ); } @@ -632,33 +623,38 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + ]); const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) { - throw new BadRequestException('This booking has no document-clearance step'); + throw new BadRequestException( + "This booking has no document-clearance step", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } // First submission (nothing in review yet): every required input field must // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer // is only fixing queried/pending docs, so the already-uploaded required docs // stay in place and we don't re-gate on the full required set. - if (booking.status === 'AWAITING_DOCUMENTS') { + if (booking.status === "AWAITING_DOCUMENTS") { await this.assertRequiredInputsPresent(bookingId, inputCode, files); } for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. - const settingCode = file.fieldname.startsWith('custom_') - ? 'custom' + const settingCode = file.fieldname.startsWith("custom_") + ? "custom" : inputCode; await this.bookingsRepository.upsertDocumentReviewPending({ bookingId, @@ -669,7 +665,7 @@ export class BookingTransitionService { } await this.bookingsRepository.update(bookingId, { - status: 'DOCUMENTS_UNDER_REVIEW', + status: "DOCUMENTS_UNDER_REVIEW", } as never); return this.bookingsService.findById(bookingId); } @@ -694,7 +690,10 @@ export class BookingTransitionService { const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; - const existing = await this.filesService.findByResource(bookingId, 'bookings'); + const existing = await this.filesService.findByResource( + bookingId, + "bookings", + ); const presentKeys = new Set([ ...existing.map((f) => f.code), ...files.map((f) => f.fieldname), @@ -702,7 +701,7 @@ export class BookingTransitionService { const missing = required.filter((f) => !presentKeys.has(f.fileKey)); if (missing.length > 0) { - const labels = missing.map((f) => f.fileLabel).join(', '); + const labels = missing.map((f) => f.fileLabel).join(", "); throw new BadRequestException( `Please upload all required documents before submitting: ${labels}`, ); @@ -713,22 +712,27 @@ export class BookingTransitionService { async reviewDocument( bookingId: string, fileKey: string, - status: 'APPROVED' | 'QUERIED', + status: "APPROVED" | "QUERIED", staffId: string, note?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { inputCode, outputCode } = clearanceCodesForBooking(booking); - const existing = await this.bookingsRepository.findDocumentReviews(bookingId); + const existing = + await this.bookingsRepository.findDocumentReviews(bookingId); const match = existing.find((r) => r.fileKey === fileKey); const settingCode = match?.settingCode ?? - (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + (fileKey.startsWith("custom_") + ? "custom" + : (inputCode ?? outputCode ?? "custom")); - if (status === 'QUERIED' && !note?.trim()) { - throw new BadRequestException('A note is required when querying a document'); + if (status === "QUERIED" && !note?.trim()) { + throw new BadRequestException( + "A note is required when querying a document", + ); } await this.bookingsRepository.setDocumentReviewStatus( @@ -739,11 +743,11 @@ export class BookingTransitionService { staffId, note, ); - if (status === 'QUERIED') { + if (status === "QUERIED") { await this.bookingsRepository.createReviewNote( bookingId, `Document "${fileKey}" queried: ${note}`, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", staffId, ); } @@ -756,18 +760,20 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { outputCode } = clearanceCodesForBooking(booking); if (!outputCode) { - throw new BadRequestException('This booking has no customs output documents'); + throw new BadRequestException( + "This booking has no customs output documents", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } for (const file of files) { await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); @@ -781,19 +787,23 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const approved = await this.isClearanceFullyApproved(booking); if (!approved) { throw new BadRequestException( - 'All required documents must be approved before clearance can be finalized', + "All required documents must be approved before clearance can be finalized", ); } const { outputCode } = clearanceCodesForBooking(booking); if (outputCode) { - const setting = await this.fileUploadSettingsService.getByCode(outputCode); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const setting = + await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource( + bookingId, + "bookings", + ); const uploaded = new Set(files.map((f) => f.code)); const missing = (setting.fields ?? []).filter( (f) => f.isRequired && !uploaded.has(f.fileKey), @@ -802,13 +812,13 @@ export class BookingTransitionService { throw new BadRequestException( `Upload all required customs output documents first: ${missing .map((m) => m.fileLabel) - .join(', ')}`, + .join(", ")}`, ); } } await this.bookingsRepository.update(bookingId, { - status: 'CLEARANCE_READY', + status: "CLEARANCE_READY", } as never); return this.bookingsService.findById(bookingId); } @@ -827,11 +837,14 @@ export class BookingTransitionService { scheduledDate: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); + assertBookingStatus(booking, [ + "CLEARANCE_READY", + "OPERATION_CHANGES_REQUESTED", + ]); const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { - throw new BadRequestException('A valid schedule date is required'); + throw new BadRequestException("A valid schedule date is required"); } // The binding shipment day must have at least one OPEN departure on the @@ -844,12 +857,12 @@ export class BookingTransitionService { ); if (!hasDeparture) { throw new BadRequestException( - 'No departures available on the selected day for this route', + "No departures available on the selected day for this route", ); } await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_REQUEST_PENDING', + status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); return this.bookingsService.findById(bookingId); @@ -865,27 +878,27 @@ export class BookingTransitionService { */ async reviewOperationRequest( bookingId: string, - decision: 'ACCEPT' | 'REQUEST_CHANGES', + decision: "ACCEPT" | "REQUEST_CHANGES", actorId: string, options: { note?: string } = {}, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); + assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]); - if (decision === 'REQUEST_CHANGES') { + if (decision === "REQUEST_CHANGES") { if (!options.note?.trim()) { throw new BadRequestException( - 'A note is required when requesting changes', + "A note is required when requesting changes", ); } await this.bookingsRepository.createReviewNote( bookingId, options.note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_CHANGES_REQUESTED', + status: "OPERATION_CHANGES_REQUESTED", } as never); return this.bookingsService.findById(bookingId); } @@ -907,9 +920,17 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, + ); + await this.invoiceService.updateStatus( + invoice.id, + Freight.InvoiceStatus.Pending, + ); if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { - status: 'ROAD_DISPATCH_PENDING', + status: "ROAD_DISPATCH_PENDING", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -917,7 +938,7 @@ export class BookingTransitionService { } await this.bookingsRepository.update(booking.id, { - status: 'FULLY_EXECUTED', + status: "FULLY_EXECUTED", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -932,21 +953,23 @@ export class BookingTransitionService { return this.bookingsService.findById(booking.id); } - async enrichBookingResponse(booking: Booking): Promise { + async enrichBookingResponse(booking: Booking): Promise< + Booking & { + latestChangeRequestNote?: string | null; + contractSummary?: string | null; + nextStep: BookingNextStep | null; + } + > { const note = await this.bookingsRepository.findLatestReviewNote( booking.id, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", ); const summary = booking.contractSummary ?? this.contractService.buildContractSummary(booking); const nextPending = - booking.status === 'PENDING_APPROVAL' || - booking.status === 'APPROVED_PENDING_SIGNATURE' + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; const nextStep = computeNextStep(booking, nextPending); @@ -957,4 +980,4 @@ export class BookingTransitionService { nextStep, }; } -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 0f413199b..9548eee88 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -14,12 +14,12 @@ import { UnauthorizedException, UploadedFiles, UseInterceptors, -} from '@nestjs/common'; -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 { AnyFilesInterceptor } from '@nestjs/platform-express'; +} from "@nestjs/common"; +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 { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiBody, @@ -27,20 +27,20 @@ import { ApiOkResponse, ApiOperation, ApiTags, -} from '@nestjs/swagger'; -import type { Response } from 'express'; +} from "@nestjs/swagger"; +import type { Response } from "express"; -import { BookingContractService } from './booking-contract.service'; -import { BookingPricingService } from './booking-pricing.service'; -import { BookingTransitionService } from './booking-transition.service'; -import { BookingReferenceDataService } from './booking-reference-data.service'; -import { BookingsService } from './bookings.service'; -import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; -import { CreateBookingDto } from './dto/create-booking.dto'; -import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; -import { FilterBookingDto } from './dto/filter-booking.dto'; -import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; -import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +import { BookingContractService } from "./booking-contract.service"; +import { BookingPricingService } from "./booking-pricing.service"; +import { BookingTransitionService } from "./booking-transition.service"; +import { BookingReferenceDataService } from "./booking-reference-data.service"; +import { BookingsService } from "./bookings.service"; +import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; +import { CreateBookingDto } from "./dto/create-booking.dto"; +import { BookingListSummaryDto } from "./dto/booking-list-summary.dto"; +import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto"; +import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; import { AcceptIntakeDto, ApproveStepDto, @@ -52,18 +52,21 @@ import { RequestOperationDto, OperationReviewDto, StaffRejectDto, -} from './dto/request-changes.dto'; -import { ContractViewDto } from './dto/contract-view.dto'; -import { SignContractDto } from './dto/sign-contract.dto'; -import { UpdateBookingDto } from './dto/update-booking.dto'; +} from "./dto/request-changes.dto"; +import { ContractViewDto } from "./dto/contract-view.dto"; +import { SignContractDto } from "./dto/sign-contract.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; import { type AuthUserPayload, resolveAuthUserId, -} from '../../common/resolve-auth-user-id'; -import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +} from "../../common/resolve-auth-user-id"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; -@ApiTags('bookings') -@Controller('bookings') +@ApiTags("bookings") +@Controller("bookings") @ApiBearerAuth() export class BookingsController { constructor( @@ -72,12 +75,12 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, - ) {} + ) { } @Post() @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @ApiBody({ type: CreateBookingDto }) async create( @Body() dto: CreateBookingDto, @@ -87,15 +90,24 @@ export class BookingsController { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } - const result = await this.bookingsService.create(dto, files ?? [], user?.id); + const result = await this.bookingsService.create( + dto, + files ?? [], + user?.id, + ); // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.bookings.staffAccept, + ); if (isStaff && !dto.isGovernment) { try { await this.pricingService.generatePrice(result.booking.id); await this.transitionService.submit(result.booking.id); - const submitted = await this.bookingsService.findById(result.booking.id); + const submitted = await this.bookingsService.findById( + result.booking.id, + ); return { booking: submitted, warnings: result.warnings }; } catch { // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. @@ -105,16 +117,16 @@ export class BookingsController { return result; } - @Patch(':id') + @Patch(":id") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Update booking', - description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + summary: "Update booking", + description: "Allowed when status is DRAFT or CHANGES_REQUESTED.", }) @ApiBody({ type: UpdateBookingDto }) update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { @@ -122,7 +134,7 @@ export class BookingsController { } @Get() - @ApiOperation({ summary: 'List freight bookings (paginated)' }) + @ApiOperation({ summary: "List freight bookings (paginated)" }) async findAll( @Query() filter: FilterBookingDto, @CurrentUser() user: TCurrentUser, @@ -139,7 +151,7 @@ export class BookingsController { return this.bookingsService.findClearanceQueue(filter); } const userId = user?.id; - if (!userId) throw new UnauthorizedException('Authentication required'); + if (!userId) throw new UnauthorizedException("Authentication required"); const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). @@ -165,27 +177,29 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } - @Get('by-company/:companyId/customer-view') - @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) + @Get("by-company/:companyId/customer-view") + @ApiOperation({ + summary: "List bookings for a company (customer-view shape, backoffice)", + }) findByCompanyCustomerView( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ) { return this.bookingsService.findCustomerBookings(companyId); } - @Get('list-summary') - @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @Get("list-summary") + @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { return this.bookingsService.getListSummary(filter); } - @Get('my') + @Get("my") @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: - 'Bookings owned by the authenticated user\'s company that are payable ' + - '(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', + "Bookings owned by the authenticated user's company that are payable " + + "(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.", }) findMyPayable( @CurrentUser() user: AuthUserPayload, @@ -194,32 +208,32 @@ export class BookingsController { return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); } - @Get('queues/:queue') + @Get("queues/:queue") @ApiOperation({ - summary: 'List bookings for a dashboard queue', - description: 'Queues: intake, approval, signatures, marketing, finance', + summary: "List bookings for a dashboard queue", + description: "Queues: intake, approval, signatures, marketing, finance", }) findQueue( - @Param('queue') queue: string, + @Param("queue") queue: string, @Query() filter: FilterBookingDto, - @Query('excludeBulk') excludeBulk?: string, + @Query("excludeBulk") excludeBulk?: string, ) { return this.bookingsService.findQueue(queue, filter, { - excludeBulk: excludeBulk === 'true', + excludeBulk: excludeBulk === "true", }); } - @Get('reference-data') - @ApiOperation({ summary: 'Booking form catalog' }) + @Get("reference-data") + @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } - @Get('by-reference/:reference') - @ApiOperation({ summary: 'Get booking by reference' }) + @Get("by-reference/:reference") + @ApiOperation({ summary: "Get booking by reference" }) async findByReference( - @Param('reference') reference: string, + @Param("reference") reference: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findByReference(reference); @@ -233,10 +247,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id') - @ApiOperation({ summary: 'Get booking by ID' }) + @Get(":id") + @ApiOperation({ summary: "Get booking by ID" }) async findOne( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -254,15 +268,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/tracking') + @Get(":id/tracking") @ApiOperation({ - summary: 'Shipment tracking timeline for a booking', + summary: "Shipment tracking timeline for a booking", description: "Returns the booking's consignment (once dispatched) and its ordered " + - 'tracking events. Scoped to the customer\'s own company.', + "tracking events. Scoped to the customer's own company.", }) async findTracking( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -276,66 +290,66 @@ export class BookingsController { return this.bookingsService.getBookingTracking(id); } - @Delete(':id') + @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete DRAFT booking" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } - @Post(':id/documents') + @Post(":id/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) async uploadDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.bookingsService.uploadDocuments(id, files ?? []); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/generate-price') + @Post(":id/generate-price") @ApiOperation({ - summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", description: - 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + "Computes and stores a price preview on the booking. Does not create rate snapshots.", }) @ApiOkResponse({ type: GeneratePriceResponseDto }) - generatePrice(@Param('id', ParseUUIDPipe) id: string) { + generatePrice(@Param("id", ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } - @Post(':id/submit') + @Post(":id/submit") @ApiOperation({ - summary: 'Customer submit booking', + summary: "Customer submit booking", description: - 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + "Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - submit(@Param('id', ParseUUIDPipe) id: string) { + submit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } - @Post(':id/confirm-submit') + @Post(":id/confirm-submit") @ApiOperation({ - summary: 'Confirm submit after price change', + summary: "Confirm submit after price change", description: - 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + "Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + confirmSubmit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } - @Post(':id/reject') + @Post(":id/reject") @ApiOperation({ - summary: 'Customer reject price estimate', + summary: "Customer reject price estimate", description: - 'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.', + "Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.", }) async reject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.reject(id, dto.reason); @@ -344,22 +358,23 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── - @Get(':id/clearance') + @Get(":id/clearance") @ApiOperation({ - summary: 'Document-clearance grid (required docs + upload + GL review status)', + summary: + "Document-clearance grid (required docs + upload + GL review status)", }) - getClearance(@Param('id', ParseUUIDPipe) id: string) { + getClearance(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.getClearanceView(id); } - @Post(':id/clearance/documents') + @Post(":id/clearance/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Customer uploads clearance documents (fieldname = document key)', + summary: "Customer uploads clearance documents (fieldname = document key)", }) async submitClearanceDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.submitClearanceDocuments( @@ -369,14 +384,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/proceed') + @Post(":id/clearance/proceed") @ApiOperation({ summary: - 'Customer requests operation with a schedule day ' + - '(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)', + "Customer requests operation with a schedule day " + + "(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)", }) async proceedToOperation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, ) { const booking = await this.transitionService.requestOperation( @@ -386,15 +401,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operation/review') + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Operations reviews an operation request: ACCEPT (→ batch pool), ' + - 'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)', + "Operations reviews an operation request: ACCEPT (→ batch pool), " + + "REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)", }) async reviewOperationRequest( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: OperationReviewDto, @CurrentUser() user: AuthUserPayload, ) { @@ -407,11 +422,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/review') + @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) - @ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' }) + @ApiOperation({ + summary: "GL reviews a clearance document (Approve | Query)", + }) async reviewClearanceDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, @CurrentUser() user: AuthUserPayload, ) { @@ -425,13 +442,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/output-documents') + @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" }) async uploadClearanceOutput( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( @@ -441,21 +458,22 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize') + @Post(":id/clearance/finalize") @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ - summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY', + summary: + "GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", }) - async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.finalizeClearance(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/request-changes') + @Post(":id/staff/request-changes") @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) - @ApiOperation({ summary: 'Staff return booking for customer updates' }) + @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { @@ -467,14 +485,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/accept') + @Post(":id/staff/accept") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: - 'Staff accept intake → set contract validity window + start approval chain', + "Staff accept intake → set contract validity window + start approval chain", }) async acceptIntake( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AcceptIntakeDto, @CurrentUser() user: AuthUserPayload, ) { @@ -486,11 +504,11 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/reject') + @Post(":id/staff/reject") @BookingStaff(FREIGHT_PERMS.bookings.reject) - @ApiOperation({ summary: 'Staff final reject' }) + @ApiOperation({ summary: "Staff final reject" }) async staffReject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, @CurrentUser() user: AuthUserPayload, ) { @@ -502,11 +520,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/government-expedite') + @Post(":id/government-expedite") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) - @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) + @ApiOperation({ + summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", + }) async governmentExpedite( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingsService.governmentExpedite( @@ -516,16 +536,16 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/approve') + @Post(":id/approval-steps/:stepId/approve") @BookingStaff([ FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.approveCeo, ]) - @ApiOperation({ summary: 'Approve one approval step in sequence' }) + @ApiOperation({ summary: "Approve one approval step in sequence" }) async approveStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { @@ -539,12 +559,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/reject') + @Post(":id/approval-steps/:stepId/reject") @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) - @ApiOperation({ summary: 'Reject at approval step' }) + @ApiOperation({ summary: "Reject at approval step" }) async rejectStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, @CurrentUser() user: AuthUserPayload, ) { @@ -557,53 +577,53 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/contract/generate') + @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) - @ApiOperation({ summary: 'Generate contract PDF from template' }) - async generateContract(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Generate contract PDF from template" }) + async generateContract(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/view') + @Get(":id/contract/view") @ApiOkResponse({ type: ContractViewDto }) - @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + @ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) getContractView( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Request() req: { user?: { id?: string; sub?: string } }, ) { const userId = req.user?.id ?? req.user?.sub; return this.contractService.getContractView(id, userId); } - @Get(':id/contract/document') - @ApiOperation({ summary: 'Download contract PDF' }) + @Get(":id/contract/document") + @ApiOperation({ summary: "Download contract PDF" }) async downloadContractDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { const { stream, record } = await this.contractService.streamContract(id); - res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader("Content-Type", record.mimeType ?? "application/pdf"); res.setHeader( - 'Content-Disposition', + "Content-Disposition", `attachment; filename="${record.name}"`, ); stream.pipe(res); } - @Get(':id/contract') - @ApiOperation({ summary: 'Download contract file (alias)' }) + @Get(":id/contract") + @ApiOperation({ summary: "Download contract file (alias)" }) async downloadContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { return this.downloadContractDocument(id, res); } - @Post(':id/contract/sign') - @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) + @Post(":id/contract/sign") + @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { @@ -615,28 +635,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/signatures') - @ApiOperation({ summary: 'List contract signatures' }) - getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/contract/signatures") + @ApiOperation({ summary: "List contract signatures" }) + getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } - @Get(':id/summary') - @ApiOperation({ summary: 'Contract summary string for dashboard' }) - getSummary(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/summary") + @ApiOperation({ summary: "Contract summary string for dashboard" }) + getSummary(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } - @Post(':id/customer/sign') + @Post(":id/customer/sign") @ApiOperation({ - summary: 'Customer digital signature (deprecated — use POST contract/sign)', + summary: "Customer digital signature (deprecated — use POST contract/sign)", }) async customerSign( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { - const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const payload: SignContractDto = { ...dto, role: "CUSTOMER" }; const booking = await this.contractService.signContract(id, payload, { signerUserId: req.user?.id ?? req.user?.sub, ipAddress: req.ip, @@ -644,20 +664,21 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/marketing/approve') + @Post(":id/marketing/approve") @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ - summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + summary: + "Staff contract signature and fully execute (use contract/sign STAFF preferred)", }) async marketingApprove( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, @Request() req: { ip?: string }, ) { const payload: SignContractDto = { ...dto, - role: 'STAFF', + role: "STAFF", }; const booking = await this.contractService.signContract(id, payload, { signerUserId: resolveAuthUserId(user), @@ -666,48 +687,48 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/start-transit') + @Post(":id/operations/start-transit") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark in transit' }) - async startTransit(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark in transit" }) + async startTransit(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.startTransit(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/complete') + @Post(":id/operations/complete") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark completed' }) - async complete(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark completed" }) + async complete(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.complete(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/cancel') + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) - @ApiOperation({ summary: 'Cancel booking' }) + @ApiOperation({ summary: "Cancel booking" }) async cancel( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelBookingDto, ) { const booking = await this.transitionService.cancel(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/consolidation') - @ApiOperation({ summary: 'Request freight consolidation' }) - requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Post(":id/consolidation") + @ApiOperation({ summary: "Request freight consolidation" }) + requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } - @Delete(':id/consolidation') - @ApiOperation({ summary: 'Remove consolidation pairing' }) - removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Delete(":id/consolidation") + @ApiOperation({ summary: "Remove consolidation pairing" }) + removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } - @Get(':id/consolidation') - @ApiOperation({ summary: 'Get consolidation details' }) - getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/consolidation") + @ApiOperation({ summary: "Get consolidation details" }) + getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 5d7e3b2c9..4cd5d10df 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,44 +1,44 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { Module, forwardRef } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; // import { CustomersModule } from '../customers/customers.module'; -import { CompaniesModule } from '../companies/companies.module'; -import { FilesModule } from '../files/files.module'; -import { MinioModule } from '../minio/minio.module'; -import { RuleEngineModule } from '../rule-engine/rule-engine.module'; -import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; -import { SignaturesModule } from '../signatures/signatures.module'; -import { BillingModule } from '../billing/billing.module'; -import { FirstMileModule } from '../first-mile/first-mile.module'; -import { BookingContractService } from './booking-contract.service'; -import { BookingInvoiceService } from './booking-invoice.service'; -import { BookingPaymentController } from './booking-payment.controller'; -import { BookingPaymentService } from './booking-payment.service'; -import { BookingPricingService } from './booking-pricing.service'; -import { BookingReferenceDataService } from './booking-reference-data.service'; -import { BookingTransitionService } from './booking-transition.service'; -import { BookingsController } from './bookings.controller'; -import { PayController } from './pay.controller'; -import { BookingsRepository } from './bookings.repository'; -import { ConsolidationService } from './consolidation.service'; -import { BookingsService } from './bookings.service'; -import { BookingApprovalStep } from './entities/booking-approval-step.entity'; -import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; -import { BookingDocumentReview } from './entities/booking-document-review.entity'; -import { BookingContainer } from './entities/booking-container.entity'; -import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; -import { BookingContractSignature } from './entities/booking-contract-signature.entity'; -import { BookingReviewNote } from './entities/booking-review-note.entity'; -import { Booking } from './entities/booking.entity'; -import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; -import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; -import { ContractRendererService } from '../../contracts/contract-renderer.service'; -import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; -import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { CompaniesModule } from "../companies/companies.module"; +import { FilesModule } from "../files/files.module"; +import { MinioModule } from "../minio/minio.module"; +import { RuleEngineModule } from "../rule-engine/rule-engine.module"; +import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; +import { SignaturesModule } from "../signatures/signatures.module"; +import { BillingModule } from "../billing/billing.module"; +import { FirstMileModule } from "../first-mile/first-mile.module"; +import { BookingContractService } from "./booking-contract.service"; +import { BookingInvoiceService } from "./booking-invoice.service"; +import { BookingPaymentController } from "./booking-payment.controller"; +import { BookingPaymentService } from "./booking-payment.service"; +import { BookingPricingService } from "./booking-pricing.service"; +import { BookingReferenceDataService } from "./booking-reference-data.service"; +import { BookingTransitionService } from "./booking-transition.service"; +import { BookingsController } from "./bookings.controller"; +import { PayController } from "./pay.controller"; +import { BookingsRepository } from "./bookings.repository"; +import { ConsolidationService } from "./consolidation.service"; +import { BookingsService } from "./bookings.service"; +import { BookingApprovalStep } from "./entities/booking-approval-step.entity"; +import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity"; +import { BookingDocumentReview } from "./entities/booking-document-review.entity"; +import { BookingContainer } from "./entities/booking-container.entity"; +import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity"; +import { BookingContractSignature } from "./entities/booking-contract-signature.entity"; +import { BookingReviewNote } from "./entities/booking-review-note.entity"; +import { Booking } from "./entities/booking.entity"; +import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; +import { ContractPdfService } from "../../contracts/contract-pdf.service"; +import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; +import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; @Module({ imports: [ @@ -66,7 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, + config.get("app.cbeExchange") ?? {}, }), ], controllers: [BookingsController, PayController, BookingPaymentController], @@ -86,6 +86,11 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService], + exports: [ + BookingsService, + BookingsRepository, + BookingPricingService, + BookingInvoiceService, + ], }) -export class BookingsModule {} +export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 5cd06091a..9e974b31e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -110,6 +110,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index f31d8561d..112c044fc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -4,22 +4,24 @@ import { Logger, NotFoundException, OnModuleInit, -} from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { Cron, SchedulerRegistry } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { Cron, SchedulerRegistry } from "@nestjs/schedule"; +import { DataSource } from "typeorm"; +import { Freight } from "@edr/types"; -import { Booking } from '../bookings/entities/booking.entity'; -import { BookingsRepository } from '../bookings/bookings.repository'; -import { Locomotive } from '../locomotives/entities/locomotive.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; -import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; -import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; -import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; -import { BookingNotifierService } from './booking-notifier.service'; -import { TrainSchedulingService } from './train-scheduling.service'; -import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { BillingService } from "../billing/billing.service"; +import { Booking } from "../bookings/entities/booking.entity"; +import { BookingsRepository } from "../bookings/bookings.repository"; +import { Locomotive } from "../locomotives/entities/locomotive.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; +import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository"; +import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository"; +import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity"; +import { BookingNotifierService } from "./booking-notifier.service"; +import { TrainSchedulingService } from "./train-scheduling.service"; +import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util"; import { BATCH_CRON, BATCH_TIMEZONE, @@ -27,13 +29,13 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, PAYMENT_WINDOW_MS, -} from './booking-batch.constants'; +} from "./booking-batch.constants"; import { bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, wagonTypeDimensionsFromEntity, -} from './train-capacity.util'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +} from "./train-capacity.util"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -53,12 +55,12 @@ interface RouteDayGroup { type WagonLengths = { container: number; bulk: number }; export type BatchBoardBookingState = - | 'ALLOCATED' - | 'SELECTED_FOR_BATCH' - | 'READY' - | 'WAITING' - | 'PENDING_CONTRACT' - | 'EXPIRED'; + | "ALLOCATED" + | "SELECTED_FOR_BATCH" + | "READY" + | "WAITING" + | "PENDING_CONTRACT" + | "EXPIRED"; export interface BatchBoardBooking { id: string; @@ -73,10 +75,10 @@ export interface BatchBoardBooking { } export type BookingAllocationStatus = - | 'NOT_ATTEMPTED' - | 'ASSIGNED' - | 'DEFERRED' - | 'FAILED'; + | "NOT_ATTEMPTED" + | "ASSIGNED" + | "DEFERRED" + | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { fullyExecutedAt: string | null; @@ -114,9 +116,9 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; - locomotive: BatchBoardSchedule['locomotive']; - capacity: BatchBoardSchedule['capacity']; - counts: BatchBoardSchedule['counts']; + locomotive: BatchBoardSchedule["locomotive"]; + capacity: BatchBoardSchedule["capacity"]; + counts: BatchBoardSchedule["counts"]; windows: BatchWindowGroup[]; pendingContract: BatchWindowGroup; allocationViolations: string[]; @@ -179,7 +181,8 @@ export class BookingBatchService implements OnModuleInit { private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, - ) {} + private readonly billing: BillingService, + ) { } /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { @@ -195,10 +198,10 @@ export class BookingBatchService implements OnModuleInit { } const reserved = await this.dataSource .getRepository(Booking) - .createQueryBuilder('b') - .select('DISTINCT b.train_schedule_id', 'scheduleId') + .createQueryBuilder("b") + .select("DISTINCT b.train_schedule_id", "scheduleId") .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) - .andWhere('b.train_schedule_id IS NOT NULL') + .andWhere("b.train_schedule_id IS NOT NULL") .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of reserved) this.armSettle(scheduleId); } @@ -276,7 +279,7 @@ export class BookingBatchService implements OnModuleInit { /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ private async openRouteDayGroups(): Promise { const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, + where: { bookingWindowStatus: "OPEN" }, }); const groups = new Map(); for (const s of open) { @@ -310,25 +313,29 @@ export class BookingBatchService implements OnModuleInit { if (!booking?.trainScheduleId) return; const isBatchPaid = - booking.status === 'SELECTED_FOR_BATCH' || - booking.status === 'AWAITING_PAYMENT' || - booking.status === 'PAID' || - booking.paymentStatus === 'PAID'; + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" || + booking.status === "PAID" || + booking.paymentStatus === "PAID"; if (!isBatchPaid) return; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID', status: 'PAID' }); - } else if (booking.paymentStatus !== 'PAID') { + .update(bookingId, { paymentStatus: "PAID", status: "PAID" }); + } else if (booking.paymentStatus !== "PAID") { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); + .update(bookingId, { paymentStatus: "PAID" }); } - const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + const linked = + await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { - await this.allocate(booking.trainScheduleId, booking, 'paid'); + await this.allocate(booking.trainScheduleId, booking, "paid"); this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, ); @@ -338,7 +345,7 @@ export class BookingBatchService implements OnModuleInit { booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( @@ -349,7 +356,11 @@ export class BookingBatchService implements OnModuleInit { `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`, ); } - if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) { + if ( + result.issues.some( + (i) => i.bookingId === bookingId && i.status !== "ASSIGNED", + ) + ) { const issue = result.issues.find((i) => i.bookingId === bookingId); this.logger.warn( `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, @@ -364,9 +375,10 @@ export class BookingBatchService implements OnModuleInit { /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { - const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); + const unlinked = + await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); for (const booking of unlinked) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); @@ -375,7 +387,7 @@ export class BookingBatchService implements OnModuleInit { // ---- cron entry point ----------------------------------------------------- - @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) + @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE }) async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -405,7 +417,7 @@ export class BookingBatchService implements OnModuleInit { destinationStation: true, route: true, }, - order: { scheduledDepartureDate: 'ASC' }, + order: { scheduledDepartureDate: "ASC" }, }); const wagonLengths = await this.loadWagonLengths(); @@ -413,7 +425,7 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; + if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -425,13 +437,15 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), }; }); @@ -442,11 +456,15 @@ export class BookingBatchService implements OnModuleInit { } /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ - async getBatchBoardDetail(scheduleId: string): Promise { - const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') { - throw new BadRequestException('Schedule is no longer active'); + async getBatchBoardDetail( + scheduleId: string, + ): Promise { + const s = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!s) + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (s.status === "ARRIVED" || s.status === "CANCELLED") { + throw new BadRequestException("Schedule is no longer active"); } const wagonLengths = await this.loadWagonLengths(); @@ -456,12 +474,18 @@ export class BookingBatchService implements OnModuleInit { const bookings = await this.bookingsRepository.findAllBySchedule(s.id); let allocationPreview: Awaited< - ReturnType + ReturnType >; try { - allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id); + allocationPreview = + await this.trainSchedulingService.previewAllocationForSchedule(s.id); } catch { - allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] }; + allocationPreview = { + assignedBookingIds: [], + deferred: [], + issues: [], + violations: [], + }; } const allocationByBooking = new Map( allocationPreview.issues.map((i) => [i.bookingId, i]), @@ -474,17 +498,23 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), - fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, - selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null, - allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED', + fullyExecutedAt: b.fullyExecutedAt + ? b.fullyExecutedAt.toISOString() + : null, + selectedForBatchAt: b.selectedForBatchAt + ? b.selectedForBatchAt.toISOString() + : null, + allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, }; }); @@ -514,11 +544,11 @@ export class BookingBatchService implements OnModuleInit { const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { const counts = emptyCounts(); for (const b of bookingsInWindow) { - if (b.state === 'ALLOCATED') counts.allocated += 1; - else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1; - else if (b.state === 'READY') counts.ready += 1; - else if (b.state === 'WAITING') counts.waiting += 1; - else if (b.state === 'EXPIRED') counts.expired += 1; + if (b.state === "ALLOCATED") counts.allocated += 1; + else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; + else if (b.state === "READY") counts.ready += 1; + else if (b.state === "WAITING") counts.waiting += 1; + else if (b.state === "EXPIRED") counts.expired += 1; else counts.pendingContract += 1; } return counts; @@ -526,7 +556,7 @@ export class BookingBatchService implements OnModuleInit { const windows: BatchWindowGroup[] = []; for (const [key, bucket] of windowBuckets) { - if (key === 'pending-contract' || !bucket.window) continue; + if (key === "pending-contract" || !bucket.window) continue; const w = bucket.window; windows.push({ key: w.key, @@ -539,44 +569,51 @@ export class BookingBatchService implements OnModuleInit { bookings: bucket.items, }); } - windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + windows.sort( + (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), + ); - const pendingBookings = windowBuckets.get('pending-contract')?.items ?? []; + const pendingBookings = windowBuckets.get("pending-contract")?.items ?? []; return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, routeName: s.route?.name ?? null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, capacity: this.computeBoardCapacity(items, loco), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, windows, pendingContract: { - key: 'pending-contract', - label: 'Pending contract', - date: '', - dateLabel: '', - start: '', - end: '', + key: "pending-contract", + label: "Pending contract", + date: "", + dateLabel: "", + start: "", + end: "", counts: countFor(pendingBookings), bookings: pendingBookings, }, @@ -597,17 +634,21 @@ export class BookingBatchService implements OnModuleInit { lengthMeters: number; }>, loco: Locomotive | null, - ): BatchBoardSchedule['capacity'] { - const allocated = items.filter((i) => i.state === 'ALLOCATED'); + ): BatchBoardSchedule["capacity"] { + const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( - (i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH', + (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), allocatedLengthMeters: - Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100, + Math.round( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, + ) / 100, maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, + usedWeightTons: + Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / + 100, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, }; } @@ -623,51 +664,66 @@ export class BookingBatchService implements OnModuleInit { trainNumber: s.trainNumber ?? null, routeName: s.route?.name ?? null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, capacity: this.computeBoardCapacity(items, loco), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, bookings: items.slice(0, 3), }; } - private boardState(booking: Booking, linked: boolean): BatchBoardBookingState { - if (linked) return 'ALLOCATED'; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { - return 'SELECTED_FOR_BATCH'; + private boardState( + booking: Booking, + linked: boolean, + ): BatchBoardBookingState { + if (linked) return "ALLOCATED"; + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { + return "SELECTED_FOR_BATCH"; } - if (booking.status === 'EXPIRED') return 'EXPIRED'; - if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY'; - if (booking.status === 'PAID') return 'WAITING'; - return 'PENDING_CONTRACT'; + if (booking.status === "EXPIRED") return "EXPIRED"; + if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt) + return "READY"; + if (booking.status === "PAID") return "WAITING"; + return "PENDING_CONTRACT"; } // ---- core fill ------------------------------------------------------------ /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return; + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== "OPEN") return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${scheduleId} has no locomotive/train set — skipped.`, + ); return; } @@ -677,7 +733,7 @@ export class BookingBatchService implements OnModuleInit { await this.syncScheduleMaxWagons(schedule, locomotive, rules); let budget = await this.remainingCapacity(schedule, limits, wagonLengths); if (budget.wagons <= 0) { - await this.setWindow(scheduleId, 'FULL'); + await this.setWindow(scheduleId, "FULL"); return; } @@ -689,7 +745,12 @@ export class BookingBatchService implements OnModuleInit { if (!this.fits(need, budget)) { if (booking.isGovernment) { - budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths); + budget = await this.preemptForGovernment( + scheduleId, + need, + budget, + wagonLengths, + ); if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { continue; // skip a booking that exceeds weight/length/wagons, try the next @@ -697,7 +758,7 @@ export class BookingBatchService implements OnModuleInit { } if (booking.isGovernment) { - await this.allocate(scheduleId, booking, 'gov'); + await this.allocate(scheduleId, booking, "gov"); } else { await this.reserve(booking, scheduleId); armed = true; @@ -706,7 +767,7 @@ export class BookingBatchService implements OnModuleInit { if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board } - if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL'); + if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -732,13 +793,14 @@ export class BookingBatchService implements OnModuleInit { const scheduleIds = bookable .filter( (s) => - s.bookingWindowStatus === 'OPEN' && + s.bookingWindowStatus === "OPEN" && s.scheduleDate != null && eatDay(new Date(s.scheduleDate)) === day, ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + new Date(a.scheduleDate).getTime() - + new Date(b.scheduleDate).getTime(), ) .map((s) => s.id); @@ -750,15 +812,22 @@ export class BookingBatchService implements OnModuleInit { // Live per-schedule budget + arm flag, in departure order. const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; for (const id of scheduleIds) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(id); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${id} has no locomotive/train set — skipped.`, + ); continue; } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + const budget = await this.remainingCapacity( + schedule, + limits, + wagonLengths, + ); trains.push({ id, budget, armed: false }); } if (trains.length === 0) return []; @@ -779,7 +848,12 @@ export class BookingBatchService implements OnModuleInit { // Government booking fits nowhere on its own — try to preempt commercial // on each train (earliest first) until one frees enough room. for (const t of trains) { - t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); + t.budget = await this.preemptForGovernment( + t.id, + need, + t.budget, + wagonLengths, + ); if (this.fits(need, t.budget)) { target = t; break; @@ -794,7 +868,7 @@ export class BookingBatchService implements OnModuleInit { } if (booking.isGovernment) { - await this.allocate(target.id, booking, 'gov'); + await this.allocate(target.id, booking, "gov"); } else { await this.reserve(booking, target.id); target.armed = true; @@ -803,7 +877,7 @@ export class BookingBatchService implements OnModuleInit { } for (const t of trains) { - if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); void this.triggerWagonAllocation(t.id); } @@ -813,18 +887,20 @@ export class BookingBatchService implements OnModuleInit { /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); let anySettled = false; for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const paid = + booking.paymentStatus === "PAID" || booking.status === "PAID"; const expired = booking.paymentDeadline ? booking.paymentDeadline.getTime() <= now : false; if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); anySettled = true; } else if (expired) { await this.expire(booking); @@ -840,17 +916,19 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const paid = + booking.paymentStatus === "PAID" || booking.status === "PAID"; const expired = booking.paymentDeadline ? booking.paymentDeadline.getTime() <= now : true; if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); } else if (expired) { await this.expire(booking); } @@ -862,11 +940,13 @@ export class BookingBatchService implements OnModuleInit { } private triggerWagonAllocation(scheduleId: string): void { - void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) => - this.logger.warn( - `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, - ), - ); + void this.trainSchedulingService + .tryAutoWagonAllocation(scheduleId) + .catch((err) => + this.logger.warn( + `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, + ), + ); } // ---- staff override actions ---------------------------------------------- @@ -878,18 +958,20 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking.trainScheduleId) { - throw new BadRequestException('Booking has no target schedule to allocate to'); + throw new BadRequestException( + "Booking has no target schedule to allocate to", + ); } await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); - await this.allocate(booking.trainScheduleId, booking, 'paid'); + .update(bookingId, { paymentStatus: "PAID" }); + await this.allocate(booking.trainScheduleId, booking, "paid"); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); } @@ -898,7 +980,10 @@ export class BookingBatchService implements OnModuleInit { * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). * Used for EXPIRED or full-schedule bookings — no re-approval. */ - async moveToSchedule(bookingId: string, newScheduleId: string): Promise { + async moveToSchedule( + bookingId: string, + newScheduleId: string, + ): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); @@ -907,15 +992,20 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: newScheduleId } }); - if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); - if (schedule.bookingWindowStatus !== 'OPEN') { - throw new BadRequestException('Target schedule is not accepting bookings'); + if (!schedule) + throw new NotFoundException(`Train schedule ${newScheduleId} not found`); + if (schedule.bookingWindowStatus !== "OPEN") { + throw new BadRequestException( + "Target schedule is not accepting bookings", + ); } if ( schedule.originStationId !== booking.originYardId || schedule.destinationStationId !== booking.destinationYardId ) { - throw new BadRequestException('Target schedule is not on the booking route'); + throw new BadRequestException( + "Target schedule is not on the booking route", + ); } await this.dataSource.transaction(async (manager) => { @@ -927,15 +1017,15 @@ export class BookingBatchService implements OnModuleInit { ); } const restoredStatus = - booking.status === 'EXPIRED' + booking.status === "EXPIRED" ? booking.isGovernment - ? 'APPROVED' - : 'FULLY_EXECUTED' + ? "APPROVED" + : "FULLY_EXECUTED" : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, status: restoredStatus, - schedulingStatus: 'ELIGIBLE', + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -949,7 +1039,8 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); await this.expire(booking); - if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId); + if (booking.trainScheduleId) + await this.fillSchedule(booking.trainScheduleId); } // ---- mutations ------------------------------------------------------------ @@ -967,11 +1058,18 @@ export class BookingBatchService implements OnModuleInit { const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, - status: 'SELECTED_FOR_BATCH', + status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, } as never); booking.trainScheduleId = scheduleId; + // The invoice was generated at booking creation/approval, before this pay + // window opened — refresh its printed due date to the real deadline. + await this.billing.syncPayableDueDate( + Freight.InvoiceSource.Booking, + booking.id, + deadline, + ); await this.notifier.payNow(booking, deadline); } @@ -979,13 +1077,14 @@ export class BookingBatchService implements OnModuleInit { private async allocate( scheduleId: string, booking: Booking, - reason: 'paid' | 'gov', + reason: "paid" | "gov", ): Promise { await this.dataSource.transaction(async (manager) => { - const exists = await this.trainScheduleBookingsRepository.existsForBooking( - booking.id, - manager, - ); + const exists = + await this.trainScheduleBookingsRepository.existsForBooking( + booking.id, + manager, + ); if (!exists) { await this.trainScheduleBookingsRepository.createMany( [{ trainScheduleId: scheduleId, bookingId: booking.id }], @@ -993,8 +1092,8 @@ export class BookingBatchService implements OnModuleInit { ); } await manager.getRepository(Booking).update(booking.id, { - status: reason === 'paid' ? 'PAID' : booking.status, - schedulingStatus: 'SCHEDULED', + status: reason === "paid" ? "PAID" : booking.status, + schedulingStatus: "SCHEDULED", scheduledAt: new Date(), paymentDeadline: null, selectedForBatchAt: null, @@ -1012,12 +1111,16 @@ export class BookingBatchService implements OnModuleInit { private async expire(booking: Booking): Promise { await this.bookingsRepository.update(booking.id, { trainScheduleId: null, - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // Pay window closed before settlement → expire the booking's open invoice too + // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays + // source-agnostic. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id); this.notifier.expired(booking); } @@ -1035,7 +1138,9 @@ export class BookingBatchService implements OnModuleInit { await this.bookingsRepository.findReservedForSchedule(scheduleId) ).filter((b) => !b.isGovernment); const allocatedCommercial = - await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId); + await this.bookingsRepository.findAllocatedCommercialForSchedule( + scheduleId, + ); // lowest priority first; reserved are cheaper to free than allocated const candidates = [...reservedCommercial, ...allocatedCommercial].sort( @@ -1052,11 +1157,18 @@ export class BookingBatchService implements OnModuleInit { manager, ); await manager.getRepository(Booking).update(victim.id, { - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); + // Displaced → EXPIRED: close its open invoice too, so a dead booking + // can't still be paid (mirrors `expire()`; enlisted in this txn). + await this.billing.expirePayable( + Freight.InvoiceSource.Booking, + victim.id, + manager, + ); }); this.notifier.displaced(victim); freed = this.add(freed, this.needFor(victim, wagonLengths)); @@ -1074,7 +1186,10 @@ export class BookingBatchService implements OnModuleInit { (sum, c) => sum + Number(c.quantity ?? 0), 0, ); - return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); + return Math.max( + DEFAULT_WAGONS_PER_BOOKING, + fromContainers || DEFAULT_WAGONS_PER_BOOKING, + ); } /** What one booking consumes along all three capacity axes. */ @@ -1161,7 +1276,7 @@ export class BookingBatchService implements OnModuleInit { Array<{ lengthMeters: number; capacityTons: number }> > { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ @@ -1172,17 +1287,23 @@ export class BookingBatchService implements OnModuleInit { private async loadWagonLengths(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); - const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)])); + const byCode = new Map( + types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), + ); return { - container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: + byCode.get("NW5")?.lengthMeters ?? + DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, }; } private async loadGlobalRules(): Promise { - return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); + return this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .findOne({ where: {} }); } /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ @@ -1194,7 +1315,9 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); const used = [...allocated, ...reserved].reduce( (acc, b) => this.add(acc, this.needFor(b, wagonLengths)), { wagons: 0, weightTons: 0, lengthMeters: 0 }, @@ -1207,7 +1330,9 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); const used = allocated.reduce((s, b) => s + this.wagonsFor(b), 0) + reserved.reduce((s, b) => s + this.wagonsFor(b), 0); @@ -1216,7 +1341,7 @@ export class BookingBatchService implements OnModuleInit { private async setWindow( scheduleId: string, - status: 'OPEN' | 'FULL' | 'CLOSED', + status: "OPEN" | "FULL" | "CLOSED", ): Promise { await this.dataSource .getRepository(TrainSchedule) @@ -1233,7 +1358,9 @@ export class BookingBatchService implements OnModuleInit { this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => - this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`), + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), ); }, PAYMENT_WINDOW_MS); this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); @@ -1242,7 +1369,7 @@ export class BookingBatchService implements OnModuleInit { private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { - if (this.scheduler.doesExist('timeout', name)) { + if (this.scheduler.doesExist("timeout", name)) { this.scheduler.deleteTimeout(name); } } catch { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 9b8efd9e0..0a56865d5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; @@ -42,6 +43,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; ImportDjiboutiOperation, ]), forwardRef(() => BookingsModule), + BillingModule, NotificationsModule, LocomotivesModule, WagonTypesModule, diff --git a/apps/edr-freight-api/src/scripts/cmds/index.ts b/apps/edr-freight-api/src/scripts/cmds/index.ts new file mode 100644 index 000000000..810945bbb --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/index.ts @@ -0,0 +1,12 @@ +import type Vorpal from "vorpal"; +import type { CommandContext } from "./types"; + +import { registerSeedTestContracts } from "./seed-test-contracts.cmd"; +import { registerSeedTestSchedules } from "./seed-test-schedules.cmd"; +import { registerSeedTestCompany } from "./seed-test-company.cmd"; + +export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void { + registerSeedTestContracts(vorpal, ctx); + registerSeedTestSchedules(vorpal, ctx); + registerSeedTestCompany(vorpal, ctx); +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts new file mode 100644 index 000000000..bef4ed031 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts @@ -0,0 +1,85 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import type { CommandContext } from "./types"; +import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; +import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; + +export function registerSeedTestCompany( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-company", "Generate a test company with approved importer/exporter profiles and an external user") + .option("--name ", "Company name (default: Test Company)") + .option("--email ", "Company email (default: company@test.com)") + .option("--tin ", "Tax ID (default: auto-generated TSTxxxxx)") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const raw = await ds.query( + `SELECT "tin" FROM "freight"."companies" WHERE "tin" LIKE 'TST%' AND "deleted_at" IS NULL ORDER BY "tin" DESC LIMIT 1`, + ); + let nextTinNum = 1; + if (raw.length > 0) { + const num = parseInt((raw[0] as any).tin.replace("TST", ""), 10); + if (!isNaN(num)) nextTinNum = num + 1; + } + + const name = args.options?.name ?? "Test Company"; + const email = args.options?.email ?? "company@test.com"; + const tin = args.options?.tin ?? `TST${String(nextTinNum).padStart(6, "0")}`; + const userId = `ffffffff-0000-4000-8000-${String(nextTinNum).padStart(12, "0")}`; + + const existing = await ds.getRepository(Company).findOne({ where: { tin } }); + if (existing) { + this.log(`Company with TIN ${tin} already exists (${existing.name})`); + return; + } + + const company = await ds.getRepository(Company).save( + ds.getRepository(Company).create({ + name, + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin, + country: "Ethiopia", + nationality: CompanyNationality.Ethiopian, + email, + phone: "+251911000000", + address: "Test Address", + }), + ); + this.log(` Created company: ${company.name} (TIN: ${tin})`); + + for (const type of [ProfileType.importer, ProfileType.exporter]) { + await ds.getRepository(CompanyProfile).save( + ds.getRepository(CompanyProfile).create({ + companyId: company.id, + type, + reference: `TST-${type.toUpperCase()}-${String(nextTinNum).padStart(3, "0")}`, + status: ProfileStatus.Active, + }), + ); + this.log(` Created ${type} profile (approved)`); + } + + await ds.getRepository(ExternalProfile).save( + ds.getRepository(ExternalProfile).create({ + userId, + companyId: company.id, + firstName: "Test", + lastName: "User", + isPrimaryContact: true, + activeProfileType: ProfileType.importer, + onboardingCompleted: true, + onboardingStep: "done", + }), + ); + this.log(` Created external profile: Test User (userId: ${userId})`); + + this.log(`\nDone — login with email "${email}" and password "password"`); + }); +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts new file mode 100644 index 000000000..a46a9dee8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts @@ -0,0 +1,330 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import type { CommandContext } from "./types"; +import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; +import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; +import { Yard } from "../../modules/rule-engine/entities/yard.entity"; +import { ServiceType } from "../../modules/rule-engine/entities/service-type.entity"; +import { CargoType } from "../../modules/rule-engine/entities/cargo-type.entity"; +import { Rate } from "../../modules/rule-engine/entities/rate.entity"; +import { Contract } from "../../modules/contracts/entities/contract.entity"; +import { ContractRoute } from "../../modules/contracts/entities/contract-route.entity"; +import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity"; +import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity"; + +export function registerSeedTestContracts( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-contracts", "Generate test contracts with companies and all deps") + .option("-n, --count ", "Number of contracts to create (default: 4)") + .option("--status ", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)") + .option("--freight ", "Freight types: CONTAINER,BULK (default: both)") + .option("--direction ", "Trade directions: IMPORT,EXPORT (default: both)") + .option("--company ", "Only create contracts for company matching name/TIN") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const count = Math.max(1, Math.min(20, parseInt(args.options?.count ?? "4", 10))); + const statusList = (args.options?.status ?? "DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE") + .split(",").map((s: string) => s.trim()).filter(Boolean); + const freightList = (args.options?.freight ?? "CONTAINER,BULK") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "CONTAINER" || s === "BULK"); + const directionList = (args.options?.direction ?? "IMPORT,EXPORT") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "IMPORT" || s === "EXPORT"); + const companyFilter = args.options?.company as string | undefined; + + if (freightList.length === 0 || directionList.length === 0) { + this.log("error: at least one freight type and trade direction required"); + return; + } + + this.log(`Seeding ${count} contracts (statuses=${statusList.join(",")}, freight=${freightList.join(",")}, dir=${directionList.join(",")})...`); + + const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + if (!djibouti || !addis) { + this.log("error: need at least DJIBOUTI and ADDIS_ABABA yards seeded"); + return; + } + + const serviceTypes = await ds + .getRepository(ServiceType) + .find({ where: { isActive: true } }); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const railContainer = stByCode.get("RAIL_CONTAINER"); + const railBulk = stByCode.get("RAIL_BULK"); + if (!railContainer && !railBulk) { + this.log("error: need at least RAIL_CONTAINER or RAIL_BULK service type seeded"); + return; + } + + const cargoTypes = await ds + .getRepository(CargoType) + .find({ where: { isActive: true } }); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + const grain = cargoByCode.get("GRAIN"); + const sugar = cargoByCode.get("SUGAR"); + const fertilizer = cargoByCode.get("FERTILIZER"); + + const rates = await ds.getRepository(Rate).find({ where: { status: "LIVE" } }); + + const companyRepo = ds.getRepository(Company); + let companies = await companyRepo.find({}); + + if (companyFilter) { + companies = companies.filter( + (c) => + c.name.toLowerCase().includes(companyFilter.toLowerCase()) || + c.tin.includes(companyFilter), + ); + } + + if (companies.length === 0) { + this.log("No existing companies found — seeding test companies..."); + companies = await seedTestCompanies(ds, (msg) => this.log(msg)); + } else { + this.log(`Using ${companies.length} existing companies from DB`); + } + + const contractRepo = ds.getRepository(Contract); + + const maxRaw = await ds.query( + `SELECT "reference" FROM "freight"."contracts" WHERE "reference" LIKE 'TST-CTR-%' AND "deleted_at" IS NULL ORDER BY "reference" DESC LIMIT 1`, + ); + let nextRef = 1; + if (maxRaw.length > 0) { + const num = parseInt(maxRaw[0].reference.replace("TST-CTR-", ""), 10); + if (!isNaN(num)) nextRef = num + 1; + } + + for (let i = 0; i < count; i++) { + const statusIdx = i % statusList.length; + const ftIdx = i % freightList.length; + const dirIdx = i % directionList.length; + const companyIdx = i % companies.length; + + const status = statusList[statusIdx]; + const freightType = freightList[ftIdx]; + const direction = directionList[dirIdx]; + const company = companies[companyIdx]; + + const profile = await ds.getRepository(CompanyProfile).findOne({ + where: { + companyId: company.id, + type: direction === "IMPORT" ? ProfileType.importer : ProfileType.exporter, + }, + }); + if (!profile) continue; + + const ref = `TST-CTR-${String(nextRef + i).padStart(5, "0")}`; + + const serviceTypeId = + freightType === "BULK" && railBulk + ? railBulk.id + : railContainer + ? railContainer.id + : serviceTypes[0].id; + + const originId = direction === "IMPORT" ? djibouti.id : addis.id; + const destId = direction === "IMPORT" ? addis.id : djibouti.id; + + const contract = contractRepo.create({ + reference: ref, + companyId: company.id, + companyProfileId: profile.id, + contractKind: "ONE_TIME" as const, + tradeDirection: direction, + freightType, + serviceTypeId, + paymentCurrency: "USD", + customsClearingEnabled: false, + equipmentReturn: "without_return", + status, + versionNumber: 1, + }); + + const saved = await contractRepo.save(contract); + + await ds.getRepository(ContractRoute).save( + ds.getRepository(ContractRoute).create({ + contractId: saved.id, + originYardId: originId, + destinationYardId: destId, + sortOrder: 1, + }), + ); + + if (freightType === "CONTAINER") { + for (const size of ["20FT", "40FT"] as const) { + await ds.getRepository(ContractCargoScope).save( + ds.getRepository(ContractCargoScope).create({ + contractId: saved.id, + containerSize: size, + }), + ); + } + } else { + const bulkCargo = grain || sugar || fertilizer; + if (bulkCargo) { + await ds.getRepository(ContractCargoScope).save( + ds.getRepository(ContractCargoScope).create({ + contractId: saved.id, + cargoTypeId: bulkCargo.id, + quantityCap: 10000, + }), + ); + } + } + + const matchingRates = rates.filter((r) => { + if (r.appliesTo === "CONTAINER" && freightType !== "CONTAINER") return false; + if (r.appliesTo === "BULK" && freightType !== "BULK") return false; + if (r.tradeDirection && r.tradeDirection !== direction) return false; + return r.status === "LIVE" && r.trigger === "ALWAYS"; + }); + + const seen = new Set(); + for (const rate of matchingRates.slice(0, 3)) { + const sig = `${rate.rateType}|${rate.currency}|${rate.rateValue}`; + if (seen.has(sig)) continue; + seen.add(sig); + + await ds.getRepository(ContractRateSnapshot).save( + ds.getRepository(ContractRateSnapshot).create({ + contractId: saved.id, + rateId: rate.id, + rateCode: rate.rateType, + unitPrice: Number(rate.rateValue), + unitOfMeasure: rate.rateUnit, + currency: rate.currency ?? "USD", + containerSize: freightType === "CONTAINER" ? "20FT" : null, + isSurcharge: rate.trigger !== "ALWAYS", + conditionalOn: rate.trigger !== "ALWAYS" ? rate.trigger : null, + }), + ); + } + + this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`); + } + + this.log(`Done — ${count} new contracts created`); + }); +} + +interface CompanySeed { + name: string; + tin: string; + profiles: Array<{ type: ProfileType; reference: string }>; + externalProfile: { userId: string; firstName: string; lastName: string }; +} + +const TEST_COMPANIES: CompanySeed[] = [ + { + name: "Test Importer Co.", tin: "TST000001", + profiles: [ + { type: ProfileType.importer, reference: "TST-IM-001" }, + { type: ProfileType.exporter, reference: "TST-EX-001" }, + ], + externalProfile: { userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" }, + }, + { + name: "Test Exporter Ltd.", tin: "TST000002", + profiles: [ + { type: ProfileType.importer, reference: "TST-IM-002" }, + { type: ProfileType.exporter, reference: "TST-EX-002" }, + ], + externalProfile: { userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" }, + }, + { + name: "Bulk Commodities PLC", tin: "TST000003", + profiles: [ + { type: ProfileType.importer, reference: "TST-IM-003" }, + { type: ProfileType.exporter, reference: "TST-EX-003" }, + ], + externalProfile: { userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" }, + }, + { + name: "Hazardous Logistics Inc.", tin: "TST000004", + profiles: [ + { type: ProfileType.importer, reference: "TST-IM-004" }, + { type: ProfileType.exporter, reference: "TST-EX-004" }, + ], + externalProfile: { userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" }, + }, +]; + +async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Promise { + const companyRepo = ds.getRepository(Company); + const profileRepo = ds.getRepository(CompanyProfile); + const extProfileRepo = ds.getRepository(ExternalProfile); + const result: Company[] = []; + + for (const seed of TEST_COMPANIES) { + let company = await companyRepo.findOne({ where: { tin: seed.tin } }); + if (!company) { + company = await companyRepo.save( + companyRepo.create({ + name: seed.name, + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: seed.tin, + country: "Ethiopia", + nationality: CompanyNationality.Ethiopian, + email: `info@${seed.name.toLowerCase().replace(/\s+/g, "")}.com`, + phone: "+251911000001", + }), + ); + log(` Created company: ${seed.name}`); + } else { + log(` Company already exists: ${seed.name}`); + } + + for (const p of seed.profiles) { + const existing = await profileRepo.findOne({ + where: { companyId: company.id, type: p.type }, + }); + if (!existing) { + await profileRepo.save( + profileRepo.create({ + companyId: company.id, + type: p.type, + reference: p.reference, + status: ProfileStatus.Active, + }), + ); + log(` Created ${p.type} profile: ${p.reference}`); + } + } + + const ext = seed.externalProfile; + const existingExt = await extProfileRepo.findOne({ + where: { companyId: company.id, userId: ext.userId }, + }); + if (!existingExt) { + await extProfileRepo.save( + extProfileRepo.create({ + userId: ext.userId, + companyId: company.id, + firstName: ext.firstName, + lastName: ext.lastName, + isPrimaryContact: true, + onboardingCompleted: true, + }), + ); + log(` Created external profile: ${ext.firstName} ${ext.lastName}`); + } + + result.push(company); + } + + return result; +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts new file mode 100644 index 000000000..eeab0e2dd --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts @@ -0,0 +1,243 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import { WagonStatus } from "@edr/types"; +import type { CommandContext } from "./types"; +import { Yard } from "../../modules/rule-engine/entities/yard.entity"; +import { Route } from "../../modules/routes/entities/route.entity"; +import { RouteMilestone } from "../../modules/routes/entities/route-milestone.entity"; +import { Locomotive } from "../../modules/locomotives/entities/locomotive.entity"; +import { Wagon } from "../../modules/wagons/entities/wagon.entity"; +import { WagonType } from "../../modules/wagon-types/entities/wagon-type.entity"; +import { TrainSet } from "../../modules/train-sets/entities/train-set.entity"; +import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity"; +import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity"; +import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity"; + +async function nextSequence(ds: DataSource, pattern: string): Promise { + const like = pattern.replace(/\*/g, "%"); + const raw = await ds.query( + `SELECT "train_number" FROM "freight"."train_schedules" WHERE "train_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "train_number" DESC LIMIT 1`, + [like.replace(/%/g, "") + "%"], + ); + if (raw.length === 0) return 1; + const ref: string = raw[0].train_number; + const num = parseInt(ref.replace(pattern.split("*")[0], ""), 10); + return isNaN(num) ? 1 : num + 1; +} + +async function nextRouteSeq(ds: DataSource, prefix: string): Promise { + const raw = await ds.query( + `SELECT "name" FROM "freight"."routes" WHERE "name" LIKE $1 AND "deleted_at" IS NULL ORDER BY "name" DESC LIMIT 1`, + [prefix + "%"], + ); + if (raw.length === 0) return 1; + const num = parseInt(raw[0].name.replace(prefix, ""), 10); + return isNaN(num) ? 1 : num + 1; +} + +async function nextWagonSeq(ds: DataSource, prefix: string): Promise { + const raw = await ds.query( + `SELECT "wagon_number" FROM "freight"."wagons" WHERE "wagon_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "wagon_number" DESC LIMIT 1`, + [prefix + "%"], + ); + if (raw.length === 0) return 1; + const num = parseInt(raw[0].wagon_number.replace(prefix, ""), 10); + return isNaN(num) ? 1 : num + 1; +} + +export function registerSeedTestSchedules( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-schedules", "Seed train schedules with routes, wagons, and all deps for booking") + .option("-n, --count ", "Number of schedules to create (default: 3)") + .option("--direction ", "IMPORT,EXPORT (default: both)") + .option("--status ", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)") + .option("--days-ahead ", "Days from now for departure (default: 3)") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const count = Math.max(1, Math.min(10, parseInt(args.options?.count ?? "3", 10))); + const directionList = (args.options?.direction ?? "IMPORT,EXPORT") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "IMPORT" || s === "EXPORT"); + const statusList = (args.options?.status ?? "SCHEDULED") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "DRAFT" || s === "SCHEDULED" || s === "DISPATCHED"); + const daysAhead = Math.max(0, parseInt(args.options?.daysAhead ?? "3", 10)); + + if (directionList.length === 0 || statusList.length === 0) { + this.log("error: at least one direction and status required"); + return; + } + + const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); + const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y])); + const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti"); + const addis = yardByCode.get("ADDIS_ABABA") ?? yards.find((y) => y.country === "Ethiopia"); + + if (!djibouti || !addis) { + this.log("error: need at least one Djibouti and one Ethiopia yard"); + return; + } + + const wagonTypes = await ds.getRepository(WagonType).find({ where: { isActive: true } }); + if (wagonTypes.length === 0) { + this.log("error: no wagon types found — seed reference data first"); + return; + } + + const wagonType = wagonTypes[0]; + const wagonCapacity = Number(wagonType.capacityTons) || 70; + const wagonLength = Number(wagonType.lengthMeters) || 14; + const tareWeight = Number(wagonType.tareWeightTons) || 14; + + const locomotiveRepo = ds.getRepository(Locomotive); + const scheduleRepo = ds.getRepository(TrainSchedule); + const trainSetRepo = ds.getRepository(TrainSet); + const wagonRepo = ds.getRepository(Wagon); + const routeRepo = ds.getRepository(Route); + const milestoneRepo = ds.getRepository(RouteMilestone); + + let nextTrainNum = await nextSequence(ds, "TST-SCH-*"); + const routePrefix = "TST-RTE-"; + let nextRouteNum = await nextRouteSeq(ds, routePrefix); + + const now = new Date(); + const travelHours = 11; + const intermediateYards = yards.filter( + (y) => y.id !== djibouti.id && y.id !== addis.id, + ); + + let loco = await locomotiveRepo.findOne({ where: { code: "TST-LOCO-01" } }); + if (!loco) { + loco = await locomotiveRepo.save( + locomotiveRepo.create({ + code: "TST-LOCO-01", + name: "Test Locomotive", + locomotiveType: "DIESEL", + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + currentYardId: djibouti.id, + }), + ); + } + + for (let i = 0; i < count; i++) { + const seq = nextTrainNum + i; + const trainNumber = `TST-SCH-${String(seq).padStart(5, "0")}`; + const dir = directionList[i % directionList.length]; + const status = statusList[i % statusList.length]; + const isDispatched = status === "DISPATCHED"; + const originYard = dir === "IMPORT" ? djibouti : addis; + const destYard = dir === "IMPORT" ? addis : djibouti; + const routeName = `${routePrefix}${String(nextRouteNum + i).padStart(3, "0")}`; + + const departure = new Date(now); + departure.setDate(departure.getDate() + daysAhead + i); + departure.setHours(7, 0, 0, 0); + const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000); + + const route = await routeRepo.save( + routeRepo.create({ + name: routeName, + originYardId: originYard.id, + destinationYardId: destYard.id, + isActive: true, + }), + ); + + await milestoneRepo.save( + milestoneRepo.create({ routeId: route.id, yardId: originYard.id, sequenceNo: 1 }), + ); + for (const [mi, y] of intermediateYards.entries()) { + await milestoneRepo.save( + milestoneRepo.create({ routeId: route.id, yardId: y.id, sequenceNo: (mi + 1) * 2 }), + ); + } + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: destYard.id, + sequenceNo: (intermediateYards.length + 1) * 2, + }), + ); + + const totalWagonWeight = 4 * (tareWeight + 20); + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: loco.id, + totalWeightTons: totalWagonWeight, + totalLengthMeters: wagonLength * 4, + wagonCount: 4, + status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED", + }), + ); + + await ds.getRepository(TrainSetLocomotive).save( + ds.getRepository(TrainSetLocomotive).create({ + trainSetId: trainSet.id, + locomotiveId: loco.id, + sequenceNo: 0, + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + routeId: route.id, + originStationId: originYard.id, + destinationStationId: destYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: isDispatched ? departure : null, + status, + trainNumber, + direction: dir, + maxWagons: 53, + bookingWindowStatus: isDispatched ? "CLOSED" : "OPEN", + }), + ); + + const wagonPrefix = `${trainNumber}-W`; + let nextWagon = await nextWagonSeq(ds, wagonPrefix); + for (let w = 0; w < 4; w++) { + const ws = nextWagon + w; + const wagonNumber = `${wagonPrefix}${String(ws).padStart(2, "0")}`; + + const wagon = wagonRepo.create({ + wagonNumber, + wagonTypeId: wagonType.id, + currentYardId: originYard.id, + currentTrainScheduleId: schedule.id, + tareWeight, + maxPayloadWeight: wagonCapacity, + status: isDispatched ? WagonStatus.Assigned : WagonStatus.Available, + notes: "Test seed wagon", + }); + const saved = await wagonRepo.save(wagon as any); + const physicalWagon = Array.isArray(saved) ? saved[0] : saved; + + await ds.getRepository(TrainSetWagon).save( + ds.getRepository(TrainSetWagon).create({ + trainSetId: trainSet.id, + wagonTypeId: wagonType.id, + physicalWagonId: physicalWagon.id, + sequenceNo: w + 1, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: 20, + status: isDispatched ? "DEPARTED" : "PLANNED", + }), + ); + } + + this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label} → ${destYard.label})`); + } + + this.log(`Done — ${count} new train schedules created`); + }); +} diff --git a/apps/edr-freight-api/src/scripts/cmds/types.ts b/apps/edr-freight-api/src/scripts/cmds/types.ts new file mode 100644 index 000000000..6c805e07d --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/types.ts @@ -0,0 +1,5 @@ +import type { INestApplicationContext } from "@nestjs/common"; + +export type CommandContext = { + app: INestApplicationContext; +}; diff --git a/apps/edr-freight-api/src/scripts/main.ts b/apps/edr-freight-api/src/scripts/main.ts new file mode 100644 index 000000000..94b26347f --- /dev/null +++ b/apps/edr-freight-api/src/scripts/main.ts @@ -0,0 +1,36 @@ +import "reflect-metadata"; +import { config } from "dotenv"; + +config(); + +import Vorpal from "vorpal"; +import { registerCommands } from "./cmds/index"; + +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "../app.module"; + +const vorpal = new Vorpal(); + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, + }); + + try { + registerCommands(vorpal, { app }); + + const args = process.argv.slice(2); + if (args.length > 0) { + await vorpal.exec(args.join(" ")); + } else { + vorpal.parse(process.argv); + } + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error("Script failed:", err); + process.exit(1); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts index 8b526fb3d..39daf7b57 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts @@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext { const escapePdfText = (value: string) => value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); -const money = (amount: unknown, currency = 'USD') => - `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; - const fmtDate = (value: unknown) => { if (!value) return '-'; const date = new Date(value as string | Date); @@ -96,12 +93,6 @@ const textOp = ( color = '0 0 0', ) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`; -const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [ - lineOp(60, 242, 535, 242), - textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN), - buildCircularSeal(452, 155, label), -]; - const buildWarehouseOfficerSealBand = () => [ lineOp(60, 218, 535, 218), textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN), @@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { return new Blob([pdf], { type: 'application/pdf' }); } -export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') { - const paid = kind === 'RECEIPT' || invoice.status === 'PAID'; - const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`; - const bookingReference = firstText(invoice.bookingReference); - const customerName = firstText(invoice.customerName); - const inventoryReference = firstText(invoice.inventoryReference); - const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription); - const clearanceStatus = firstText( - invoice.clearanceStatus, - paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT', - ); - const lines: PdfLine[] = [ - { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, - { text: title, size: 23, bold: true, yGap: 28, align: 'center' }, - { text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' }, - { text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' }, - { text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' }, - { text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' }, - { text: `Clearance: ${clearanceStatus}`, align: 'center' }, - { text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' }, - { text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' }, - ...(invoice.items ?? []).flatMap((item) => [ - { text: item.description, bold: true, align: 'center' as const }, - { - text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`, - yGap: 13, - align: 'center' as const, - }, - ]), - { text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' }, - { text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' }, - { text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' }, - { text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' }, - { text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' }, - { text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' }, - ]; - const authorizationOps = [ - ...buildAuthorizationBand('PAID'), - textOp('Prepared by EDR warehouse finance', 72, 196, 10), - textOp('Finance officer name / signature / date:', 72, 164, 10), - lineOp(245, 162, 360, 162, '0 0 0'), - ]; - const invoiceOps = [ - lineOp(60, 242, 535, 242), - textOp('Prepared by EDR warehouse finance', 72, 196, 10), - textOp('Finance officer name / signature / date:', 72, 164, 10), - lineOp(245, 162, 360, 162, '0 0 0'), - ]; - return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps); -} - const firstText = (...values: Array) => { for (const value of values) { if (value !== null && value !== undefined && String(value).trim()) return String(value); diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 07e271cb6..842276815 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,12 +1,7 @@ - -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -//export const API_BASE_URL = 'http://localhost:3001'; - - +export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; // export const API_BASE_URL = 'http://localhost:3001'; - /** * URL that streams an uploaded file through the API by its UUID. Routes the * bytes through `GET /api/files/:id` (served from MinIO with backend diff --git a/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts b/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts new file mode 100644 index 000000000..f381f49d5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +/** + * Scroll to the element whose `id` matches the URL hash. Retries for a short + * window so it still lands on sections that mount after an async fetch (there is + * no router-level hash handling). Deep-link targets give a card an `id`. + */ +export function useScrollToHash(): void { + const { hash } = useLocation(); + + useEffect(() => { + if (!hash) return; + const id = decodeURIComponent(hash.slice(1)); + let tries = 0; + let timer: ReturnType; + + const tick = () => { + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + if (tries++ < 20) timer = setTimeout(tick, 100); + }; + + timer = setTimeout(tick, 100); + return () => clearTimeout(timer); + }, [hash]); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 75badc5b3..7a13888fe 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -50,6 +50,7 @@ import { useBookingDetail, useBookingMutations, } from "@/hooks/bookings/useBookings"; +import { useScrollToHash } from "@/hooks/useScrollToHash"; import toast from "react-hot-toast"; // Signature / generated-contract files are surfaced on the contract page, not @@ -65,6 +66,8 @@ export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + // Deep-link from a warehouse fee invoice → this booking's warehouse section. + useScrollToHash(); const { data: booking, isLoading, @@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() { - + + + = { @@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() { function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { const { toast } = useToast(); + const navigate = useNavigate(); const { data: inv, isLoading } = useQuery( api.warehouses.invoice.queryOptions({ input: { id: id ?? '' }, @@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => { - const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE'); - openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); + const pdfWindow = window.open('', '_blank'); + try { + const { data } = await warehouseService.downloadInvoiceDocument(invoice.id); + openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Download failed', + description: extractErrorMessage(error), + }); + } }; const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => { - const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT'); - openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`); + const pdfWindow = window.open('', '_blank'); + try { + const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id); + openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Download failed', + description: extractErrorMessage(error), + }); + } }; const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => { @@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => )} + {inv.bookingId && ( + + )} + )} - )} + {hasReceipt && ( + + )} + {payable && ( + + )} + - {payMutation.isError && ( - } title="Payment could not be started"> - Please try again, or contact support if the problem persists. - - )} - {/* Summary */} @@ -241,6 +340,27 @@ export default function InvoiceDetailPage() { + + { + if (!payMutation.isPending) { + setPayModalOpen(false); + payMutation.reset(); + } + }} + amountLabel={formatCurrency(Number(invoice.totalAmount), invoice.currency)} + currency={invoice.currency} + processing={payMutation.isPending} + error={ + payMutation.isError + ? payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again." + : null + } + onConfirm={(method) => payMutation.mutate(method)} + /> ); diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx index 1e19b1c9a..3503fbc71 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx @@ -22,6 +22,7 @@ const STATUS_STYLE: Record< [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" }, [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" }, [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" }, + [Freight.InvoiceStatus.Expired]: { label: "Expired", bg: "#FBEAE7", fg: "#C0392B" }, }; export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 065ee8385..7c2c2631c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -24,19 +24,22 @@ import { ConsolidationPairedNotice, ConsolidationWaitingBanner, } from "./components/Notices"; +import { BookingPaymentPanel } from "./components/BookingPaymentPanel"; import { HeaderButton, PageHeader } from "./components/PageHeader"; -import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard"; import { PaymentMethodModal } from "./components/PaymentMethodModal"; -import { PaymentCard } from "./components/pricing"; import { ScheduleCard } from "./components/ScheduleCard"; +import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard"; import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; import { fmtDate, isNegative, priceTotal } from "./utils"; +import { useScrollToHash } from "@/hooks/useScrollToHash"; export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const navigate = useNavigate(); + // Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice. + useScrollToHash(); const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); const { view, viewer } = useFileViewer(); @@ -164,6 +167,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) + + {booking.files && booking.files.length > 0 && ( @@ -215,14 +220,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } right={ <> - {showCountdown && ( - setPayModalOpen(true)} - paying={payMutation.isPending} - /> - )} - + setPayModalOpen(true)} + paying={payMutation.isPending} + showCountdown={showCountdown} + /> ; + +// ── Pay-window countdown ───────────────────────────────────────────────────── + +interface Remaining { + days: number; + hours: number; + minutes: number; + seconds: number; + expired: boolean; +} + +function getRemaining(deadlineMs: number): Remaining { + const diff = deadlineMs - Date.now(); + if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + const total = Math.floor(diff / 1000); + return { + days: Math.floor(total / 86400), + hours: Math.floor((total % 86400) / 3600), + minutes: Math.floor((total % 3600) / 60), + seconds: total % 60, + expired: false, + }; +} + +function Segment({ value, label }: { value: number; label: string }) { + return ( + + + {String(value).padStart(2, "0")} + + + {label} + + + ); +} + +function Countdown({ + deadline, + onPay, + paying, +}: { + deadline: string; + onPay?: () => void; + paying?: boolean; +}) { + const deadlineMs = new Date(deadline).getTime(); + const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); + + useEffect(() => { + setRemaining(getRemaining(deadlineMs)); + const interval = setInterval(() => { + const next = getRemaining(deadlineMs); + setRemaining(next); + if (next.expired) clearInterval(interval); + }, 1000); + return () => clearInterval(interval); + }, [deadlineMs]); + + if (remaining.expired) { + return ( + + The payment window has closed. Move this booking to another schedule or + contact support. + + ); + } + + return ( + <> + + + + + + + + Deadline:{" "} + {new Date(deadline).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + {onPay && ( + + )} + + ); +} + +// ── Merged payment panel ───────────────────────────────────────────────────── + +/** + * One card covering the whole payment story for a booking: the live pay-window + * countdown (when open), the price breakdown, and the invoice(s) — each with a + * link to its detail page and a download. Replaces the separate deadline + + * breakdown cards. + */ +export function BookingPaymentPanel({ + booking, + pricing, + onPay, + paying, + showCountdown, +}: { + booking: Freight.IBooking; + pricing: Pricing; + onPay?: () => void; + paying?: boolean; + showCountdown?: boolean; +}) { + const navigate = useNavigate(); + const paid = booking.paymentStatus === "PAID"; + const isAdjusted = + booking.adjustedTotalAmount !== null && + booking.adjustedTotalAmount !== undefined; + const currency = pricing?.currency ?? booking.paymentCurrency; + const total = isAdjusted + ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` + : priceTotal(pricing); + const items = priceLineItems(pricing); + + const { data: invoices = [] } = useQuery({ + queryKey: ["booking-invoices", booking.id], + queryFn: () => invoicesService.listForSource("booking", booking.id), + }); + // The invoice worth a prominent "Download" — the first issued one, else any. + const primary = + invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0]; + const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false; + + const downloadInvoice = async (inv: PortalInvoice) => { + try { + saveBlob( + await invoicesService.downloadDocument(inv.id), + `invoice-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists."); + } + }; + + const downloadReceipt = async (inv: PortalInvoice) => { + try { + saveBlob( + await invoicesService.downloadReceipt(inv.id), + `receipt-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Receipt isn't available yet."); + } + }; + + return ( + + + Payment + + {paid ? : showCountdown ? : null} + {paid + ? "Paid" + : showCountdown + ? "Pay window open" + : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} + + + + {showCountdown && booking.paymentDeadline && ( + + + + + )} + + + + {total} + + {isAdjusted && ( + + Adjusted by EDR + + )} + {isAdjusted && booking.adjustmentReason && ( + + {booking.adjustmentReason} + + )} + {paid && ( + + Paid · {fmtDate(booking.updatedAt)} + + )} + + + {items.length > 0 && ( + <> + + + {items.map((it) => ( + + + {it.label} + + + {it.value} + + + ))} + + + + {isAdjusted ? "Adjusted total" : "Total"} + + + {total} + + + + )} + + {invoices.length > 0 && ( + <> + + + Invoices + + {invoices.length} + + + + {invoices.map((inv) => ( + + + navigate(`/billing/${inv.id}`)} + > + {inv.invoiceNumber} + + + {titleCase(inv.type)} + + + + + downloadInvoice(inv)} + > + + + + + ))} + + + )} + + {primary && ( + + )} + {primary && primaryPaid && ( + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx deleted file mode 100644 index 54f900ab5..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Box, Button, Group, Stack, Text } from "@mantine/core"; -import { CreditCard, Timer } from "lucide-react"; -import { useEffect, useState } from "react"; - -import { CardTitle, SectionCard } from "./layout"; - -interface Remaining { - days: number; - hours: number; - minutes: number; - seconds: number; - expired: boolean; -} - -function getRemaining(deadlineMs: number): Remaining { - const diff = deadlineMs - Date.now(); - if (diff <= 0) { - return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; - } - const totalSeconds = Math.floor(diff / 1000); - return { - days: Math.floor(totalSeconds / 86400), - hours: Math.floor((totalSeconds % 86400) / 3600), - minutes: Math.floor((totalSeconds % 3600) / 60), - seconds: totalSeconds % 60, - expired: false, - }; -} - -function Segment({ value, label }: { value: number; label: string }) { - return ( - - - {String(value).padStart(2, "0")} - - - {label} - - - ); -} - -export function PaymentDeadlineCard({ - paymentDeadline, - onPay, - paying, -}: { - /** ISO timestamp marking the end of the pay window. */ - paymentDeadline: string; - onPay?: () => void; - paying?: boolean; -}) { - const deadlineMs = new Date(paymentDeadline).getTime(); - const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); - - useEffect(() => { - setRemaining(getRemaining(deadlineMs)); - const interval = setInterval(() => { - const next = getRemaining(deadlineMs); - setRemaining(next); - if (next.expired) clearInterval(interval); - }, 1000); - return () => clearInterval(interval); - }, [deadlineMs]); - - const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6"; - const accentFg = remaining.expired ? "#C0392B" : "#B07D14"; - - return ( - - - Payment deadline - - - {remaining.expired ? "Expired" : "Pay window open"} - - - - {remaining.expired ? ( - - The payment window has closed. Move this booking to another schedule or - contact support. - - ) : ( - <> - - - - - - - - Complete payment before the window closes to secure your slot. - - {onPay && ( - - )} - - )} - - - - Deadline:{" "} - {new Date(paymentDeadline).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - })} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx new file mode 100644 index 000000000..146e31c1e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -0,0 +1,156 @@ +import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Download, Receipt } from "lucide-react"; +import toast from "react-hot-toast"; + +import { + warehouseInvoicesService, + type PortalWarehouseInvoice, +} from "@/services/warehouse-invoices.service"; +import { saveBlob } from "@/utils/download"; + +import { CardTitle, SectionCard } from "./layout"; + +const money = (amount: number | string | null | undefined, currency: string) => + `${Number(amount ?? 0).toLocaleString()} ${currency}`; + +const STATUS_STYLE: Record = { + DRAFT: { bg: "#EEF2F6", fg: "#64748B" }, + ISSUED: { bg: "#FEF3E2", fg: "#B45309" }, + PARTIALLY_PAID: { bg: "#FEF9E7", fg: "#A16207" }, + PAID: { bg: "#E6F7EF", fg: "#0A6F4D" }, + CANCELLED: { bg: "#EEF2F6", fg: "#64748B" }, +}; + +function StatusPill({ status }: { status: string }) { + const s = STATUS_STYLE[status] ?? { bg: "#EEF2F6", fg: "#64748B" }; + return ( + + {status.replace(/_/g, " ")} + + ); +} + +/** + * Warehouse fee invoices linked to this booking — display + PDF download only. + * Paying them online is tracked separately (in-system demurrage/storage + * payment). Renders nothing when the booking has no warehouse fees. Carries + * `id="warehouse-payments"` so the invoice detail page can deep-link here. + */ +export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { + const { data: invoices = [] } = useQuery({ + queryKey: ["booking-warehouse-invoices", bookingId], + queryFn: () => warehouseInvoicesService.listForBooking(bookingId), + }); + + if (invoices.length === 0) return null; + + const download = async (inv: PortalWarehouseInvoice) => { + try { + saveBlob( + await warehouseInvoicesService.downloadDocument(inv.id), + `warehouse-invoice-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Warehouse invoice PDF isn't ready yet."); + } + }; + + const downloadReceipt = async (inv: PortalWarehouseInvoice) => { + try { + saveBlob( + await warehouseInvoicesService.downloadReceipt(inv.id), + `warehouse-receipt-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Receipt isn't available yet."); + } + }; + + return ( + + + Warehouse payments + + {invoices.length} {invoices.length === 1 ? "invoice" : "invoices"} + + + + {invoices.map((inv) => { + const detail = [ + inv.invoiceType?.replace(/_/g, " "), + inv.cargoDescription ?? + inv.containerNumber ?? + inv.inventoryReference ?? + undefined, + ] + .filter(Boolean) + .join(" · "); + return ( + + + + + {inv.invoiceNumber} + + + + {detail && ( + + {detail} + + )} + + Total {money(inv.totalAmount, inv.currency)} · Balance{" "} + {money(inv.balanceAmount, inv.currency)} + + + + download(inv)} + > + + + {Number(inv.paidAmount) > 0 && ( + downloadReceipt(inv)} + > + + + )} + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx index 93d843065..8c4ab9bcb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -1,9 +1,7 @@ import { Box, Group, Stack, Text } from "@mantine/core"; -import { CheckCircle2, Clock } from "lucide-react"; +import { Clock } from "lucide-react"; -import type { Freight } from "@edr/types"; - -import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; +import { priceLineItems, priceTotal, type Pricing } from "../utils"; import { CardTitle, SectionCard } from "./layout"; function LineItems({ pricing }: { pricing: Pricing }) { @@ -107,113 +105,6 @@ export function EstimateCard({ ); } -export function PaymentCard({ - booking, - pricing, -}: { - booking: Freight.IBooking; - pricing: Pricing; -}) { - const paid = booking.paymentStatus === "PAID"; - // Customer sees the grand total plus the price breakdown that makes it up. - // A staff adjustment, when present, overrides the computed total and is - // flagged with an "Adjusted by EDR" badge. - const isAdjusted = - booking.adjustedTotalAmount !== null && - booking.adjustedTotalAmount !== undefined; - const currency = pricing?.currency ?? booking.paymentCurrency; - const total = isAdjusted - ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` - : priceTotal(pricing); - const hasItems = priceLineItems(pricing).length > 0; - - return ( - - - Payment - - {paid && } - {paid - ? "Paid" - : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} - - - - - {total} - - {isAdjusted && ( - - Adjusted by EDR - - )} - {isAdjusted && booking.adjustmentReason && ( - - {booking.adjustmentReason} - - )} - {paid && ( - - Paid · {fmtDate(booking.updatedAt)} - - )} - - {hasItems && ( - <> - - - - - {isAdjusted ? "Adjusted total" : "Total"} - - - {total} - - - - )} - {/* */} - - ); -} +// The booking payment card (countdown + breakdown + invoices + download) now +// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and +// changes-requested views, which only show an estimate. diff --git a/apps/edr-freight-web/portal/src/services/invoices.service.ts b/apps/edr-freight-web/portal/src/services/invoices.service.ts index 99b1d0dee..2ee18ec4e 100644 --- a/apps/edr-freight-web/portal/src/services/invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/invoices.service.ts @@ -29,12 +29,39 @@ export const invoicesService = { return data.data ?? data; }, + /** The customer's invoices for one source record (e.g. a booking). */ + listForSource: async ( + source: string, + sourceId: string, + ): Promise => { + const { data } = await client.get(B.MY_INVOICES, { + params: { source, sourceId }, + }); + return data.data ?? data; + }, + /** One of the customer's invoices, with its line items. */ get: async (id: string): Promise => { const { data } = await client.get(B.MY_INVOICE_BY_ID(id)); return data.data ?? data; }, + /** The sealed invoice PDF for one of the customer's invoices. */ + downloadDocument: async (id: string): Promise => { + const { data } = await client.get(B.MY_INVOICE_DOCUMENT(id), { + responseType: "blob", + }); + return data; + }, + + /** The sealed payment-receipt PDF (available once paid). */ + downloadReceipt: async (id: string): Promise => { + const { data } = await client.get(B.MY_INVOICE_RECEIPT(id), { + responseType: "blob", + }); + return data; + }, + /** Initiate gateway payment for an open invoice; returns the client action. */ pay: async ( id: string, diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts new file mode 100644 index 000000000..f1fa4e841 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts @@ -0,0 +1,54 @@ +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "../utils/api"; + +const W = URL_CONSTANTS.WAREHOUSE_INVOICES; + +/** + * A warehouse fee invoice as the freight API projects it for the customer + * (the historical `WarehouseFeeInvoice` view shape — a subset is used here). + */ +export interface PortalWarehouseInvoice { + id: string; + invoiceNumber: string; + invoiceType: string; + status: string; + currency: string; + totalAmount: number | string; + paidAmount: number | string; + balanceAmount: number | string; + issuedAt?: string | null; + dueDate?: string | null; + paidAt?: string | null; + bookingId?: string | null; + inventoryId?: string | null; + bookingReference?: string | null; + inventoryReference?: string | null; + cargoDescription?: string | null; + containerNumber?: string | null; +} + +export const warehouseInvoicesService = { + /** Warehouse fee invoices linked to a booking (via its inventory items). */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(W.FOR_BOOKING(bookingId)); + return data.data ?? data; + }, + + /** A single warehouse fee invoice (carries `bookingId` for source linking). */ + get: async (id: string): Promise => { + const { data } = await client.get(W.BY_ID(id)); + return data.data ?? data; + }, + + /** The sealed warehouse fee invoice PDF. */ + downloadDocument: async (id: string): Promise => { + const { data } = await client.get(W.DOCUMENT(id), { responseType: "blob" }); + return data; + }, + + /** The sealed warehouse fee payment receipt PDF (available once paid). */ + downloadReceipt: async (id: string): Promise => { + const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" }); + return data; + }, +}; diff --git a/apps/edr-freight-web/portal/src/utils/download.ts b/apps/edr-freight-web/portal/src/utils/download.ts new file mode 100644 index 000000000..594513566 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/download.ts @@ -0,0 +1,11 @@ +/** Trigger a browser download of a Blob under `filename`. */ +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 171a294fb..3d92a5652 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -140,6 +140,8 @@ export enum InvoiceStatus { Overdue = "OVERDUE", Cancelled = "CANCELLED", Refunded = "REFUNDED", + /** Pay window closed before settlement; terminal, cannot be paid. */ + Expired = "EXPIRED", } /** Originating subsystem an invoice bills for; namespaces invoice events. */ @@ -149,14 +151,6 @@ export enum InvoiceSource { Demurrage = "demurrage", } -/** - * What an invoice bills for within its source — the discriminator when one - * entity carries several invoices (e.g. a booking's up-front vs final charge). - */ -export enum InvoiceType { - Prepaid = "PREPAID", -} - export enum SchedulingStatus { NotScheduled = "NOT_SCHEDULED", Holding = "HOLDING", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd8c81c83..2f4a28bec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,13 +83,13 @@ importers: version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.1 - version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) + version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58) amqp-connection-manager: specifier: ^5.0.0 version: 5.0.0(amqplib@2.0.1) @@ -137,7 +137,7 @@ importers: version: 7.8.2 typeorm: specifier: ^0.3.30 - version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + version: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) devDependencies: '@edr/eslint-config': specifier: workspace:* @@ -147,10 +147,10 @@ importers: version: link:../../packages/config/tsconfig '@nestjs/cli': specifier: ^11.0.0 - version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3) + version: 11.0.21(@types/node@20.19.42) '@nestjs/schematics': specifier: ^11.0.0 - version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + version: 11.1.0(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.0 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24) @@ -175,15 +175,18 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + '@types/vorpal': + specifier: ^1.12.8 + version: 1.12.8 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + version: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) supertest: specifier: ^7.0.0 version: 7.2.2 ts-jest: specifier: ^29.2.5 - version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 version: 9.6.0(typescript@5.9.3)(webpack@5.106.0) @@ -196,6 +199,9 @@ importers: typescript: specifier: ^5.5.4 version: 5.9.3 + vorpal: + specifier: ^1.12.0 + version: 1.12.0 apps/edr-freight-web/backoffice: dependencies: @@ -661,7 +667,7 @@ importers: version: 8.5.15 tailwindcss: specifier: ^3.4.13 - version: 3.4.19(yaml@2.9.0) + version: 3.4.19(tsx@4.22.4)(yaml@2.9.0) typescript: specifier: ^5.5.4 version: 5.9.3 @@ -749,7 +755,7 @@ importers: version: 8.5.15 tailwindcss: specifier: ^3.4.13 - version: 3.4.19(yaml@2.9.0) + version: 3.4.19(tsx@4.22.4)(yaml@2.9.0) typescript: specifier: ^5.5.4 version: 5.9.3 @@ -1491,138 +1497,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4406,6 +4568,9 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/vorpal@1.12.8': + resolution: {integrity: sha512-Qt+Yxa1q6QCaYMxZFXlyPOF3ktIscTelNr1AFYuKM7/Dhlki4gvc476uFyA/hYvskSA6V8W+55x9FjlbAPcYdQ==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -4852,6 +5017,10 @@ packages: resolution: {integrity: sha512-zAfb1fokXsq4BoZBkL0eK+6MfFctbzX3R4UMcoWrL1n2WHewFKentTvOZv2P11u6P4NtW/V47hVjaN7fJiefOg==} engines: {node: '>=0.10.0'} + ansi-escapes@1.4.0: + resolution: {integrity: sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw==} + engines: {node: '>=0.10.0'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -4892,6 +5061,10 @@ packages: resolution: {integrity: sha512-ewaIr5y+9CUTGFwZfpECUbFlGcC0GCw1oqR9RI6h1gQCd9Aj2GxSckCnPsVJnmfMZbwFYE+leZGASgkWl06Jow==} engines: {node: '>=0.10.0'} + ansi-regex@2.1.1: + resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} + engines: {node: '>=0.10.0'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4908,6 +5081,10 @@ packages: resolution: {integrity: sha512-gWkLPDvHH2pC9YEKqp8dIl0mg3sRglMPvioqGDIOXiwxjxUwIJ1gF86E2o4R5yLNh8IAkwHbaMtASkJfkQ2hIA==} engines: {node: '>=0.10.0'} + ansi-styles@2.2.1: + resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} + engines: {node: '>=0.10.0'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -5159,6 +5336,9 @@ packages: '@babel/core': ^7.0.0 styled-components: '>= 2' + babel-polyfill@6.26.0: + resolution: {integrity: sha512-F2rZGQnAdaHWQ8YAoeRbukc7HS9QgdgeyJ0rQDd485v9opwuPvjpPFcOOT/WmkKTdgy9ESgSPXDcTNpzrGr6iQ==} + babel-preset-current-node-syntax@1.2.0: resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} peerDependencies: @@ -5170,6 +5350,9 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-runtime@6.26.0: + resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -5422,6 +5605,10 @@ packages: chainsaw@0.1.0: resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@1.1.3: + resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} + engines: {node: '>=0.10.0'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -5490,6 +5677,10 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@1.0.2: + resolution: {integrity: sha512-25tABq090YNKkF6JH7lcwO0zFJTRke4Jcq9iX2nr/Sz0Cjjv4gckmwlW6Ty/aoyFd6z3ysR2hMGC2GFugmBo6A==} + engines: {node: '>=0.10.0'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -5510,6 +5701,9 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cli-width@1.1.1: + resolution: {integrity: sha512-eMU2akIeEIkCxGXUNmDnJq1KzOIiPnJ+rKqRe6hcxE3vIOPvpMrBYOn/Bl7zNlYJj/zQxXquAnozHUCf9Whnsg==} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -5553,6 +5747,10 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + code-point-at@1.1.0: + resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} + engines: {node: '>=0.10.0'} + codepage@1.15.0: resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} engines: {node: '>=0.8'} @@ -5697,6 +5895,10 @@ packages: resolution: {integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==} engines: {node: '>=0.10.0'} + core-js@2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} @@ -6282,6 +6484,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -6289,6 +6496,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -6499,6 +6710,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + exit-hook@1.1.1: + resolution: {integrity: sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg==} + engines: {node: '>=0.10.0'} + exit@0.1.2: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} @@ -6632,6 +6847,10 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@1.7.0: + resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==} + engines: {node: '>=0.10.0'} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -6904,7 +7123,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -6989,6 +7208,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + has-ansi@2.0.0: + resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==} + engines: {node: '>=0.10.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -7185,6 +7408,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + in-publish@2.0.1: + resolution: {integrity: sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==} + hasBin: true + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -7214,6 +7441,9 @@ packages: react-dom: optional: true + inquirer@0.11.0: + resolution: {integrity: sha512-LIwC+g/fJbmKhDm341+RqDIV4jPf/n3pMway9xg8Ovt6CCQo1ozXhmuKTcoNIWhWJJKsSGZP+Rnuq7JgM7mE2A==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -7327,6 +7557,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@1.0.0: + resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -8137,6 +8371,9 @@ packages: lodash.upperfirst@4.3.1: resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + lodash@3.10.1: + resolution: {integrity: sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -8155,6 +8392,10 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + log-update@1.0.2: + resolution: {integrity: sha512-4vSow8gbiGnwdDNrpy1dyNaXWKSCIPop0EHdE8GrnngHoJujM3QhvHUN/igsYCgPoHo7pFOezlJ61Hlln0KHyA==} + engines: {node: '>=0.10.0'} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -8444,6 +8685,9 @@ packages: resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} + mute-stream@0.0.5: + resolution: {integrity: sha512-EbrziT4s8cWPmzr47eYVW3wimS4HsvlnV5ri1xw1aR6JQo/OrJX5rkl32K/QQHdxeabJETtfeaROGhd8W7uBgg==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -8559,6 +8803,10 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-localstorage@0.6.0: + resolution: {integrity: sha512-t9dKMce8qUs2KK02ZiBgzZSykUxc+5UcML7/20a62ruHwfh7+bNQvrH/auxY5gFNexTwAFdr+DbptxlLq4+7qQ==} + engines: {node: '>=0.10'} + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} @@ -8585,6 +8833,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + number-is-nan@1.0.1: + resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} + engines: {node: '>=0.10.0'} + nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} @@ -8655,6 +8907,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@1.1.0: + resolution: {integrity: sha512-GZ+g4jayMqzCRMgB2sol7GiCLjKfS1PINkjmx8spcKce1LiVqcbQreXwqs2YAFXC6R03VIG28ZS31t8M866v6A==} + engines: {node: '>=0.10.0'} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -9462,6 +9718,9 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readline2@1.0.1: + resolution: {integrity: sha512-8/td4MmwUB6PkZUbV25uKz7dfrmjYWxsW8DVfibWdlHRk/l/DfHKn4pU+dfcoGLFgWOdyGCzINRQD7jn+Bv+/g==} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -9503,6 +9762,12 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.10.5: + resolution: {integrity: sha512-02YopEIhAgiBHWeoTiA8aitHDt8z6w+rQqNuIftlM+ZtvSl/brTouaU7DW6GO/cHtvxJvS4Hwv2ibKdxIRi24w==} + + regenerator-runtime@0.11.1: + resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==} + regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -9587,6 +9852,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restore-cursor@1.0.1: + resolution: {integrity: sha512-reSjH4HuiFlxlaBaFCiS6O76ZGG2ygKoSlCsipKdaZuKSPx/+bt9mULkn4l0asVzbEfQQmXRg6Wp6gv6m0wElw==} + engines: {node: '>=0.10.0'} + restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -9658,9 +9927,15 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + run-async@0.1.0: + resolution: {integrity: sha512-qOX+w+IxFgpUpJfkv2oGN0+ExPs68F4sZHfaRRx4dDexAQkG83atugKVEylyT5ARees3HBbfmuvnjbrd8j9Wjw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rx-lite@3.1.2: + resolution: {integrity: sha512-1I1+G2gteLB8Tkt8YI1sJvSIfa0lWuRtC8GjvtyPBcLSF5jBCCJJqKrpER5JU5r6Bhe+i9/pK3VMuUcXu0kdwQ==} + rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} @@ -10011,6 +10286,10 @@ packages: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} + string-width@1.0.2: + resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==} + engines: {node: '>=0.10.0'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -10056,6 +10335,10 @@ packages: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} + strip-ansi@3.0.1: + resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} + engines: {node: '>=0.10.0'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -10146,6 +10429,10 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@2.0.0: + resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==} + engines: {node: '>=0.8.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -10512,6 +10799,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo@2.9.16: resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} hasBin: true @@ -10922,6 +11214,10 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + vorpal@1.12.0: + resolution: {integrity: sha512-lYEhd75l75P3D1LKpm4KqdOSpNyNdDJ9ixEZmC5ZAZUKGy6JNexfMdQ9SNaT5pCHuzuXXRJQedJ+CdqNg/D4Kw==} + engines: {iojs: '>= 1.0.0', node: '>= 0.10.0'} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -11046,6 +11342,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@2.1.0: + resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==} + engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -11300,11 +11600,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11353,6 +11653,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.29.7(supports-color@5.5.0)': dependencies: '@babel/traverse': 7.29.7(supports-color@5.5.0) @@ -11363,9 +11670,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -11539,6 +11846,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -11823,72 +12142,150 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -12280,6 +12677,41 @@ snapshots: - supports-color - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.42 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -12495,10 +12927,10 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.0(react@19.2.6) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.6 @@ -12771,6 +13203,42 @@ snapshots: axios: 1.17.0 rxjs: 7.8.2 + '@nestjs/cli@11.0.21(@types/node@20.19.42)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.24(@types/node@20.19.42)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@20.19.42) + '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0) + glob: 13.0.6 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.106.0 + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - prettier + - uglify-js + - webpack-cli + '@nestjs/cli@11.0.21(@types/node@20.19.42)(prettier@3.8.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -12921,6 +13389,17 @@ snapshots: transitivePeerDependencies: - chokidar + '@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + comment-json: 5.0.0 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + '@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -12974,6 +13453,14 @@ snapshots: rxjs: 7.8.2 typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + '@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))': + dependencies: + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + '@next/env@14.2.35': {} '@next/eslint-plugin-next@14.2.35': @@ -13160,7 +13647,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -15224,7 +15711,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -15233,50 +15720,6 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)': - dependencies: - '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) - argon2: 0.43.1 - axios: 1.17.0 - change-case: 5.4.4 - class-transformer: 0.5.1 - class-validator: 0.14.4 - dotenv: 16.6.1 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-date: 0.0.6 - exceljs: 4.4.0 - file-type: 21.3.4 - handlebars: 4.7.9 - handlebars-helpers: 0.10.0 - jmespath: 0.16.0 - jose: 5.10.0 - jsonwebtoken: 9.0.3 - libphonenumber-js: 1.13.6 - libreoffice-convert: 1.8.1 - nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - passport-jwt: 4.0.1 - qrcode: 1.5.4 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - style-object-to-css-string: 1.1.3 - typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - uuid: 11.1.1 - xlsx: 0.18.5 - transitivePeerDependencies: - - '@faker-js/faker' - - debug - - supports-color - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15321,6 +15764,50 @@ snapshots: - debug - supports-color + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51)': + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58) + argon2: 0.43.1 + axios: 1.17.0 + change-case: 5.4.4 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 16.6.1 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-date: 0.0.6 + exceljs: 4.4.0 + file-type: 21.3.4 + handlebars: 4.7.9 + handlebars-helpers: 0.10.0 + jmespath: 0.16.0 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + libreoffice-convert: 1.8.1 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + style-object-to-css-string: 1.1.3 + typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + xlsx: 0.18.5 + transitivePeerDependencies: + - '@faker-js/faker' + - debug + - supports-color + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15356,7 +15843,7 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(4837bbb980f4864b0c26765895373b58)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15366,8 +15853,8 @@ snapshots: '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(d6b22b11dde6cd6764a6a0c7e4c9ae51) api-common: 1.2.2 argon2: 0.43.1 axios: 1.17.0 @@ -15384,8 +15871,8 @@ snapshots: qrcode: 1.5.4 reflect-metadata: 0.2.2 rxjs: 7.8.2 - typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) uuid: 11.1.1 transitivePeerDependencies: - '@faker-js/faker' @@ -15399,7 +15886,7 @@ snapshots: '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -15820,6 +16307,8 @@ snapshots: '@types/validator@13.15.10': {} + '@types/vorpal@1.12.8': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -16169,7 +16658,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -16318,6 +16807,8 @@ snapshots: dependencies: ansi-wrap: 0.1.0 + ansi-escapes@1.4.0: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -16358,6 +16849,8 @@ snapshots: dependencies: ansi-wrap: 0.1.0 + ansi-regex@2.1.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -16370,6 +16863,8 @@ snapshots: dependencies: ansi-wrap: 0.1.0 + ansi-styles@2.2.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -16673,6 +17168,12 @@ snapshots: transitivePeerDependencies: - supports-color + babel-polyfill@6.26.0: + dependencies: + babel-runtime: 6.26.0 + core-js: 2.6.12 + regenerator-runtime: 0.10.5 + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -16698,6 +17199,11 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-runtime@6.26.0: + dependencies: + core-js: 2.6.12 + regenerator-runtime: 0.11.1 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -16805,7 +17311,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -16994,6 +17500,14 @@ snapshots: dependencies: traverse: 0.3.9 + chalk@1.1.3: + dependencies: + ansi-styles: 2.2.1 + escape-string-regexp: 1.0.5 + has-ansi: 2.0.0 + strip-ansi: 3.0.1 + supports-color: 2.0.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -17064,6 +17578,10 @@ snapshots: classnames@2.5.1: {} + cli-cursor@1.0.2: + dependencies: + restore-cursor: 1.0.1 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -17085,6 +17603,8 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 + cli-width@1.1.1: {} + cli-width@4.1.0: {} client-only@0.0.1: {} @@ -17129,6 +17649,8 @@ snapshots: code-block-writer@13.0.3: {} + code-point-at@1.1.0: {} + codepage@1.15.0: {} collect-v8-coverage@1.0.3: {} @@ -17245,6 +17767,8 @@ snapshots: copy-descriptor@0.1.1: {} + core-js@2.6.12: {} + core-js@3.49.0: optional: true @@ -17319,6 +17843,21 @@ snapshots: - supports-color - ts-node + create-jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + create-require@1.1.1: {} cron@4.4.0: @@ -17470,6 +18009,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -17484,6 +18027,8 @@ snapshots: decode-uri-component@0.2.2: {} + dedent@1.7.2: {} + dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -17881,10 +18426,42 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + optional: true + escalade@3.2.0: {} escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} @@ -17905,7 +18482,7 @@ snapshots: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -17929,7 +18506,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@5.5.0) @@ -17944,14 +18521,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -17966,7 +18543,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -18197,6 +18774,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + exit-hook@1.1.1: {} + exit@0.1.2: {} expand-brackets@2.1.4: @@ -18270,7 +18849,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -18321,7 +18900,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -18409,6 +18988,11 @@ snapshots: fflate@0.8.3: {} + figures@1.7.0: + dependencies: + escape-string-regexp: 1.0.5 + object-assign: 4.1.1 + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -18463,7 +19047,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -18697,7 +19281,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -18843,6 +19427,10 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + has-ansi@2.0.0: + dependencies: + ansi-regex: 2.1.1 + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -18959,21 +19547,21 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -19035,6 +19623,8 @@ snapshots: imurmurhash@0.1.4: {} + in-publish@2.0.1: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -19055,6 +19645,21 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + inquirer@0.11.0: + dependencies: + ansi-escapes: 1.4.0 + ansi-regex: 2.1.1 + chalk: 1.1.3 + cli-cursor: 1.0.2 + cli-width: 1.1.1 + figures: 1.7.0 + lodash: 3.10.1 + readline2: 1.0.1 + run-async: 0.1.0 + rx-lite: 3.1.2 + strip-ansi: 3.0.1 + through: 2.3.8 + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -19164,6 +19769,10 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@1.0.0: + dependencies: + number-is-nan: 1.0.1 + is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@4.0.0: {} @@ -19356,7 +19965,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -19400,6 +20009,32 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.42 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-circus@29.7.0(babel-plugin-macros@3.1.0): dependencies: '@jest/environment': 29.7.0 @@ -19445,6 +20080,25 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-config@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.7 @@ -19476,6 +20130,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.42 + ts-node: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -19709,6 +20394,18 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jiti@1.21.7: {} jiti@2.6.1: {} @@ -20126,6 +20823,8 @@ snapshots: lodash.upperfirst@4.3.1: {} + lodash@3.10.1: {} + lodash@4.17.21: {} lodash@4.18.1: {} @@ -20145,6 +20844,11 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + log-update@1.0.2: + dependencies: + ansi-escapes: 1.4.0 + cli-cursor: 1.0.2 + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -20233,7 +20937,7 @@ snapshots: mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@tabler/icons-react': 3.44.0(react@19.2.6) '@tanstack/match-sorter-utils': 8.19.4 @@ -20436,6 +21140,8 @@ snapshots: concat-stream: 2.0.0 type-is: 1.6.18 + mute-stream@0.0.5: {} + mute-stream@2.0.0: {} mute-stream@3.0.0: {} @@ -20555,6 +21261,8 @@ snapshots: node-int64@0.4.0: {} + node-localstorage@0.6.0: {} + node-releases@2.0.47: {} normalize-path@3.0.0: {} @@ -20580,6 +21288,8 @@ snapshots: dependencies: boolbase: 1.0.0 + number-is-nan@1.0.1: {} + nwsapi@2.2.24: {} nypm@0.6.6: @@ -20658,6 +21368,8 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@1.1.0: {} + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -20750,7 +21462,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -20965,12 +21677,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.15 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(tsx@4.22.4)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.15 + tsx: 4.22.4 yaml: 2.9.0 postcss-nested@6.2.0(postcss@8.5.15): @@ -21064,7 +21777,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -21091,7 +21804,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -21642,6 +22355,12 @@ snapshots: readdirp@4.1.2: {} + readline2@1.0.1: + dependencies: + code-point-at: 1.1.0 + is-fullwidth-code-point: 1.0.0 + mute-stream: 0.0.5 + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -21708,6 +22427,10 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.10.5: {} + + regenerator-runtime@0.11.1: {} + regenerator-runtime@0.13.11: optional: true @@ -21782,6 +22505,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@1.0.1: + dependencies: + exit-hook: 1.1.1 + onetime: 1.1.0 + restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -21855,7 +22583,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -21869,10 +22597,16 @@ snapshots: run-applescript@7.1.0: {} + run-async@0.1.0: + dependencies: + once: 1.4.0 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + rx-lite@3.1.2: {} + rxjs@7.8.1: dependencies: tslib: 2.8.1 @@ -21967,7 +22701,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -22199,7 +22933,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -22316,6 +23050,12 @@ snapshots: char-regex: 1.0.2 strip-ansi: 6.0.1 + string-width@1.0.2: + dependencies: + code-point-at: 1.1.0 + is-fullwidth-code-point: 1.0.0 + strip-ansi: 3.0.1 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -22399,6 +23139,10 @@ snapshots: is-obj: 3.0.0 is-regexp: 3.1.0 + strip-ansi@3.0.1: + dependencies: + ansi-regex: 2.1.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -22477,7 +23221,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -22495,6 +23239,8 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@2.0.0: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -22543,7 +23289,7 @@ snapshots: dependencies: tailwindcss: 4.3.0 - tailwindcss@3.4.19(yaml@2.9.0): + tailwindcss@3.4.19(tsx@4.22.4)(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -22562,7 +23308,7 @@ snapshots: postcss: 8.5.15 postcss-import: 15.1.0(postcss@8.5.15) postcss-js: 4.1.0(postcss@8.5.15) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)(tsx@4.22.4)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.15) postcss-selector-parser: 6.1.2 resolve: 1.22.12 @@ -22793,6 +23539,26 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.7) jest-util: 29.7.0 + ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@20.19.42)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.2 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-util: 29.7.0 + ts-loader@9.6.0(typescript@5.9.3)(webpack@5.106.0): dependencies: chalk: 4.1.2 @@ -22869,6 +23635,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + optional: true + turbo@2.9.16: optionalDependencies: '@turbo/darwin-64': 2.9.16 @@ -22961,6 +23734,19 @@ snapshots: typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) yargs: 18.0.0 + typeorm-extension@3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))): + dependencies: + '@faker-js/faker': 10.4.0 + consola: 3.4.2 + envix: 1.5.0 + locter: 2.2.1 + pascal-case: 3.1.2 + rapiq: 0.9.0 + reflect-metadata: 0.2.2 + smob: 1.6.2 + typeorm: 0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + yargs: 18.0.0 + typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 @@ -23009,6 +23795,30 @@ snapshots: - babel-plugin-macros - supports-color + typeorm@0.3.30(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)): + dependencies: + '@sqltools/formatter': 1.2.5 + ansis: 4.3.1 + app-root-path: 3.1.0 + buffer: 6.0.3 + dayjs: 1.11.21 + debug: 4.4.3 + dedent: 1.7.2 + dotenv: 16.6.1 + glob: 10.5.0 + reflect-metadata: 0.2.2 + sha.js: 2.4.12 + sql-highlight: 6.1.0 + tslib: 2.8.1 + uuid: 11.1.1 + yargs: 17.7.2 + optionalDependencies: + pg: 8.21.0 + ts-node: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + typescript@5.9.3: {} uglify-js@3.19.3: @@ -23351,6 +24161,19 @@ snapshots: void-elements@3.1.0: {} + vorpal@1.12.0: + dependencies: + babel-polyfill: 6.26.0 + chalk: 1.1.3 + in-publish: 2.0.1 + inquirer: 0.11.0 + lodash: 4.18.1 + log-update: 1.0.2 + minimist: 1.2.8 + node-localstorage: 0.6.0 + strip-ansi: 3.0.1 + wrap-ansi: 2.1.0 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -23520,6 +24343,11 @@ snapshots: wordwrap@1.0.0: {} + wrap-ansi@2.1.0: + dependencies: + string-width: 1.0.2 + strip-ansi: 3.0.1 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0