mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -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,
|
||||
|
||||
163
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal file
163
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal 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 0–30
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
212
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal file
212
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal 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 })),
|
||||
};
|
||||
}
|
||||
17
apps/edr-freight-api/src/common/mile-distance.util.spec.ts
Normal file
17
apps/edr-freight-api/src/common/mile-distance.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
apps/edr-freight-api/src/common/mile-distance.util.ts
Normal file
48
apps/edr-freight-api/src/common/mile-distance.util.ts
Normal 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;
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
114
apps/edr-freight-api/src/contracts/templates/last-mile.hbs
Normal file
114
apps/edr-freight-api/src/contracts/templates/last-mile.hbs
Normal 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 & 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>
|
||||
@@ -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")
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
32
apps/edr-freight-api/src/modules/audit/audit.controller.ts
Normal file
32
apps/edr-freight-api/src/modules/audit/audit.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
apps/edr-freight-api/src/modules/audit/audit.module.ts
Normal file
13
apps/edr-freight-api/src/modules/audit/audit.module.ts
Normal 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 {}
|
||||
70
apps/edr-freight-api/src/modules/audit/audit.service.ts
Normal file
70
apps/edr-freight-api/src/modules/audit/audit.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 0–30 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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] },
|
||||
];
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user