resolve conflict

This commit is contained in:
yaschalew
2026-06-27 08:14:05 +03:00
69 changed files with 3289 additions and 299 deletions

View File

@@ -17,6 +17,7 @@
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"

View File

@@ -80,8 +80,15 @@ export class ContractPdfService {
this.logger.error(
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
@@ -113,4 +120,85 @@ export class ContractPdfService {
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
);
}
private htmlToBasicPdfBuffer(html: string): Buffer {
const text = this.htmlToPlainText(html);
const lines = this.wrapLines(text, 92).slice(0, 72);
const body = lines
.map((line, index) => {
const prefix = index === 0 ? '50 790 Td' : '0 -12 Td';
return `${prefix} (${this.escapePdfText(line)}) Tj`;
})
.join('\n');
const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`;
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, 'latin1'));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
pdf += '% fallback padding\n';
}
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += '0000000000 65535 f \n';
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, 'latin1');
}
private htmlToPlainText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n');
}
private wrapLines(text: string, width: number): string[] {
const wrapped: string[] = [];
for (const rawLine of text.split('\n')) {
const words = rawLine.split(' ');
let line = '';
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > width && line) {
wrapped.push(line);
line = word;
} else {
line = next;
}
}
if (line) wrapped.push(line);
}
return wrapped.length ? wrapped : ['Document'];
}
private escapePdfText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
}

View File

@@ -78,6 +78,7 @@ export class CreateWarehouseModule1790000000000 implements MigrationInterface {
weight NUMERIC(14,3) NOT NULL DEFAULT 0,
volume NUMERIC(12,3) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE',
inspection_status VARCHAR(20) NULL,
arrived_at TIMESTAMPTZ NULL,
inspected_at TIMESTAMPTZ NULL,
ready_for_loading_at TIMESTAMPTZ NULL,

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Catch-up for environments where AddWarehouseInspection ran before the
* warehouse module table existed. Production needs this column for unload and
* inspection flows because the WarehouseInventory entity maps inspectionStatus.
*/
export class EnsureWarehouseInventoryInspectionStatus1821000000001 implements MigrationInterface {
private readonly table = 'freight.warehouse_inventory';
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
await queryRunner.addColumn(
this.table,
new TableColumn({
name: 'inspection_status',
type: 'varchar',
length: '20',
isNullable: true,
}),
);
}
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_inspection_status
ON freight.warehouse_inventory(inspection_status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_warehouse_inventory_inspection_status
`);
if (await queryRunner.hasColumn(this.table, 'inspection_status')) {
await queryRunner.dropColumn(this.table, 'inspection_status');
}
}
}

View File

@@ -45,7 +45,9 @@ export class FirstMileService {
* unknown or the booking has not reached PAID status.
*/
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findById(bookingId);
const booking = await this.bookingsRepository.findById(bookingId, {
relations: { serviceType: true },
});
if (!booking) {
return null;
@@ -55,6 +57,10 @@ export class FirstMileService {
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
@@ -62,7 +68,11 @@ export class FirstMileService {
}
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
const [booking] = await this.bookingsRepository.findAll({
where: { reference: bookingReference },
relations: { serviceType: true },
take: 1,
});
if (!booking) {
return null;
@@ -72,6 +82,10 @@ export class FirstMileService {
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
@@ -131,6 +145,11 @@ export class FirstMileService {
}
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
const existing = await this.findByBookingId(dto.bookingId);
if (existing) {
return existing;
}
return this.firstMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
@@ -142,6 +161,25 @@ export class FirstMileService {
});
}
private async findByBookingId(bookingId: string): Promise<FirstMile | null> {
const [records] = await this.firstMileRepository.findAndCount({
where: { bookingId },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
take: 1,
});
return records[0] ?? null;
}
private bookingRequestsFirstMile(booking: {
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile);
}
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
const existing = await this.findById(id);

View File

@@ -32,6 +32,7 @@ export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,

View File

@@ -344,10 +344,10 @@ export class PaymentService {
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" },
);
await this.firstMileService.acceptBooking(input.bookingId);
});
await this.firstMileService.acceptBooking(input.bookingId);
if (isGeneralContract) {
this.logger.log(
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,

View File

@@ -724,9 +724,7 @@ export class TrainSchedulingService {
}
});
const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation });
return this.getTrainScheduleById(scheduleId);
}
async finalizeSchedule(scheduleId: string) {
@@ -1136,7 +1134,9 @@ export class TrainSchedulingService {
}
});
return this.getTrainScheduleById(scheduleId);
const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation });
}
async getContainerTrainSchedules() {

View File

@@ -1,5 +1,154 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
export class TruckEntranceDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
ownerName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
consigneeDetails?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
edrDigitalBookingId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tin?: string;
@ApiProperty()
@IsString()
truckPlateNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
trailerPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
assignedEquipmentNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
customsSealNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
declarationNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
incoterms?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
hsCodes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
itemCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
itemDescription?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
packagingType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
unitCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
grossWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
netWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
volumeDimensions?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
conditionAtReceipt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
damagedRejectedQuantity?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
warehouseCodeLocation?: string;
@ApiProperty()
@IsString()
driverName!: string;
@ApiProperty()
@IsString()
driverPhone!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverLicenseNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckType?: string;
@ApiProperty()
@IsNumber()
@Min(0)
entranceTareWeightKg!: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
exitTareWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverSignatoryName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
warehouseManagerName?: string;
}
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
export class BulkReceiveDto {
@@ -25,6 +174,9 @@ export class BulkReceiveDto {
@IsUUID('all', { each: true })
bookingIds!: string[];
@ApiProperty({ type: TruckEntranceDto })
truckEntrance!: TruckEntranceDto;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { TruckEntranceDto } from './bulk-receive.dto';
export class ReceiveWarehouseInventoryDto {
@ApiProperty({ format: 'uuid' })
@@ -55,6 +56,9 @@ export class ReceiveWarehouseInventoryDto {
@IsString()
notes?: string;
@ApiProperty({ type: TruckEntranceDto })
truckEntrance!: TruckEntranceDto;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -21,6 +21,9 @@ export interface ImportTrainRow {
totalBookings: number;
totalContainers: number;
totalCargoes: number;
unloadedBookings: number;
pendingUnloadBookings: number;
fullyUnloaded: boolean;
status: string;
}
@@ -34,6 +37,7 @@ export interface ImportTrainItemRow {
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
inspectionStatus: string | null;
lastMileRequested: boolean;
pickupOption: string;
}
@@ -133,7 +137,7 @@ export class SchedulingReadFacade {
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
FROM freight.wagons
WHERE deleted_at IS NULL
AND status NOT IN ('RETIRED', 'MAINTENANCE')
AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE')
ORDER BY wagon_number ASC`,
);
}
@@ -205,7 +209,24 @@ export class SchedulingReadFacade {
WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers",
(SELECT count(*) FROM freight.cargoes cg
JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes"
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes",
(SELECT count(*) FROM freight.train_schedule_bookings tsbp
JOIN freight.bookings bp ON bp.id = tsbp.booking_id AND bp.deleted_at IS NULL
WHERE tsbp.train_schedule_id = ts.id
AND tsbp.deleted_at IS NULL
AND bp.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')
AND (
NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory invp
WHERE invp.booking_id = bp.id AND invp.deleted_at IS NULL
)
OR EXISTS (
SELECT 1 FROM freight.warehouse_inventory invr
WHERE invr.booking_id = bp.id
AND invr.deleted_at IS NULL
AND invr.status = 'RECEIVED'
)
)) AS "pendingUnloadBookings"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
@@ -219,13 +240,22 @@ export class SchedulingReadFacade {
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
...rest,
totalBookings: Number(rest.totalBookings) || 0,
totalContainers: Number(rest.totalContainers) || 0,
totalCargoes: Number(rest.totalCargoes) || 0,
route: rest.origin || rest.destination ? `${rest.origin ?? '?'}${rest.destination ?? '?'}` : null,
}));
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => {
const totalBookings = Number(rest.totalBookings) || 0;
const pendingUnloadBookings = Number(rest.pendingUnloadBookings) || 0;
const unloadedBookings = Math.max(totalBookings - pendingUnloadBookings, 0);
return {
...rest,
totalBookings,
totalContainers: Number(rest.totalContainers) || 0,
totalCargoes: Number(rest.totalCargoes) || 0,
unloadedBookings,
pendingUnloadBookings,
fullyUnloaded: totalBookings > 0 && pendingUnloadBookings === 0,
route: rest.origin || rest.destination ? `${rest.origin ?? '?'}${rest.destination ?? '?'}` : null,
};
});
}
/** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */
@@ -242,6 +272,7 @@ export class SchedulingReadFacade {
b.cargo_total_weight_vgm AS "weight",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
COALESCE(inv.status, b.status) AS "currentStatus",
inv.inspection_status AS "inspectionStatus",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption"
@@ -281,7 +312,15 @@ export class SchedulingReadFacade {
];
params.push(
filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'],
['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'],
[
'LOADED',
'DISPATCHED',
'IN_TRANSIT',
'ARRIVED_AT_DJIBOUTI',
'ARRIVED_AT_PORT',
'ARRIVED_AT_DESTINATION',
'UNLOADED_AT_DJIBOUTI_PORT',
],
);
if (filter.scheduleId) {

View File

@@ -6,9 +6,8 @@ import { Cargo } from '../cargoes/entities/cargoes.entity';
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
@@ -36,10 +35,20 @@ import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
/** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
const normalizeWagonStatus = (status: string | null | undefined) =>
(status ?? '')
.trim()
.replace(/[\s-]+/g, '_')
.toUpperCase();
const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
export interface InventoryInquiryResult {
id: string;
inventoryId: string | null;
@@ -190,12 +199,18 @@ export interface EligibleBookingRow {
weight: string | null;
paymentStatus: string;
status: string;
hasFirstMile: boolean;
firstMileRequestId: string | null;
firstMileStatus: string | null;
firstMileVehicleId: string | null;
firstMileTruckPlateNumber: string | null;
firstMileTrailerPlateNumber: string | null;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
}
export interface LoadPassedExportResult {
@@ -283,7 +298,7 @@ export class WarehouseInventoryService {
private readonly allocation: WarehouseAllocationService,
private readonly invoices: WarehouseInvoiceService,
private readonly inspectionService: WarehouseInspectionService,
private readonly pdfService: ContractPdfService,
private readonly releaseDocuments: WarehouseReleaseDocumentService,
private readonly interchangeDocuments: InterchangeDocumentsService,
private readonly lastMileService: LastMileService,
) {}
@@ -294,18 +309,53 @@ export class WarehouseInventoryService {
* inspection / storage / loading steps — only the final release.
*/
async gateClearance(id: string, performedBy?: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
const [item]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
`SELECT id, warehouse_id AS "warehouseId"
FROM freight.warehouse_inventory
WHERE id = $1 AND deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!item) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
const blocking = await this.invoices.findBlockingInvoice(id);
if (blocking) {
throw new BadRequestException(
'Warehouse demurrage/storage fee must be paid before terminal release.',
);
}
const now = new Date();
await this.inventoryRepository.update(id, {
gateClearedAt: now,
releaseDate: item.releaseDate ?? now,
});
const [gateColumn]: Array<{ exists: boolean }> = await this.dataSource.query(
`SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name = 'warehouse_inventory'
AND column_name = 'gate_cleared_at'
) AS "exists"`,
);
if (gateColumn?.exists) {
await this.dataSource.query(
`UPDATE freight.warehouse_inventory
SET gate_cleared_at = $2,
release_date = COALESCE(release_date, $2),
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`,
[id, now],
);
} else {
await this.dataSource.query(
`UPDATE freight.warehouse_inventory
SET release_date = COALESCE(release_date, $2),
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`,
[id, now],
);
}
await this.activityLog.record({
activityType: 'INVENTORY_DISPATCHED',
inventoryId: id,
@@ -602,13 +652,29 @@ export class WarehouseInventoryService {
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
b.cargo_total_weight_vgm AS "weight",
b.payment_status AS "paymentStatus",
b.status AS "status"
b.status AS "status",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus",
fm.vehicle_id AS "firstMileVehicleId",
v.plate_number AS "firstMileTruckPlateNumber",
v.trailer_plate_no AS "firstMileTrailerPlateNumber"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
FROM freight.first_mile first_mile
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
ORDER BY first_mile.created_at DESC
LIMIT 1
) fm ON true
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID'
AND inv.id IS NULL
@@ -629,6 +695,7 @@ export class WarehouseInventoryService {
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
this.assertTruckEntrance(dto.truckEntrance);
await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, {
@@ -645,10 +712,22 @@ export class WarehouseInventoryService {
const [booking] = await manager.query(
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
oy.country AS "originCountry", dy.country AS "destinationCountry"
oy.country AS "originCountry", dy.country AS "destinationCountry",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus"
FROM freight.bookings b
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status
FROM freight.first_mile first_mile
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
ORDER BY first_mile.created_at DESC
LIMIT 1
) fm ON true
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
[bookingId],
);
@@ -663,10 +742,28 @@ export class WarehouseInventoryService {
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
continue;
}
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
if (!booking.firstMileRequestId) {
skip('First-mile request not created');
continue;
}
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
skip('First-mile truck has not arrived');
continue;
}
}
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const receiveNote = this.buildReceiveNote({
grnNumber,
direction: dto.direction,
notes: `Bulk received (${dto.direction})`,
truckEntrance: dto.truckEntrance,
});
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
@@ -676,8 +773,8 @@ export class WarehouseInventoryService {
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
notes: `Bulk received (${dto.direction})`,
arrivedAt: now,
notes: receiveNote,
}),
);
@@ -686,14 +783,14 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `Bulk received ${dto.direction} booking`,
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`,
performedBy: dto.performedBy,
},
manager,
);
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id });
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
}
});
@@ -892,6 +989,7 @@ export class WarehouseInventoryService {
/** Booking statuses that must never be unloaded into warehouse inventory. */
private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED'];
private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [
'LOADED',
'DISPATCHED',
'IN_TRANSIT',
'ARRIVED_AT_DJIBOUTI',
@@ -1263,16 +1361,22 @@ export class WarehouseInventoryService {
});
if (result.unloadedCount > 0) {
const document = await this.interchangeDocuments.generateFromSchedule({
let document = await this.interchangeDocuments.generateFromSchedule({
scheduleId,
direction: 'EXPORT',
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
handoverFrom: 'EDR',
handoverTo: 'Djibouti Port Operator',
portOperatorName: 'Doraleh Multipurpose Port',
generatedBy: performedBy,
remarks: 'Generated after export unloading at Djibouti Port',
generatedBy: performedBy ?? 'EDR Operations',
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
});
if (document.status !== 'ACKNOWLEDGED') {
document = await this.interchangeDocuments.acknowledge(document.id, {
acknowledgedBy: 'Djibouti Port Operator',
remarks: 'Auto acknowledged after Djibouti export unloading.',
});
}
result.interchangeDocument = {
id: document.id,
documentNo: document.documentNo,
@@ -1379,9 +1483,11 @@ export class WarehouseInventoryService {
}
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
this.assertTruckEntrance(dto.truckEntrance);
const weight = Number(dto.weight) || 0;
const volume = Number(dto.volume) || 0;
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
const id = await this.dataSource.transaction(async (manager) => {
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
@@ -1395,6 +1501,12 @@ export class WarehouseInventoryService {
this.assertCapacity('Zone', zone, weight, volume, containerCount);
const now = new Date();
const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now);
const receiveNote = this.buildReceiveNote({
grnNumber,
notes: dto.notes?.trim() || 'Single booking received',
truckEntrance: dto.truckEntrance,
});
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
@@ -1409,7 +1521,7 @@ export class WarehouseInventoryService {
volume: dto.volume ?? null,
status: 'RECEIVED',
arrivedAt: now,
notes: dto.notes?.trim() ?? null,
notes: receiveNote,
}),
);
@@ -1420,7 +1532,7 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `Received ${weight}kg at warehouse location`,
description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`,
performedBy: dto.performedBy,
},
manager,
@@ -1688,15 +1800,10 @@ export class WarehouseInventoryService {
}
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const item = await this.findById(id);
if (!item.releaseDate) {
throw new BadRequestException('A release order must be issued before downloading the exit paper');
}
const [row] = await this.dataSource.query(
`SELECT inv.id,
inv.release_order_reference AS "releaseOrderReference",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.quantity,
inv.weight,
inv.status,
@@ -1706,7 +1813,7 @@ export class WarehouseInventoryService {
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
company.name AS "customerName",
container.container_number AS "containerNumber",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
@@ -1720,23 +1827,29 @@ export class WarehouseInventoryService {
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON (
(inv.container_id IS NOT NULL AND container.id = inv.container_id)
OR (inv.container_id IS NULL AND container.booking_id = b.id)
) AND container.deleted_at IS NULL
LEFT JOIN freight.cargoes cargo ON (
(inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id)
) AND cargo.deleted_at IS NULL
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (!row.releaseDate) {
throw new BadRequestException('A release order must be issued before downloading the exit paper');
}
const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`;
const bookingReference = row?.bookingReference || item.bookingId || 'N/A';
const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date();
const bookingReference = row?.bookingReference || 'N/A';
const reference =
row?.releaseOrderReference ||
(row?.bookingReference ? `REL-${String(row.bookingReference).replace(/^BK-?/i, '')}` : 'REL-N/A');
const issuedAt = new Date(row.releaseDate);
const html = this.buildReleaseDocumentHtml({
reference,
issuedAt,
@@ -1747,17 +1860,18 @@ export class WarehouseInventoryService {
tradeDirection: row?.tradeDirection ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
quantity: Number(row?.quantity ?? item.quantity ?? 0),
weight: Number(row?.weight ?? item.weight ?? 0),
quantity: Number(row?.quantity ?? 0),
weight: Number(row?.weight ?? 0),
warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null,
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row?.status ?? item.status,
inventoryStatus: row?.status ?? null,
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
});
return {
filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.pdfService.htmlToPdfBuffer(html),
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
@@ -1842,7 +1956,7 @@ export class WarehouseInventoryService {
// 4. wagon must be available, or already selected by an existing train schedule.
const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId);
if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) {
if (!isLoadableWagonStatus(wagon.status) && !scheduled) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
);
@@ -2257,6 +2371,7 @@ export class WarehouseInventoryService {
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
clearanceStatus: string;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -2273,71 +2388,85 @@ export class WarehouseInventoryService {
minute: '2-digit',
});
const rows = [
['Booking reference', data.bookingReference],
['Customer', data.customerName],
['Booking status', data.bookingStatus],
['Freight type', data.freightType],
['Trade direction', data.tradeDirection],
['Container number', data.containerNumber],
['Cargo / goods', data.cargoDescription],
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Weight', `${data.weight.toLocaleString()} kg`],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory status', data.inventoryStatus],
['Inventory Status', data.inventoryStatus],
['Clearance Status', data.clearanceStatus],
];
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Release Exit Paper</title>
<title>Warehouse Gate Clearance / Release Order</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 18px 8px; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 18px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.ref { text-align: right; font-size: 13px; color: #475569; }
.ref strong { display: block; color: #0f172a; font-size: 18px; margin-top: 6px; }
.notice { margin: 22px 0; padding: 14px 16px; background: #ecfdf5; border: 1px solid #99f6e4; border-radius: 8px; font-weight: 700; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th { width: 32%; text-align: left; color: #475569; background: #f8fafc; }
th, td { border: 1px solid #cbd5e1; padding: 10px 12px; font-size: 13px; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-top: 42px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
.footer { margin-top: 28px; font-size: 11px; color: #64748b; line-height: 1.5; }
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.doc { position: relative; padding: 0; }
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; max-width: 360px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.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: #064c27; margin: 16px 0 22px; }
.notice { width: 74%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
.seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">EDR Warehouse Operations</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Gate Clearance / Release Order</h1>
<div class="subtitle">Official warehouse release and exit authorization</div>
</div>
<div class="ref">
Release reference
Document / Release No.
<strong>${esc(data.reference)}</strong>
Issued: ${esc(issuedAt)}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This document authorizes the listed booking/goods to leave the warehouse after release checks.
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
</div>
<div class="section-title">Release Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="signatures">
<div class="line">Warehouse officer name / signature / date</div>
<div class="line">Customer or driver name / signature / date</div>
<div class="section-title">Authorization Clause</div>
<div class="clause">
The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity,
cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
</div>
<div class="footer">
Present this release paper at the warehouse gate. Gate staff should verify booking reference,
customer/driver identity, cargo details, and any unpaid blocking fees before exit.
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Cleared</span></div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</div>
</body>
@@ -2378,6 +2507,71 @@ export class WarehouseInventoryService {
return trimmed ? `${trimmed}\n${note}` : note;
}
private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void {
if (!truckEntrance?.truckPlateNumber?.trim()) {
throw new BadRequestException('Truck plate number is required for entrance registration');
}
if (!truckEntrance.driverName?.trim()) {
throw new BadRequestException('Driver name is required for entrance registration');
}
if (!truckEntrance.driverPhone?.trim()) {
throw new BadRequestException('Driver phone is required for entrance registration');
}
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
throw new BadRequestException('Entrance tare weight is required for entrance registration');
}
}
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}
private buildReceiveNote(input: {
grnNumber: string;
direction?: string | null;
notes?: string | null;
truckEntrance: TruckEntranceDto;
}): string {
const truck = input.truckEntrance;
const rows = [
`GRN Number: ${input.grnNumber}`,
input.direction ? `Direction: ${input.direction}` : null,
truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
truck.tin ? `TIN: ${truck.tin}` : null,
`Truck Plate: ${truck.truckPlateNumber}`,
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
truck.truckType ? `Truck Type: ${truck.truckType}` : null,
`Driver: ${truck.driverName}`,
`Driver Phone: ${truck.driverPhone}`,
truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
`Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`,
truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
truck.incoterms ? `Incoterms: ${truck.incoterms}` : null,
truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
truck.itemCode ? `Item Code: ${truck.itemCode}` : null,
truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
];
return rows.filter(Boolean).join('\n');
}
private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise<InventoryAllocationCriteria> {
const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null;

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -54,6 +55,26 @@ export class WarehouseInvoiceController {
return this.invoiceService.findById(id);
}
@Get('warehouse-fee-invoices/:id/document')
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.document(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get('warehouse-fee-invoices/:id/receipt')
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.receipt(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Patch('warehouse-fee-invoices/:id/cancel')
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
cancel(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -10,6 +10,7 @@ import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
interface GenerateOptions {
confirmZero?: boolean;
@@ -27,6 +28,22 @@ export interface PayInvoiceDto {
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
export interface InvoiceDocumentDetails {
bookingReference: string | null;
customerName: string | null;
inventoryReference: string | null;
inventoryInfo: string | null;
inventoryStatus: string | null;
containerNumber: string | null;
cargoDescription: string | null;
clearanceStatus: string;
warehouseName: string | null;
yardName: string | null;
zoneName: string | null;
}
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
@Injectable()
export class WarehouseInvoiceService {
constructor(
@@ -34,6 +51,7 @@ export class WarehouseInvoiceService {
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
private readonly feeService: WarehouseFeeService,
private readonly documents: WarehouseReleaseDocumentService,
) {}
// ── Generation ───────────────────────────────────────────────────────────
@@ -150,11 +168,35 @@ export class WarehouseInvoiceService {
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoice & { items: unknown[] }> {
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] };
const details = await this.getInvoiceDocumentDetails(invoice);
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
}
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
return {
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
}
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException('A receipt is available only after payment is recorded.');
}
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
return {
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
@@ -213,4 +255,207 @@ export class WarehouseInvoiceService {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
}
async assertClearanceAllowed(inventoryId: string): Promise<void> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
);
}
if (invoices.some((inv) => inv.status === 'PAID')) return;
const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
if (payableAmount > 0) {
throw new BadRequestException(
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
);
}
}
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
CONCAT_WS(
' / ',
NULLIF(inv.status, ''),
NULLIF(COALESCE(container.container_number, booking_container.container_number), ''),
NULLIF(COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description), '')
) AS "inventoryInfo",
wh.name AS "warehouseName",
yard.name AS "yardName",
zone.name AS "zoneName",
CASE
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
ELSE 'PENDING PAYMENT'
END AS "clearanceStatus"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
WHERE fee.id = $1
LIMIT 1`,
[invoice.id, invoice.status],
);
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
inventoryReference: row?.inventoryReference ?? null,
inventoryInfo: row?.inventoryInfo ?? null,
inventoryStatus: row?.inventoryStatus ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
warehouseName: row?.warehouseName ?? null,
yardName: row?.yardName ?? null,
zoneName: row?.zoneName ?? null,
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
};
}
private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT',
details: InvoiceDocumentDetails,
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const money = (amount: unknown, currency = invoice.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const items = invoice.items as Array<{
id?: string;
description?: string;
feeType?: string;
quantity?: number;
unitRate?: number;
amount?: number;
currency?: string;
chargeableDays?: number | null;
}>;
const lastPayment = [...(invoice.payments ?? [])].pop();
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.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; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(invoice.invoiceNumber)}</strong>
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
<div><span>Customer</span>${esc(details.customerName)}</div>
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Fee type</th>
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${items
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
</tr>`,
)
.join('')}
</tbody>
</table>
<div class="totals">
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
</div>
<div class="footer">
<div class="line">Prepared by EDR warehouse finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
private safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
}

View File

@@ -0,0 +1,240 @@
import { existsSync } from 'fs';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
const MIN_VALID_PDF_BYTES = 2_000;
const RELEASE_DOCUMENT_PRINT_STYLES = `
<style id="warehouse-release-document-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
@Injectable()
export class WarehouseReleaseDocumentService {
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
}
this.logger.log(
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('warehouse-release-document-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
}
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((path) => existsSync(path));
}
private isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
}
private htmlToBasicPdfBuffer(html: string): Buffer {
const text = this.htmlToPlainText(html);
const lines = this.wrapLines(text, 86).slice(0, 52);
const body = lines
.map((line, index) => {
const y = 770 - index * 12;
const isTitle = index < 2 || /clearance|release order/i.test(line);
const size = index === 0 ? 13 : isTitle ? 11 : 9.6;
const font = isTitle ? 'F2' : 'F1';
return this.textOp(line, 48, y, size, font);
})
.join('\n');
const stream = [
this.lineOp(48, 752, 548, 752),
body,
this.circularSealOps(184, 154),
this.lineOp(48, 92, 278, 92, '0 0 0'),
this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'),
this.lineOp(326, 92, 548, 92, '0 0 0'),
this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'),
this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'),
].join('\n');
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>',
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, 'latin1'));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
pdf += '% fallback padding\n';
}
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += '0000000000 65535 f \n';
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, 'latin1');
}
private htmlToPlainText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n');
}
private wrapLines(text: string, width: number): string[] {
const wrapped: string[] = [];
for (const rawLine of text.split('\n')) {
const words = rawLine.split(' ');
let line = '';
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > width && line) {
wrapped.push(line);
line = word;
} else {
line = next;
}
}
if (line) wrapped.push(line);
}
return wrapped.length ? wrapped : ['Warehouse release document'];
}
private escapePdfText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
private textOp(
text: string,
x: number,
y: number,
size: number,
font: 'F1' | 'F2' = 'F1',
color = '0 0 0',
): string {
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
}
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string {
return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
}
private circularSealOps(cx: number, cy: number): string {
return [
'q',
'0.08 0.32 0.18 RG',
'0.08 0.32 0.18 rg',
'2.2 w',
this.circlePath(cx, cy, 51),
'S',
'0.8 w',
this.circlePath(cx, cy, 41),
'S',
this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'),
this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'),
this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'),
'Q',
].join('\n');
}
private circlePath(cx: number, cy: number, r: number): string {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
'h',
].join('\n');
}
}

View File

@@ -3,7 +3,6 @@ import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
@@ -32,6 +31,7 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseInventoryService } from './warehouse-inventory.service';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseLoadingsController } from './warehouse-loadings.controller';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository';
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
@@ -111,8 +111,8 @@ import { WarehousesService } from './warehouses.service';
WarehouseFeeService,
WarehouseInvoiceService,
WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService,
SchedulingReadFacade,
ContractPdfService,
],
exports: [
WarehousesService,

View File

@@ -0,0 +1,40 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder';
import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder';
import { Batch7TestDataSeeder } from '../seed/batch7-test-data.seeder';
import { Batch8TestDataSeeder } from '../seed/batch8-test-data.seeder';
import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder';
import { PricingDataSeeder } from '../seed/pricing-data.seeder';
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
await app.get(PricingDataSeeder).run();
await app.get(IndodeFacilitySeeder).run();
await app.get(Batch14TestDataSeeder).run();
await app.get(Batch5TestDataSeeder).run();
await app.get(Batch7TestDataSeeder).run();
await app.get(Batch8TestDataSeeder).run();
await app.get(WarehouseDemoSeeder).run();
console.log('Warehouse demo data seeded.');
} finally {
await app.close();
}
}
main().catch((err) => {
console.error('Warehouse demo seed failed:', err);
process.exit(1);
});