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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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