diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts index e2ff1bfd9..e60bf4f4e 100644 --- a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts @@ -85,6 +85,48 @@ describe('computeLastMileCharge', () => { expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' }); }); + it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null }); + const near = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 12, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 }); + const boundary = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 30, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(boundary).toMatchObject({ total: 10 * 30 * 22 }); + }); + + it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const fallback = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear, bulkRate], // bulkRate has no band + }); + expect(fallback).toMatchObject({ total: 10 * 50 * 25 }); + expect( + computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear], + }), + ).toBeNull(); + }); + it('returns null on mixed currencies, unknown km, and uncovered freight types', () => { const usd40 = rate({ ...band40a, currency: 'USD' }); expect( diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.ts index 706e21238..055e8e5f9 100644 --- a/apps/edr-freight-api/src/common/last-mile-charge.util.ts +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.ts @@ -30,10 +30,12 @@ const round2 = (n: number): number => Math.round(n * 100) / 100; /** * Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in. * - * BULK: one PER_TON_KM rate → price = tons × km × rate. + * BULK: the PER_TON_KM rate whose distance band holds the km (a legacy + * bandless row — NULL minKm — is the fallback and prices every distance) → + * price = tons × km × rate. * CONTAINER: per container size, the PER_KM rate whose distance band holds the - * km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price = - * km × rate × quantity, summed across sizes. + * km → price = km × rate × quantity, summed across sizes. + * Bands are half-open [minKm, maxKm), NULL maxKm = open-ended. * * Returns null whenever the rules don't fully cover the shipment (no rate, a * container size without a matching band, mixed currencies, km/tons unknown) — @@ -56,7 +58,15 @@ export function computeLastMileCharge(input: { if (freightType === 'BULK') { if (!tons || tons <= 0) return null; - const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM'); + const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM'); + const rate = + bulkRates.find( + (r) => + r.minKm !== null && + r.minKm !== undefined && + Number(r.minKm) <= km && + (r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)), + ) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined); if (!rate) return null; const unitRate = Number(rate.rateValue); const amount = round2(tons * km * unitRate); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index ec7d4ccf6..8ed5ae8aa 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -460,8 +460,21 @@ export class LastMileService { @OnEvent("last_mile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { 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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts index 5db5b72ae..b99b33a29 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -43,6 +43,12 @@ export class YardsRepository implements IYardsRepository { findPaged(query: ListYardsQueryDto): Promise> { 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'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 5645334e5..971097377 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -7,7 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; -import { Not } from 'typeorm'; +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'; @@ -344,11 +344,12 @@ export class RatesService { /** * Validate and normalise the last-mile band fields for a rate shape. * - * Last-mile rates come in two calculation modes: bulk (PER_TON_KM — price = - * tons × km × rate, one row, no scope) and container (PER_KM — one row per - * container type per distance band, price = km × rate × quantity). Every - * other rate shape has its band fields cleared, mirroring how yard scope is - * cleared for non-route rates. + * 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']; @@ -366,7 +367,21 @@ export class RatesService { 'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.', ); } - return { minKm: null, maxKm: null }; + 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') { @@ -393,14 +408,16 @@ export class RatesService { } /** - * Reject a container last-mile band that overlaps an existing band for the - * same container type. 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. + * 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: { - containerTypeId: string; + rateUnit: 'PER_KM' | 'PER_TON_KM'; + containerTypeId: string | null; minKm: number; maxKm: number | null; ignoreId?: string; @@ -408,8 +425,8 @@ export class RatesService { const siblings = await this.repository.findAll({ where: { rateType: 'LAST_MILE', - rateUnit: 'PER_KM', - containerTypeId: input.containerTypeId, + rateUnit: input.rateUnit, + containerTypeId: input.containerTypeId ?? IsNull(), status: Not('SUPERSEDED'), }, }); @@ -425,7 +442,7 @@ export class RatesService { 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 this container type. Adjust the ranges so each distance falls in exactly one band.`, + `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.`, ); } } @@ -538,8 +555,12 @@ export class RatesService { minKm: dto.minKm, maxKm: dto.maxKm, }); - if (appliesTo === 'LAST_MILE' && rateUnit === 'PER_KM' && containerTypeId && minKm !== null) { - await this.assertNoBandOverlap({ containerTypeId, minKm, maxKm }); + if ( + appliesTo === 'LAST_MILE' && + (rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') && + minKm !== null + ) { + await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm }); } await this.assertNoDuplicatePattern({ @@ -742,12 +763,12 @@ export class RatesService { updates.maxKm = maxKm; if ( appliesTo === 'LAST_MILE' && - rateUnit === 'PER_KM' && - updates.containerTypeId && + (rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') && minKm !== null ) { await this.assertNoBandOverlap({ - containerTypeId: updates.containerTypeId, + rateUnit, + containerTypeId: updates.containerTypeId ?? null, minKm, maxKm, ignoreId: id, diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx index 392bf6dfb..762f5bc84 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx @@ -234,7 +234,7 @@ const RuleEngineCardGrid = ({ {col.header}:
- {formatCell(displayValue, col.format)} + {formatCell(displayValue, col.format, record)}
); diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index d205aae26..4684a3ad1 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -127,6 +127,8 @@ const buildInitialValues = ( } else { values[field.name] = raw; } + } else if (field.defaultValue !== undefined) { + values[field.name] = field.defaultValue; } else if (field.type === "boolean") { values[field.name] = false; } else if (field.type === "number") { diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx index 173c75b5f..529f53a43 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx @@ -17,7 +17,13 @@ const extractLabel = (value: unknown): string | null => { ); }; -export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => { +export const formatCell = ( + value: unknown, + format?: ColumnFormat, + // The row the cell came from — currency amounts read their code off it so a + // last-mile rate priced in birr does not render as USD. + row?: Record, +): ReactNode => { if (value === null || value === undefined || value === "") { return ; } @@ -109,9 +115,10 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => if (format === "currency") { const num = Number(value); + const code = typeof row?.currency === "string" ? row.currency : "USD"; return ( - {Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`} + {Number.isNaN(num) ? String(value) : `${code} ${num.toLocaleString()}`} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 3a0a36ce4..885fcb82b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -306,34 +306,27 @@ const RuleEngineResourcePage = () => { const formFields = useMemo(() => { if (!config) return []; - // Last-mile container bands: creating uses the multi-row tier list (one - // rate per tier); editing an existing band row keeps the single - // From/To/value fields (a rate row IS one band). + // Last-mile bands (both modes): creating uses the multi-row tier list (one + // rate per tier, each tier carrying its own rate value); editing an + // existing band row keeps the single From/To/value fields (a rate row IS + // one band). const bandFields = config.formFields.filter((field) => { if (config.slug !== "rates") return true; if (field.type === "tierList") return !editing; if (editing) return true; - return field.name !== "minKm" && field.name !== "maxKm"; - }); - return bandFields.map((field) => { - // On create, the tier rows carry the per-band rate values — the single - // last-mile "Rate value" field then only applies to bulk mode. + // On create the tier rows carry From/To/value — drop the single fields, + // including the last-mile "Rate value" (the non-last-mile one keeps its + // own showIf). if ( - config.slug === "rates" && - !editing && field.name === "rateValue" && field.showWhen?.field === "appliesTo" && field.showWhen.equals.includes("LAST_MILE") ) { - return { - ...field, - showWhen: undefined, - showIf: (values: Record) => - values.appliesTo === "LAST_MILE" && values.lastMileMode === "BULK", - }; + return false; } - return field; - }).map((field) => { + return field.name !== "minKm" && field.name !== "maxKm"; + }); + return bandFields.map((field) => { if (isPriorityRules && field.name === "minWagonCount") { return { ...field, @@ -501,7 +494,7 @@ const RuleEngineResourcePage = () => { header: col.header, meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const cell = formatCell(row.original[col.accessorKey], col.format); + const cell = formatCell(row.original[col.accessorKey], col.format, row.original); // On the rate column, show the proposed value under the live one — the // live value stays the headline because it is what still gets charged. if (!isRates || col.accessorKey !== "rateValue") return cell; @@ -621,19 +614,15 @@ const RuleEngineResourcePage = () => { currency: isLastMile ? (values.currency ?? "ETB") : "USD", trigger: isSurcharge ? values.trigger : "ALWAYS", ...(isLastMile - ? lastMileMode === "BULK" - ? { - rateUnit: "PER_TON_KM", - containerTypeId: undefined, - minKm: undefined, - maxKm: undefined, - } - : { - rateUnit: "PER_KM", - // Empty "To km" means an open-ended band — send null so an - // edit can clear a previously-set ceiling. - maxKm: values.maxKm ?? null, - } + ? { + // Empty "To km" means an open-ended band — send null so an + // edit can clear a previously-set ceiling. On create the tier + // spread below overrides the band fields per tier. + maxKm: values.maxKm ?? null, + ...(lastMileMode === "BULK" + ? { rateUnit: "PER_TON_KM", containerTypeId: undefined } + : { rateUnit: "PER_KM" }), + } : {}), }; // Editing a LIVE rate files a change request — the rate keeps charging diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index a51b542c2..d5998515e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -70,6 +70,8 @@ export interface FormFieldDef { * relation list (`wagonTypeIds` read from `record.wagonTypes`). */ getInitialValue?: (record: Record) => unknown; + /** Pre-selected value on create (no record yet) — e.g. last-mile currency = ETB. */ + defaultValue?: string; /** * Fully derived field: its value is computed from the live form values on * every render and the input is locked. Used for the priority-rule min @@ -297,8 +299,8 @@ export const rateUnitOptions = ( }; const CURRENCIES = [ + { label: "ETB (Birr)", value: "ETB" }, { label: "USD", value: "USD" }, - { label: "ETB", value: "ETB" }, ]; const PRIORITY_CONFIG_TYPES = [ @@ -1001,7 +1003,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "0", description: "Band start (inclusive). Use 0 for the first band.", showIf: (v) => - v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + v.appliesTo === "LAST_MILE" && + (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, { name: "maxKm", @@ -1011,7 +1014,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Leave empty for no upper limit", description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.", showIf: (v) => - v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + v.appliesTo === "LAST_MILE" && + (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, { name: "currency", @@ -1020,6 +1024,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ required: true, options: CURRENCIES, showWhen: { field: "appliesTo", equals: ["LAST_MILE"] }, + // Birr is the norm for domestic trucking; USD stays selectable. + defaultValue: "ETB", getInitialValue: (record) => String(record.currency ?? "ETB"), }, // ── Distance tiers (create only — the page swaps this for the single @@ -1031,9 +1037,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ type: "tierList", required: true, description: - "One rate per distance range. To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.", + "One rate per distance range — the rate value is per km (container mode) or per ton per km (bulk mode). To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.", showIf: (v) => - v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + v.appliesTo === "LAST_MILE" && + (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, // ── Container type — Container freight, container-kind intercity, and // the empty-container return surcharge (20ft vs 40ft price differently) ─