mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 02:23:25 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user