Merge branch 'dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-04 16:31:21 +03:00
54 changed files with 5330 additions and 271 deletions

View File

@@ -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,

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`);
}
}

View File

@@ -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);
});
});

View File

@@ -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<Booking> {
* {@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<Booking[]> {
): Promise<Array<{ booking: Booking; ft20Quantity: number }>> {
const qb = this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
@@ -381,30 +398,65 @@ export class BookingsRepository extends BaseRepository<Booking> {
],
});
// 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<Map<string, number>> {
const byBooking = new Map<string, number>();
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;
}
/**

View File

@@ -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);
});
});

View File

@@ -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,
}));
}
/**

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreatePublicationDto } from "./create-publication.dto";
export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<Publication> {
constructor(
@InjectRepository(Publication)
repository: Repository<Publication>,
) {
super(repository);
}
/** Public list: published rows only, in display order. */
findPublished(): Promise<Publication[]> {
return this.repository.find({
where: { published: true },
order: { sortOrder: "ASC", publishedAt: "DESC" },
});
}
/** Admin list: every row, published or not. */
override findAll(): Promise<Publication[]> {
return this.repository.find({ order: { sortOrder: "ASC" } });
}
}

View File

@@ -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<Publication> {
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<Publication> {
const existing = await this.getByIdOrThrow(id);
const patch: Partial<Publication> = {
...(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<Publication> {
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<void> {
await this.getByIdOrThrow(id);
await this.repository.softDelete(id);
}
/** Admin list — every row, published or not. */
list(): Promise<Publication[]> {
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<PublicationSummary[]> {
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<Publication> {
const record = await this.repository.findById(id);
if (!record) throw new NotFoundException(`Publication ${id} not found`);
return record;
}
}

View File

@@ -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<Rate>;
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<string, unknown>) => {
if (opts.previewThrows) throw opts.previewThrows;
return { ...dto, ...(opts.derived ?? {}) } as Partial<Rate>;
}),
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<Rate>,
});
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', () => {

View File

@@ -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<string, unknown> {
private changedFieldsOnly(rate: Rate, resolved: Partial<Rate>): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const field of DIFFABLE_FIELDS) {
const proposed = (update as Record<string, unknown>)[field];
const proposed = (resolved as Record<string, unknown>)[field];
if (proposed === undefined) continue;
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
patch[field] = proposed;

View File

@@ -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<Partial<Rate>> {
return this.buildUpdate(await this.findById(id), dto);
}
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
const updates = await this.buildUpdate(existing, dto);
const updated = await this.repository.update(existing.id, updates);

View File

@@ -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" })

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -177,6 +177,7 @@ export class WagonsService {
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
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<void> {
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<Wagon> {
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<WagonMovement[]> {
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<void> {
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<void> {

View File

@@ -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",