mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -49,6 +49,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
||||
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 { OtpModule } from "./modules/otp/otp.module";
|
||||
@@ -112,6 +113,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { RequestLogMiddleware } from "@edr/api-common";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||
@@ -208,6 +210,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
StampSettingsModule,
|
||||
LogoSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
SupportContentModule,
|
||||
OtpModule,
|
||||
@@ -244,6 +247,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
EimsModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pric
|
||||
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
|
||||
import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service';
|
||||
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
|
||||
|
||||
export interface ContractSignatureView {
|
||||
@@ -111,6 +112,8 @@ export interface ContractViewModel {
|
||||
hasCustomerSignature: boolean;
|
||||
hasStaffSignature: boolean;
|
||||
dynamicTemplate?: ContractDynamicTemplateView;
|
||||
/** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */
|
||||
logoImageUrl?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -121,6 +124,7 @@ export class ContractViewModelBuilder {
|
||||
private readonly pricingBuilder: ContractPricingScheduleBuilder,
|
||||
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
|
||||
@@ -138,6 +142,7 @@ export class ContractViewModelBuilder {
|
||||
template.freight,
|
||||
);
|
||||
const signatures = await this.loadSignatures(bookingId);
|
||||
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
@@ -194,6 +199,7 @@ export class ContractViewModelBuilder {
|
||||
hasContractDocument: hasContractFile,
|
||||
hasCustomerSignature: hasCustomer,
|
||||
hasStaffSignature: hasStaff,
|
||||
logoImageUrl,
|
||||
};
|
||||
|
||||
return { booking, view };
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
letter-spacing: 0.08em;
|
||||
width: 72px;
|
||||
}
|
||||
.logo-mark img {
|
||||
display: block;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.kicker {
|
||||
color: #0e5b45;
|
||||
font-family: Arial, sans-serif;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
|
||||
<section class="cover page-section">
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Freight Transport Services</p>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<main class="contract">
|
||||
<section class="cover page-section">
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Freight Transport Contract</p>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
main { padding: 32px 40px; }
|
||||
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
|
||||
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
|
||||
.logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; }
|
||||
.kicker { margin: 0; font-weight: 700; }
|
||||
.muted { margin: 0; color: #666; }
|
||||
h1 { font-size: 20px; margin: 24px 0 4px; }
|
||||
@@ -32,7 +33,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div class="logo-mark">{{#if logoImageUrl}}<img src="{{logoImageUrl}}" alt="Company logo" />{{else}}EDR{{/if}}</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Last-Mile Delivery Contract</p>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company logo image stamped onto every
|
||||
* generated document (see LogoSettingsService). Same single-row shape as
|
||||
* stamp_settings; the app never inserts more than one row.
|
||||
*/
|
||||
export class LogoSettings3500000000000 implements MigrationInterface {
|
||||
name = "LogoSettings3500000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.logo_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
logo_file_id uuid REFERENCES freight.files(id),
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel =
|
||||
});
|
||||
|
||||
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
||||
const service = new InvoiceDocumentService({} as never, {} as never);
|
||||
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
||||
|
||||
it("renders no QR block when qrImageUrl is unset", () => {
|
||||
const html = service.buildHtml(model());
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
|
||||
import { LogoSettingsService } from "../../logo-settings/logo-settings.service";
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
|
||||
import { logoImageCss, logoMarkup } from "./logo-markup.util";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
@@ -62,6 +64,7 @@ export interface InvoiceDocumentModel {
|
||||
* explicitly only to override that default for one document.
|
||||
*/
|
||||
stampImageUrl?: string | null;
|
||||
logoImageUrl?: string | null;
|
||||
/**
|
||||
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
|
||||
* see that column's comment). Set only once an invoice is actually registered; the IRN text
|
||||
@@ -81,6 +84,7 @@ export class InvoiceDocumentService {
|
||||
constructor(
|
||||
private readonly pdf: PdfRenderService,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
async render(
|
||||
@@ -90,7 +94,11 @@ export class InvoiceDocumentService {
|
||||
model.stampImageUrl !== undefined
|
||||
? model.stampImageUrl
|
||||
: await this.stampSettings.getStampImageUrl();
|
||||
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl };
|
||||
const logoImageUrl =
|
||||
model.logoImageUrl !== undefined
|
||||
? model.logoImageUrl
|
||||
: await this.logoSettings.getLogoImageUrl();
|
||||
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl, logoImageUrl };
|
||||
|
||||
const html = this.buildHtml(resolvedModel);
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
@@ -250,6 +258,7 @@ export class InvoiceDocumentService {
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
const sealInner = sealMarkup(model.stampImageUrl, sealText);
|
||||
const sealCssClass = sealClass(model.stampImageUrl);
|
||||
const logoInner = logoMarkup(model.logoImageUrl);
|
||||
|
||||
const qrMarkup = model.qrImageUrl
|
||||
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
|
||||
@@ -293,6 +302,7 @@ export class InvoiceDocumentService {
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
${sealImageCss()}
|
||||
${logoImageCss()}
|
||||
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
|
||||
.qr img { width: 90px; height: 90px; }
|
||||
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
|
||||
@@ -322,6 +332,7 @@ export class InvoiceDocumentService {
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
${logoInner}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The single decision every EDR document makes about its header logo: draw
|
||||
* the one uploaded company logo when configured (LogoSettingsService), or
|
||||
* render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next
|
||||
* to it already covers the no-logo case, so there is no text fallback here
|
||||
* (contrast seal-markup.util.ts, whose seal has no text of its own).
|
||||
*/
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* `<img>` markup for the header logo, or "" when unset. `logoImageUrl` is
|
||||
* expected to be a data URL from LogoSettingsService.getLogoImageUrl().
|
||||
* `className` defaults to "doc-logo" — each document supplies that class's
|
||||
* sizing in its own <style> block (see logoImageCss()).
|
||||
*/
|
||||
export function logoMarkup(
|
||||
logoImageUrl: string | null | undefined,
|
||||
className = "doc-logo",
|
||||
): string {
|
||||
if (!logoImageUrl) return "";
|
||||
return `<img class="${className}" src="${escapeHtml(logoImageUrl)}" alt="Company logo" />`;
|
||||
}
|
||||
|
||||
/** Default CSS for the header logo — append inside a document's <style> block. */
|
||||
export function logoImageCss(className = "doc-logo"): string {
|
||||
return `.${className} { display: block; max-height: 48px; max-width: 180px; margin-bottom: 6px; object-fit: contain; }`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
@@ -96,14 +96,16 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
|
||||
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
|
||||
});
|
||||
|
||||
it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => {
|
||||
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
|
||||
it("refuses re-cancelling an already-cancelled invoice, per IRC-N010 — no silent no-op", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancellationDate: "Sun Dec 22 2024" }),
|
||||
]);
|
||||
const postBearer = jest.fn();
|
||||
|
||||
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||
|
||||
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(postBearer).not.toHaveBeenCalled();
|
||||
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||
});
|
||||
|
||||
it("refuses to cancel an invoice that was never registered", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
|
||||
@@ -38,8 +38,11 @@ export class EimsCancellationService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice
|
||||
* that was never registered — there is no IRN to cancel.
|
||||
* Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in
|
||||
* MoR's Master Compliance Checklist requires "an appropriate error or rejection message" for a
|
||||
* repeat cancellation, not a quiet success. No HTTP call either way: this is a local check, not
|
||||
* a retry against MoR. Also refuses an invoice that was never registered — there is no IRN to
|
||||
* cancel.
|
||||
*/
|
||||
async cancelInvoiceWithEims(
|
||||
invoiceId: string,
|
||||
@@ -48,7 +51,12 @@ export class EimsCancellationService {
|
||||
): Promise<EimsInvoiceStatusView> {
|
||||
const eligible = await this.dataSource.transaction(async (manager) => {
|
||||
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null;
|
||||
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_ALREADY_CANCELLED",
|
||||
message: `Invoice ${invoice.invoiceNumber} was already cancelled with EIMS${invoice.eimsCancellationDate ? ` (${invoice.eimsCancellationDate})` : ""}.`,
|
||||
});
|
||||
}
|
||||
if (!invoice.eimsIrn) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_NOT_REGISTERED",
|
||||
@@ -57,7 +65,6 @@ export class EimsCancellationService {
|
||||
}
|
||||
return invoice;
|
||||
});
|
||||
if (!eligible) return this.getEimsCancellationStatus(invoiceId);
|
||||
|
||||
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
|
||||
// Outside any transaction — no DB lock is held across the wire.
|
||||
|
||||
@@ -81,7 +81,7 @@ export class EimsInvoiceController {
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.",
|
||||
"Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled invoice rather than a silent no-op — see IRC-N010.",
|
||||
})
|
||||
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
|
||||
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FilesService } from '../files/files.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
|
||||
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
||||
@@ -41,6 +42,7 @@ export class LastMileContractService {
|
||||
private readonly pdfService: ContractPdfService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
async getContractView(id: string, viewerUserId?: string | null) {
|
||||
@@ -194,6 +196,7 @@ export class LastMileContractService {
|
||||
return {
|
||||
companyName: booking.company?.name ?? 'Customer',
|
||||
bookingReference: booking.reference ?? request.bookingId,
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
containerCount: containers.length || null,
|
||||
containerList: containers.join(', '),
|
||||
cargoDescription,
|
||||
|
||||
@@ -48,6 +48,19 @@ export class LastMileRequestsController {
|
||||
return this.requestsService.freeTruckCount().then((count) => ({ count }));
|
||||
}
|
||||
|
||||
// Customer-facing like :id — booking detail (portal + backoffice) lists the
|
||||
// booking's requests to link the stored LM contract. Ownership-checked in
|
||||
// the service for portal callers.
|
||||
@Get('by-booking/:bookingId')
|
||||
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" })
|
||||
findForBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.requestsService.findForBooking(bookingId, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Get(':id/price-estimate')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -221,6 +221,28 @@ export class LastMileRequestsService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every request on a booking, newest first — the booking-detail pages
|
||||
* (portal + backoffice) use this to surface the LM contract later. Portal
|
||||
* callers pass their userId and are ownership-checked against the booking's
|
||||
* company, mirroring findById.
|
||||
*/
|
||||
async findForBooking(bookingId: string, userId?: string | null): Promise<LastMileRequest[]> {
|
||||
if (userId) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
if (companyId) {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (booking?.companyId && booking.companyId !== companyId) {
|
||||
throw new BadRequestException('This booking does not belong to your company');
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.requestsRepository.findAll({
|
||||
where: { bookingId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
|
||||
* delivery point, straight-line) × the LIVE last-mile rate rules against the
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateLogoSettingDto {
|
||||
@ApiProperty({ description: "Logo image as a base64 data URL (PNG/JPG)." })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
logoImageBase64!: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { FileRecord } from "../../files/entities/file.entity";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company logo image stamped onto every
|
||||
* generated document (invoices/receipts, contracts, warehouse papers,
|
||||
* train-scheduling manifests, payment receipts). Same single-row shape as
|
||||
* stamp_settings — `get()` lazily creates the row, and there is never more
|
||||
* than one.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "logo_settings" })
|
||||
export class LogoSetting extends BaseEntity {
|
||||
@Column({ name: "logo_file_id", type: "uuid", nullable: true })
|
||||
logoFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: "logo_file_id" })
|
||||
logoFile?: FileRecord | null;
|
||||
|
||||
/** IAM user id of the last operator to set/clear the logo. */
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { UpdateLogoSettingDto } from "./dto/update-logo-setting.dto";
|
||||
import { LogoSettingsService } from "./logo-settings.service";
|
||||
|
||||
@ApiTags("logo-settings")
|
||||
@ApiBearerAuth()
|
||||
@Controller("logo-settings")
|
||||
export class LogoSettingsController {
|
||||
constructor(private readonly service: LogoSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Current company logo used on every generated document" })
|
||||
get() {
|
||||
return this.service.getView();
|
||||
}
|
||||
|
||||
@Put()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Replace the company logo" })
|
||||
update(@Body() dto: UpdateLogoSettingDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.setLogo(dto.logoImageBase64, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({
|
||||
summary: "Clear the company logo (documents fall back to their text mark)",
|
||||
})
|
||||
clear(@CurrentUser() user: TCurrentUser) {
|
||||
return this.service.clearLogo(user?.id ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { LogoSetting } from "./entities/logo-setting.entity";
|
||||
import { LogoSettingsController } from "./logo-settings.controller";
|
||||
import { LogoSettingsRepository } from "./logo-settings.repository";
|
||||
import { LogoSettingsService } from "./logo-settings.service";
|
||||
|
||||
/**
|
||||
* Global so every document-generating module (billing, contracts,
|
||||
* warehouses, train-scheduling, payment) can inject {@link LogoSettingsService}
|
||||
* without pulling in a circular dependency — same reasoning as
|
||||
* StampSettingsModule.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LogoSetting]), FilesModule, MinioModule],
|
||||
controllers: [LogoSettingsController],
|
||||
providers: [LogoSettingsRepository, LogoSettingsService],
|
||||
exports: [LogoSettingsService],
|
||||
})
|
||||
export class LogoSettingsModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
|
||||
import { LogoSetting } from "./entities/logo-setting.entity";
|
||||
|
||||
@Injectable()
|
||||
export class LogoSettingsRepository extends BaseRepository<LogoSetting> {
|
||||
constructor(
|
||||
@InjectRepository(LogoSetting)
|
||||
repo: Repository<LogoSetting>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The single settings row, with its logo file joined, or null before first upload. */
|
||||
findSingleton(): Promise<LogoSetting | null> {
|
||||
return this.repository.findOne({ where: {}, relations: ["logoFile"] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { LogoSettingsRepository } from "./logo-settings.repository";
|
||||
import { LogoSetting } from "./entities/logo-setting.entity";
|
||||
|
||||
export interface LogoSettingView {
|
||||
logoImageUrl: string | null;
|
||||
updatedById: string | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the single `logo_settings` row: the one company logo image used on
|
||||
* every generated document. Same single-row shape as StampSettingsService,
|
||||
* the value is an uploaded image (via FilesService) rather than a scalar.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LogoSettingsService {
|
||||
private readonly logger = new Logger(LogoSettingsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: LogoSettingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** The settings row, created empty on first access. */
|
||||
async get(): Promise<LogoSetting> {
|
||||
const existing = await this.repository.findSingleton();
|
||||
if (existing) return existing;
|
||||
return this.repository.create({ logoFileId: null, updatedById: null });
|
||||
}
|
||||
|
||||
/** Current logo, with the image inlined as a data URL (or null if unset). */
|
||||
async getView(): Promise<LogoSettingView> {
|
||||
const setting = await this.get();
|
||||
return {
|
||||
logoImageUrl: await this.inlineImageUrl(setting.logoFile?.url),
|
||||
updatedById: setting.updatedById ?? null,
|
||||
updatedAt: setting.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo image for embedding into generated documents, ALWAYS as a
|
||||
* `data:` URL or null. Never throws — document generation must succeed even
|
||||
* if the logo lookup fails; callers render their existing text/mark
|
||||
* fallback on null (see logo-markup.util.ts).
|
||||
*/
|
||||
async getLogoImageUrl(): Promise<string | null> {
|
||||
try {
|
||||
const setting = await this.get();
|
||||
const inlined = await this.inlineImageUrl(setting.logoFile?.url);
|
||||
if (inlined && !inlined.startsWith("data:")) {
|
||||
this.logger.warn(
|
||||
`Company logo could not be inlined for document rendering (falling back to the text mark): ${inlined}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return inlined;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not load company logo for PDF rendering: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the logo image, storing it in MinIO via FilesService. */
|
||||
async setLogo(
|
||||
logoImageBase64: string,
|
||||
updatedById?: string | null,
|
||||
): Promise<LogoSettingView> {
|
||||
const current = await this.get();
|
||||
const previousFileId = current.logoFileId ?? null;
|
||||
|
||||
const fileRecord = await this.filesService.upload({
|
||||
resourceId: current.id,
|
||||
resource: "logo_settings",
|
||||
code: "logo",
|
||||
file: this.toUploadFile(logoImageBase64),
|
||||
uploadedByUserId: updatedById ?? null,
|
||||
});
|
||||
|
||||
await this.repository.update(current.id, {
|
||||
logoFileId: fileRecord.id,
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
|
||||
if (previousFileId && previousFileId !== fileRecord.id) {
|
||||
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
||||
}
|
||||
|
||||
this.logger.log(`Company logo updated by ${updatedById ?? "unknown user"}`);
|
||||
return this.getView();
|
||||
}
|
||||
|
||||
/** Clear the logo (documents fall back to their text/mark). */
|
||||
async clearLogo(updatedById?: string | null): Promise<LogoSettingView> {
|
||||
const current = await this.get();
|
||||
const previousFileId = current.logoFileId ?? null;
|
||||
|
||||
await this.repository.update(current.id, {
|
||||
logoFileId: null,
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
|
||||
if (previousFileId) {
|
||||
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
||||
}
|
||||
|
||||
return this.getView();
|
||||
}
|
||||
|
||||
private toUploadFile(base64: string): Express.Multer.File {
|
||||
const raw = base64.includes(",") ? base64.split(",")[1]! : base64;
|
||||
const buffer = Buffer.from(raw, "base64");
|
||||
return {
|
||||
fieldname: "logo",
|
||||
originalname: "company-logo.png",
|
||||
encoding: "7bit",
|
||||
mimetype: "image/png",
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: "",
|
||||
filename: "",
|
||||
path: "",
|
||||
};
|
||||
}
|
||||
|
||||
private async inlineImageUrl(url?: string | null): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
if (url.startsWith("data:")) return url;
|
||||
try {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
return `data:image/png;base64,${buffer.toString("base64")}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import {
|
||||
companyNotifyEmailExpr,
|
||||
companyNotifyPhoneExpr,
|
||||
@@ -42,3 +45,41 @@ export async function sendCompanyChannels(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer their export carriage acceptance sheet is ready to
|
||||
* download from the portal — the sheet itself is generated on demand by
|
||||
* BookingsService.carriageAcceptanceSheet, never stored, so this is a
|
||||
* "ready" notice + link, not an attachment (the email pipeline carries text
|
||||
* only). Shared by every path that makes a booking's handover final: the
|
||||
* warehouse gate on receive, and direct truck-to-train on load (that cargo
|
||||
* never sees a warehouse, so its handover moment IS the load).
|
||||
*/
|
||||
export async function notifyCarriageAcceptanceReady(
|
||||
dataSource: DataSource,
|
||||
notifications: NotificationsService,
|
||||
inbox: NotificationInboxService,
|
||||
bookingId: string,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`;
|
||||
await inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Carriage acceptance sheet ready',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference: b.reference },
|
||||
});
|
||||
await sendCompanyChannels(dataSource, notifications, b.companyId, body);
|
||||
} catch (err) {
|
||||
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ describe("PaymentService.confirmOtp", () => {
|
||||
repo as never,
|
||||
client as never,
|
||||
billing as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo, billing };
|
||||
};
|
||||
@@ -197,6 +198,7 @@ describe("PaymentService.markIntentSucceeded", () => {
|
||||
repo as never,
|
||||
{} as never,
|
||||
billing as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo, billing };
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { LogoSettingsService } from "../logo-settings/logo-settings.service";
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
@@ -104,6 +105,7 @@ export class PaymentService {
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billing: BillingService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) { }
|
||||
|
||||
async getAll(filters: {
|
||||
@@ -657,6 +659,7 @@ export class PaymentService {
|
||||
total: payment.amount.toString(),
|
||||
currency: payment.currency,
|
||||
reason: payment.reason,
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header .doc-logo {
|
||||
display: block;
|
||||
margin: 0 auto 10px;
|
||||
max-height: 48px;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -126,6 +134,7 @@
|
||||
<body>
|
||||
<div class="receipt-box">
|
||||
<div class="header">
|
||||
{{#if logoImageUrl}}<img class="doc-logo" src="{{logoImageUrl}}" alt="Company logo" />{{/if}}
|
||||
<h1>{{vendorName}}</h1>
|
||||
<p>{{vendorAddress}}</p>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
|
||||
{} as never, // yardFacilities
|
||||
{} as never, // facilityHandling
|
||||
{ emit: jest.fn() } as never, // events
|
||||
{} as never, // notifications
|
||||
{} as never, // inbox
|
||||
);
|
||||
|
||||
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
|
||||
|
||||
@@ -24,7 +24,10 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util';
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
@@ -52,6 +55,8 @@ export class BookingJourneyService {
|
||||
private readonly yardFacilities: YardFacilitiesService,
|
||||
private readonly facilityHandling: FacilityHandlingService,
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
@@ -76,6 +81,21 @@ export class BookingJourneyService {
|
||||
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||
// however it arrived and whatever it is allocated to.
|
||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
|
||||
// handover moment — the carriage acceptance sheet must go out to the
|
||||
// customer right here, not on a receive event that will never fire.
|
||||
if (
|
||||
booking.tradeDirection === 'EXPORT' &&
|
||||
booking.exportHandoverMode === DIRECT_TO_TRAIN
|
||||
) {
|
||||
await notifyCarriageAcceptanceReady(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
this.inbox,
|
||||
booking.id,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
|
||||
@@ -181,6 +181,7 @@ describe('TrainSchedulingService', () => {
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} as never, // bookingJourneyService
|
||||
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
||||
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -177,6 +177,8 @@ import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
|
||||
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
|
||||
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
|
||||
import { LogoSettingsService } from '../../logo-settings/logo-settings.service';
|
||||
import { logoImageCss, logoMarkup } from '../../billing/documents/logo-markup.util';
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
@@ -376,6 +378,7 @@ export class TrainSchedulingService {
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly bookingNotifier: BookingNotifierService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
// forwardRef: BookingBatchService injects this service back; @Optional so
|
||||
@@ -3002,6 +3005,9 @@ export class TrainSchedulingService {
|
||||
.map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? null,
|
||||
equatedLengthM: wagon.wagonType?.equatedLengthM ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
@@ -3022,7 +3028,7 @@ export class TrainSchedulingService {
|
||||
const loadList = await this.generateImportLoadList(scheduleId, {
|
||||
performedBy: 'DOCUMENT_GENERATION',
|
||||
});
|
||||
const html = this.buildImportLoadListHtml(loadList);
|
||||
const html = this.buildImportLoadListHtml(loadList, await this.logoSettings.getLogoImageUrl());
|
||||
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
|
||||
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
|
||||
@@ -3042,7 +3048,9 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
|
||||
}
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule);
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
|
||||
const reference = schedule.trainNumber ?? schedule.id;
|
||||
@@ -3114,6 +3122,7 @@ export class TrainSchedulingService {
|
||||
positionLabel,
|
||||
wagons,
|
||||
unassignedBookings,
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||||
@@ -3157,6 +3166,7 @@ export class TrainSchedulingService {
|
||||
positionLabel?: string;
|
||||
wagons?: TrainSetWagon[];
|
||||
unassignedBookings?: Booking[];
|
||||
logoImageUrl?: string | null;
|
||||
},
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
@@ -3275,6 +3285,7 @@ export class TrainSchedulingService {
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
${logoImageCss()}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
@@ -3289,6 +3300,7 @@ export class TrainSchedulingService {
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
${logoMarkup(opts?.logoImageUrl)}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
|
||||
</div>
|
||||
@@ -3468,7 +3480,10 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||
private buildImportLoadListHtml(
|
||||
loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>,
|
||||
logoImageUrl?: string | null,
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
@@ -3501,26 +3516,37 @@ export class TrainSchedulingService {
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) => {
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>`;
|
||||
<td>${esc(wagon.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType)}</td>
|
||||
<td class="num">${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))}</td>
|
||||
<td class="num">${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}</td>
|
||||
<td>${esc(loadList.origin)}</td>
|
||||
<td>${esc(loadList.destination)}</td>`;
|
||||
// An empty wagon still runs in the consist, so it still gets a line — see
|
||||
// buildExportLoadListHtml.
|
||||
if (wagon.allocations.length === 0) {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
<td colspan="7">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return wagon.allocations.map(
|
||||
(allocation) => {
|
||||
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
|
||||
const sealNumbers = (allocation.containerItems ?? [])
|
||||
.map((item) => item.sealNumber)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(companyName)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td>${esc(sealNumbers || '-')}</td>
|
||||
<td></td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`;
|
||||
},
|
||||
@@ -3548,6 +3574,7 @@ export class TrainSchedulingService {
|
||||
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
|
||||
.tile strong { font-size: 13px; }
|
||||
${logoImageCss()}
|
||||
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
|
||||
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
|
||||
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
|
||||
@@ -3569,6 +3596,7 @@ export class TrainSchedulingService {
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
${logoMarkup(logoImageUrl)}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Import Load List /<br />Marshalling Document</h1>
|
||||
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
|
||||
@@ -3609,15 +3637,22 @@ export class TrainSchedulingService {
|
||||
<tr>
|
||||
<th>Seq</th>
|
||||
<th>Wagon</th>
|
||||
<th>Wagon Type</th>
|
||||
<th class="num">Tare</th>
|
||||
<th class="num">Equated</th>
|
||||
<th>Departure Station</th>
|
||||
<th>Arrival Station</th>
|
||||
<th>Booking</th>
|
||||
<th>Company</th>
|
||||
<th>Load</th>
|
||||
<th>Container numbers</th>
|
||||
<th>Seal No</th>
|
||||
<th>Note</th>
|
||||
<th class="num">Weight T</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
|
||||
${allocationRows || '<tr><td colspan="14">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -28,13 +28,18 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import {
|
||||
sendCompanyChannels,
|
||||
notifyCarriageAcceptanceReady as notifyCarriageAcceptanceReadyShared,
|
||||
} from '../notifications/notify-company.util';
|
||||
import {
|
||||
companyNotifyPhoneExpr,
|
||||
primaryContactUserJoin,
|
||||
} from '../notifications/resolve-company-phone.util';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
|
||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
|
||||
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
@@ -417,6 +422,7 @@ export class WarehouseInventoryService {
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -3748,6 +3754,7 @@ export class WarehouseInventoryService {
|
||||
reference,
|
||||
issuedAt,
|
||||
stampImageUrl: await this.stampSettings.getStampImageUrl(),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
bookingReference,
|
||||
bookingStatus: row?.bookingStatus ?? null,
|
||||
customerName: row?.customerName ?? null,
|
||||
@@ -4219,6 +4226,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
const html = this.buildGrnDocumentHtml({
|
||||
grnNumber: row.grnNumber,
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
|
||||
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
|
||||
bookingStatus: row.bookingStatus ?? null,
|
||||
@@ -4803,6 +4811,7 @@ export class WarehouseInventoryService {
|
||||
reference,
|
||||
handedOverAt,
|
||||
stampImageUrl: await this.stampSettings.getStampImageUrl(),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
bookingReference,
|
||||
bookingStatus: row.bookingStatus ?? null,
|
||||
customerName: row.customerName ?? null,
|
||||
@@ -5484,6 +5493,8 @@ export class WarehouseInventoryService {
|
||||
zone: string | null;
|
||||
inventoryStatus: string | null;
|
||||
receiveSummary: string | null;
|
||||
/** The one global company logo; null renders the plain text brand. */
|
||||
logoImageUrl?: string | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -5540,6 +5551,7 @@ export class WarehouseInventoryService {
|
||||
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
|
||||
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
|
||||
.rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
|
||||
${logoImageCss()}
|
||||
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
|
||||
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
@@ -5553,6 +5565,7 @@ export class WarehouseInventoryService {
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
${logoMarkup(data.logoImageUrl)}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Goods Received Note</h1>
|
||||
<div class="subtitle">Warehouse receiving confirmation</div>
|
||||
@@ -5610,6 +5623,8 @@ export class WarehouseInventoryService {
|
||||
truckWeightTons?: number | null;
|
||||
/** The one global company stamp; null falls back to the drawn text seal. */
|
||||
stampImageUrl?: string | null;
|
||||
/** The one global company logo; null renders the plain text brand. */
|
||||
logoImageUrl?: string | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -5697,12 +5712,14 @@ export class WarehouseInventoryService {
|
||||
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||||
.seal span { position: relative; }
|
||||
${sealImageCss()}
|
||||
${logoImageCss()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
${logoMarkup(data.logoImageUrl)}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Warehouse Release / Exit Paper</h1>
|
||||
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
|
||||
@@ -5772,6 +5789,8 @@ export class WarehouseInventoryService {
|
||||
} | null;
|
||||
/** The one global company stamp; null falls back to the drawn text seal. */
|
||||
stampImageUrl?: string | null;
|
||||
/** The one global company logo; null renders the plain text brand. */
|
||||
logoImageUrl?: string | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -5850,11 +5869,13 @@ export class WarehouseInventoryService {
|
||||
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||||
.seal span { position: relative; }
|
||||
${sealImageCss()}
|
||||
${logoImageCss()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
${logoMarkup(data.logoImageUrl)}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Import Goods Handover Document</h1>
|
||||
<div class="subtitle">EDR to customer warehouse handover</div>
|
||||
@@ -6167,26 +6188,13 @@ export class WarehouseInventoryService {
|
||||
* fires right after receive, not at marshalling.
|
||||
*/
|
||||
private async notifyCarriageAcceptanceReady(bookingId: string): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Carriage acceptance sheet ready',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference: b.reference },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
await notifyCarriageAcceptanceReadyShared(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
this.inbox,
|
||||
bookingId,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
private async notifyOwnerInventoryReceived(params: {
|
||||
|
||||
@@ -1260,6 +1260,16 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:settings:stamp:manage",
|
||||
"Manage the company stamp",
|
||||
),
|
||||
perm(
|
||||
"b4b00004-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:settings:logo:view",
|
||||
"View the company logo",
|
||||
),
|
||||
perm(
|
||||
"b4b00004-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:settings:logo:manage",
|
||||
"Manage the company logo",
|
||||
),
|
||||
// The per-officer approval teeter (ማህተም) — an individual's own stamp +
|
||||
// signature, not the company seal. It used to ride on settings:stamp:*, which
|
||||
// now gates the ONE company stamp; this key was split out when the two were
|
||||
@@ -1985,6 +1995,12 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:stamp:view",
|
||||
manage: "edr_freight_app:settings:stamp:manage",
|
||||
},
|
||||
// The ONE company logo, applied to every generated document (invoices,
|
||||
// receipts, contracts, warehouse papers, train-scheduling manifests).
|
||||
logo: {
|
||||
view: "edr_freight_app:settings:logo:view",
|
||||
manage: "edr_freight_app:settings:logo:manage",
|
||||
},
|
||||
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||
// gate the company stamp before the two were untangled.
|
||||
|
||||
@@ -52,6 +52,7 @@ import NoAccessPage from "./pages/NoAccessPage";
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||
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 ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
@@ -1044,6 +1045,15 @@ const App = () => {
|
||||
path="invoice-stamp-settings"
|
||||
element={<Navigate to="/dashboard/stamp-settings" replace />}
|
||||
/>
|
||||
{/* The ONE company logo, shown in the header of every generated document. */}
|
||||
<Route
|
||||
path="logo-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.settings.logo.view}>
|
||||
<LogoSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract-templates"
|
||||
element={
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Truck } from "lucide-react";
|
||||
import { SimpleGrid, Stack } from "@mantine/core";
|
||||
import { Download, Truck } from "lucide-react";
|
||||
import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
@@ -16,18 +19,44 @@ export interface BookingMileServicesCardProps {
|
||||
handoverSection?: ReactNode;
|
||||
}
|
||||
|
||||
/** First / last mile addresses, plus the export handover control. Renders
|
||||
* nothing when none of the three are present. */
|
||||
/**
|
||||
* First / last mile addresses, plus the export handover control and the
|
||||
* stored last-mile contract reference (signed status + PDF download) for
|
||||
* Truck & Machinery once a request on this booking is approved. Renders
|
||||
* nothing when none of the three are present.
|
||||
*/
|
||||
export function BookingMileServicesCard({
|
||||
booking,
|
||||
handoverSection,
|
||||
}: BookingMileServicesCardProps) {
|
||||
const hasAddresses =
|
||||
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
|
||||
|
||||
const { data: requestsResponse } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }),
|
||||
queryFn: async () =>
|
||||
(await lastMileRequestsService.list({ bookingId: booking.id })).data,
|
||||
enabled: Boolean(booking.lastMileDeliveryAddress),
|
||||
});
|
||||
const approvedRequest = (requestsResponse?.data ?? []).find(
|
||||
(r) => r.status === "APPROVED",
|
||||
);
|
||||
|
||||
if (!hasAddresses && !handoverSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadContract = async () => {
|
||||
if (!approvedRequest) return;
|
||||
const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard icon={Truck} title="Mile services" accent="grape">
|
||||
<Stack gap="md">
|
||||
@@ -41,6 +70,32 @@ export function BookingMileServicesCard({
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{approvedRequest && (
|
||||
<Group justify="space-between" align="center" wrap="wrap">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
Last-mile contract
|
||||
</Text>
|
||||
<Text size="xs" c={approvedRequest.customerSignedAt ? "green.8" : "orange.8"}>
|
||||
{approvedRequest.customerSignedAt
|
||||
? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${
|
||||
approvedRequest.signerDisplayName
|
||||
? ` by ${approvedRequest.signerDisplayName}`
|
||||
: ""
|
||||
}`
|
||||
: "Awaiting customer signature"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => void downloadContract()}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{handoverSection}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
|
||||
import { ImageIcon, RefreshCw, X } from "lucide-react";
|
||||
|
||||
const MAX_LOGO_MB = 10;
|
||||
|
||||
export interface LogoUploadProps {
|
||||
/** Logo image as a data URL, or null when none is attached yet. */
|
||||
value: string | null;
|
||||
onChange: (dataUrl: string | null) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Company logo picker — reads the picked image straight into a data URL,
|
||||
* same transport as {@link StampUpload}. Kept as its own component (not a
|
||||
* generalized image-upload) matching how stamp/teeter are already separate
|
||||
* files here despite the near-identical shape.
|
||||
*/
|
||||
export function LogoUpload({
|
||||
value,
|
||||
onChange,
|
||||
label = "Company logo",
|
||||
description = "Attach the official company logo.",
|
||||
}: LogoUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const readFile = (file: File | undefined | null) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("The logo must be an image file (PNG or JPG).");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_LOGO_MB * 1024 * 1024) {
|
||||
setError(`The logo image must be under ${MAX_LOGO_MB} MB.`);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setError(null);
|
||||
setFileName(file.name);
|
||||
onChange(typeof reader.result === "string" ? reader.result : null);
|
||||
};
|
||||
reader.onerror = () => setError("Could not read that file. Try another.");
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const openPicker = () => inputRef.current?.click();
|
||||
|
||||
const clear = () => {
|
||||
setFileName(null);
|
||||
setError(null);
|
||||
onChange(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hidden
|
||||
onChange={(e) => readFile(e.currentTarget.files?.[0])}
|
||||
/>
|
||||
|
||||
{value ? (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group gap="md" wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
background:
|
||||
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={value}
|
||||
alt="Company logo"
|
||||
fit="contain"
|
||||
h={92}
|
||||
w={92}
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{fileName ?? "Logo attached"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Shown in the header of every generated document.
|
||||
</Text>
|
||||
<Group gap="xs" mt={2}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<RefreshCw size={13} />}
|
||||
onClick={openPicker}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<X size={13} />}
|
||||
onClick={clear}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="lg"
|
||||
onClick={openPicker}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
readFile(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
style={{
|
||||
borderColor: dragging
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: undefined,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: dragging
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: undefined,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<ImageIcon size={26} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text size="sm" fw={500}>
|
||||
Upload company logo
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{description} Drop an image here or click to browse — PNG or JPG,
|
||||
up to {MAX_LOGO_MB} MB.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="xs" c="red.7">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
Hammer,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
MapPin,
|
||||
@@ -492,6 +493,12 @@ export const buildSidebarSections = (
|
||||
icon: <Stamp />,
|
||||
permission: FREIGHT_PERMS.settings.stamp.view,
|
||||
},
|
||||
{
|
||||
label: "Company logo",
|
||||
href: "/dashboard/logo-settings",
|
||||
icon: <ImageIcon />,
|
||||
permission: FREIGHT_PERMS.settings.logo.view,
|
||||
},
|
||||
{
|
||||
label: "Contract templates",
|
||||
href: "/dashboard/contract-templates",
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
// Repeat, // used by the hidden Move (reassign) button
|
||||
Train,
|
||||
TrainFront,
|
||||
Truck,
|
||||
Weight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -38,6 +39,7 @@ import { CountdownTimer } from "@edr/ui-common";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
EligibleContainerBooking,
|
||||
@@ -412,6 +414,31 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
};
|
||||
|
||||
// Export cargo that skipped the warehouse (customer truck straight onto the
|
||||
// wagon) has no GRN and never will — loadBooking's GRN gate would keep
|
||||
// rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage
|
||||
// acceptance sheet is the handover document instead, then loads in one click.
|
||||
const [truckToTrainPending, setTruckToTrainPending] = useState<string | null>(null);
|
||||
const doTruckToTrain = (bookingId: string, ref: string) => {
|
||||
setTruckToTrainPending(bookingId);
|
||||
bookingsService
|
||||
.setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN")
|
||||
.then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId }))
|
||||
.then(() => {
|
||||
toast({ title: `${ref} loaded — direct truck-to-train handover` });
|
||||
onChanged();
|
||||
void yardWorkQuery.refetch();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not load as direct truck-to-train",
|
||||
description: apiErrorMessage(error, "Please try again."),
|
||||
variant: "destructive",
|
||||
}),
|
||||
)
|
||||
.finally(() => setTruckToTrainPending(null));
|
||||
};
|
||||
|
||||
const doUnload = (bookingId: string, ref: string) => {
|
||||
unloadJourney
|
||||
.mutateAsync({ scheduleId: schedule.id, bookingId })
|
||||
@@ -710,6 +737,12 @@ export function ScheduleWorkspacePanel({
|
||||
const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId;
|
||||
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
|
||||
const showUnload = canWork && riding && (journey?.canUnload ?? false);
|
||||
const showTruckToTrain =
|
||||
canWork &&
|
||||
!riding &&
|
||||
!done &&
|
||||
boardHere &&
|
||||
b.tradeDirection === "EXPORT";
|
||||
return (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
@@ -767,6 +800,24 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showTruckToTrain ? (
|
||||
<Tooltip
|
||||
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
leftSection={<Truck size={13} />}
|
||||
loading={truckToTrainPending === b.id}
|
||||
onClick={() => doTruckToTrain(b.id, ref)}
|
||||
>
|
||||
Truck to Train
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showUnload ? (
|
||||
<Tooltip
|
||||
label={
|
||||
|
||||
45
apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts
Normal file
45
apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { logoSettingsService } from "@/services/logoSettings.service";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const QUERY_KEY = ["logoSettings"];
|
||||
|
||||
export const useLogoSettingsQuery = () =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => logoSettingsService.get(),
|
||||
});
|
||||
|
||||
export const useSetLogo = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (logoImageBase64: string) =>
|
||||
logoSettingsService.set(logoImageBase64),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
toast.success(t("logoSettings.updated", "Company logo updated"));
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
};
|
||||
|
||||
export const useClearLogo = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => logoSettingsService.clear(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
toast.success(t("logoSettings.cleared", "Company logo removed"));
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
};
|
||||
@@ -334,6 +334,12 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:stamp:view",
|
||||
manage: "edr_freight_app:settings:stamp:manage",
|
||||
},
|
||||
// The ONE company logo, applied to every generated document (invoices,
|
||||
// receipts, contracts, warehouse papers, train-scheduling manifests).
|
||||
logo: {
|
||||
view: "edr_freight_app:settings:logo:view",
|
||||
manage: "edr_freight_app:settings:logo:manage",
|
||||
},
|
||||
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||
// gate the company stamp before the two were untangled.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
|
||||
import { LogoUpload } from "@/components/contracts/LogoUpload";
|
||||
import {
|
||||
useClearLogo,
|
||||
useSetLogo,
|
||||
useLogoSettingsQuery,
|
||||
} from "@/hooks/useLogoSettings";
|
||||
|
||||
/**
|
||||
* The ONE company logo, read by every document path server-side via
|
||||
* LogoSettingsService: invoices and receipts, contract cover pages, warehouse
|
||||
* GRN/release/handover papers, train-scheduling manifests, and the payment
|
||||
* receipt. Single global image — no per-document choice.
|
||||
*/
|
||||
export default function LogoSettingsPage() {
|
||||
const { data, isLoading } = useLogoSettingsQuery();
|
||||
const setLogo = useSetLogo();
|
||||
const clearLogo = useClearLogo();
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(null);
|
||||
}, [data?.logoImageUrl]);
|
||||
|
||||
const value = draft !== null ? draft : (data?.logoImageUrl ?? null);
|
||||
const dirty = draft !== null && draft !== data?.logoImageUrl;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draft) return;
|
||||
await setLogo.mutateAsync(draft);
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!data?.logoImageUrl) return;
|
||||
await clearLogo.mutateAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full max-w-screen-sm mx-auto">
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Company logo</CardTitle>
|
||||
<CardDescription>
|
||||
The single EDR logo, applied to every generated document —
|
||||
invoices and receipts, contracts, warehouse papers, train-scheduling
|
||||
manifests, and payment receipts. Replacing it here changes it
|
||||
everywhere at once.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LogoUpload
|
||||
value={isLoading ? null : value}
|
||||
onChange={setDraft}
|
||||
label="Company logo"
|
||||
description="Shown in the header of every generated document."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleSave} disabled={!dirty || setLogo.isPending}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
{data?.logoImageUrl && !dirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
disabled={clearLogo.isPending}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
const BASE = "/logo-settings";
|
||||
|
||||
/** Company logo used on every generated document. */
|
||||
export interface LogoSettings {
|
||||
logoImageUrl: string | null;
|
||||
updatedById: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export const logoSettingsService = {
|
||||
get: async (): Promise<LogoSettings> => {
|
||||
const response = await client.get<ApiResponse<LogoSettings>>(BASE);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
set: async (logoImageBase64: string): Promise<LogoSettings> => {
|
||||
const response = await client.put<ApiResponse<LogoSettings>>(BASE, {
|
||||
logoImageBase64,
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
clear: async (): Promise<LogoSettings> => {
|
||||
const response = await client.delete<ApiResponse<LogoSettings>>(BASE);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -224,6 +224,7 @@ export const URL_CONSTANTS = {
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
|
||||
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
|
||||
SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`,
|
||||
CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -8,6 +9,7 @@ import type {
|
||||
MileLegSummary,
|
||||
MileVehicleSummary,
|
||||
} from "@/services/bookings.service";
|
||||
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
@@ -171,12 +173,85 @@ function LegBlock({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference row for the stored last-mile contract: signed status, open the
|
||||
* contract page (view / sign), download the PDF.
|
||||
*/
|
||||
function LastMileContractRow({
|
||||
bookingId,
|
||||
requestId,
|
||||
signedAt,
|
||||
signerDisplayName,
|
||||
}: {
|
||||
bookingId: string;
|
||||
requestId: string;
|
||||
signedAt?: string | null;
|
||||
signerDisplayName?: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const download = async () => {
|
||||
const blob = await lastMileRequestsService.downloadContractDocument(requestId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "last-mile-contract.pdf";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
pt={8}
|
||||
mt={4}
|
||||
style={{ borderTop: "1px solid #F2F5F8" }}
|
||||
>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text fz="13px" fw={700} c="#10202F">
|
||||
Last-mile contract
|
||||
</Text>
|
||||
<Text fz="12px" c={signedAt ? "#0A6F4D" : "#B45309"}>
|
||||
{signedAt
|
||||
? `Signed ${new Date(signedAt).toLocaleDateString()}${
|
||||
signerDisplayName ? ` by ${signerDisplayName}` : ""
|
||||
}`
|
||||
: "Awaiting your signature"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={8}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
navigate(`/bookings/${bookingId}/last-mile-contract?requestId=${requestId}`)
|
||||
}
|
||||
>
|
||||
{signedAt ? "View contract" : "View & sign"}
|
||||
</Button>
|
||||
<Button size="xs" variant="default" onClick={() => void download()}>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["booking-mile-summary", booking.id],
|
||||
queryFn: () => bookingsService.mileSummary(booking.id),
|
||||
});
|
||||
|
||||
// The stored LM contract lives on the booking's approved last-mile request.
|
||||
const { data: lmRequests } = useQuery({
|
||||
queryKey: ["booking-last-mile-requests", booking.id],
|
||||
queryFn: () => lastMileRequestsService.listForBooking(booking.id),
|
||||
enabled: !!booking.lastMileDeliveryAddress,
|
||||
});
|
||||
const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED");
|
||||
|
||||
const firstLeg = data?.firstMile ?? null;
|
||||
const lastLeg = data?.lastMile ?? null;
|
||||
|
||||
@@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
|
||||
/>
|
||||
)}
|
||||
{showLast && (
|
||||
<LegBlock
|
||||
title="Last mile"
|
||||
leg={lastLeg}
|
||||
address={booking.lastMileDeliveryAddress}
|
||||
/>
|
||||
<Box>
|
||||
<LegBlock
|
||||
title="Last mile"
|
||||
leg={lastLeg}
|
||||
address={booking.lastMileDeliveryAddress}
|
||||
/>
|
||||
{approvedRequest && (
|
||||
<LastMileContractRow
|
||||
bookingId={booking.id}
|
||||
requestId={approvedRequest.id}
|
||||
signedAt={approvedRequest.customerSignedAt}
|
||||
signerDisplayName={approvedRequest.signerDisplayName}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface LastMileRequest {
|
||||
requestedContainerNumbers?: string[] | null;
|
||||
requestedDeliveryDate?: string | null;
|
||||
customerSignedAt?: string | null;
|
||||
signerDisplayName?: string | null;
|
||||
rejectionReason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -46,6 +47,12 @@ export const lastMileRequestsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The booking's requests, newest first — links the stored LM contract. */
|
||||
listForBooking: async (bookingId: string): Promise<LastMileRequest[]> => {
|
||||
const { data } = await client.get(L.BY_BOOKING(bookingId));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Confirm which containers go via EDR last-mile and the requested delivery date. */
|
||||
submit: async (
|
||||
id: string,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { buildSeatSummary } from './booking-sms.utils';
|
||||
|
||||
const seat = (passengerName: string, seatNumber: string, leg = 1, coachType = 'VIP Bed') => ({
|
||||
passengerName,
|
||||
leg,
|
||||
seat: { seatNumber, coach: { number: 'VIP-0001 (DJ)', coachType: { name: coachType } } },
|
||||
});
|
||||
|
||||
describe('buildSeatSummary', () => {
|
||||
it('greets a solo traveller by name and omits the name from the seat line', () => {
|
||||
const { passengerName, trainSeatLines } = buildSeatSummary([seat('Yanet', '9')], 'ONE_WAY');
|
||||
|
||||
expect(passengerName).toBe('Yanet');
|
||||
expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9');
|
||||
expect(trainSeatLines).not.toContain('Train/Seat');
|
||||
expect(trainSeatLines).not.toContain('Yanet');
|
||||
});
|
||||
|
||||
it('greets a group collectively and names each seat', () => {
|
||||
const { passengerName, trainSeatLines } = buildSeatSummary(
|
||||
[seat('Yanet', '4'), seat('Abebe', '6'), seat('Sara', '9'), seat('Helen', '10')],
|
||||
'ONE_WAY',
|
||||
);
|
||||
|
||||
expect(passengerName).toBe('Passengers');
|
||||
expect(trainSeatLines).toBe(
|
||||
[
|
||||
'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 4',
|
||||
'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6',
|
||||
'Sara, VIP-0001 (DJ) VIP Bed, seat no. 9',
|
||||
'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
// The reported bug: the seats query had no orderBy, so Postgres heap order put the LAST
|
||||
// passenger first and the SMS greeted them while texting the first passenger's phone.
|
||||
it('is immune to seat rows arriving in an arbitrary order', () => {
|
||||
const rows = [seat('Yanet', '9'), seat('Helen', '10'), seat('Abebe', '6'), seat('Sara', '4')];
|
||||
|
||||
const { passengerName, trainSeatLines } = buildSeatSummary(rows, 'ONE_WAY');
|
||||
|
||||
expect(passengerName).toBe('Passengers');
|
||||
// Every line pairs the right person with their own seat, regardless of input order.
|
||||
expect(trainSeatLines).toBe(
|
||||
[
|
||||
'Sara, VIP-0001 (DJ) VIP Bed, seat no. 4',
|
||||
'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6',
|
||||
'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9',
|
||||
'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('sorts seat numbers numerically, not lexicographically', () => {
|
||||
const { trainSeatLines } = buildSeatSummary(
|
||||
[seat('A', '9'), seat('B', '10'), seat('C', '6'), seat('D', '4')],
|
||||
'ONE_WAY',
|
||||
);
|
||||
|
||||
expect(trainSeatLines.match(/seat no\. \d+/g)).toEqual([
|
||||
'seat no. 4',
|
||||
'seat no. 6',
|
||||
'seat no. 9',
|
||||
'seat no. 10',
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels round-trip legs as Outbound/Return, listing each passenger once per leg', () => {
|
||||
const { passengerName, trainSeatLines } = buildSeatSummary(
|
||||
[seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)],
|
||||
'ROUND_TRIP',
|
||||
);
|
||||
|
||||
expect(passengerName).toBe('Passengers');
|
||||
expect(trainSeatLines).toBe(
|
||||
[
|
||||
'Outbound:',
|
||||
'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9',
|
||||
'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 10',
|
||||
'Return:',
|
||||
'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 3',
|
||||
'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 4',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
// TRANSIT leg 2 is a connecting segment of the same outbound journey — never a return.
|
||||
it('labels transit legs as Leg 1/Leg 2, never Return', () => {
|
||||
const { trainSeatLines } = buildSeatSummary(
|
||||
[seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)],
|
||||
'TRANSIT',
|
||||
);
|
||||
|
||||
expect(trainSeatLines).toContain('Leg 1:');
|
||||
expect(trainSeatLines).toContain('Leg 2:');
|
||||
expect(trainSeatLines).not.toContain('Return');
|
||||
expect(trainSeatLines).not.toContain('Outbound');
|
||||
});
|
||||
|
||||
it('labels all four round-trip-transit legs', () => {
|
||||
const { trainSeatLines } = buildSeatSummary(
|
||||
[1, 2, 3, 4].map((leg) => seat('Yanet', String(leg), leg)),
|
||||
'ROUND_TRIP_TRANSIT',
|
||||
);
|
||||
|
||||
expect(trainSeatLines).toBe(
|
||||
[
|
||||
'Outbound leg 1:',
|
||||
'VIP-0001 (DJ) VIP Bed, seat no. 1',
|
||||
'Outbound leg 2:',
|
||||
'VIP-0001 (DJ) VIP Bed, seat no. 2',
|
||||
'Return leg 1:',
|
||||
'VIP-0001 (DJ) VIP Bed, seat no. 3',
|
||||
'Return leg 2:',
|
||||
'VIP-0001 (DJ) VIP Bed, seat no. 4',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('greets a solo round-trip traveller by name (same person on both legs)', () => {
|
||||
const { passengerName } = buildSeatSummary(
|
||||
[seat('Yanet', '9', 1), seat('Yanet', '3', 2)],
|
||||
'ROUND_TRIP',
|
||||
);
|
||||
|
||||
expect(passengerName).toBe('Yanet');
|
||||
});
|
||||
|
||||
it('trims a trailing space on the coach type instead of emitting "Bed , seat"', () => {
|
||||
const { trainSeatLines } = buildSeatSummary([seat('Yanet', '9', 1, 'VIP Bed ')], 'ONE_WAY');
|
||||
|
||||
expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9');
|
||||
});
|
||||
|
||||
it('falls back safely on empty or malformed input', () => {
|
||||
expect(buildSeatSummary([], 'ONE_WAY')).toEqual({ passengerName: 'Passenger', trainSeatLines: '' });
|
||||
|
||||
const { passengerName, trainSeatLines } = buildSeatSummary([{ leg: 1 }], 'ONE_WAY');
|
||||
expect(passengerName).toBe('Passenger');
|
||||
expect(trainSeatLines).toBe('-, seat no. -');
|
||||
});
|
||||
|
||||
it('falls back to a generic leg heading for an unknown booking type', () => {
|
||||
const { trainSeatLines } = buildSeatSummary(
|
||||
[seat('Yanet', '9', 1), seat('Yanet', '3', 2)],
|
||||
'SOMETHING_NEW',
|
||||
);
|
||||
|
||||
expect(trainSeatLines).toContain('Leg 1:');
|
||||
expect(trainSeatLines).toContain('Leg 2:');
|
||||
});
|
||||
});
|
||||
115
apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts
Normal file
115
apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Builds the two passenger-facing values the `booking.created` SMS/email template needs:
|
||||
* the `{{passengerName}}` salutation and the `{{trainSeatLines}}` block.
|
||||
*
|
||||
* Why this is a shared pure helper rather than inline logic: the salutation used to be
|
||||
* `seats[0]?.passengerName`, and the query loading those seats had no `orderBy`. Postgres
|
||||
* returns heap order for an unordered SELECT, and an UPDATE relocates a row to the end of
|
||||
* the heap — so a group booking regularly greeted the LAST passenger while texting the
|
||||
* first one's phone. Deriving both values from the whole seat set, sorted deterministically,
|
||||
* removes the dependency on row order entirely, and keeps the formatting unit-testable
|
||||
* without a Nest testing module.
|
||||
*
|
||||
* Group bookings send ONE SMS to Booking.contactPhone by design — BookingSeat has no
|
||||
* phone/email column, so there is no per-passenger recipient. Hence 2+ passengers are
|
||||
* greeted collectively and each seat line names its own occupant.
|
||||
*/
|
||||
|
||||
export interface SeatSummary {
|
||||
/** Salutation: the traveller's name when solo, otherwise 'Passengers'. */
|
||||
passengerName: string;
|
||||
/** One line per booked seat, newline-joined, with a heading per leg on multi-leg bookings. */
|
||||
trainSeatLines: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seat numbers are stored as strings of digits (Seat.seatNumber), so they must be compared
|
||||
* numerically — a plain string compare orders '10' before '9'. Non-numeric labels sort last,
|
||||
* then alphabetically among themselves.
|
||||
*/
|
||||
function compareSeatNumber(a: string, b: string): number {
|
||||
const na = Number.parseInt(a, 10);
|
||||
const nb = Number.parseInt(b, 10);
|
||||
const aNum = Number.isNaN(na);
|
||||
const bNum = Number.isNaN(nb);
|
||||
if (aNum && bNum) return a.localeCompare(b);
|
||||
if (aNum) return 1;
|
||||
if (bNum) return -1;
|
||||
return na - nb || a.localeCompare(b);
|
||||
}
|
||||
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim());
|
||||
|
||||
const ROUND_TRIP_TRANSIT_LEGS: Record<number, string> = {
|
||||
1: 'Outbound leg 1',
|
||||
2: 'Outbound leg 2',
|
||||
3: 'Return leg 1',
|
||||
4: 'Return leg 2',
|
||||
};
|
||||
|
||||
/**
|
||||
* Leg numbering means different things per booking type — see the enum documented on
|
||||
* TicketsController.validate. TRANSIT's leg 2 is a connecting segment of the SAME outbound
|
||||
* journey, so it must never be labelled 'Return'.
|
||||
*/
|
||||
function legLabel(bookingType: string | undefined, leg: number): string {
|
||||
switch (bookingType) {
|
||||
case 'ROUND_TRIP':
|
||||
return leg === 1 ? 'Outbound' : leg === 2 ? 'Return' : `Leg ${leg}`;
|
||||
case 'TRANSIT':
|
||||
return `Leg ${leg}`;
|
||||
case 'ROUND_TRIP_TRANSIT':
|
||||
return ROUND_TRIP_TRANSIT_LEGS[leg] ?? `Leg ${leg}`;
|
||||
default:
|
||||
// Unknown or newly added booking type — degrade to a generic heading rather than guessing.
|
||||
return `Leg ${leg}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSeatSummary(seats: any[], bookingType?: string): SeatSummary {
|
||||
const rows = [...(seats ?? [])].sort(
|
||||
(a, b) =>
|
||||
(a?.leg ?? 1) - (b?.leg ?? 1) ||
|
||||
str(a?.seat?.coach?.number).localeCompare(str(b?.seat?.coach?.number)) ||
|
||||
compareSeatNumber(str(a?.seat?.seatNumber), str(b?.seat?.seatNumber)),
|
||||
);
|
||||
|
||||
// Distinct travellers. A round-trip/transit booking has one row per passenger PER LEG, so
|
||||
// the same name legitimately repeats — count people, not rows.
|
||||
const names: string[] = [];
|
||||
for (const row of rows) {
|
||||
const name = str(row?.passengerName);
|
||||
if (name && !names.includes(name)) names.push(name);
|
||||
}
|
||||
const isGroup = names.length > 1;
|
||||
|
||||
const line = (row: any): string => {
|
||||
const coach = str(row?.seat?.coach?.number) || '-';
|
||||
const coachType = str(row?.seat?.coach?.coachType?.name);
|
||||
const seatNo = str(row?.seat?.seatNumber) || '-';
|
||||
// Trim each part before joining: the coach-type name carries a trailing space in some
|
||||
// records, which a `.replace(/ +/g, ' ')` collapse cannot remove (it shrinks runs of
|
||||
// spaces but leaves a single one), and it surfaced as 'VIP Bed , seat no. 9'.
|
||||
const where = [coach, coachType].filter(Boolean).join(' ');
|
||||
const who = isGroup ? `${str(row?.passengerName) || 'Passenger'}, ` : '';
|
||||
return `${who}${where}, seat no. ${seatNo}`;
|
||||
};
|
||||
|
||||
const legs = [...new Set(rows.map((row) => row?.leg ?? 1))];
|
||||
const trainSeatLines =
|
||||
legs.length > 1
|
||||
? legs
|
||||
.map((leg) =>
|
||||
[
|
||||
`${legLabel(bookingType, leg)}:`,
|
||||
...rows.filter((row) => (row?.leg ?? 1) === leg).map(line),
|
||||
].join('\n'),
|
||||
)
|
||||
.join('\n')
|
||||
: rows.map(line).join('\n');
|
||||
|
||||
return {
|
||||
passengerName: isGroup ? 'Passengers' : (names[0] || 'Passenger'),
|
||||
trainSeatLines,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||
import { buildSeatSummary } from '../../common/utils/booking-sms.utils';
|
||||
|
||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||
|
||||
@@ -305,7 +306,7 @@ export class NotificationsService {
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } }, orderBy: { leg: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -367,30 +368,19 @@ export class NotificationsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
|
||||
* pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
|
||||
* several lines).
|
||||
* Builds the interpolation context for the `booking.created` template. `passengerName` and
|
||||
* `trainSeatLines` both come from buildSeatSummary — a solo booking is greeted by name with
|
||||
* bare "coach, seat no." lines, while a group is greeted as "Passengers" and each line names
|
||||
* its own occupant (one SMS goes to Booking.contactPhone for the whole party).
|
||||
*/
|
||||
private buildBookingCreatedContext(booking: any, ref: string): Record<string, unknown> {
|
||||
const s = booking?.schedule ?? {};
|
||||
const trainName = s.train?.name ?? s.train?.number ?? '';
|
||||
const fmtDate = (d: any) =>
|
||||
d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD';
|
||||
const fmtTime = (d: any) =>
|
||||
d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD';
|
||||
|
||||
const seats = booking?.seats ?? [];
|
||||
const trainSeatLines = seats
|
||||
.map((bs: any) => {
|
||||
const coach = bs.seat?.coach?.number ?? '-';
|
||||
const cls = bs.seat?.coach?.coachType?.name ?? '';
|
||||
const seatNo = bs.seat?.seatNumber ?? '-';
|
||||
return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim();
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
|
||||
const passengerName = seats[0]?.passengerName ?? 'Passenger';
|
||||
const { passengerName, trainSeatLines } = buildSeatSummary(booking?.seats ?? [], booking?.bookingType);
|
||||
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
|
||||
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user