mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
Merge pull request #1141 from Tria-plc/lastmilerequest
Lastmile Payment in Birr
This commit is contained in:
@@ -85,6 +85,48 @@ describe('computeLastMileCharge', () => {
|
|||||||
expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' });
|
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', () => {
|
it('returns null on mixed currencies, unknown km, and uncovered freight types', () => {
|
||||||
const usd40 = rate({ ...band40a, currency: 'USD' });
|
const usd40 = rate({ ...band40a, currency: 'USD' });
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -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.
|
* 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
|
* 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 → price = km × rate × quantity, summed across sizes.
|
||||||
* 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
|
* 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) —
|
* container size without a matching band, mixed currencies, km/tons unknown) —
|
||||||
@@ -56,7 +58,15 @@ export function computeLastMileCharge(input: {
|
|||||||
|
|
||||||
if (freightType === 'BULK') {
|
if (freightType === 'BULK') {
|
||||||
if (!tons || tons <= 0) return null;
|
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;
|
if (!rate) return null;
|
||||||
const unitRate = Number(rate.rateValue);
|
const unitRate = Number(rate.rateValue);
|
||||||
const amount = round2(tons * km * unitRate);
|
const amount = round2(tons * km * unitRate);
|
||||||
|
|||||||
@@ -460,8 +460,21 @@ export class LastMileService {
|
|||||||
@OnEvent("last_mile.invoice.paid")
|
@OnEvent("last_mile.invoice.paid")
|
||||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Invoice paid → the delivery is complete. Route through update() so it
|
if (payload.type === 'LAST_MILE_ADVANCE') {
|
||||||
// also frees the trucks + records history (same as "Mark Delivered").
|
// 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);
|
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`);
|
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export class YardsRepository implements IYardsRepository {
|
|||||||
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('yard')
|
.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')
|
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
|
||||||
.addOrderBy('yard.label', 'ASC');
|
.addOrderBy('yard.label', 'ASC');
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||||
import { Not } from 'typeorm';
|
import { IsNull, Not } from 'typeorm';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.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.
|
* 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 =
|
* Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row
|
||||||
* tons × km × rate, one row, no scope) and container (PER_KM — one row per
|
* per distance band, price = tons × km × rate) and container (PER_KM — one
|
||||||
* container type per distance band, price = km × rate × quantity). Every
|
* row per container type per distance band, price = km × rate × quantity).
|
||||||
* other rate shape has its band fields cleared, mirroring how yard scope is
|
* A bandless bulk row (NULL minKm) is the legacy pre-band shape and still
|
||||||
* cleared for non-route rates.
|
* 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: {
|
private resolveLastMileBand(input: {
|
||||||
appliesTo: Rate['appliesTo'];
|
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.',
|
'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') {
|
if (rateUnit === 'PER_KM') {
|
||||||
@@ -393,14 +408,16 @@ export class RatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a container last-mile band that overlaps an existing band for the
|
* Reject a last-mile band that overlaps an existing band for the same scope —
|
||||||
* same container type. Bands are half-open [minKm, maxKm) with NULL maxKm =
|
* container bands collide per container type (PER_KM), bulk bands collide
|
||||||
* open-ended, so 0–30 and 30–∞ tile cleanly. Checked across every
|
* with each other (PER_TON_KM, no container scope). Bands are half-open
|
||||||
* non-superseded row (DRAFT included) — two drafts with colliding bands would
|
* [minKm, maxKm) with NULL maxKm = open-ended, so 0–30 and 30–∞ tile
|
||||||
* only defer the conflict to approval.
|
* 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: {
|
private async assertNoBandOverlap(input: {
|
||||||
containerTypeId: string;
|
rateUnit: 'PER_KM' | 'PER_TON_KM';
|
||||||
|
containerTypeId: string | null;
|
||||||
minKm: number;
|
minKm: number;
|
||||||
maxKm: number | null;
|
maxKm: number | null;
|
||||||
ignoreId?: string;
|
ignoreId?: string;
|
||||||
@@ -408,8 +425,8 @@ export class RatesService {
|
|||||||
const siblings = await this.repository.findAll({
|
const siblings = await this.repository.findAll({
|
||||||
where: {
|
where: {
|
||||||
rateType: 'LAST_MILE',
|
rateType: 'LAST_MILE',
|
||||||
rateUnit: 'PER_KM',
|
rateUnit: input.rateUnit,
|
||||||
containerTypeId: input.containerTypeId,
|
containerTypeId: input.containerTypeId ?? IsNull(),
|
||||||
status: Not('SUPERSEDED'),
|
status: Not('SUPERSEDED'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -425,7 +442,7 @@ export class RatesService {
|
|||||||
if (input.minKm < sibMax && sibMin < newMax) {
|
if (input.minKm < sibMax && sibMin < newMax) {
|
||||||
const sibLabel = `${sibMin}–${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`;
|
const sibLabel = `${sibMin}–${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`;
|
||||||
throw new ConflictException(
|
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,
|
minKm: dto.minKm,
|
||||||
maxKm: dto.maxKm,
|
maxKm: dto.maxKm,
|
||||||
});
|
});
|
||||||
if (appliesTo === 'LAST_MILE' && rateUnit === 'PER_KM' && containerTypeId && minKm !== null) {
|
if (
|
||||||
await this.assertNoBandOverlap({ containerTypeId, minKm, maxKm });
|
appliesTo === 'LAST_MILE' &&
|
||||||
|
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
|
||||||
|
minKm !== null
|
||||||
|
) {
|
||||||
|
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
@@ -742,12 +763,12 @@ export class RatesService {
|
|||||||
updates.maxKm = maxKm;
|
updates.maxKm = maxKm;
|
||||||
if (
|
if (
|
||||||
appliesTo === 'LAST_MILE' &&
|
appliesTo === 'LAST_MILE' &&
|
||||||
rateUnit === 'PER_KM' &&
|
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
|
||||||
updates.containerTypeId &&
|
|
||||||
minKm !== null
|
minKm !== null
|
||||||
) {
|
) {
|
||||||
await this.assertNoBandOverlap({
|
await this.assertNoBandOverlap({
|
||||||
containerTypeId: updates.containerTypeId,
|
rateUnit,
|
||||||
|
containerTypeId: updates.containerTypeId ?? null,
|
||||||
minKm,
|
minKm,
|
||||||
maxKm,
|
maxKm,
|
||||||
ignoreId: id,
|
ignoreId: id,
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ const RuleEngineCardGrid = ({
|
|||||||
{col.header}:
|
{col.header}:
|
||||||
</Text>
|
</Text>
|
||||||
<div style={{ textAlign: "right", flex: 1 }}>
|
<div style={{ textAlign: "right", flex: 1 }}>
|
||||||
{formatCell(displayValue, col.format)}
|
{formatCell(displayValue, col.format, record)}
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ const buildInitialValues = (
|
|||||||
} else {
|
} else {
|
||||||
values[field.name] = raw;
|
values[field.name] = raw;
|
||||||
}
|
}
|
||||||
|
} else if (field.defaultValue !== undefined) {
|
||||||
|
values[field.name] = field.defaultValue;
|
||||||
} else if (field.type === "boolean") {
|
} else if (field.type === "boolean") {
|
||||||
values[field.name] = false;
|
values[field.name] = false;
|
||||||
} else if (field.type === "number") {
|
} else if (field.type === "number") {
|
||||||
|
|||||||
@@ -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<string, unknown>,
|
||||||
|
): ReactNode => {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return <Text size="sm" c="dimmed">—</Text>;
|
return <Text size="sm" c="dimmed">—</Text>;
|
||||||
}
|
}
|
||||||
@@ -109,9 +115,10 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
|
|||||||
|
|
||||||
if (format === "currency") {
|
if (format === "currency") {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
|
const code = typeof row?.currency === "string" ? row.currency : "USD";
|
||||||
return (
|
return (
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`}
|
{Number.isNaN(num) ? String(value) : `${code} ${num.toLocaleString()}`}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,34 +306,27 @@ const RuleEngineResourcePage = () => {
|
|||||||
|
|
||||||
const formFields = useMemo(() => {
|
const formFields = useMemo(() => {
|
||||||
if (!config) return [];
|
if (!config) return [];
|
||||||
// Last-mile container bands: creating uses the multi-row tier list (one
|
// Last-mile bands (both modes): creating uses the multi-row tier list (one
|
||||||
// rate per tier); editing an existing band row keeps the single
|
// rate per tier, each tier carrying its own rate value); editing an
|
||||||
// From/To/value fields (a rate row IS one band).
|
// existing band row keeps the single From/To/value fields (a rate row IS
|
||||||
|
// one band).
|
||||||
const bandFields = config.formFields.filter((field) => {
|
const bandFields = config.formFields.filter((field) => {
|
||||||
if (config.slug !== "rates") return true;
|
if (config.slug !== "rates") return true;
|
||||||
if (field.type === "tierList") return !editing;
|
if (field.type === "tierList") return !editing;
|
||||||
if (editing) return true;
|
if (editing) return true;
|
||||||
return field.name !== "minKm" && field.name !== "maxKm";
|
// 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
|
||||||
return bandFields.map((field) => {
|
// own showIf).
|
||||||
// On create, the tier rows carry the per-band rate values — the single
|
|
||||||
// last-mile "Rate value" field then only applies to bulk mode.
|
|
||||||
if (
|
if (
|
||||||
config.slug === "rates" &&
|
|
||||||
!editing &&
|
|
||||||
field.name === "rateValue" &&
|
field.name === "rateValue" &&
|
||||||
field.showWhen?.field === "appliesTo" &&
|
field.showWhen?.field === "appliesTo" &&
|
||||||
field.showWhen.equals.includes("LAST_MILE")
|
field.showWhen.equals.includes("LAST_MILE")
|
||||||
) {
|
) {
|
||||||
return {
|
return false;
|
||||||
...field,
|
|
||||||
showWhen: undefined,
|
|
||||||
showIf: (values: Record<string, unknown>) =>
|
|
||||||
values.appliesTo === "LAST_MILE" && values.lastMileMode === "BULK",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return field;
|
return field.name !== "minKm" && field.name !== "maxKm";
|
||||||
}).map((field) => {
|
});
|
||||||
|
return bandFields.map((field) => {
|
||||||
if (isPriorityRules && field.name === "minWagonCount") {
|
if (isPriorityRules && field.name === "minWagonCount") {
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
@@ -501,7 +494,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
header: col.header,
|
header: col.header,
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => {
|
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
|
// 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.
|
// live value stays the headline because it is what still gets charged.
|
||||||
if (!isRates || col.accessorKey !== "rateValue") return cell;
|
if (!isRates || col.accessorKey !== "rateValue") return cell;
|
||||||
@@ -621,19 +614,15 @@ const RuleEngineResourcePage = () => {
|
|||||||
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||||
...(isLastMile
|
...(isLastMile
|
||||||
? lastMileMode === "BULK"
|
? {
|
||||||
? {
|
// Empty "To km" means an open-ended band — send null so an
|
||||||
rateUnit: "PER_TON_KM",
|
// edit can clear a previously-set ceiling. On create the tier
|
||||||
containerTypeId: undefined,
|
// spread below overrides the band fields per tier.
|
||||||
minKm: undefined,
|
maxKm: values.maxKm ?? null,
|
||||||
maxKm: undefined,
|
...(lastMileMode === "BULK"
|
||||||
}
|
? { rateUnit: "PER_TON_KM", containerTypeId: undefined }
|
||||||
: {
|
: { rateUnit: "PER_KM" }),
|
||||||
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,
|
|
||||||
}
|
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
// Editing a LIVE rate files a change request — the rate keeps charging
|
// Editing a LIVE rate files a change request — the rate keeps charging
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export interface FormFieldDef {
|
|||||||
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
||||||
*/
|
*/
|
||||||
getInitialValue?: (record: Record<string, unknown>) => unknown;
|
getInitialValue?: (record: Record<string, unknown>) => 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
|
* 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
|
* every render and the input is locked. Used for the priority-rule min
|
||||||
@@ -297,8 +299,8 @@ export const rateUnitOptions = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
|
{ label: "ETB (Birr)", value: "ETB" },
|
||||||
{ label: "USD", value: "USD" },
|
{ label: "USD", value: "USD" },
|
||||||
{ label: "ETB", value: "ETB" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRIORITY_CONFIG_TYPES = [
|
const PRIORITY_CONFIG_TYPES = [
|
||||||
@@ -1001,7 +1003,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "0",
|
placeholder: "0",
|
||||||
description: "Band start (inclusive). Use 0 for the first band.",
|
description: "Band start (inclusive). Use 0 for the first band.",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
v.appliesTo === "LAST_MILE" &&
|
||||||
|
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "maxKm",
|
name: "maxKm",
|
||||||
@@ -1011,7 +1014,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "Leave empty for no upper limit",
|
placeholder: "Leave empty for no upper limit",
|
||||||
description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.",
|
description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
v.appliesTo === "LAST_MILE" &&
|
||||||
|
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "currency",
|
name: "currency",
|
||||||
@@ -1020,6 +1024,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
required: true,
|
required: true,
|
||||||
options: CURRENCIES,
|
options: CURRENCIES,
|
||||||
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||||
|
// Birr is the norm for domestic trucking; USD stays selectable.
|
||||||
|
defaultValue: "ETB",
|
||||||
getInitialValue: (record) => String(record.currency ?? "ETB"),
|
getInitialValue: (record) => String(record.currency ?? "ETB"),
|
||||||
},
|
},
|
||||||
// ── Distance tiers (create only — the page swaps this for the single
|
// ── Distance tiers (create only — the page swaps this for the single
|
||||||
@@ -1031,9 +1037,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
type: "tierList",
|
type: "tierList",
|
||||||
required: true,
|
required: true,
|
||||||
description:
|
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) =>
|
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
|
// ── Container type — Container freight, container-kind intercity, and
|
||||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||||
|
|||||||
Reference in New Issue
Block a user