Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-08-06 23:49:48 +00:00
122 changed files with 6618 additions and 547 deletions

View File

@@ -16,6 +16,7 @@ import {
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import { MezgebModule } from "@tria-plc/auditlog";
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
@@ -102,13 +103,19 @@ import { ProcurementModule } from "./modules/procurement/procurement.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
if (!process.env.APPLICATION_NAME) {
process.env.APPLICATION_NAME = "freight";
}
@Module({
imports: [
ConfigModule.forRoot({
@@ -156,6 +163,19 @@ import { PositionTypePermissionsCache } from "./common/position-type-permissions
return dataSource;
},
}),
// Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog).
// Must come after TypeOrmModule above so it picks up this app's DataSource.
// rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL
// does: the dev broker only provisions the `edr` user on the `payment`
// vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset
// RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED).
MezgebModule.forRoot({
applicationName: "freight-api",
rmqUrl:
process.env.RABBITMQ_URL ??
process.env.PAYMENT_RABBITMQ_URL ??
"amqp://localhost:5672",
}),
SharedAuthModule,
IamModule.forRoot({
applications: [EDR_FREIGHT_APPLICATION],
@@ -223,11 +243,13 @@ import { PositionTypePermissionsCache } from "./common/position-type-permissions
GpsTrackingModule,
FirstMileModule,
LastMileModule,
LastMileRequestsModule,
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
FleetHistoryModule,
AiModule,
AuditModule,
],
providers: [
EdrOrgSeeder,

View File

@@ -0,0 +1,163 @@
import { computeLastMileCharge } from './last-mile-charge.util';
import type { Rate } from '../modules/rule-engine/entities/rate.entity';
const rate = (over: Partial<Rate>): Rate =>
({
appliesTo: 'LAST_MILE',
status: 'LIVE',
currency: 'ETB',
trigger: 'ALWAYS',
rateType: 'LAST_MILE',
...over,
}) as Rate;
const band20a = rate({
rateUnit: 'PER_KM',
rateValue: 1800,
minKm: 0,
maxKm: 30,
containerType: { sizeFt: 20 } as Rate['containerType'],
});
const band20b = rate({
rateUnit: 'PER_KM',
rateValue: 1500,
minKm: 30,
maxKm: null,
containerType: { sizeFt: 20 } as Rate['containerType'],
});
const band40a = rate({
rateUnit: 'PER_KM',
rateValue: 2200,
minKm: 0,
maxKm: 30,
containerType: { sizeFt: 40 } as Rate['containerType'],
});
const bulkRate = rate({ rateUnit: 'PER_TON_KM', rateValue: 25 });
describe('computeLastMileCharge', () => {
it('prices containers per band × size × quantity', () => {
const charge = computeLastMileCharge({
freightType: 'CONTAINER',
tons: 0,
km: 13,
containers: [
{ sizeLabel: '20DC', qty: 5 },
{ sizeLabel: '40HC', qty: 1 },
],
liveRates: [band20a, band20b, band40a],
});
// 13 × 1800 × 5 + 13 × 2200 × 1
expect(charge).toMatchObject({ mode: 'CONTAINER', total: 117000 + 28600, currency: 'ETB' });
expect(charge!.lines).toHaveLength(2);
});
it('band boundary is half-open: km = 30 falls in the 30+ band', () => {
const charge = computeLastMileCharge({
freightType: 'CONTAINER',
tons: 0,
km: 30,
containers: [{ sizeLabel: '20DC', qty: 1 }],
liveRates: [band20a, band20b],
});
expect(charge!.lines[0].unitRate).toBe(1500);
expect(charge!.total).toBe(30 * 1500);
});
it('returns null when a size has no matching band', () => {
const charge = computeLastMileCharge({
freightType: 'CONTAINER',
tons: 0,
km: 50,
containers: [{ sizeLabel: '40HC', qty: 2 }],
liveRates: [band40a], // 40ft only covers 030
});
expect(charge).toBeNull();
});
it('prices bulk as tons × km × rate', () => {
const charge = computeLastMileCharge({
freightType: 'BULK',
tons: 60,
km: 26,
containers: [],
liveRates: [bulkRate],
});
expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' });
});
it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => {
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null });
const near = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 12,
containers: [],
liveRates: [bulkNear, bulkFar],
});
expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 });
const boundary = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 30,
containers: [],
liveRates: [bulkNear, bulkFar],
});
expect(boundary).toMatchObject({ total: 10 * 30 * 22 });
});
it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => {
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
const fallback = computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 50,
containers: [],
liveRates: [bulkNear, bulkRate], // bulkRate has no band
});
expect(fallback).toMatchObject({ total: 10 * 50 * 25 });
expect(
computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 50,
containers: [],
liveRates: [bulkNear],
}),
).toBeNull();
});
it('returns null on mixed currencies, unknown km, and uncovered freight types', () => {
const usd40 = rate({ ...band40a, currency: 'USD' });
expect(
computeLastMileCharge({
freightType: 'CONTAINER',
tons: 0,
km: 10,
containers: [
{ sizeLabel: '20DC', qty: 1 },
{ sizeLabel: '40HC', qty: 1 },
],
liveRates: [band20a, usd40],
}),
).toBeNull();
expect(
computeLastMileCharge({
freightType: 'BULK',
tons: 10,
km: 0,
containers: [],
liveRates: [bulkRate],
}),
).toBeNull();
expect(
computeLastMileCharge({
freightType: 'BREAK_BULK',
tons: 10,
km: 10,
containers: [],
liveRates: [bulkRate],
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,212 @@
import type { DataSource } from 'typeorm';
import type { Rate } from '../modules/rule-engine/entities/rate.entity';
import { bookingContainerSizes } from './truck-load.util';
/** One priced line of a rule-based last-mile charge. */
export interface LastMileChargeLine {
description: string;
quantity: number;
unitRate: number;
amount: number;
}
/** A fully-resolved rule-based last-mile charge. */
export interface LastMileCharge {
mode: 'BULK' | 'CONTAINER';
total: number;
currency: string;
lines: LastMileChargeLine[];
}
/** What the last-mile leg is hauling, in the shape the rate rules price. */
export interface LastMileShipmentShape {
freightType: string | null;
tons: number;
containers: Array<{ sizeLabel: string; qty: number }>;
}
const round2 = (n: number): number => Math.round(n * 100) / 100;
/**
* Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in.
*
* BULK: the PER_TON_KM rate whose distance band holds the km (a legacy
* bandless row — NULL minKm — is the fallback and prices every distance) →
* price = tons × km × rate.
* CONTAINER: per container size, the PER_KM rate whose distance band holds the
* km → price = km × rate × quantity, summed across sizes.
* Bands are half-open [minKm, maxKm), NULL maxKm = open-ended.
*
* Returns null whenever the rules don't fully cover the shipment (no rate, a
* container size without a matching band, mixed currencies, km/tons unknown) —
* callers keep their existing pricing as the fallback. Never throws.
*/
export function computeLastMileCharge(input: {
freightType: string | null;
tons: number;
km: number;
containers: Array<{ sizeLabel: string; qty: number }>;
/** LIVE rates with the containerType relation loaded (findLiveRatesDetailed). */
liveRates: Rate[];
}): LastMileCharge | null {
const { freightType, tons, km, containers, liveRates } = input;
if (!km || km <= 0) return null;
const candidates = liveRates.filter(
(rate) => rate.appliesTo === 'LAST_MILE' && rate.status === 'LIVE',
);
if (freightType === 'BULK') {
if (!tons || tons <= 0) return null;
const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM');
const rate =
bulkRates.find(
(r) =>
r.minKm !== null &&
r.minKm !== undefined &&
Number(r.minKm) <= km &&
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined);
if (!rate) return null;
const unitRate = Number(rate.rateValue);
const amount = round2(tons * km * unitRate);
return {
mode: 'BULK',
total: amount,
currency: rate.currency,
lines: [
{
description: `Last-mile bulk delivery — ${tons} t × ${km} km × ${unitRate}/t·km`,
quantity: tons,
unitRate,
amount,
},
],
};
}
if (freightType === 'CONTAINER') {
if (!containers.length) return null;
const lines: LastMileChargeLine[] = [];
const currencies = new Set<string>();
for (const group of containers) {
const rate = candidates.find(
(r) =>
r.rateUnit === 'PER_KM' &&
r.minKm !== null &&
r.minKm !== undefined &&
r.containerType?.sizeFt !== null &&
r.containerType?.sizeFt !== undefined &&
group.sizeLabel.includes(String(r.containerType.sizeFt)) &&
Number(r.minKm) <= km &&
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
);
// A size the rules don't cover means the rule set can't price this job.
if (!rate) return null;
const unitRate = Number(rate.rateValue);
const amount = round2(km * unitRate * group.qty);
currencies.add(rate.currency);
lines.push({
description: `Last-mile delivery — ${group.qty} × ${group.sizeLabel} container, ${km} km @ ${unitRate}/km`,
quantity: group.qty,
unitRate,
amount,
});
}
// A charge can't mix birr and dollar lines on one invoice.
if (currencies.size !== 1) return null;
return {
mode: 'CONTAINER',
total: round2(lines.reduce((sum, line) => sum + line.amount, 0)),
currency: [...currencies][0],
lines,
};
}
return null;
}
/**
* Rule-based charge for an operational last-mile record: prices what its
* trucks actually haul (last_mile_vehicle_containers / weighed net tons)
* against the given km. Shared by setDistances (writes remainingPayment) and
* DELIVERY_FEE invoicing so the two never disagree on the math. Null = the
* rules don't cover this job — callers keep the per-vehicle price/km path.
*/
export async function ruleBasedLastMileCharge(
dataSource: DataSource,
liveRates: Rate[],
lastMileId: string,
km: number,
): Promise<LastMileCharge | null> {
if (!km || km <= 0) return null;
const [record]: Array<{ bookingId: string }> = await dataSource.query(
`SELECT booking_id AS "bookingId"
FROM freight.last_mile
WHERE id = $1 AND deleted_at IS NULL`,
[lastMileId],
);
if (!record) return null;
const containerRows: Array<{ containerNumber: string }> = await dataSource.query(
`SELECT container_number AS "containerNumber"
FROM freight.last_mile_vehicle_containers
WHERE last_mile_id = $1 AND deleted_at IS NULL`,
[lastMileId],
);
const shape = await lastMileShipmentShape(
dataSource,
record.bookingId,
containerRows.map((r) => r.containerNumber),
);
// Bulk: bill the weighed tonnage on this record's trucks when known,
// falling back to the booking's declared VGM total.
const [tonsRow]: Array<{ tons: string | null }> = await dataSource.query(
`SELECT SUM(net_weight_tons) AS "tons"
FROM freight.last_mile_vehicle_assignments
WHERE last_mile_id = $1 AND deleted_at IS NULL`,
[lastMileId],
);
const weighedTons = Number(tonsRow?.tons ?? 0);
return computeLastMileCharge({
...shape,
tons: weighedTons > 0 ? weighedTons : shape.tons,
km,
liveRates,
});
}
/**
* Load a booking's shipment shape for the charge resolver: freight type, bulk
* tonnage, and the container numbers grouped into size × quantity.
*/
export async function lastMileShipmentShape(
dataSource: DataSource,
bookingId: string,
containerNumbers: string[],
): Promise<LastMileShipmentShape> {
const [booking]: Array<{ freightType: string | null; tons: string | null }> =
await dataSource.query(
`SELECT freight_type AS "freightType", cargo_total_weight_vgm AS "tons"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const sizes = await bookingContainerSizes(
dataSource,
bookingId,
containerNumbers.map((n) => n.trim().toUpperCase()),
);
const bySize = new Map<string, number>();
for (const size of sizes) {
if (!size) continue;
bySize.set(size, (bySize.get(size) ?? 0) + 1);
}
return {
freightType: booking?.freightType ?? null,
tons: Number(booking?.tons ?? 0),
containers: [...bySize.entries()].map(([sizeLabel, qty]) => ({ sizeLabel, qty })),
};
}

View File

@@ -0,0 +1,17 @@
import { haversineKm } from './mile-distance.util';
describe('haversineKm', () => {
it('is zero for the same point', () => {
expect(haversineKm(8.9, 38.6, 8.9, 38.6)).toBe(0);
});
it('matches one degree of longitude at the equator (~111.19 km)', () => {
expect(haversineKm(0, 0, 0, 1)).toBeCloseTo(111.19, 1);
});
it('Sebeta yard → Indode yard is roughly 26 km', () => {
const km = haversineKm(8.9096, 38.636, 8.7386, 38.7913);
expect(km).toBeGreaterThan(20);
expect(km).toBeLessThan(35);
});
});

View File

@@ -0,0 +1,48 @@
import { DataSource } from 'typeorm';
/** Great-circle distance in km between two WGS84 points (haversine). */
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * 6371 * Math.asin(Math.sqrt(a));
}
/**
* Estimated road-leg distance for a booking's first/last mile: straight-line km
* from the yard (freight.yard_locations) to the customer's pickup/delivery GPS
* point on the booking. FIRST = origin yard → pickup point, LAST = destination
* yard → delivery point. Null when either end has no coordinates.
*
* ponytail: haversine straight-line, not road routing — plug a routing API in
* here if real road km is ever required.
*/
export async function estimateMileKm(
dataSource: DataSource,
bookingId: string,
mile: 'FIRST' | 'LAST',
): Promise<number | null> {
const [row] = await dataSource.query(
mile === 'LAST'
? `SELECT b.last_mile_delivery_lat AS lat, b.last_mile_delivery_lng AS lng,
l.latitude AS yard_lat, l.longitude AS yard_lng
FROM freight.bookings b
LEFT JOIN freight.yard_locations l
ON l.yard_id = b.destination_yard_id AND l.deleted_at IS NULL
WHERE b.id = $1 AND b.deleted_at IS NULL`
: `SELECT b.first_mile_pickup_lat AS lat, b.first_mile_pickup_lng AS lng,
l.latitude AS yard_lat, l.longitude AS yard_lng
FROM freight.bookings b
LEFT JOIN freight.yard_locations l
ON l.yard_id = b.origin_yard_id AND l.deleted_at IS NULL
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
if (!row || row.lat == null || row.lng == null || row.yard_lat == null || row.yard_lng == null) {
return null;
}
const km = haversineKm(Number(row.yard_lat), Number(row.yard_lng), Number(row.lat), Number(row.lng));
return Math.round(km * 100) / 100;
}

View File

@@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog";
// @tria-plc/auditlog's entities live in node_modules, same as the iam ones —
// the glob below only matches this app's own src/**/*.entity.ts.
const auditEntities = [AuditLog, AuditLogCommand];
const iamEntities = [
UnitSetting,
@@ -177,7 +182,11 @@ export function buildDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: "public",
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
entities: [
__dirname + "/../**/*.entity.{ts,js}",
...iamEntities,
...auditEntities,
],
migrations: [],
};
}

View File

@@ -43,6 +43,7 @@ const UNIT_LABELS: Record<string, string> = {
PER_TON: 'per ton',
PER_CONTAINER: 'per container',
PER_KM: 'per km',
PER_TON_KM: 'per ton per km',
PER_INVOICE: 'per invoice',
FLAT: 'flat',
};

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Last-Mile Delivery Contract — {{bookingReference}}</title>
<style>
* { box-sizing: border-box; }
body { font-family: 'Helvetica Neue', Arial, sans-serif; color: #1a1a1a; margin: 0; font-size: 12px; line-height: 1.5; }
main { padding: 32px 40px; }
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
.kicker { margin: 0; font-weight: 700; }
.muted { margin: 0; color: #666; }
h1 { font-size: 20px; margin: 24px 0 4px; }
h2 { font-size: 14px; margin: 26px 0 8px; color: #1a5632; border-bottom: 1px solid #d8d8d8; padding-bottom: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #d8d8d8; padding: 6px 10px; text-align: left; vertical-align: top; }
th { background: #f2f6f3; width: 32%; font-weight: 600; }
.rates th { width: auto; }
.rates .amount { text-align: right; white-space: nowrap; }
.rates tfoot td { font-weight: 700; background: #f2f6f3; }
.terms p { margin: 6px 0; }
.sig-grid { display: flex; gap: 24px; margin-top: 18px; }
.sig-card { flex: 1; border: 1px solid #d8d8d8; border-radius: 6px; padding: 14px; min-height: 130px; }
.sig-card h3 { margin: 0 0 8px; font-size: 12px; color: #1a5632; }
.sig-card img { max-height: 60px; max-width: 100%; }
.sig-line { border-top: 1px solid #999; margin-top: 40px; padding-top: 4px; color: #666; }
.consent { margin-top: 6px; font-style: italic; color: #444; }
.pending { color: #999; font-style: italic; }
</style>
</head>
<body>
<main>
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Last-Mile Delivery Contract</p>
</div>
</div>
<h1>Last-Mile Delivery Contract</h1>
<p class="muted">Booking {{bookingReference}}{{companyName}}</p>
<h2>Shipment Details</h2>
<table>
<tr><th>Client</th><td>{{companyName}}</td></tr>
<tr><th>Booking Reference</th><td>{{bookingReference}}</td></tr>
{{#if containerCount}}
<tr><th>Number of Containers</th><td>{{containerCount}}</td></tr>
<tr><th>Containers</th><td>{{containerList}}</td></tr>
{{/if}}
{{#if cargoDescription}}
<tr><th>Cargo Description</th><td>{{cargoDescription}}</td></tr>
{{/if}}
{{#if deliveryAddress}}
<tr><th>Delivery Address</th><td>{{deliveryAddress}}</td></tr>
{{/if}}
{{#if trainDepartureDate}}
<tr><th>Train Departure from Djibouti</th><td>{{trainDepartureDate}}</td></tr>
{{/if}}
<tr><th>Last-Mile Delivery Date</th><td>{{deliveryDate}}</td></tr>
<tr><th>Request Date</th><td>{{requestDate}}</td></tr>
<tr><th>Approval Date</th><td>{{approvalDate}}</td></tr>
</table>
<h2>Rates</h2>
<table class="rates">
<thead>
<tr><th>Description</th><th class="amount">Amount{{#if currency}} ({{currency}}){{/if}}</th></tr>
</thead>
<tbody>
{{#each rateLines}}
<tr><td>{{description}}</td><td class="amount">{{amount}}</td></tr>
{{/each}}
{{#if estimatedKm}}
<tr><td class="muted">Estimated distance</td><td class="amount">{{estimatedKm}} km</td></tr>
{{/if}}
</tbody>
<tfoot>
<tr><td>Advance payable on signing</td><td class="amount">{{advanceAmount}}{{#if currency}} {{currency}}{{/if}}</td></tr>
</tfoot>
</table>
<h2>Terms</h2>
<div class="terms">
<p>1. The Service Provider shall deliver the goods identified above from the arrival yard to the Client's delivery address on or about the last-mile delivery date stated above.</p>
<p>2. The Client shall pay the advance stated above upon signing this contract. The final delivery fee is computed on completion per the Service Provider's published last-mile rates and actual distance.</p>
<p>3. The Client shall ensure access and receipt of the goods at the delivery address. Waiting time and truck detention beyond free time may incur additional charges per the applicable tariff.</p>
<p>4. This contract is governed by the laws applicable to the Ethio-Djibouti Standard Gauge Railway Share Company's freight services.</p>
</div>
<h2>Signatures</h2>
<div class="sig-grid">
<div class="sig-card">
<h3>Client</h3>
{{#if signature}}
<img src="{{signature.imageUrl}}" alt="Customer signature" />
<div class="sig-line">{{signature.signerDisplayName}} — signed {{signature.signedAt}}</div>
{{#if signature.consentText}}<div class="consent">"{{signature.consentText}}"</div>{{/if}}
{{else}}
<p class="pending">Awaiting customer signature.</p>
<div class="sig-line">Name, signature &amp; date</div>
{{/if}}
</div>
<div class="sig-card">
<h3>Service Provider</h3>
<p>Ethio-Djibouti Standard Gauge Railway Share Company</p>
<div class="sig-line">Authorized representative</div>
</div>
</div>
</main>
</body>
</html>

View File

@@ -10,6 +10,7 @@ import {
ResponseTransformInterceptor,
createValidationPipe,
} from "@edr/api-common";
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
import { AppModule } from "./app.module";
@@ -160,6 +161,13 @@ export async function createFreightApp(): Promise<NestExpressApplication> {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
// Audit listener: consumes the RMQ events MezgebModule's client interceptor
// (app.module.ts) emits and persists them via the AuditLogController /
// AuditLogCommandController @EventPattern handlers. Same queue config the
// client side uses, reused from the package so the two never drift apart.
app.connectMicroservice(getAuditLoggerConfig());
await app.startAllMicroservices();
const config = new DocumentBuilder()
.setTitle("EDR Freight API")
.setDescription("API for the EDR Freight Management application")

View File

@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create freight.last_mile_requests — the pre-approval confirmation stage that
* sits in front of freight.last_mile: a train departs Djibouti, the customer
* confirms which containers go via EDR last-mile, and the Truck & Machinery
* chief approves/rejects before a freight.last_mile execution record exists.
*/
export class CreateLastMileRequests3250000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile_requests');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.last_mile_requests',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'gen_random_uuid()' },
{ name: 'booking_id', type: 'uuid', isNullable: false },
{ name: 'train_schedule_id', type: 'uuid', isNullable: false },
{
name: 'status',
type: 'varchar',
length: '30',
default: `'AWAITING_CONFIRMATION'`,
isNullable: false,
},
{ name: 'requested_container_numbers', type: 'text', isArray: true, isNullable: true },
{ name: 'reminder_sent_at', type: 'timestamptz', isNullable: true },
{ name: 'submitted_by_user_id', type: 'uuid', isNullable: true },
{ name: 'submitted_at', type: 'timestamptz', isNullable: true },
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
{ name: 'rejection_reason', type: 'text', isNullable: true },
{ name: 'resulting_last_mile_id', type: 'uuid', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.last_mile_requests',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'freight.bookings',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.last_mile_requests',
new TableForeignKey({
columnNames: ['train_schedule_id'],
referencedTableName: 'freight.train_schedules',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.last_mile_requests',
new TableForeignKey({
columnNames: ['resulting_last_mile_id'],
referencedTableName: 'freight.last_mile',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
// One request per booking per departure — remind()/submit() are idempotent on this.
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_last_mile_requests_booking_schedule" ON "freight"."last_mile_requests" ("booking_id", "train_schedule_id") WHERE "deleted_at" IS NULL`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_last_mile_requests_status" ON "freight"."last_mile_requests" ("status")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile_requests');
if (exists) {
await queryRunner.dropTable('freight.last_mile_requests');
}
}
}

View File

@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Creates freight.yard_locations (GPS per yard, decimal degrees WGS84) and
* seeds the five facility yards: Sebeta, GMP/Indode (KALITY), Mojo, Adama,
* Dire Dawa. Also normalizes has_facility — only those five load/unload cargo.
*
* Coordinates are approximate — adjust rows directly if surveyed values arrive.
*/
export class YardGpsLocation3270000000000 implements MigrationInterface {
name = 'YardGpsLocation3270000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.yard_locations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
yard_id uuid NOT NULL UNIQUE REFERENCES freight.yards(id) ON DELETE CASCADE,
latitude double precision NOT NULL,
longitude double precision NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// LEGACY_DEST is the historical code for Sebeta in some environments.
await queryRunner.query(`
INSERT INTO freight.yard_locations (yard_id, latitude, longitude)
SELECT y.id, v.lat, v.lng
FROM (VALUES
('SEBETA', 8.9096, 38.6360),
('LEGACY_DEST', 8.9096, 38.6360),
('KALITY', 8.7386, 38.7913),
('MOJO', 8.5794, 39.1200),
('ADAMA', 8.5622, 39.2440),
('DIRE_DAWA', 9.6009, 41.8103)
) AS v(code, lat, lng)
JOIN freight.yards y ON y.code = v.code AND y.deleted_at IS NULL
ON CONFLICT (yard_id) DO NOTHING
`);
await queryRunner.query(`
UPDATE freight.yards
SET has_facility = (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA'))
WHERE deleted_at IS NULL
AND has_facility <> (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA'))
`);
}
public async down(): Promise<void> {
// Additive table + data normalization; no rollback.
}
}

View File

@@ -0,0 +1,68 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Last-mile rate bands: adds min_km/max_km to freight.rates so container
* last-mile rates can be one row per (container size × distance band),
* and extends UQ_rates_pattern with the band start so sibling bands don't
* collide. Existing rows all have NULL min_km (COALESCE → -1), so the
* uniqueness semantics for every current rate are unchanged.
*/
export class LastMileRateBands3280000000000 implements MigrationInterface {
name = 'LastMileRateBands3280000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS min_km numeric(10,2)
`);
await queryRunner.query(`
ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS max_km numeric(10,2)
`);
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'CK_rates_km_band'
AND conrelid = 'freight.rates'::regclass
) THEN
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_km_band"
CHECK (max_km IS NULL OR (min_km IS NOT NULL AND max_km > min_km));
END IF;
END $$
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''::character varying),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit,
COALESCE(min_km, '-1'::numeric)
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''::character varying),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
`);
await queryRunner.query(`
ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_km_band"
`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS max_km`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS min_km`);
}
}

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Last-mile contract fields on freight.last_mile_requests: the customer-chosen
* delivery date, the chief-approved advance amount (held until the invoice is
* generated at signing time), the rate snapshot rendered into the contract,
* and the customer signature bookkeeping. The signed PDF and signature image
* live in freight.files (resource 'last_mile_requests').
*/
export class LastMileRequestContract3290000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_requests
ADD COLUMN IF NOT EXISTS requested_delivery_date date,
ADD COLUMN IF NOT EXISTS approved_advance_amount numeric(14,2),
ADD COLUMN IF NOT EXISTS contract_summary jsonb,
ADD COLUMN IF NOT EXISTS contract_generated_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_signed_at timestamptz,
ADD COLUMN IF NOT EXISTS signer_display_name varchar(160),
ADD COLUMN IF NOT EXISTS consent_text text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_requests
DROP COLUMN IF EXISTS requested_delivery_date,
DROP COLUMN IF EXISTS approved_advance_amount,
DROP COLUMN IF EXISTS contract_summary,
DROP COLUMN IF EXISTS contract_generated_at,
DROP COLUMN IF EXISTS customer_signed_at,
DROP COLUMN IF EXISTS signer_display_name,
DROP COLUMN IF EXISTS consent_text
`);
}
}

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AuditService } from "./audit.service";
@ApiTags("audit")
@Controller("audit")
@BookingStaff(FREIGHT_PERMS.audit.view)
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get("logs")
@ApiOperation({ summary: "List freight-api audit log commands" })
@ApiQuery({ name: "skip", type: Number, required: false })
@ApiQuery({ name: "take", type: Number, required: false })
list(@Query("skip") skip?: string, @Query("take") take?: string) {
// Same fallback chain @tria-plc/auditlog's client interceptor uses to
// stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js)
// — reading it here instead of a hardcoded literal means this can't
// silently drift out of sync with whatever APPLICATION_NAME/APP_NAME
// actually is at runtime.
const application =
process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT";
return this.auditService.list(
application,
skip !== undefined ? parseInt(skip, 10) : undefined,
take !== undefined ? parseInt(take, 10) : undefined,
);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AuditLogCommand } from "@tria-plc/auditlog";
import { AuditController } from "./audit.controller";
import { AuditService } from "./audit.service";
@Module({
imports: [TypeOrmModule.forFeature([AuditLogCommand])],
controllers: [AuditController],
providers: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,70 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { AuditLogCommand } from "@tria-plc/auditlog";
import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware";
export interface AuditLogListResult {
count: number;
items: AuditLogCommand[];
}
/**
* Own read path onto @tria-plc/auditlog's tables, gated by AuditController's
* @BookingStaff — the package's own AuditLogCommandController (mounted at
* /api/audit-log-commands) ships with no guards at all, so it can't be used
* directly for a permission-gated UI. Query mirrors the package's
* AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly.
*/
@Injectable()
export class AuditService {
constructor(
@InjectRepository(AuditLogCommand)
private readonly auditLogCommandRepository: Repository<AuditLogCommand>,
) {}
async list(
application: string,
skip = 0,
take = 10,
): Promise<AuditLogListResult> {
const [items, count] = await this.auditLogCommandRepository
.createQueryBuilder("audit_log_commands")
.leftJoinAndSelect("audit_log_commands.auditLog", "auditLog")
.andWhere(
"(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)",
{ application },
)
.andWhere(
"(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)",
{ status: "Commit" },
)
// Backoffice-only view: portal (customer-facing) writes carry the same
// request-header set by every axios call from that app — see
// login-audience.middleware.ts. Rows with no linked auditLog (child/
// event commands with no request context) stay visible; they aren't
// attributable to any frontend, so they're not portal noise either.
.andWhere(
"(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)",
{ clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" },
)
.select([
"audit_log_commands.id",
"audit_log_commands.createdAt",
"audit_log_commands.deletedAt",
"audit_log_commands.entityName",
"audit_log_commands.queryMethod",
"audit_log_commands.changes",
"audit_log_commands.payload",
"auditLog.id",
"auditLog.user",
])
.addOrderBy("audit_log_commands.createdAt", "DESC")
.skip(skip)
.take(take)
.getManyAndCount();
return { count, items };
}
}

View File

@@ -668,3 +668,66 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
});
});
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
// totalAmount — money missing from the bank with the books saying paid.
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
const invoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "ETB",
// .40 — the case Math.round gets wrong (rounds down, underpays).
balanceAmount: 12345.4,
totalAmount: 12345.4,
company: { name: "Acme PLC" },
paymentId: null,
dueAt: null,
};
const build = (payment: Record<string, unknown> = {}) => {
const repo = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ getRepository: () => repo } as never,
{} as never,
{} as never,
makeEvents() as never,
payment as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("opens the intent for the ceiled balance, never below it", async () => {
const initiate = jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
response: { intentId: "intent-1", status: "REQUIRES_ACTION" },
});
const { service } = build({ initiate });
await service.payInvoice("inv-1", { method: "CBE_BILL" });
expect(initiate).toHaveBeenCalledWith(
expect.objectContaining({ amountMinor: 12346 }),
);
});
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
const { service } = build();
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
stillPayable: true,
currentAmountMinor: 12346,
});
});
});

View File

@@ -10,6 +10,7 @@ import {
import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { CompaniesService } from "../companies/companies.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
@@ -1190,7 +1191,11 @@ export class BillingService {
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)),
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
// land below the outstanding balance — Math.round would let a .40 balance
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
// ceil in billQuery keeps the quoted and debited amounts identical.
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -1209,6 +1214,17 @@ export class BillingService {
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against
// at any CBE channel. Persist it on the booking so it survives the initiate response and
// shows on the booking/contract everywhere. The payment service reissues the same reference
// while the bill stays open, so re-initiating overwrites with an identical value.
const billReference = result.response.clientAction?.billReference;
if (billReference && invoice.source === Freight.InvoiceSource.Booking) {
await this.dataSource
.getRepository(Booking)
.update({ id: invoice.sourceId }, { pnrCode: billReference });
}
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept for local demos only.
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
@@ -1324,7 +1340,9 @@ export class BillingService {
});
if (open) {
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
@@ -1359,7 +1377,7 @@ export class BillingService {
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.round(Number(latest.totalAmount)),
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),

View File

@@ -727,8 +727,16 @@ export class BookingPricingService {
for (const leg of legs) {
if (!leg.active) continue;
// New-style last-mile rates (PER_TON_KM bulk / distance-banded PER_KM)
// price the operational leg via last-mile-charge.util, not the booking
// quote — this legacy lookup must never pick one up.
const rate = liveRates.find(
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
(r) =>
r.rateType === leg.rateType &&
r.currency === 'USD' &&
r.status === 'LIVE' &&
r.rateUnit !== 'PER_TON_KM' &&
r.minKm == null,
);
if (!rate) continue;

View File

@@ -280,15 +280,104 @@ describe("Fayda identity verification binds a person to the company", () => {
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
});
it("refuses to rename a verified person by hand", async () => {
const { service } = makeService({
it("stages nothing for a verified field an approved company resubmits", async () => {
// Approving it could not move the live row — the verified value is written
// back over it — so it must never reach a reviewer as a pending change.
const { service, deps } = makeService({
status: CompanyStatus.Active,
attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" },
});
await expect(
service.updateProfile("user-1", {
companyEmail: "someone-else@example.com",
} as never),
).resolves.toBeDefined();
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
expect(deps.changeRequestRepo.update).not.toHaveBeenCalled();
expect(deps.companiesRepo.update).not.toHaveBeenCalled();
});
// The verified value wins, and it wins by overwriting rather than by
// rejecting: nobody types these fields, so a submission that disagrees is a
// stale form echoing itself back, not an edit. Failing it would block a save
// the customer never made — and leave them no way through, since re-verifying
// returns the same value they are being 400'd for.
it("overwrites a hand-renamed verified person with the verified name", async () => {
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
files: [paper()],
});
await expect(
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
).rejects.toBeInstanceOf(BadRequestException);
).resolves.toBeDefined();
expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName);
});
// Fayda's email and phone claims are optional — a verification can prove the
// person and return neither. Holding the company mirrors to "the owner is
// verified" rather than to "the verification supplied this value" would
// clobber the fallbacks the portal is built to send (account email, eTrade's
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that
// shape: a sub, no contact details.
it("keeps company contact details a Fayda verification never supplied", async () => {
const { service, deps } = makeService({
attributes: { ...OWNER_VERIFIED },
});
await expect(
service.updateProfile("user-1", {
companyEmail: "account@example.com",
companyPhone: "+251911777777",
} as never),
).resolves.toBeDefined();
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
expect(patch.email).toBe("account@example.com");
expect(patch.phone).toBe("+251911777777");
});
it("overwrites company contact details the verification did supply", async () => {
const { service, deps } = makeService({
attributes: {
...OWNER_VERIFIED,
ownerEmail: "abebe@example.com",
ownerPhone: "+251911000000",
},
});
await expect(
service.updateProfile("user-1", {
companyEmail: "someone-else@example.com",
companyPhone: "+251911999999",
} as never),
).resolves.toBeDefined();
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
expect(patch.email).toBe("abebe@example.com");
expect(patch.phone).toBe("+251911000000");
});
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
// onboarding, hidden by the portal's link card and unwritable at once.
it("lets the GM's details be typed when the copied owner identity carried none", async () => {
const { service } = makeService({
attributes: {
...OWNER_VERIFIED,
gmSameAsOwner: true,
gmFaydaSub: "owner-sub",
generalManagerName: "Abebe Bikila",
generalManagerEmail: null,
generalManagerPhone: null,
},
});
await expect(
service.updateProfile("user-1", {
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911888888",
} as never),
).resolves.toBeDefined();
});
it("never locks or gates the general manager — it is not the verified subject", async () => {

View File

@@ -739,6 +739,39 @@ export class CompaniesService {
return out;
}
/**
* `UpdateProfileDto` keys this company's completed verifications own — the
* ones `mapProfileDtoToCompanyUpdates` overwrites with the verified value
* whatever a request submits for them.
*
* A key only lands here once there is a verified value to hold it to: Fayda's
* email and phone claims are optional, and a verification that returned
* neither owns nothing to overwrite with.
*
* The map is the enforcement; this is the list used to keep those keys out of
* a change request in the first place. If the two ever drift the map still
* wins — the cost is a staged field that approving turns out not to move.
*/
private faydaOwnedKeys(company: Company): string[] {
const attrs = company.attributes ?? {};
const held = (key: string) => {
const v = attrs[key];
return v !== null && v !== undefined && v !== "";
};
const keys: string[] = [];
if (attrs.ownerFaydaSub) {
// The Company-column mirrors of the owner's verified contact details.
if (held("ownerEmail")) keys.push("companyEmail");
if (held("ownerPhone")) keys.push("companyPhone");
}
for (const subject of IDENTITY_SUBJECTS) {
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
}
return keys;
}
/**
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
@@ -829,49 +862,46 @@ export class CompaniesService {
// lets the customer type them once verified) — lock them the same way
// ownerEmail/ownerPhone themselves are locked below, once there is a
// verified owner to lock them to.
//
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
// phone claims are optional, so a verification can prove the person while
// supplying neither (see completeIdentityVerification's conditional
// spreads). The portal falls back to the account email / eTrade's
// registered phone in exactly that case and submits it on every save of
// the company step — locking against an absent value would 400 that
// forever, and re-verifying could never clear it because Fayda still has
// nothing to return.
if (attrUpdates.ownerFaydaSub) {
if (
dto.companyEmail !== undefined &&
dto.companyEmail !== attrUpdates.ownerEmail
) {
throw new BadRequestException(
"companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
);
}
if (
dto.companyPhone !== undefined &&
normalizeE164(dto.companyPhone) !==
normalizeE164(String(attrUpdates.ownerPhone ?? ""))
) {
throw new BadRequestException(
"companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
);
}
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
companyUpdates.email = attrUpdates.ownerEmail;
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
}
// Renaming a Fayda-verified person by hand would launder the guarantee
// away, so the fields the verification owns are refused once it exists.
// away, so the verification keeps these fields: a submission that disagrees
// is overwritten with the verified value rather than rejected — the same
// doctrine `applyEtradeSourcedFields` uses for eTrade's fields, and for the
// same reason. The customer never types these (the portal derives them, and
// a stale form or a re-render can echo back something else entirely), so a
// 400 punishes a save they never made while an overwrite lands the truth.
for (const subject of IDENTITY_SUBJECTS) {
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
const incoming = (dto as Record<string, unknown>)[field];
if (incoming === undefined) continue;
// The verification itself is allowed to write them; anything else is
// compared against what is already stored, not against the value this
// same call just copied into the patch. Phones are compared normalized:
// a form that re-renders +251911000000 as 0911000000 is echoing the
// stored value back, not trying to change it.
if ((dto as Record<string, unknown>)[field] === undefined) continue;
// The verification itself is what writes them; it must not be undone by
// the value this same call just copied into the patch.
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
const stored = company.attributes?.[field];
const same = field.endsWith("Phone")
? normalizeE164(String(incoming)) ===
normalizeE164(String(stored ?? ""))
: incoming === stored;
if (!same) {
throw new BadRequestException(
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
);
}
// A verification that supplied nothing for this field left no guarantee
// to protect, so it stays typeable. Matters most for the GM —
// `setGmSameAsOwner` copies `ownerEmail ?? null` onto
// `generalManagerEmail` while setting `gmFaydaSub`, and
// REQUIRED_COMPANY_INFO still demands that email, so holding a null
// here makes it required, hidden by the portal's "same as owner" card,
// and unwritable all at once.
if (stored === null || stored === undefined || stored === "") continue;
attrUpdates[field] = stored;
}
}
@@ -977,6 +1007,11 @@ export class CompaniesService {
// for review with the live row left intact.
await this.assertTinAvailable(company, dto.tin);
const fields = this.pickDefined(dto);
// Drop what the verifications own before anything is staged. Approving one
// of these could not change the live row — mapProfileDtoToCompanyUpdates
// writes the verified value back over it — so showing it to a reviewer
// asks them to rule on a change that does not exist.
for (const key of this.faydaOwnedKeys(company)) delete fields[key];
const selfService: Record<string, any> = {};
const staged: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {
@@ -2022,6 +2057,11 @@ export class CompaniesService {
// replace it before the application counts as complete.
const flaggedDelegation = delegationDue && delegation.flagged;
// Mirrors `poaProven` in buildCompanyIdentityState — see the note there.
const poaProven = identity.faydaRequired
? identity.poa.verified
: identity.poa.verified || Boolean(identity.poa.name?.trim());
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
@@ -2039,8 +2079,19 @@ export class CompaniesService {
...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"]
: []),
...((poaRequired || poaProvided) && !identity.poa.verified
? ["Verify your Power of Attorney's identity with Fayda"]
// Nationality-aware, exactly like `poaProven` in
// buildCompanyIdentityState and the check in `assertIdentityVerified`:
// Fayda is an Ethiopian national ID, so a foreign company's typed
// representative has to count. Demanding a verification here regardless
// made this list disagree with the rule actually enforced, and left a
// foreign freight forwarder unable to submit — asked for a Fayda
// verification its representative may have no way to obtain.
...((poaRequired || poaProvided) && !poaProven
? [
identity.faydaRequired
? "Verify your Power of Attorney's identity with Fayda"
: "Name your Power of Attorney, or verify them with Fayda",
]
: []),
...(identity.passportRequired && !identity.owner.passportNumber
? ["Add the company owner's passport number"]
@@ -2054,7 +2105,10 @@ export class CompaniesService {
const poaItemCount = delegationDue ? 1 : 0;
// One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — that one is Fayda whatever the nationality.
// there is one — Fayda for an Ethiopian company, a named representative
// for a foreign one, same rule as `poaProven` above. Counting a foreign
// company's typed PoA as unproven here left the progress bar permanently
// short of 100% on an item it had already satisfied.
const ownerCredentialDue =
identity.faydaRequired || identity.passportRequired;
const ownerCredentialProven = identity.faydaRequired
@@ -2064,7 +2118,7 @@ export class CompaniesService {
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const missingIdentityCount =
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !identity.poa.verified ? 1 : 0);
(delegationDue && !poaProven ? 1 : 0);
const total =
requiredInfo.length +
requiredDocCount +
@@ -2732,7 +2786,13 @@ export class CompaniesService {
// The verified payload owns the person's details from here on.
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
// Fayda returns whatever the national registry holds, which is routinely a
// local number ("0911223344"). Every typed phone in this service is stored
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
// becomes a value the portal reads back and cannot resubmit.
...(result.phoneNumber
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
: {}),
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
};

View File

@@ -1,4 +1,12 @@
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator';
import {
IsString,
IsOptional,
IsEmail,
MaxLength,
IsEnum,
IsIn,
Matches,
} from 'class-validator';
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -39,9 +47,13 @@ export class UpdateProfileDto {
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin?: string;
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
// TIN. Both portal forms enforce that; without it here the API happily stored
// whatever a stale client sent, and the two layers disagreed about what the
// column may hold.
@IsOptional()
@IsString()
@MaxLength(50)
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' })
vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the

View File

@@ -177,8 +177,14 @@ export class ContractPricingService {
}
}
if (contract.lastMileDeliveryAddress) {
// New-style last-mile rates (PER_TON_KM / distance-banded PER_KM) are
// priced operationally per job, not as a single contract unit price.
const lm = liveRates.find(
(r) => r.rateType === 'LAST_MILE' && r.currency === 'USD',
(r) =>
r.rateType === 'LAST_MILE' &&
r.currency === 'USD' &&
r.rateUnit !== 'PER_TON_KM' &&
r.minKm == null,
);
if (lm && Number(lm.rateValue) > 0) {
lineItems.push({

View File

@@ -3,6 +3,7 @@ import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { attachMileFinancials } from '../../common/mile-financials.util';
import { estimateMileKm } from '../../common/mile-distance.util';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { BookingsRepository } from "../bookings/bookings.repository";
import { DriversService } from "../drivers/drivers.service";
@@ -285,7 +286,7 @@ export class FirstMileService {
status: dto.status ?? "READY_TO_TRANSIT",
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'FIRST')),
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, Min } from 'class-validator';
export class ApproveLastMileRequestDto {
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
// but the chief can still override — the typed value is what's invoiced.
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
@Transform(({ value }) => Number(value))
@IsNumber()
@Min(0.01)
advanceAmount!: number;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class RejectLastMileRequestDto {
@ApiProperty({ description: 'Why the request is rejected (e.g. no truck available)' })
@IsString()
@IsNotEmpty()
@MaxLength(500)
reason!: string;
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class SignLastMileContractDto {
@ApiPropertyOptional({
description:
'Signature PNG as base64 (data URI or raw). Omitted = reuse the saved profile signature.',
})
@IsOptional()
@IsString()
signatureImageBase64?: string;
@ApiProperty({ description: 'Name shown under the signature.' })
@IsString()
@IsNotEmpty()
@MaxLength(160)
signerDisplayName!: string;
@ApiPropertyOptional({ description: 'The consent statement the customer agreed to.' })
@IsOptional()
@IsString()
@MaxLength(500)
consentText?: string;
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, ArrayUnique, IsArray, IsDateString, IsString } from 'class-validator';
export class SubmitLastMileRequestDto {
@ApiProperty({
type: [String],
description:
'Container numbers the customer wants delivered via EDR last-mile — pass every booking container to select "all".',
})
@IsArray()
@ArrayNotEmpty()
@ArrayUnique()
@IsString({ each: true })
containerNumbers!: string[];
@ApiProperty({
description:
'Requested last-mile delivery date (ISO date), chosen by the customer against the train departure from Djibouti.',
example: '2026-08-15',
})
@IsDateString()
deliveryDate!: string;
}

View File

@@ -0,0 +1,112 @@
import { BaseEntity } from '@edr/api-common';
import { LastMileRequestStatus } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { LastMile } from '../../last-mile/entities/last-mile.entity';
export const LAST_MILE_REQUEST_STATUSES = [
LastMileRequestStatus.AwaitingConfirmation,
LastMileRequestStatus.Submitted,
LastMileRequestStatus.Approved,
LastMileRequestStatus.Rejected,
] as const;
/**
* The pre-approval confirmation stage in front of `LastMile`: fired when a
* train departs Djibouti, filled by the customer, reviewed by the Truck &
* Machinery chief. One row per (bookingId, trainScheduleId) — a booking whose
* containers arrive across several departures gets a request per departure.
*/
@Entity({ name: 'last_mile_requests', schema: 'freight' })
@Index(['bookingId'])
@Index(['status'])
export class LastMileRequest extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { nullable: false, eager: false })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@ManyToOne(() => TrainSchedule, { nullable: false, eager: false })
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'status', type: 'varchar', length: 30, default: LastMileRequestStatus.AwaitingConfirmation })
status!: LastMileRequestStatus;
/** Customer's container selection — "all" is just every booking container listed here. */
@Column({ name: 'requested_container_numbers', type: 'text', array: true, nullable: true })
requestedContainerNumbers?: string[] | null;
@Column({ name: 'reminder_sent_at', type: 'timestamptz', nullable: true })
reminderSentAt?: Date | null;
@Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true })
submittedByUserId?: string | null;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt?: Date | null;
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
reviewedByStaffId?: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt?: Date | null;
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
rejectionReason?: string | null;
@Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true })
resultingLastMileId?: string | null;
/** Customer-chosen last-mile delivery date (guided by the train's Djibouti departure). */
@Column({ name: 'requested_delivery_date', type: 'date', nullable: true })
requestedDeliveryDate?: string | null;
/** Chief-approved advance — invoiced only after the customer signs the LM contract. */
@Column({
name: 'approved_advance_amount',
type: 'numeric',
precision: 14,
scale: 2,
nullable: true,
transformer: {
to: (v?: number | null) => v,
from: (v?: string | null) => (v == null ? null : Number(v)),
},
})
approvedAdvanceAmount?: number | null;
/** Rate snapshot taken at approval, rendered into the contract document. */
@Column({ name: 'contract_summary', type: 'jsonb', nullable: true })
contractSummary?: {
estimatedKm: number | null;
mode: string | null;
currency: string | null;
total: number | null;
lines: Array<{ description: string; amount: number }>;
advanceAmount: number;
} | null;
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
customerSignedAt?: Date | null;
@Column({ name: 'signer_display_name', type: 'varchar', length: 160, nullable: true })
signerDisplayName?: string | null;
@Column({ name: 'consent_text', type: 'text', nullable: true })
consentText?: string | null;
@ManyToOne(() => LastMile, { nullable: true, eager: false })
@JoinColumn({ name: 'resulting_last_mile_id' })
resultingLastMile?: LastMile | null;
}

View File

@@ -0,0 +1,318 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import Handlebars from 'handlebars';
import { Readable } from 'stream';
import { DataSource } from 'typeorm';
import { LastMileRequestStatus } from '@edr/types';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsService } from '../bookings/bookings.service';
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 { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
import { LastMileRequestsService } from './last-mile-requests.service';
const FILE_RESOURCE = 'last_mile_requests';
/**
* The LM contract in front of the advance payment: generated when the chief
* approves the request, viewed and signed by the customer in the portal, and
* only then invoiced (LastMileRequestsService.generateAdvanceInvoice). Single
* signer (customer), so the signature lives on the request row itself — no
* signature-rows table like bookings/CRSP contracts need for multi-role.
*/
@Injectable()
export class LastMileContractService {
private readonly logger = new Logger(LastMileContractService.name);
private compiledTemplate: Handlebars.TemplateDelegate | null = null;
constructor(
private readonly requestsRepository: LastMileRequestsRepository,
private readonly requestsService: LastMileRequestsService,
private readonly bookingsService: BookingsService,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly pdfService: ContractPdfService,
private readonly signaturesService: SignaturesService,
private readonly dataSource: DataSource,
) {}
async getContractView(id: string, viewerUserId?: string | null) {
const request = await this.requireApprovedRequest(id);
const booking = await this.requireBooking(request);
const view = await this.buildViewModel(request, booking);
const html = this.render(view);
const savedSignature = viewerUserId
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
: undefined;
return {
requestId: request.id,
bookingId: request.bookingId,
bookingReference: booking.reference,
status: request.status,
html,
customerSignedAt: request.customerSignedAt ?? null,
signerDisplayName: request.signerDisplayName ?? null,
canSign: !request.customerSignedAt,
savedSignature,
};
}
async streamContract(id: string) {
const request = await this.requireApprovedRequest(id);
const booking = await this.requireBooking(request);
const record = await this.upsertContractPdf(request, booking);
return this.filesService.streamById(record.id);
}
async sign(
id: string,
dto: SignLastMileContractDto,
signerUserId: string | null,
): Promise<LastMileRequest> {
const request = await this.requireApprovedRequest(id);
if (request.customerSignedAt) {
throw new BadRequestException('This last-mile contract is already signed');
}
const booking = await this.requireBooking(request);
if (signerUserId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(signerUserId);
if (companyId && booking.companyId && companyId !== booking.companyId) {
throw new BadRequestException('This request does not belong to your company');
}
}
// Drawn signature wins; otherwise fall back to the saved profile signature
// (same contract-signing convention as modules/contracts).
let imageBase64 = dto.signatureImageBase64;
if (!imageBase64 && signerUserId) {
const saved = await this.signaturesService.getForUser(signerUserId);
if (saved?.signatureImageUrl?.startsWith('data:')) {
imageBase64 = saved.signatureImageUrl;
}
}
if (!imageBase64) {
throw new BadRequestException(
'No signature image provided and no saved signature on your profile',
);
}
const buffer = this.decodeSignatureImage(imageBase64);
const sigFile = this.toUploadFile(
`signature-customer-${booking.reference ?? request.id}.png`,
'image/png',
buffer,
);
const fileRecord = await this.filesService.upsertByCode({
resourceId: request.id,
resource: FILE_RESOURCE,
code: 'signature_customer',
file: sigFile,
});
await this.requestsRepository.update(id, {
customerSignedAt: new Date(),
signerDisplayName: dto.signerDisplayName,
consentText: dto.consentText ?? null,
} as Partial<LastMileRequest>);
// Best-effort: keep the reusable profile signature fresh for next time.
if (signerUserId && dto.signatureImageBase64) {
try {
await this.signaturesService.upsertForUser({
userId: signerUserId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
} catch (err) {
this.logger.warn(`Could not save reusable signature for user ${signerUserId}: ${err}`);
}
}
const signed = (await this.requestsRepository.findById(id, {
relations: { booking: { company: true } },
}))!;
// Render + store the signed PDF, then invoice the advance. PDF failure must
// not block the invoice — the document re-renders on view/download.
try {
await this.upsertContractPdf(signed, booking, fileRecord);
} catch (err) {
this.logger.warn(
`Signed LM contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
);
}
await this.requestsService.generateAdvanceInvoice(signed);
return signed;
}
private async upsertContractPdf(
request: LastMileRequest,
booking: Booking,
signatureRecord?: FileRecord,
): Promise<FileRecord> {
const view = await this.buildViewModel(request, booking, signatureRecord);
const html = this.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const companyName = booking.company?.name ?? 'Customer';
const fileName = `LM_${companyName.replace(/[^A-Za-z0-9._-]+/g, '_')}.pdf`;
const file = this.toUploadFile(fileName, 'application/pdf', pdfBuffer);
return this.filesService.upsertByCode({
resourceId: request.id,
resource: FILE_RESOURCE,
code: 'contract',
file,
});
}
private async buildViewModel(
request: LastMileRequest,
booking: Booking,
signatureRecord?: FileRecord,
) {
const summary = request.contractSummary;
const containers = request.requestedContainerNumbers ?? [];
const cargoDescription =
booking.cargoFreeText || booking.cargoType?.cargoTypeName || null;
const departedRows: Array<{ departedAt: Date | null }> = await this.dataSource.query(
`SELECT departed_from_djibouti_at AS "departedAt"
FROM freight.import_djibouti_operations
WHERE train_schedule_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[request.trainScheduleId],
);
return {
companyName: booking.company?.name ?? 'Customer',
bookingReference: booking.reference ?? request.bookingId,
containerCount: containers.length || null,
containerList: containers.join(', '),
cargoDescription,
deliveryAddress: booking.lastMileDeliveryAddress ?? null,
trainDepartureDate: this.formatDate(departedRows[0]?.departedAt),
deliveryDate: this.formatDate(request.requestedDeliveryDate) ?? '—',
requestDate: this.formatDate(request.submittedAt ?? request.reminderSentAt ?? request.createdAt) ?? '—',
approvalDate: this.formatDate(request.reviewedAt) ?? '—',
currency: summary?.currency ?? booking.paymentCurrency ?? 'ETB',
rateLines: (summary?.lines ?? []).map((l) => ({
description: l.description,
amount: this.formatAmount(l.amount),
})),
estimatedKm: summary?.estimatedKm ?? null,
advanceAmount: this.formatAmount(
summary?.advanceAmount ?? request.approvedAdvanceAmount ?? 0,
),
signature: request.customerSignedAt
? {
signerDisplayName: request.signerDisplayName ?? '',
signedAt: this.formatDate(request.customerSignedAt) ?? '',
consentText: request.consentText ?? null,
imageUrl: await this.signatureImageDataUri(request, signatureRecord),
}
: null,
};
}
/** Signature PNG as a data URI so the PDF renderer needs no MinIO access. */
private async signatureImageDataUri(
request: LastMileRequest,
signatureRecord?: FileRecord,
): Promise<string | null> {
try {
const record =
signatureRecord ??
(await this.filesService.findByCode(request.id, FILE_RESOURCE, 'signature_customer'));
if (!record.url) return null;
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
return `data:image/png;base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
private render(view: Record<string, unknown>): string {
if (!this.compiledTemplate) {
const source = fs.readFileSync(
path.join(__dirname, '..', '..', 'contracts', 'templates', 'last-mile.hbs'),
'utf-8',
);
this.compiledTemplate = Handlebars.compile(source);
}
return this.compiledTemplate(view);
}
private async requireApprovedRequest(id: string): Promise<LastMileRequest> {
const request = await this.requestsService.findById(id);
if (request.status !== LastMileRequestStatus.Approved) {
throw new BadRequestException(
`The last-mile contract is available once the request is approved (current status: ${request.status})`,
);
}
return request;
}
private async requireBooking(request: LastMileRequest): Promise<Booking> {
const booking = await this.dataSource.manager.findOne(Booking, {
where: { id: request.bookingId },
relations: { company: true, cargoType: true },
});
if (!booking) throw new BadRequestException(`Booking ${request.bookingId} not found`);
return booking;
}
private formatDate(value?: Date | string | null): string | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString().slice(0, 10);
}
private formatAmount(value: number): string {
return Number(value).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
private toUploadFile(name: string, mimetype: string, buffer: Buffer): Express.Multer.File {
return {
fieldname: 'file',
originalname: name,
encoding: '7bit',
mimetype,
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
}
private decodeSignatureImage(base64: string): Buffer {
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
return Buffer.from(raw, 'base64');
}
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

@@ -0,0 +1,132 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { LastMileRequestStatus } from '@edr/types';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto';
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto';
import { LastMileContractService } from './last-mile-contract.service';
import { LastMileRequestsService } from './last-mile-requests.service';
@ApiTags('last-mile-requests')
@ApiBearerAuth()
@Controller('last-mile-requests')
export class LastMileRequestsController {
constructor(
private readonly requestsService: LastMileRequestsService,
private readonly contractService: LastMileContractService,
) {}
@Get()
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'List last-mile confirmation requests' })
findAll(
@Query('status') status?: string,
@Query('bookingId') bookingId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.requestsService.findAll({
status: status as LastMileRequestStatus | undefined,
bookingId,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
});
}
@Get('free-truck-count')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'Free (ACTIVE + unassigned) trucks — informational context for approval' })
freeTruckCount() {
return this.requestsService.freeTruckCount().then((count) => ({ count }));
}
@Get(':id/price-estimate')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({
summary:
'Rule-based last-mile price estimate (estimated km × live last-mile rates) — informational context for approval',
})
priceEstimate(@Param('id', ParseUUIDPipe) id: string) {
return this.requestsService.priceEstimate(id);
}
// Customer-facing like :id/submit — the service ownership-checks against the
// resolved company; staff may also open it (read-only view).
@Get(':id/contract/view')
@ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' })
contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
return this.contractService.getContractView(id, user?.id ?? null);
}
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download the LM contract PDF (LM_<CustomerName>.pdf)' })
async contractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${record.name}"`);
stream.pipe(res);
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' })
signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignLastMileContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.contractService.sign(id, dto, user?.id ?? null);
}
@Get(':id')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.requestsService.findById(id);
}
// No @BookingStaff — the customer (portal) fills this, not backoffice staff.
// TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service
// still cross-checks the request's booking against the resolved company.
@Post(':id/submit')
@ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" })
submit(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SubmitLastMileRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers, dto.deliveryDate);
}
@Post(':id/approve')
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveLastMileRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.approve(id, user?.id ?? null, dto.advanceAmount);
}
@Post(':id/reject')
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
@ApiOperation({ summary: 'Truck & Machinery chief rejects the request with a reason' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectLastMileRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.reject(id, user?.id ?? null, dto.reason);
}
}

View File

@@ -0,0 +1,38 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { FilesModule } from '../files/files.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { MinioModule } from '../minio/minio.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileContractService } from './last-mile-contract.service';
import { LastMileRequestsController } from './last-mile-requests.controller';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
import { LastMileRequestsService } from './last-mile-requests.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMileRequest]),
BillingModule,
forwardRef(() => BookingsModule),
LastMileModule,
NotificationInboxModule,
FilesModule,
MinioModule,
SignaturesModule,
],
controllers: [LastMileRequestsController],
providers: [
LastMileRequestsRepository,
LastMileRequestsService,
LastMileContractService,
ContractPdfService,
],
exports: [LastMileRequestsService],
})
export class LastMileRequestsModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { LastMileRequest } from './entities/last-mile-request.entity';
@Injectable()
export class LastMileRequestsRepository extends BaseRepository<LastMileRequest> {
constructor(
@InjectRepository(LastMileRequest)
repository: Repository<LastMileRequest>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,421 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { Freight, LastMileRequestStatus } from '@edr/types';
import {
LastMileCharge,
computeLastMileCharge,
lastMileShipmentShape,
} from '../../common/last-mile-charge.util';
import { estimateMileKm } from '../../common/mile-distance.util';
import { usesEdrMileService } from '../../common/mile-haulage.util';
import { RatesService } from '../rule-engine/services/rates.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BillingService } from '../billing/billing.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationAudience, NotificationPriority, NotificationType } from '@edr/types';
import { LastMileService } from '../last-mile/last-mile.service';
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
type ListFilter = {
status?: LastMileRequestStatus;
bookingId?: string;
page?: number;
pageSize?: number;
};
/** Just what remind() needs off a departed schedule — deliberately not the full
* `TrainSchedule` entity so this module never has to import train-scheduling code. */
type DepartedSchedule = { id: string; trainNumber?: string | null };
@Injectable()
export class LastMileRequestsService {
private readonly logger = new Logger(LastMileRequestsService.name);
constructor(
private readonly requestsRepository: LastMileRequestsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingsService: BookingsService,
private readonly lastMileService: LastMileService,
private readonly billing: BillingService,
private readonly notifications: NotificationInboxService,
private readonly ratesService: RatesService,
private readonly dataSource: DataSource,
) {}
/** Container numbers on the booking (upper-cased) — mirrors LastMileService's own helper. */
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
/**
* Poll for trains that have departed Djibouti and remind their eligible
* bookings. Deliberately a self-contained poller (raw SQL against
* `import_djibouti_operations`/`train_schedules`, no import of train-scheduling
* module code) rather than a hook inside `TrainSchedulingService.dispatchSchedule`
* — keeps this feature decoupled from that module entirely. `remindForDeparture`
* is idempotent per (bookingId, scheduleId), so re-scanning the same recent
* window on every tick is safe — a schedule already fully reminded is a no-op.
*/
@Cron('*/2 * * * *', { name: 'last-mile-request-departure-scan' })
async scanDepartedSchedules(): Promise<void> {
let schedules: DepartedSchedule[] = [];
try {
schedules = await this.dataSource.query(
`SELECT ts.id AS "id", ts.train_number AS "trainNumber"
FROM freight.import_djibouti_operations op
JOIN freight.train_schedules ts
ON ts.id = op.train_schedule_id AND ts.deleted_at IS NULL
WHERE op.deleted_at IS NULL
AND op.departed_from_djibouti_at IS NOT NULL
AND op.departed_from_djibouti_at > now() - interval '14 days'`,
);
} catch (err) {
this.logger.warn(`Failed to scan for departed schedules: ${(err as Error).message}`);
return;
}
for (const schedule of schedules) {
await this.remindForDeparture(schedule);
}
}
/**
* Fired for a train that has departed Djibouti (import direction). For every booking
* already loaded on this schedule that bought EDR last-mile, idempotently
* creates the AWAITING_CONFIRMATION request and reminds both the customer and
* the Truck & Machinery department. Fire-and-forget per booking — one bad
* booking must never block the rest of the departure notification.
*/
async remindForDeparture(schedule: DepartedSchedule): Promise<void> {
let bookingIds: string[] = [];
try {
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
`SELECT booking_id AS "bookingId"
FROM freight.train_schedule_bookings
WHERE train_schedule_id = $1 AND loading_status = 'LOADED' AND deleted_at IS NULL`,
[schedule.id],
);
bookingIds = rows.map((r) => r.bookingId);
} catch (err) {
this.logger.warn(`Failed to load schedule bookings for ${schedule.id}: ${(err as Error).message}`);
return;
}
if (!bookingIds.length) return;
for (const bookingId of bookingIds) {
try {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) continue;
if (
!usesEdrMileService({
tradeDirection: booking.tradeDirection,
firstMile: booking.firstMilePickupAddress ?? null,
lastMile: booking.lastMileDeliveryAddress ?? null,
})
) {
continue;
}
await this.remind(booking, schedule);
} catch (err) {
this.logger.warn(`Failed to remind booking ${bookingId} for schedule ${schedule.id}: ${(err as Error).message}`);
}
}
}
private async remind(booking: Booking, schedule: DepartedSchedule): Promise<void> {
const [existing] = await this.requestsRepository.findAll({
where: { bookingId: booking.id, trainScheduleId: schedule.id },
take: 1,
});
if (existing) return; // already reminded for this departure
const request = await this.requestsRepository.create({
bookingId: booking.id,
trainScheduleId: schedule.id,
status: LastMileRequestStatus.AwaitingConfirmation,
reminderSentAt: new Date(),
});
const trainLabel = schedule.trainNumber ? `train ${schedule.trainNumber}` : 'your train';
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Confirm your last-mile delivery',
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti. Confirm which containers go via EDR last-mile.`,
link: `/bookings/${booking.id}/last-mile-confirm?requestId=${request.id}`,
data: { bookingId: booking.id, requestId: request.id },
priority: NotificationPriority.HIGH,
});
}
void this.notifications.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Last-mile confirmation expected',
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti — awaiting the customer's last-mile confirmation.`,
link: `/dashboard/operations/last-mile?tab=requests`,
data: { bookingId: booking.id, requestId: request.id },
priority: NotificationPriority.HIGH,
});
}
async findAll(filter: ListFilter = {}): Promise<{
data: LastMileRequest[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const where: FindOptionsWhere<LastMileRequest> = {};
if (filter.status) where.status = filter.status;
if (filter.bookingId) where.bookingId = filter.bookingId;
const [data, total] = await this.requestsRepository.findAndCount({
where,
relations: { booking: { company: true } },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
};
}
async findById(id: string): Promise<LastMileRequest> {
const record = await this.requestsRepository.findById(id, {
relations: { booking: { company: true } },
});
if (!record) throw new NotFoundException(`Last-mile request ${id} not found`);
return record;
}
/**
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
* delivery point, straight-line) × the LIVE last-mile rate rules against the
* containers the customer confirmed (or the booking's bulk tonnage). All
* nulls when km or rate coverage is missing — the dialog then behaves as
* before (manually typed advance).
*/
async priceEstimate(id: string): Promise<{
estimatedKm: number | null;
mode: LastMileCharge['mode'] | null;
currency: string | null;
total: number | null;
lines: Array<{ description: string; amount: number }>;
}> {
const request = await this.findById(id);
const estimatedKm = await estimateMileKm(this.dataSource, request.bookingId, 'LAST');
if (!estimatedKm) {
return { estimatedKm: null, mode: null, currency: null, total: null, lines: [] };
}
const shape = await lastMileShipmentShape(
this.dataSource,
request.bookingId,
request.requestedContainerNumbers ?? [],
);
const charge = computeLastMileCharge({
...shape,
km: estimatedKm,
liveRates: await this.ratesService.findLiveRatesDetailed(),
});
return {
estimatedKm,
mode: charge?.mode ?? null,
currency: charge?.currency ?? null,
total: charge?.total ?? null,
lines: (charge?.lines ?? []).map(({ description, amount }) => ({ description, amount })),
};
}
/** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */
async freeTruckCount(): Promise<number> {
return this.dataSource.manager.count(Vehicle, {
where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE },
});
}
async submit(
id: string,
userId: string | null,
containerNumbers: string[],
deliveryDate: string,
): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.AwaitingConfirmation) {
throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`);
}
if (userId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
if (companyId && request.booking?.companyId && companyId !== request.booking.companyId) {
throw new BadRequestException('This request does not belong to your company');
}
}
const bookingNumbers = await this.bookingContainerNumbers(request.bookingId);
const selected = containerNumbers.map((n) => n.trim().toUpperCase());
const unknown = selected.filter((n) => !bookingNumbers.includes(n));
if (unknown.length) {
throw new BadRequestException(`Container(s) not on this booking: ${unknown.join(', ')}`);
}
await this.requestsRepository.update(id, {
requestedContainerNumbers: selected,
requestedDeliveryDate: deliveryDate,
status: LastMileRequestStatus.Submitted,
submittedByUserId: userId,
submittedAt: new Date(),
} as Partial<LastMileRequest>);
void this.notifications.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title: 'Last-mile request ready for review',
body: `Booking ${request.booking?.reference ?? request.bookingId} confirmed ${selected.length} container(s) for EDR last-mile.`,
link: `/dashboard/operations/last-mile?tab=requests`,
data: { bookingId: request.bookingId, requestId: request.id },
priority: NotificationPriority.NORMAL,
});
return this.findById(id);
}
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
}
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
// Idempotent per booking — reuses the record if one already exists.
const lastMile = await this.lastMileService.create({
bookingId: request.bookingId,
status: 'PAYMENT_PENDING',
advancedPayment: 0,
});
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
// once the customer has signed the LM contract — doc first, then payment.
// Snapshot the rate estimate now so the contract shows the numbers the
// chief actually approved against, immune to later rate edits.
const estimate = await this.priceEstimate(id);
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Approved,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
resultingLastMileId: lastMile.id,
approvedAdvanceAmount: advanceAmount,
contractSummary: { ...estimate, advanceAmount },
contractGeneratedAt: new Date(),
} as Partial<LastMileRequest>);
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.CONTRACT_STATUS,
title: 'Last-mile contract ready — view and sign',
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Review and sign the last-mile contract to receive your advance invoice.`,
link: `/bookings/${booking.id}/last-mile-contract?requestId=${id}`,
data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id },
priority: NotificationPriority.HIGH,
});
}
return this.findById(id);
}
/** The advance invoice, deferred from approve() until the LM contract is signed. */
async generateAdvanceInvoice(request: LastMileRequest): Promise<void> {
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
const advanceAmount = request.approvedAdvanceAmount;
if (!advanceAmount || !request.resultingLastMileId) {
throw new BadRequestException('Request has no approved advance to invoice');
}
await this.billing.generateInvoice({
// 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to
// match the existing source string LastMileInvoiceService/LastMileService
// already query by (findBySourceIds/findPayable/attachInvoices).
source: 'last_mile' as Freight.InvoiceSource,
sourceId: request.resultingLastMileId,
type: 'LAST_MILE_ADVANCE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId || '',
currency: booking.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'LAST_MILE_ADVANCE',
description: 'Last-mile delivery advance',
amount: advanceAmount,
},
],
totalAmount: advanceAmount,
});
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.INVOICE_ISSUED,
title: 'Last-mile contract signed — payment due',
body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`,
link: '/billing/invoices',
data: { bookingId: booking.id, requestId: request.id, lastMileId: request.resultingLastMileId },
priority: NotificationPriority.HIGH,
});
}
}
async reject(id: string, staffId: string | null, reason: string): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be rejected (current status: ${request.status})`);
}
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Rejected,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
rejectionReason: reason,
} as Partial<LastMileRequest>);
if (booking?.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Last-mile request rejected',
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was rejected: ${reason}`,
link: `/bookings/${booking.id}`,
data: { bookingId: booking.id, requestId: id },
priority: NotificationPriority.HIGH,
});
}
return this.findById(id);
}
}

View File

@@ -1,13 +1,16 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { RatesService } from '../rule-engine/services/rates.service';
import { LastMileRepository } from './last-mile.repository';
import { LastMile } from './entities/last-mile.entity';
@@ -26,6 +29,8 @@ export class LastMileInvoiceService {
constructor(
private readonly billing: BillingService,
private readonly lastMileRepo: LastMileRepository,
private readonly ratesService: RatesService,
private readonly dataSource: DataSource,
) {}
/**
@@ -54,6 +59,41 @@ export class LastMileInvoiceService {
return null;
}
// Rule-based pricing first (bulk per-ton-km / container distance bands
// against the exact km): when a LIVE last-mile rate covers the job, it —
// not the per-vehicle price/km — is the delivery fee, with its own
// currency and per-size breakdown. Same resolver setDistances used to
// write remainingPayment, recomputed here so a rate change between the
// two moments settles on the invoice's side.
const exactKm = Number(record.exactKm) || 0;
const rule =
exactKm > 0
? await ruleBasedLastMileCharge(
this.dataSource,
await this.ratesService.findLiveRatesDetailed(),
record.id,
exactKm,
)
: null;
if (rule && rule.total > 0) {
return this.billing.generateInvoice({
source: 'last_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: rule.currency,
lines: rule.lines.map((line) => ({
chargeType: 'DELIVERY',
description: line.description,
quantity: line.quantity,
unitRate: line.unitRate,
amount: line.amount,
})),
totalAmount: rule.total,
});
}
// numeric columns come back as strings — coerce before billing.
const totalAmount = Number(record.remainingPayment) || 0;
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {

View File

@@ -42,6 +42,7 @@ function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean })
{ query } as unknown as DataSource,
{ record: jest.fn() } as never, // history
{} as never, // billing
{ findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService
{} as never, // filesService
);

View File

@@ -13,6 +13,9 @@ import {
usesEdrMileService,
} from '../../common/mile-haulage.util';
import { attachMileFinancials } from '../../common/mile-financials.util';
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
import { estimateMileKm } from '../../common/mile-distance.util';
import { RatesService } from '../rule-engine/services/rates.service';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
@@ -69,6 +72,7 @@ export class LastMileService {
private readonly dataSource: DataSource,
private readonly history: FleetHistoryService,
private readonly billing: BillingService,
private readonly ratesService: RatesService,
private readonly filesService: FilesService,
) {}
@@ -426,7 +430,7 @@ export class LastMileService {
status: dto.status ?? 'READY_TO_TRANSIT',
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')),
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
@@ -456,8 +460,21 @@ export class LastMileService {
@OnEvent("last_mile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
// Invoice paid → the delivery is complete. Route through update() so it
// also frees the trucks + records history (same as "Mark Delivered").
if (payload.type === 'LAST_MILE_ADVANCE') {
// Advance paid → the leg becomes dispatchable, not delivered.
await this.update(payload.sourceId, {
status: 'READY_TO_TRANSIT',
advancedPayment: payload.totalAmount,
} as unknown as UpdateLastMileDto);
this.logger.log(
`Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`,
);
return;
}
if (payload.type !== 'DELIVERY_FEE') return;
// Delivery-fee invoice paid → the delivery is complete. Route through
// update() so it also frees the trucks + records history (same as
// "Mark Delivered").
await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
} catch (err) {
@@ -985,9 +1002,18 @@ export class LastMileService {
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
// Prefer the rule-based last-mile rate (bulk per-ton-km / container
// distance bands) over the per-vehicle price; the truck math stays as the
// fallback when no LIVE rule covers this job.
const rule = await ruleBasedLastMileCharge(
this.dataSource,
await this.ratesService.findLiveRatesDetailed(),
id,
total,
);
await this.lastMileRepository.update(id, {
exactKm: total,
remainingPayment: amount,
remainingPayment: rule?.total ?? amount,
} as any);
return this.findById(id);
}

View File

@@ -1,8 +1,8 @@
import {
IsEnum,
IsIn,
IsInt,
IsISO8601,
IsNumber,
IsOptional,
IsPositive,
IsString,
@@ -37,7 +37,9 @@ export class PaymentEventDto {
@ApiProperty() @IsString() referenceId!: string;
@ApiProperty() @IsString() merchantOrderId!: string;
@ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string;
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
// Major units, fractional (payment-api stores it as double precision) — an
// invoice of 12345.67 must not be rejected by an integer-only validator.
@ApiProperty() @IsNumber() @IsPositive() amountMinor!: number;
@ApiProperty() @IsString() currency!: string;
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;

View File

@@ -8,7 +8,8 @@ import {
} from '../entities/rate.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const CURRENCIES = ['USD'] as const;
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
const CURRENCIES = ['USD', 'ETB'] as const;
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
@@ -92,6 +93,28 @@ export class CreateRateDto {
@IsOptional()
@IsIn([...RATE_UNITS])
rateUnit?: string;
@ApiPropertyOptional({
description:
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
minKm?: number;
@ApiPropertyOptional({
description:
'Distance band end (km, exclusive). Null/omitted = open-ended band. Container last-mile rates only.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
maxKm?: number;
}
export class SubmitRateForApprovalDto {

View File

@@ -93,8 +93,12 @@ function unitsForShape(input: {
case 'INTERCITY':
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
case 'FIRST_MILE':
case 'LAST_MILE':
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
case 'LAST_MILE':
// PER_KM = container mode (banded by distance + container size),
// PER_TON_KM = bulk mode (tons × km × rate). Legacy units kept for
// existing rows.
return ['PER_KM', 'PER_TON_KM', 'PER_CONTAINER', 'PER_TON', 'FLAT'];
default:
return ['FLAT'];
}

View File

@@ -39,6 +39,8 @@ export const RATE_UNITS = [
'PER_ITEM',
'PER_CONTAINER',
'PER_KM',
// Last-mile bulk: price = tons × km × rateValue.
'PER_TON_KM',
'PER_INVOICE',
'FLAT',
] as const;
@@ -156,6 +158,17 @@ export class Rate extends BaseEntity {
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
rateUnit!: RateUnit;
/**
* Distance band for container last-mile rates (rateUnit = PER_KM, scoped by
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
* open-ended). NULL on every other rate shape.
*/
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
minKm?: number | null;
@Column({ name: 'max_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
maxKm?: number | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: RateStatus;

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
import { Yard } from './yard.entity';
/**
* GPS position of a yard (decimal degrees, WGS84). One record per yard;
* today only the five facility yards (Sebeta, GMP/Indode, Mojo, Adama,
* Dire Dawa) are seeded.
*/
@Entity({ schema: 'freight', name: 'yard_locations' })
@Index(['yardId'], { unique: true })
export class YardLocation extends BaseEntity {
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
@Column({ name: 'latitude', type: 'double precision' })
latitude!: number;
@Column({ name: 'longitude', type: 'double precision' })
longitude!: number;
}

View File

@@ -21,6 +21,8 @@ export interface IRatesRepository {
tradeDirection?: string | null;
originYardId?: string | null;
destinationYardId?: string | null;
/** Band start for container last-mile rates; omitted/null elsewhere. */
minKm?: number | null;
}): Promise<Rate | null>;
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;

View File

@@ -74,6 +74,7 @@ export class RatesRepository implements IRatesRepository {
tradeDirection?: string | null;
originYardId?: string | null;
destinationYardId?: string | null;
minKm?: number | null;
}): Promise<Rate | null> {
const qb = this.repo
.createQueryBuilder('rate')
@@ -111,6 +112,13 @@ export class RatesRepository implements IRatesRepository {
} else {
qb.andWhere('rate.destination_yard_id IS NULL');
}
// Band start distinguishes sibling last-mile bands, mirroring the
// COALESCE(min_km, -1) column of UQ_rates_pattern.
if (pattern.minKm !== null && pattern.minKm !== undefined) {
qb.andWhere('rate.min_km = :minKm', { minKm: pattern.minKm });
} else {
qb.andWhere('rate.min_km IS NULL');
}
return qb.getOne();
}

View File

@@ -43,6 +43,12 @@ export class YardsRepository implements IYardsRepository {
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
const qb = this.repo
.createQueryBuilder('yard')
// createQueryBuilder does NOT auto-apply the soft-delete filter that
// repo.find()/findOne() get for free — without this, a renamed/replaced
// yard (e.g. an old "DMP" superseded by a new one) still shows up
// alongside the live one in every picker built off this endpoint, and a
// route picked against the dead yard id never matches any LIVE rate.
.where('yard.deleted_at IS NULL')
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
.addOrderBy('yard.label', 'ASC');

View File

@@ -27,6 +27,7 @@ import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { Yard } from './entities/yard.entity';
import { YardDistance } from './entities/yard-distance.entity';
import { YardFacility } from './entities/yard-facility.entity';
import { YardLocation } from './entities/yard-location.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
@@ -88,6 +89,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
Yard,
YardDistance,
YardFacility,
YardLocation,
ShippingLine,
Rate,
ApprovalRule,

View File

@@ -37,6 +37,10 @@ const DIFFABLE_FIELDS = [
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
'destinationYardId',
// Container last-mile distance bands. Missing here, a band-range edit on a
// LIVE last-mile rate would diff to "nothing changed".
'minKm',
'maxKm',
] as const;
/**

View File

@@ -7,6 +7,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PaginatedResponse, YardCountry } from '@edr/types';
import { IsNull, Not } from 'typeorm';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
@@ -340,6 +341,113 @@ export class RatesService {
return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
}
/**
* Validate and normalise the last-mile band fields for a rate shape.
*
* Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row
* per distance band, price = tons × km × rate) and container (PER_KM — one
* row per container type per distance band, price = km × rate × quantity).
* A bandless bulk row (NULL minKm) is the legacy pre-band shape and still
* prices every distance. Every other rate shape has its band fields cleared,
* mirroring how yard scope is cleared for non-route rates.
*/
private resolveLastMileBand(input: {
appliesTo: Rate['appliesTo'];
rateUnit: Rate['rateUnit'];
containerTypeId: string | null;
minKm?: number | null;
maxKm?: number | null;
}): { minKm: number | null; maxKm: number | null } {
const { appliesTo, rateUnit, containerTypeId } = input;
if (appliesTo !== 'LAST_MILE') return { minKm: null, maxKm: null };
if (rateUnit === 'PER_TON_KM') {
if (containerTypeId) {
throw new BadRequestException(
'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.',
);
}
const minKm = input.minKm ?? null;
const maxKm = input.maxKm ?? null;
if (minKm === null) {
if (maxKm !== null) {
throw new BadRequestException(
'"To km" needs a "From km" — set the band start (0 for the first tier).',
);
}
// Legacy bandless bulk rate — prices every distance.
return { minKm: null, maxKm: null };
}
if (maxKm !== null && maxKm <= minKm) {
throw new BadRequestException('"To km" must be greater than "From km".');
}
return { minKm, maxKm };
}
if (rateUnit === 'PER_KM') {
if (!containerTypeId) {
throw new BadRequestException(
'A container last-mile rate must name the container type it covers (20ft and 40ft price differently).',
);
}
const minKm = input.minKm ?? null;
const maxKm = input.maxKm ?? null;
if (minKm === null) {
throw new BadRequestException(
'A container last-mile rate needs a distance band — set "From km" (0 for the first band).',
);
}
if (maxKm !== null && maxKm <= minKm) {
throw new BadRequestException('"To km" must be greater than "From km".');
}
return { minKm, maxKm };
}
// Legacy last-mile shapes (FLAT / PER_CONTAINER / PER_TON) carry no band.
return { minKm: null, maxKm: null };
}
/**
* Reject a last-mile band that overlaps an existing band for the same scope —
* container bands collide per container type (PER_KM), bulk bands collide
* with each other (PER_TON_KM, no container scope). Bands are half-open
* [minKm, maxKm) with NULL maxKm = open-ended, so 030 and 30∞ tile
* cleanly. Checked across every non-superseded row (DRAFT included) — two
* drafts with colliding bands would only defer the conflict to approval.
*/
private async assertNoBandOverlap(input: {
rateUnit: 'PER_KM' | 'PER_TON_KM';
containerTypeId: string | null;
minKm: number;
maxKm: number | null;
ignoreId?: string;
}): Promise<void> {
const siblings = await this.repository.findAll({
where: {
rateType: 'LAST_MILE',
rateUnit: input.rateUnit,
containerTypeId: input.containerTypeId ?? IsNull(),
status: Not('SUPERSEDED'),
},
});
const newMax = input.maxKm ?? Number.POSITIVE_INFINITY;
for (const sibling of siblings) {
if (sibling.id === input.ignoreId) continue;
if (sibling.minKm === null || sibling.minKm === undefined) continue; // legacy row, no band
const sibMin = Number(sibling.minKm);
const sibMax =
sibling.maxKm === null || sibling.maxKm === undefined
? Number.POSITIVE_INFINITY
: Number(sibling.maxKm);
if (input.minKm < sibMax && sibMin < newMax) {
const sibLabel = `${sibMin}${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`;
throw new ConflictException(
`This distance band overlaps the existing ${sibLabel} band for ${input.rateUnit === 'PER_TON_KM' ? 'bulk last-mile' : 'this container type'}. Adjust the ranges so each distance falls in exactly one band.`,
);
}
}
}
/**
* Reject a second rate with the same identity pattern (rateType + scope). With
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
@@ -360,6 +468,8 @@ export class RatesService {
tradeDirection: string | null;
originYardId: string | null;
destinationYardId: string | null;
/** Band start — part of the identity for container last-mile bands only. */
minKm?: number | null;
ignoreId?: string;
}): Promise<void> {
const existing = await this.repository.findByPattern(pattern);
@@ -438,6 +548,21 @@ export class RatesService {
cargoTypeId,
);
const { minKm, maxKm } = this.resolveLastMileBand({
appliesTo,
rateUnit,
containerTypeId,
minKm: dto.minKm,
maxKm: dto.maxKm,
});
if (
appliesTo === 'LAST_MILE' &&
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
minKm !== null
) {
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
}
await this.assertNoDuplicatePattern({
rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
@@ -446,6 +571,7 @@ export class RatesService {
tradeDirection,
originYardId,
destinationYardId,
minKm,
});
return this.repository.create({
@@ -457,9 +583,13 @@ export class RatesService {
tradeDirection,
originYardId,
destinationYardId,
currency: dto.currency ?? 'USD',
// Last-mile is the one shape sold in birr (or USD); everything else is
// USD by contract.
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
rateValue: dto.rateValue,
rateUnit,
minKm,
maxKm,
status: 'DRAFT',
proposedByStaffId,
});
@@ -622,6 +752,29 @@ export class RatesService {
);
updates.rateUnit = rateUnit;
const { minKm, maxKm } = this.resolveLastMileBand({
appliesTo,
rateUnit,
containerTypeId: updates.containerTypeId,
minKm: dto.minKm !== undefined ? dto.minKm : existing.minKm,
maxKm: dto.maxKm !== undefined ? dto.maxKm : existing.maxKm,
});
updates.minKm = minKm;
updates.maxKm = maxKm;
if (
appliesTo === 'LAST_MILE' &&
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
minKm !== null
) {
await this.assertNoBandOverlap({
rateUnit,
containerTypeId: updates.containerTypeId ?? null,
minKm,
maxKm,
ignoreId: id,
});
}
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({
rateType,
@@ -631,10 +784,14 @@ export class RatesService {
tradeDirection: updates.tradeDirection,
originYardId: updates.originYardId,
destinationYardId: updates.destinationYardId,
minKm,
ignoreId: id,
});
updates.currency = dto.currency ?? existing.currency ?? 'USD';
updates.currency =
appliesTo === 'LAST_MILE'
? (dto.currency ?? existing.currency ?? 'ETB')
: 'USD';
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
return updates;
}

View File

@@ -35,13 +35,24 @@ async function main() {
// Demo seeders are intentionally not AppModule providers (they'd run on every
// boot), so construct them against the app's DataSource instead of via DI.
const dataSource = app.get(DataSource);
await new PricingDataSeeder(dataSource).run();
await new IndodeFacilitySeeder(dataSource).run();
await new Batch14TestDataSeeder(dataSource).run();
await new Batch5TestDataSeeder(dataSource).run();
await new Batch7TestDataSeeder(dataSource).run();
await new Batch8TestDataSeeder(dataSource).run();
await new WarehouseDemoSeeder(dataSource).run();
// Each bucket is independent: a seeder that has drifted from the current
// schema shouldn't stop the rest of the demo data from landing.
const step = async (name: string, run: () => Promise<void>) => {
try {
await run();
} catch (error) {
console.warn(` ! ${name} skipped: ${error instanceof Error ? error.message : String(error)}`);
}
};
await step('PricingDataSeeder', () => new PricingDataSeeder(dataSource).run());
await step('IndodeFacilitySeeder', () => new IndodeFacilitySeeder(dataSource).run());
await step('Batch14TestDataSeeder', () => new Batch14TestDataSeeder(dataSource).run());
await step('Batch5TestDataSeeder', () => new Batch5TestDataSeeder(dataSource).run());
await step('Batch7TestDataSeeder', () => new Batch7TestDataSeeder(dataSource).run());
await step('Batch8TestDataSeeder', () => new Batch8TestDataSeeder(dataSource).run());
await step('WarehouseDemoSeeder', () => new WarehouseDemoSeeder(dataSource).run());
console.log('Warehouse demo data seeded.');
} finally {

View File

@@ -306,4 +306,5 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
{ key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] },
];

View File

@@ -227,6 +227,9 @@ export const MILE_PERMISSIONS: FreightPermissionSeed[] = [
perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'),
perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'),
perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'),
perm('d3b00001-0001-4000-8000-000000000009', 'edr_freight_app:last_mile:request_view', 'View last-mile confirmation requests'),
perm('d3b00001-0001-4000-8000-00000000000a', 'edr_freight_app:last_mile:request_review', 'Review last-mile confirmation requests (T&M dept)'),
perm('d3b00001-0001-4000-8000-00000000000b', 'edr_freight_app:last_mile:request_approve', 'Approve/reject last-mile confirmation requests'),
];
// F. Fleet — rail assets (splits the flat fleet:view/manage)
@@ -366,6 +369,7 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'),
perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'),
];
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
@@ -527,6 +531,11 @@ export const FREIGHT_PERMS = {
assignVehicles: 'edr_freight_app:last_mile:assign_vehicles',
setDistances: 'edr_freight_app:last_mile:set_distances',
generateInvoice: 'edr_freight_app:last_mile:generate_invoice',
// Pre-approval confirmation stage (Truck & Machinery department): view/review
// a submitted request, approve/reject it.
requestView: 'edr_freight_app:last_mile:request_view',
requestReview: 'edr_freight_app:last_mile:request_review',
requestApprove: 'edr_freight_app:last_mile:request_approve',
},
locomotives: {
view: 'edr_freight_app:locomotives:view',
@@ -700,6 +709,9 @@ export const FREIGHT_PERMS = {
manage: 'edr_freight_app:settings:dropdown:manage',
},
},
audit: {
view: 'edr_freight_app:audit:view',
},
staff: {
roles: {
view: 'edr_freight_app:staff:roles:view',
@@ -1011,6 +1023,17 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.bookings.operations,
]),
// Truck & Machinery chief: reviews and approves/rejects last-mile
// confirmation requests (the pre-approval gate ahead of vehicle assignment),
// plus enough fleet visibility to judge truck availability.
truckMachineryChief: dedupe([
FREIGHT_PERMS.lastMile.view,
FREIGHT_PERMS.lastMile.requestView,
FREIGHT_PERMS.lastMile.requestReview,
FREIGHT_PERMS.lastMile.requestApprove,
FREIGHT_PERMS.fleetDashboard.view,
FREIGHT_PERMS.vehicles.view,
]),
} as const;
/** Derive the module bucket from the resource segment of a permission key. */

View File

@@ -29,6 +29,7 @@ const STAFF_USERS = [
{ email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' },
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' },
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' },
{ email: 'tm-chief@edr.local', username: 'tm_chief', roleKey: 'edr_operations_officer', positionKey: 'truck_machinery_chief' },
] as const;
@Injectable()

View File

@@ -4,6 +4,11 @@ import { DataSource } from 'typeorm';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import {
CompanyProfile,
ProfileStatus,
ProfileType,
} from '../modules/companies/entities/company-profile.entity';
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
@@ -13,7 +18,8 @@ import { ServiceType } from '../modules/rule-engine/entities/service-type.entity
import { Yard } from '../modules/rule-engine/entities/yard.entity';
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
const COMPANY_TIN = 'PAIDMILE001';
// companies.tin is varchar(10) — an 11-char TIN 22001s the whole seeder.
const COMPANY_TIN = 'PAIDMILE01';
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
const YARDS = [
@@ -183,6 +189,22 @@ export class PaidImportExportMileDemoSeeder {
manager.getRepository(ContainerType).find(),
]);
// bookings.company_profile_id is NOT NULL — the demo company needs an
// approved importer profile of its own (no unique key to upsert on).
const profileRepo = manager.getRepository(CompanyProfile);
const companyProfile =
(await profileRepo.findOne({
where: { companyId: company.id, type: ProfileType.importer },
})) ??
(await profileRepo.save(
profileRepo.create({
companyId: company.id,
type: ProfileType.importer,
status: ProfileStatus.Active,
businessLicense: 'PMD-LIC-0001',
}),
));
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
const containerTypeByCode = new Map(
containerTypes.map((containerType) => [containerType.code, containerType]),
@@ -206,6 +228,7 @@ export class PaidImportExportMileDemoSeeder {
{
reference: demoBooking.reference,
companyId: company.id,
companyProfileId: companyProfile.id,
status: 'APPROVED',
scheduledDate: new Date(demoBooking.scheduledDate),
estimatedShipmentDate: new Date(demoBooking.scheduledDate),