diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 84e3e4b7a..982728241 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -58,6 +58,7 @@ import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.mod import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; +import { PublicationsModule } from "./modules/publications/publications.module"; import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; @@ -231,6 +232,7 @@ if (!process.env.APPLICATION_NAME) { LogoSettingsModule, ContractTemplatesModule, SupportContentModule, + PublicationsModule, OtpModule, HealthModule, RuleEngineModule, diff --git a/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts new file mode 100644 index 000000000..d44f57931 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Public document library for the freight portal (PDFs, Markdown write-ups, + * PowerPoint decks about the platform), managed from the backoffice. Each row + * is one whole file stored in MinIO under `publications/` — a re-upload + * replaces the object and the row's file columns, there is no per-version + * history table like `support_documents` has. + */ +export class Publications3850000000000 implements MigrationInterface { + name = 'Publications3850000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.publications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + title varchar(200) NOT NULL, + description text, + category varchar(60), + file_key varchar(512) NOT NULL, + file_name varchar(255) NOT NULL, + file_mime_type varchar(120) NOT NULL, + file_size_bytes bigint NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + published boolean NOT NULL DEFAULT true, + published_at timestamptz, + uploaded_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // Serves the public list: published rows in display order. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_publications_published_sort + ON freight.publications (published, sort_order) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts index b2937e98d..5e3987de5 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -70,4 +70,75 @@ describe('BookingsRepository', () => { expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS')); }); + + it('findManualConsolidationCandidates offers an odd-20ft partner booked on another day', async () => { + const qb = mockQueryBuilder(); + // Same route/direction, odd 20ft count, but sitting on a different + // scheduled_date than the booking being completed. GL completes both halves + // onto the date chosen on the form, so this is still a legal partner. + qb.getMany.mockResolvedValue([ + { + id: 'partner', + reference: 'BK-2026-000303', + scheduledDate: new Date('2026-09-02T08:00:00.000Z'), + bookingContainers: [{ quantity: 1, containerType: { sizeFt: 20 } }], + }, + ]); + repository.createQueryBuilder.mockReturnValue(qb as never); + + const result = await bookingsRepository.findManualConsolidationCandidates({ + id: 'own', + originYardId: 'yard-a', + destinationYardId: 'yard-b', + tradeDirection: 'IMPORT', + scheduledDate: new Date('2026-09-01T08:00:00.000Z'), + } as Booking); + + expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-000303']); + expect(result[0].ft20Quantity).toBe(1); + // The booking day must not narrow this list at all. + const dateFilters = qb.andWhere.mock.calls.filter(([clause]) => + String(clause).includes('scheduled_date'), + ); + expect(dateFilters).toHaveLength(0); + }); + + it('findManualConsolidationCandidates falls back to the requested lines before cargo is persisted', async () => { + const qb = mockQueryBuilder(); + // The ordinary state of a CLEARANCE_READY customs booking: container lines + // are written by completion, so there are none yet and the accepted booking + // request is the only statement of what it will carry. + qb.getMany.mockResolvedValue([ + { id: 'b-odd', reference: 'BK-2026-001116', bookingContainers: [] }, + { id: 'b-even', reference: 'BK-EVEN', bookingContainers: [] }, + // No request at all — count unknown, so not offerable. + { id: 'b-unknown', reference: 'BK-UNKNOWN', bookingContainers: [] }, + ]); + repository.createQueryBuilder.mockReturnValue(qb as never); + dataSource.getRepository.mockReturnValue({ + createQueryBuilder: () => ({ + where: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([ + { + createdBookingId: 'b-odd', + requestedLines: { containers: [{ containerSize: '20ft', quantity: 1 }] }, + }, + { + createdBookingId: 'b-even', + requestedLines: { containers: [{ containerSize: '20ft', quantity: 2 }] }, + }, + ]), + }), + }); + + const result = await bookingsRepository.findManualConsolidationCandidates({ + id: 'own', + originYardId: 'yard-a', + destinationYardId: 'yard-b', + tradeDirection: 'EXPORT', + } as Booking); + + expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-001116']); + expect(result[0].ft20Quantity).toBe(1); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 21428c77d..0947dc3ae 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -20,6 +20,7 @@ import { Contract } from '../contracts/entities/contract.entity'; import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; +import { BookingRequest } from '../contracts/entities/booking-request.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { CARGO_TYPE_SUBTREE_SQL, @@ -336,19 +337,35 @@ export class BookingsRepository extends BaseRepository { * {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact * quantity complement — this lists CANDIDATES for a human to choose from, but * every row must still be a legal pick: another customs booking on the same - * route/direction, riding the same booking day, that is itself carrying an odd - * 20ft count. Two odd counts always sum to even, so any pick fills the shared - * wagon. + * route/direction that is itself carrying an odd 20ft count. Two odd counts + * always sum to even, so any pick fills the shared wagon. * - * A booking whose cargo is not entered yet is NOT a candidate: with no - * container lines its 20ft count is unknown, so pairing with it cannot be - * shown to fill the wagon. Same rule as - * {@link findRebookConsolidationCandidates}. + * Deliberately NOT filtered on booking day, unlike + * {@link findComplementaryConsolidationPartner} and + * {@link findRebookConsolidationCandidates}. Those pair bookings that keep the + * dates they already hold, so a mismatched day means two different trains. Here + * both halves are completed together by GL in one shot and + * `completeConsolidatedPair` writes the SAME operator-chosen scheduled_date and + * train to each — see CompleteConsolidatedPairDto — so a partner's stored date + * is about to be overwritten and says nothing about whether it can share the + * wagon. Filtering on it only hid legal partners whose customs clearance + * happened to finish on another day. + * + * The 20ft count comes from the booking's persisted container lines when it + * has them, and otherwise from the accepted booking request that created it. + * That fallback is the normal case here, not an edge case: on a customs + * contract the container lines are written BY completion, so a booking still + * sitting in CLEARANCE_READY — exactly what this list is for — has none yet, + * and its requested quantities are the only statement of what it will carry. + * Reading only the persisted lines left the picker permanently empty. + * + * A booking with neither source is still NOT a candidate: its 20ft count is + * unknown, so pairing with it cannot be shown to fill the wagon. */ async findManualConsolidationCandidates( booking: Booking, limit = 50, - ): Promise { + ): Promise> { const qb = this.repository .createQueryBuilder('b') .leftJoinAndSelect('b.bookingContainers', 'bc') @@ -381,30 +398,65 @@ export class BookingsRepository extends BaseRepository { ], }); - // Same EAT booking day — the pair shares one physical wagon, so it must - // board one train. Applied only when this booking has a date of its own; - // without one there is no day to match against and route/direction stand - // alone, mirroring findComplementaryConsolidationPartner. - if (booking.scheduledDate) { - qb.andWhere( - `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, - { bookingDate: booking.scheduledDate }, - ); - } - const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany(); - // Odd-20ft test in memory. A booking with no container lines has an unknown - // 20ft count, so it cannot be shown to complete the wagon and is not - // offered. - return rows.filter((row) => { + // Requested 20ft quantities for the rows that carry no persisted cargo yet, + // keyed by booking id. One query for the whole page rather than per row. + const pendingIds = rows + .filter((row) => (row.bookingContainers ?? []).length === 0) + .map((row) => row.id); + const requested = await this.findRequested20ftByBooking(pendingIds); + + // Odd-20ft test in memory: two 20ft to a wagon, so odd + odd = whole wagons. + // The resolved count rides along so callers render the same number this + // decision was made on rather than re-deriving it from the empty lines. + const candidates: Array<{ booking: Booking; ft20Quantity: number }> = []; + for (const row of rows) { const lines = row.bookingContainers ?? []; - if (lines.length === 0) return false; - const ft20 = lines - .filter((line) => Number(line.containerType?.sizeFt) === 20) + const ft20 = + lines.length > 0 + ? lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0) + : requested.get(row.id); + // Neither persisted nor requested cargo — the count is unknown, so this + // booking cannot be shown to fill the wagon. + if (ft20 === undefined) continue; + if (ft20 % 2 !== 1) continue; + candidates.push({ booking: row, ft20Quantity: ft20 }); + } + return candidates; + } + + /** + * 20ft quantity each of `bookingIds` was requested with, from the accepted + * booking request that created it. Used to judge bookings whose container + * lines are not written yet — on a customs contract that is every booking + * before completion. Bookings with no request are absent from the map, which + * the caller reads as "unknown", not zero. + */ + private async findRequested20ftByBooking( + bookingIds: string[], + ): Promise> { + const byBooking = new Map(); + if (bookingIds.length === 0) return byBooking; + + const requests = await this.dataSource + .getRepository(BookingRequest) + .createQueryBuilder('r') + .where('r.createdBookingId IN (:...bookingIds)', { bookingIds }) + .getMany(); + + for (const request of requests) { + if (!request.createdBookingId) continue; + // containerSize is free text on the request ('20ft', '20FT'), so parse the + // leading number rather than comparing strings. + const ft20 = (request.requestedLines?.containers ?? []) + .filter((line) => parseInt(String(line.containerSize), 10) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0); - return ft20 % 2 === 1; - }); + byBooking.set(request.createdBookingId, ft20); + } + return byBooking; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index b601e1c3e..4fe111468 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -165,52 +165,34 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { ).rejects.toThrow(/cannot be consolidated with itself/i); }); - it('offers only bookings whose own 20ft count is odd', async () => { - // Two odd counts always sum to even, so an odd partner is exactly what fills - // the wagon; an even one would leave the pair partial again. - const rows = [ - { - id: 'odd', - reference: 'BK-ODD', - bookingContainers: [ - { quantity: 3, containerType: { sizeFt: 20 } }, - ], - }, - { - id: 'even', - reference: 'BK-EVEN', - bookingContainers: [ - { quantity: 4, containerType: { sizeFt: 20 } }, - ], - }, - // Cargo not entered yet — its 20ft count is unknown, so it cannot be - // shown to fill the wagon and is not offered. - { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, - ]; - + // Which bookings qualify is the repository's decision (and its own spec's); + // what matters here is that the odd 20ft count it resolved survives into the + // response. A booking awaiting completion has no container lines of its own, + // so re-deriving the count from bookingContainers would report 0 and the + // picker would show every candidate as empty. + it('reports the 20ft count the repository resolved, not the persisted lines', async () => { const { service } = makeService({ bookingsRepository: { findByIdWithFiles: jest .fn() .mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking), - findManualConsolidationCandidates: jest.fn(async (booking: Booking) => - // Mirror the repository's in-memory odd filter. - rows.filter((row) => { - void booking; - const lines = row.bookingContainers ?? []; - if (lines.length === 0) return false; - const ft20 = lines - .filter((l) => Number(l.containerType?.sizeFt) === 20) - .reduce((sum, l) => sum + Number(l.quantity || 0), 0); - return ft20 % 2 === 1; - }), - ), + findManualConsolidationCandidates: jest.fn().mockResolvedValue([ + { + // Cargo not persisted yet — the count came from its booking request. + booking: { + id: 'odd', + reference: 'BK-2026-001116', + bookingContainers: [], + }, + ft20Quantity: 1, + }, + ]), }, }); const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); - expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']); - expect(candidates[0].ft20Quantity).toBe(3); + expect(candidates.map((c) => c.reference)).toEqual(['BK-2026-001116']); + expect(candidates[0].ft20Quantity).toBe(1); expect(candidates[0].hasCargo).toBe(true); }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 88a2175b9..b8205fd33 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -660,24 +660,25 @@ export class ContractBookingService { const rows = await this.bookingsRepository.findManualConsolidationCandidates( booking, ); - return rows.map((row) => { - const lines = row.bookingContainers ?? []; - return { - id: row.id, - reference: row.reference, - contractId: row.contractId ?? null, - companyName: row.company?.name ?? null, - status: row.status, - tradeDirection: row.tradeDirection ?? null, - originYardId: row.originYardId ?? null, - destinationYardId: row.destinationYardId ?? null, - scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null, - ft20Quantity: lines - .filter((line) => Number(line.containerType?.sizeFt) === 20) - .reduce((sum, line) => sum + Number(line.quantity || 0), 0), - hasCargo: lines.length > 0, - }; - }); + // ft20Quantity comes back from the repository already resolved — persisted + // container lines when the booking has them, otherwise the quantities its + // booking request was accepted with. Recomputing it here from + // bookingContainers would report 0 for every not-yet-completed booking. + return rows.map(({ booking: row, ft20Quantity }) => ({ + id: row.id, + reference: row.reference, + contractId: row.contractId ?? null, + companyName: row.company?.name ?? null, + status: row.status, + tradeDirection: row.tradeDirection ?? null, + originYardId: row.originYardId ?? null, + destinationYardId: row.destinationYardId ?? null, + scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null, + ft20Quantity, + // Cargo is known — from either source — since a candidate with an unknown + // count is never offered. + hasCargo: true, + })); } /** diff --git a/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts new file mode 100644 index 000000000..313ff182c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts @@ -0,0 +1,33 @@ +import { Transform } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator"; + +/** + * Metadata fields for `POST /publications`, sent alongside the file as + * multipart/form-data — every field arrives as a string, so numeric/boolean + * fields need an explicit `@Transform` (global `enableImplicitConversion` is + * off, see main.ts). + */ +export class CreatePublicationDto { + @IsString() + @MaxLength(200) + title!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(60) + category?: string; + + @IsOptional() + @IsInt() + @Transform(({ value }) => Number(value ?? 0)) + sortOrder?: number; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === undefined || value === "true" || value === true) + published?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts new file mode 100644 index 000000000..677b08c32 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreatePublicationDto } from "./create-publication.dto"; + +export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {} diff --git a/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts new file mode 100644 index 000000000..e627e638c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +/** + * One document in the freight portal's public library (/publications) — a + * PDF, Markdown write-up, or PowerPoint deck about the platform, uploaded and + * curated from the backoffice. Unlike `SupportDocument`'s five fixed slugs + * edited in place, this is a real table of many rows and each upload is a + * whole new file — there is no version-history log here, a re-upload just + * replaces the file columns (see `PublicationsService.replaceFile`). + */ +@Entity({ schema: "freight", name: "publications" }) +@Index(["published", "sortOrder"]) +export class Publication extends BaseEntity { + @Column({ name: "title", type: "varchar", length: 200 }) + title!: string; + + @Column({ name: "description", type: "text", nullable: true }) + description?: string | null; + + @Column({ name: "category", type: "varchar", length: 60, nullable: true }) + category?: string | null; + + /** MinIO object key. Never a signed URL — those expire; sign on read instead. */ + @Column({ name: "file_key", type: "varchar", length: 512 }) + fileKey!: string; + + /** Original filename, used for the download's Content-Disposition. */ + @Column({ name: "file_name", type: "varchar", length: 255 }) + fileName!: string; + + @Column({ name: "file_mime_type", type: "varchar", length: 120 }) + fileMimeType!: string; + + @Column({ name: "file_size_bytes", type: "bigint" }) + fileSizeBytes!: number; + + /** Manual ordering in the backoffice list and the public grid. */ + @Column({ name: "sort_order", type: "integer", default: 0 }) + sortOrder!: number; + + /** Unpublish without deleting — hides it from the public list only. */ + @Column({ name: "published", type: "boolean", default: true }) + published!: boolean; + + @Column({ name: "published_at", type: "timestamptz", nullable: true }) + publishedAt?: Date | null; + + @Column({ name: "uploaded_by_id", type: "uuid", nullable: true }) + uploadedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts new file mode 100644 index 000000000..8a95ffb39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts @@ -0,0 +1,51 @@ +import { Public } from "@edr/api-common"; +import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Res } from "@nestjs/common"; +import { Response } from "express"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; + +import { PublicationsService } from "./publications.service"; + +/** + * The portal's /publications page — a public library of PDFs, Markdown + * write-ups and PowerPoint decks about the platform. No login required, same + * as /help, /faq and the legal pages: prospects reach it before any account + * exists. + */ +@ApiTags("publications") +@Public() +@Controller("publications") +export class PublicPublicationsController { + constructor(private readonly service: PublicationsService) {} + + @Get() + // Cheap to serve stale for a few minutes; every anonymous page view hits it. + @Header("Cache-Control", "public, max-age=300") + @ApiOperation({ summary: "List published publications for the public library" }) + list() { + return this.service.listPublic(); + } + + @Get(":id/file") + @ApiQuery({ + name: "download", + required: false, + description: "Set to 1/true to force a download instead of inline preview.", + }) + @ApiOperation({ summary: "Stream a published publication's file" }) + async getFile( + @Param("id", ParseUUIDPipe) id: string, + @Query("download") download: string | undefined, + @Res() res: Response, + ) { + const { stream, record } = await this.service.getPublishedFileStream(id); + const forceDownload = download === "1" || download === "true"; + + res.setHeader("Content-Type", record.fileMimeType); + res.setHeader( + "Content-Disposition", + `${forceDownload ? "attachment" : "inline"}; filename="${record.fileName}"`, + ); + res.setHeader("Cache-Control", "public, max-age=300"); + stream.pipe(res); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.controller.ts b/apps/edr-freight-api/src/modules/publications/publications.controller.ts new file mode 100644 index 000000000..79f18e9e6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.controller.ts @@ -0,0 +1,76 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFile, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff } from "../../common/booking-guards"; +import { documentUploadMulterOptions } from "../../common/document-upload.options"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { CreatePublicationDto } from "./dto/create-publication.dto"; +import { UpdatePublicationDto } from "./dto/update-publication.dto"; +import { PublicationsService } from "./publications.service"; + +const READ = [FREIGHT_PERMS.settings.publications.view, FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin]; +const WRITE = [FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin]; + +@ApiTags("publications") +@ApiBearerAuth() +@Controller("publications") +export class PublicationsController { + constructor(private readonly service: PublicationsService) {} + + @Get("admin") + @BookingStaff(READ) + @ApiOperation({ summary: "List every publication, published or not" }) + list() { + return this.service.list(); + } + + @Post() + @BookingStaff(WRITE) + @UseInterceptors(FileInterceptor("file", documentUploadMulterOptions)) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload a new publication" }) + create( + @UploadedFile() file: Express.Multer.File, + @Body() dto: CreatePublicationDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.create(file, dto, user?.id ?? null); + } + + @Patch(":id") + @BookingStaff(WRITE) + @ApiOperation({ summary: "Update a publication's title, description, category, order or published state" }) + update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdatePublicationDto) { + return this.service.update(id, dto); + } + + @Post(":id/file") + @BookingStaff(WRITE) + @UseInterceptors(FileInterceptor("file", documentUploadMulterOptions)) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Replace a publication's file" }) + replaceFile(@Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File) { + return this.service.replaceFile(id, file); + } + + @Delete(":id") + @BookingStaff(WRITE) + @ApiOperation({ summary: "Remove a publication" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.module.ts b/apps/edr-freight-api/src/modules/publications/publications.module.ts new file mode 100644 index 000000000..46e612ebe --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { MinioModule } from "../minio/minio.module"; +import { Publication } from "./entities/publication.entity"; +import { PublicationsController } from "./publications.controller"; +import { PublicationsRepository } from "./publications.repository"; +import { PublicationsService } from "./publications.service"; +import { PublicPublicationsController } from "./public-publications.controller"; + +@Module({ + imports: [TypeOrmModule.forFeature([Publication]), MinioModule], + controllers: [PublicPublicationsController, PublicationsController], + providers: [PublicationsRepository, PublicationsService], + exports: [PublicationsService], +}) +export class PublicationsModule {} diff --git a/apps/edr-freight-api/src/modules/publications/publications.repository.ts b/apps/edr-freight-api/src/modules/publications/publications.repository.ts new file mode 100644 index 000000000..315f630bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.repository.ts @@ -0,0 +1,29 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Publication } from "./entities/publication.entity"; + +@Injectable() +export class PublicationsRepository extends BaseRepository { + constructor( + @InjectRepository(Publication) + repository: Repository, + ) { + super(repository); + } + + /** Public list: published rows only, in display order. */ + findPublished(): Promise { + return this.repository.find({ + where: { published: true }, + order: { sortOrder: "ASC", publishedAt: "DESC" }, + }); + } + + /** Admin list: every row, published or not. */ + override findAll(): Promise { + return this.repository.find({ order: { sortOrder: "ASC" } }); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.service.ts b/apps/edr-freight-api/src/modules/publications/publications.service.ts new file mode 100644 index 000000000..e7ff2b2a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.service.ts @@ -0,0 +1,151 @@ +import { + PublicationSummary, + PUBLICATION_ALLOWED_MIME_TYPES, + PUBLICATION_FILE_PREFIX, +} from "@edr/types"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { extname } from "path"; +import { Readable } from "stream"; +import { randomUUID } from "crypto"; + +import { MinioService } from "../minio/minio.service"; +import { CreatePublicationDto } from "./dto/create-publication.dto"; +import { UpdatePublicationDto } from "./dto/update-publication.dto"; +import { Publication } from "./entities/publication.entity"; +import { PublicationsRepository } from "./publications.repository"; + +@Injectable() +export class PublicationsService { + constructor( + private readonly repository: PublicationsRepository, + private readonly minio: MinioService, + ) {} + + private assertAllowedFile(file?: Express.Multer.File): asserts file is Express.Multer.File { + if (!file) throw new BadRequestException("No file uploaded"); + if (!(PUBLICATION_ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) { + throw new BadRequestException( + `Unsupported file type ${file.mimetype} — PDF, Markdown and PowerPoint only`, + ); + } + } + + async create( + file: Express.Multer.File | undefined, + dto: CreatePublicationDto, + actorId: string | null, + ): Promise { + this.assertAllowedFile(file); + + const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`; + await this.minio.uploadFile(key, file.buffer, file.mimetype); + + const published = dto.published ?? true; + return this.repository.create({ + title: dto.title, + description: dto.description ?? null, + category: dto.category ?? null, + fileKey: key, + fileName: file.originalname, + fileMimeType: file.mimetype, + fileSizeBytes: file.size, + sortOrder: dto.sortOrder ?? 0, + published, + publishedAt: published ? new Date() : null, + uploadedById: actorId, + }); + } + + async update(id: string, dto: UpdatePublicationDto): Promise { + const existing = await this.getByIdOrThrow(id); + + const patch: Partial = { + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.category !== undefined && { category: dto.category }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }; + + if (dto.published !== undefined && dto.published !== existing.published) { + patch.published = dto.published; + patch.publishedAt = dto.published ? new Date() : null; + } + + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Publication ${id} not found`); + return updated; + } + + /** Swaps the stored file for one row; the old MinIO object is dropped after the new one is saved. */ + async replaceFile(id: string, file?: Express.Multer.File): Promise { + this.assertAllowedFile(file); + const existing = await this.getByIdOrThrow(id); + + const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`; + await this.minio.uploadFile(key, file.buffer, file.mimetype); + + const updated = await this.repository.update(id, { + fileKey: key, + fileName: file.originalname, + fileMimeType: file.mimetype, + fileSizeBytes: file.size, + }); + + await this.minio.deleteFile(existing.fileKey); + return updated!; + } + + async remove(id: string): Promise { + await this.getByIdOrThrow(id); + await this.repository.softDelete(id); + } + + /** Admin list — every row, published or not. */ + list(): Promise { + return this.repository.findAll(); + } + + /** + * Public list — published rows only. No file URL here: a presigned MinIO + * URL isn't reachable from the browser (see `fileViewUrl` in the portal's + * `apiConfig.ts`); the portal builds each file's URL itself from `id` via + * `GET /publications/:id/file`. + */ + async listPublic(): Promise { + const rows = await this.repository.findPublished(); + return rows.map((row) => this.toSummary(row)); + } + + private toSummary(row: Publication): PublicationSummary { + return { + id: row.id, + title: row.title, + description: row.description ?? null, + category: row.category ?? null, + fileName: row.fileName, + fileMimeType: row.fileMimeType, + fileSizeBytes: Number(row.fileSizeBytes), + sortOrder: row.sortOrder, + publishedAt: row.publishedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + /** For the public/staff file route: streams a published row's bytes. */ + async getPublishedFileStream( + id: string, + ): Promise<{ stream: Readable; record: Publication }> { + const record = await this.repository.findById(id); + if (!record || !record.published) { + throw new NotFoundException(`Publication ${id} not found`); + } + return { stream: await this.minio.getFileStream(record.fileKey), record }; + } + + private async getByIdOrThrow(id: string): Promise { + const record = await this.repository.findById(id); + if (!record) throw new NotFoundException(`Publication ${id} not found`); + return record; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index f8617cd65..60121c2fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => { rate?: Rate; pending?: RateChangeRequest | null; applyThrows?: Error; + /** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */ + derived?: Partial; + previewThrows?: Error; } = {}) => { const rate = opts.rate ?? liveRate(); const saved: RateChangeRequest[] = []; @@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => { const rates = { findById: jest.fn(async () => rate), assertUpdateValid: jest.fn(async () => undefined), + // Stands in for buildUpdate: it resolves a patch into the full column + // set, including columns the form never posts (rateType and friends). + previewUpdate: jest.fn(async (_id: string, dto: Record) => { + if (opts.previewThrows) throw opts.previewThrows; + return { ...dto, ...(opts.derived ?? {}) } as Partial; + }), applyApprovedUpdate: jest.fn(async () => { if (opts.applyThrows) throw opts.applyThrows; return rate; @@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => { }); it('validates up front so the requester hears about a bad patch, not the approver', async () => { - const { service, rates } = build(); - rates.assertUpdateValid.mockRejectedValueOnce( - new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), - ); + // Resolving the patch IS the validation — buildUpdate throws on a bad + // unit, so previewUpdate surfaces it at submit time. + const { service } = build({ + previewThrows: new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), + }); await expect( service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }), ).rejects.toThrow(/not valid for this rate/); }); + + it('shows the approver a bulk switch, which only exists as a derived column', async () => { + // The form posts intercityKind: BULK — never stored. The real edit lands + // on rateType (+ the cargo/container swap), so that is what the approver + // must see. Diffing the raw patch showed an empty change list. + const { service } = build({ + rate: liveRate({ + rateType: 'INTERCITY_CONTAINER', + appliesTo: 'INTERCITY', + containerTypeId: 'ct-1', + }), + derived: { + rateType: 'INTERCITY_BULK', + containerTypeId: null, + cargoTypeId: 'cargo-9', + } as Partial, + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never, + }); + + expect(request.payload).toMatchObject({ + rateType: 'INTERCITY_BULK', + containerTypeId: null, + cargoTypeId: 'cargo-9', + }); + expect(request.previousValues).toMatchObject({ + rateType: 'INTERCITY_CONTAINER', + containerTypeId: 'ct-1', + }); + }); }); describe('approve', () => { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 8913c9ef9..68d2ccb2e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice page where both the queue and the rates live. */ const RATES_LINK = '/dashboard/rules/rates'; -/** Fields a change request may carry — anything else in the patch is ignored. */ +/** + * Persisted columns an approver is shown a before→after for. + * + * These are RESOLVED entity columns, not raw form fields: the diff runs + * against `RatesService.previewUpdate`, so a change the form expresses through + * a non-stored selector still shows up here as the column it actually moves + * (a flip to bulk lands on `rateType` + the container/cargo swap). + */ const DIFFABLE_FIELDS = [ 'rateValue', 'currency', @@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [ 'tradeDirection', 'containerTypeId', 'cargoTypeId', + // The container-vs-bulk shape of the rate. Missing here, switching a LIVE + // rate to bulk showed the approver an empty change list — the only column + // that records the kind is rateType, and the form never posts it directly. + 'rateType', + // Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping + // line diffed to nothing. + 'shippingLineCompanyId', // The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate // diffed to nothing and the submit was refused as "nothing changed". 'originYardId', @@ -82,7 +96,11 @@ export class RateChangeRequestsService { ); } - const payload = this.changedFieldsOnly(rate, dto.update); + // Diff the RESOLVED columns, not the raw patch: the form's cargoKind / + // intercityKind selectors are never stored, so a bulk switch only shows up + // once the patch is resolved into the columns it moves. + const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto); + const payload = this.changedFieldsOnly(rate, resolved); if (Object.keys(payload).length === 0) { throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); } @@ -98,7 +116,9 @@ export class RateChangeRequestsService { ); } - await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + // previewUpdate above already ran the full validation (it IS buildUpdate), + // so re-validating here would only repeat it — and the trimmed payload is + // resolved columns, not a form patch, so it is not the right input for it. const request = await this.repo.save( this.repo.create({ @@ -186,13 +206,14 @@ export class RateChangeRequestsService { } /** - * Keep only fields the requester actually changed. A form posts every field - * back, so without this the diff would list untouched values as changes. + * Keep only columns the edit actually moves. `buildUpdate` returns a full + * resolved column set (it re-derives scope on every patch), so without this + * the diff would list every untouched column as a change. */ - private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + private changedFieldsOnly(rate: Rate, resolved: Partial): Record { const patch: Record = {}; for (const field of DIFFABLE_FIELDS) { - const proposed = (update as Record)[field]; + const proposed = (resolved as Record)[field]; if (proposed === undefined) continue; if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; patch[field] = proposed; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 6677fcce5..a3361e526 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -753,6 +753,19 @@ export class RatesService { await this.buildUpdate(await this.findById(id), dto); } + /** + * The exact column changes applying this patch would make, without writing. + * + * A change request diffs against THIS rather than the raw patch: the form + * posts selectors that are never stored (`cargoKind`, `intercityKind`), and + * the real edit they encode lands on derived columns — flipping a rate to + * bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing + * the raw patch missed all of it, so the approver saw an empty change list. + */ + async previewUpdate(id: string, dto: UpdateRateDto): Promise> { + return this.buildUpdate(await this.findById(id), dto); + } + private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { const updates = await this.buildUpdate(existing, dto); const updated = await this.repository.update(existing.id, updates); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 4fc921ccb..1c7cd11ca 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -867,6 +867,27 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/wagons/export") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)", + }) + async scheduleWagonListExport( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = + await this.trainSchedulingService.scheduleWagonListWorkbook(id); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + @Get("schedules/:id/export/load-list/document") @TrainSchedulingView() @ApiOperation({ summary: "Download printable export marshalling / load list PDF" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 4f416cdcf..03d75da18 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; +import { TabularExportService } from '../../exports/tabular-export.service'; + +/** One line of the schedule wagon-list export (raw SQL projection). */ +interface ScheduleWagonListRow { + sequenceNo: number | null; + wagonNumber: string | null; + wagonType: string | null; + containerNumber: string | null; + containerSizeFt: number | null; + loadType: string | null; + status: string | null; + bulkCargoDescription: string | null; + /** numeric columns arrive as strings from pg. */ + vgmTons: string | null; + originLabel: string | null; + destinationLabel: string | null; + bookingReference: string | null; + customerName: string | null; +} import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -427,6 +446,9 @@ export class TrainSchedulingService { // Per-wagon history ledger (global module). @Optional keeps the positional // spec constructors working; production always has it. @Optional() private readonly wagonHistory?: WagonHistoryService, + // Trailing + @Optional so the positional constructors in the existing specs + // keep working; production always resolves it from ExportsModule. + @Optional() private readonly tabularExport?: TabularExportService, ) {} /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ @@ -3747,6 +3769,132 @@ export class TrainSchedulingService { }; } + /** + * The schedule detail page's wagon-list Excel export. + * + * One row per container (a wagon carrying two boxes yields two rows, repeating + * the wagon number) so each container's own VGM is present and totals footable. + * Bulk wagons, having no containers, yield a single row carrying the bulk + * description and the allocated tonnage as the VGM figure. + * + * Only wagon slots that actually carry an allocation are listed — empty slots + * on the consist are omitted. + */ + async scheduleWagonListWorkbook( + scheduleId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.tabularExport) { + throw new BadRequestException('Tabular export service is unavailable'); + } + + // Row grain is the container item; the LEFT JOIN keeps bulk (and any + // container-less) allocation as one row. `booking_container_units` is joined + // on BOTH container number and its booking_container line — container + // numbers repeat across bookings, so number alone would multiply rows. + const rows: ScheduleWagonListRow[] = await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + w.wagon_number AS "wagonNumber", + COALESCE(wt.name, wt.code) AS "wagonType", + ci.container_number AS "containerNumber", + cit.size_ft AS "containerSizeFt", + a.load_type AS "loadType", + a.status AS "status", + bl.cargo_description AS "bulkCargoDescription", + COALESCE( + ci.gross_weight_tons, + bcu.vgm_tons, + bc.vgm_per_unit_tons, + a.allocated_weight_tons + ) AS "vgmTons", + COALESCE(by_.label, so.label) AS "originLabel", + COALESCE(ay.label, sd.label) AS "destinationLabel", + b.reference AS "bookingReference", + COALESCE( + slc.name, + CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END, + c.name + ) AS "customerName" + FROM freight.train_schedules s + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations a + ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.bookings b ON b.id = a.booking_id + LEFT JOIN freight.companies c ON c.id = b.company_id + LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id + LEFT JOIN freight.booking_container bc + ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL + LEFT JOIN freight.booking_container_units bcu + ON bcu.container_number = ci.container_number + AND bcu.booking_container_id = bc.id + AND bcu.deleted_at IS NULL + LEFT JOIN freight.wagon_allocation_bulk_loads bl + ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id + LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id + WHERE s.id = $1 AND s.deleted_at IS NULL + ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`, + [scheduleId], + ); + + // "number" is the printed line number of the sheet, not the wagon sequence — + // a two-container wagon occupies two lines, and the reader counts lines. + const sheetRows = rows.map((row, index) => ({ + number: index + 1, + wagonNumber: row.wagonNumber ?? '—', + containerNumber: + row.containerNumber ?? + (row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'), + vgmTons: row.vgmTons === null ? null : Number(row.vgmTons), + originLabel: row.originLabel ?? '—', + destinationLabel: row.destinationLabel ?? '—', + customerName: row.customerName ?? '—', + })); + + const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0); + const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id; + + const buffer = await this.tabularExport.toXlsx({ + title: `Wagons ${reference}`.slice(0, 31), + description: `Wagon list for train ${reference}`, + label: 'train-schedule:wagon-list', + kpis: [ + { label: 'Lines', value: sheetRows.length }, + { + label: 'Wagons', + value: new Set(rows.map((r) => r.sequenceNo)).size, + }, + { label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' }, + ], + columns: [ + { key: 'number', label: 'No.', type: 'number' }, + { key: 'wagonNumber', label: 'Wagon', type: 'string' }, + { key: 'containerNumber', label: 'Container number', type: 'string' }, + { key: 'vgmTons', label: 'VGM', type: 'tons' }, + { key: 'originLabel', label: 'Origin', type: 'string' }, + { key: 'destinationLabel', label: 'Destination', type: 'string' }, + { key: 'customerName', label: 'Customer', type: 'string' }, + ], + rows: sheetRows, + }); + + return { + filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`, + buffer, + }; + } + async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { 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 dd5cc74bc..fdbc504b5 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 @@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; +import { ExportsModule } from '../exports/exports.module'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FacilityHandlingService } from './facility-handling.service'; @@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module'; UserTradeAccessModule, NotificationsModule, NotificationInboxModule, + ExportsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 1e2db1631..4631430e0 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -105,4 +105,18 @@ export class ListWagonsQueryDto { @IsOptional() @IsDateString() maintenanceTo?: string; + + @ApiPropertyOptional({ + description: + 'Window (days) the per-row load/move counts are counted over. Does not filter rows.', + default: 90, + minimum: 1, + maximum: 3650, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(3650) + statsWindowDays?: number; } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index f15722b67..1a0de17d7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -177,6 +177,7 @@ export class WagonsService { async findAll(query: ListWagonsQueryDto = {}): Promise> { const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); await this.attachStatusDates(page.items); + await this.attachMovementStats(page.items, query.statsWindowDays ?? 90); return page; } @@ -216,6 +217,56 @@ export class WagonsService { } } + /** + * Per-wagon movement rollups for the wagon performance report: when the + * wagon last arrived anywhere (the idle clock), and how many loaded / total + * moves it made inside `windowDays`. One grouped query per page, in the same + * shape as `attachStatusDates` above — never one request per row. + */ + private async attachMovementStats(wagons: Wagon[], windowDays: number): Promise { + if (!wagons.length) return; + const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); + const rows: Array<{ + wagonId: string; + lastMovedAt: Date | null; + loadsInWindow: string; + movesInWindow: string; + emptyMovesInWindow: string; + }> = await this.dataSource + .getRepository(WagonMovement) + .createQueryBuilder('m') + .select('m.wagon_id', 'wagonId') + .addSelect('MAX(m.occurred_at)', 'lastMovedAt') + .addSelect( + 'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :loaded)', + 'loadsInWindow', + ) + .addSelect( + 'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :empty)', + 'emptyMovesInWindow', + ) + .addSelect('COUNT(*) FILTER (WHERE m.occurred_at >= :since)', 'movesInWindow') + .where('m.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) }) + .setParameters({ + since, + loaded: WagonMovementKind.Loaded, + empty: WagonMovementKind.EmptyReposition, + }) + .groupBy('m.wagon_id') + .getRawMany(); + + const byId = new Map(rows.map((r) => [r.wagonId, r])); + for (const w of wagons) { + const r = byId.get(w.id); + Object.assign(w, { + lastMovedAt: r?.lastMovedAt ?? null, + loadsInWindow: Number(r?.loadsInWindow ?? 0), + movesInWindow: Number(r?.movesInWindow ?? 0), + emptyMovesInWindow: Number(r?.emptyMovesInWindow ?? 0), + }); + } + } + async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, @@ -328,11 +379,35 @@ export class WagonsService { /** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */ async listMovements(wagonId: string): Promise { await this.findById(wagonId); // 404 on unknown wagon - return this.dataSource.getRepository(WagonMovement).find({ + const movements = await this.dataSource.getRepository(WagonMovement).find({ where: { wagonId }, relations: { fromYard: true, toYard: true }, order: { occurredAt: 'DESC', createdAt: 'DESC' }, }); + await this.attachBookingReferences(movements); + return movements; + } + + /** + * Resolve each loaded move's booking to its human reference, so the UI can + * show (and link to) "BKG-11284" rather than a raw uuid. One query for the + * whole ledger; `wagon_movements` deliberately has no FK to bookings, so + * this is a read-time join on primary keys, exactly like the labels in + * `wagon-history.service`. + */ + private async attachBookingReferences(movements: WagonMovement[]): Promise { + const ids = [...new Set(movements.map((m) => m.bookingId).filter((v): v is string => !!v))]; + if (!ids.length) return; + const rows: Array<{ id: string; reference: string }> = await this.dataSource.query( + `SELECT id, reference FROM freight.bookings WHERE id = ANY($1::uuid[])`, + [ids], + ); + const byId = new Map(rows.map((r) => [r.id, r.reference])); + for (const m of movements) { + Object.assign(m, { + bookingReference: m.bookingId ? (byId.get(m.bookingId) ?? null) : null, + }); + } } async remove(id: string, userId?: string | null): Promise { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0a3d26d71..93e558dfe 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2498,6 +2498,11 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:support_content:view", manage: "edr_freight_app:settings:support_content:manage", }, + // Public /publications library (PDFs, Markdown, PowerPoint), edited from the backoffice. + publications: { + view: "edr_freight_app:settings:publications:view", + manage: "edr_freight_app:settings:publications:manage", + }, }, support: { agentView: "edr_freight_app:support:agent_view", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f95fe7cca..cef024ede 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -60,8 +60,11 @@ import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage" import LogoSettingsPage from "./pages/settings/LogoSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; +import PublicationsPage from "./pages/publications/PublicationsPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage"; +import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerformanceDetailPage"; import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; @@ -232,6 +235,24 @@ const App = () => { } /> + {/* Wagon performance — a read-only executive report beside Overview. + Separate from the Fleet Management wagons desk, which owns CRUD. */} + + + + } + /> + + + + } + /> {/* One drill-down route per overview domain — the old per-tab charts, now each on its own page. Single source of truth for the permission gate is OVERVIEW_DOMAINS, shared with the summary @@ -1173,6 +1194,19 @@ const App = () => { } /> + + + + } + /> - + /> */} {consolidateOdd ? ( + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx new file mode 100644 index 000000000..f7e6a8b2d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx @@ -0,0 +1,211 @@ +import type { Publication } from "@edr/types"; +import { useMutation } from "@tanstack/react-query"; +import { Loader2, UploadCloud } from "lucide-react"; +import { useRef, useState, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/services/api"; + +export interface EditPublicationDialogProps { + mode?: "create" | "edit"; + publication?: Publication; + children: ReactNode; +} + +const ACCEPT = + ".pdf,.md,.markdown,.ppt,.pptx,application/pdf,text/markdown,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation"; + +export default function EditPublicationDialog({ + mode = "create", + publication, + children, +}: EditPublicationDialogProps) { + const isEdit = mode === "edit"; + const fileInputRef = useRef(null); + + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(publication?.title ?? ""); + const [description, setDescription] = useState(publication?.description ?? ""); + const [category, setCategory] = useState(publication?.category ?? ""); + const [file, setFile] = useState(null); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + + const createMutation = useMutation(api.publications.create.mutationOptions()); + const updateMutation = useMutation(api.publications.update.mutationOptions()); + const replaceFileMutation = useMutation(api.publications.replaceFile.mutationOptions()); + const pending = + createMutation.isPending || updateMutation.isPending || replaceFileMutation.isPending; + + const reset = () => { + setTitle(publication?.title ?? ""); + setDescription(publication?.description ?? ""); + setCategory(publication?.category ?? ""); + setFile(null); + setProgress(null); + setError(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleSubmit = async () => { + setError(null); + if (!title.trim()) { + setError("Title is required."); + return; + } + if (!isEdit && !file) { + setError("Choose a file to upload."); + return; + } + + const meta = { + title: title.trim(), + description: description.trim() || undefined, + category: category.trim() || undefined, + }; + + try { + if (isEdit && publication) { + await updateMutation.mutateAsync({ id: publication.id, dto: meta }); + if (file) { + await replaceFileMutation.mutateAsync({ + id: publication.id, + file, + onProgress: setProgress, + }); + } + } else if (file) { + await createMutation.mutateAsync({ file, meta, onProgress: setProgress }); + } + setOpen(false); + if (!isEdit) reset(); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong. Try again."); + } finally { + setProgress(null); + } + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + {children} + + + + + {isEdit ? "Edit publication" : "New publication"} + + + {isEdit + ? "Update this document's title, description or category, or replace its file." + : "Upload a PDF, Markdown or PowerPoint file for the public library."} + + + +
+
+ + setTitle(e.target.value)} + placeholder="e.g. EDR Freight Platform Guide" + /> +
+ +
+ + setCategory(e.target.value)} + placeholder="e.g. Guides, Reports" + /> +
+ +
+ +