feat(freight): add company logo setting applied to every generated document

New logo-settings module (mirrors stamp-settings): single uploaded logo,
stored via FilesService/MinIO, injected as a data URL into invoice/receipt,
contract, warehouse, train-scheduling, and payment-receipt PDFs. Adds a
matching backoffice settings page and settings:logo:view/manage permissions.

>
This commit is contained in:
Hagernesh
2026-08-13 10:53:42 +00:00
parent 63b40ea4f8
commit c4bf3c9479
31 changed files with 800 additions and 8 deletions

View File

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

View File

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

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* `<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; }`;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = [

View File

@@ -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
@@ -3025,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');
@@ -3045,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;
@@ -3117,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');
@@ -3160,6 +3166,7 @@ export class TrainSchedulingService {
positionLabel?: string;
wagons?: TrainSetWagon[];
unassignedBookings?: Booking[];
logoImageUrl?: string | null;
},
): string {
const esc = (value: unknown) =>
@@ -3278,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; }
@@ -3292,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>
@@ -3471,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, '&amp;')
@@ -3562,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; }
@@ -3583,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>

View File

@@ -38,6 +38,8 @@ import {
} 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';
@@ -420,6 +422,7 @@ export class WarehouseInventoryService {
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
) {}
/**
@@ -3751,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,
@@ -4222,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,
@@ -4806,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,
@@ -5487,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 ?? '-')
@@ -5543,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; }
@@ -5556,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>
@@ -5613,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 ?? '-')
@@ -5700,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>
@@ -5775,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 ?? '-')
@@ -5853,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>