mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
add lashing surcharge for cargo types with hasLashing flag
add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
@@ -13,6 +13,9 @@ export interface RenderedClause {
|
||||
/** A dynamic article ready for the Handlebars template. */
|
||||
export interface RenderedArticle {
|
||||
number: number;
|
||||
/** Stable article id from the template (e.g. "pricing") — lets the layout
|
||||
* inject the live rate schedule table under the pricing article. */
|
||||
id: string;
|
||||
title: string;
|
||||
/** Set (instead of clauses) when the body is a single plain paragraph. */
|
||||
paragraph?: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ContractDynamicTemplateView,
|
||||
ContractViewModel,
|
||||
} from './contract-view-model.builder';
|
||||
import { RateSchedule } from './contract-rate-schedule.builder';
|
||||
|
||||
/**
|
||||
* Signature row for the contract PDF. Mirrors the booking builder's
|
||||
@@ -135,6 +136,7 @@ export class ContractDocumentViewModelBuilder {
|
||||
}
|
||||
|
||||
const pricing = this.buildPricing(contract);
|
||||
const rateSchedule = this.buildRateSchedule(pricing);
|
||||
const signatures = await this.loadSignatures(contractId);
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
@@ -177,6 +179,7 @@ export class ContractDocumentViewModelBuilder {
|
||||
},
|
||||
schedule: this.buildSchedule(contract),
|
||||
pricing: pricing as unknown as ContractViewModel['pricing'],
|
||||
rateSchedule,
|
||||
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||||
// view-model's narrower CUSTOMER|STAFF role union.
|
||||
signatures: signatures as unknown as ContractViewModel['signatures'],
|
||||
@@ -230,6 +233,40 @@ export class ContractDocumentViewModelBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A rate schedule for the contract PDF, sourced from the contract's own frozen
|
||||
* unit rates (its agreed lane prices) rather than the global rate config — a
|
||||
* signed contract must show the prices it was signed on. Rendered as freight
|
||||
* lanes labelled with the contract's primary origin → destination route.
|
||||
*/
|
||||
private buildRateSchedule(pricing: ContractUnitRateSchedule): RateSchedule {
|
||||
const route = `${pricing.originLabel} → ${pricing.destinationLabel}`;
|
||||
const freightLanes = pricing.unitRates.map((line) => ({
|
||||
route,
|
||||
cargo: line.label,
|
||||
currency: line.currency,
|
||||
amount: this.formatAmount(line.unitPrice),
|
||||
unit: line.unit.startsWith('per ') ? line.unit : `per ${line.unit}`,
|
||||
}));
|
||||
|
||||
return {
|
||||
freightLanes,
|
||||
additionalServices: [],
|
||||
surcharges: [],
|
||||
isEmpty: freightLanes.length === 0,
|
||||
currencyLabel: pricing.currency,
|
||||
};
|
||||
}
|
||||
|
||||
private formatAmount(value: number | string): string {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return String(value);
|
||||
return num.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||||
const firstRoute = this.firstRoute(contract);
|
||||
const cargoScope = (contract.cargoScope ?? [])[0];
|
||||
|
||||
@@ -133,6 +133,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
originLabel: 'Nagad',
|
||||
destinationLabel: 'Galaan Multipurpose Port',
|
||||
} as unknown as ContractViewModel['pricing'],
|
||||
rateSchedule: {
|
||||
freightLanes: [
|
||||
{ route: 'Nagad → Galaan Multipurpose Port', cargo: 'Wheat', currency: 'USD', amount: '100', unit: 'per wagon' },
|
||||
],
|
||||
additionalServices: [
|
||||
{ route: 'First-mile pickup by truck', cargo: '—', currency: 'USD', amount: '50', unit: 'per wagon' },
|
||||
],
|
||||
surcharges: [],
|
||||
isEmpty: false,
|
||||
currencyLabel: 'USD',
|
||||
},
|
||||
signatures: [],
|
||||
canSignCustomer: false,
|
||||
canSignStaff: false,
|
||||
@@ -151,11 +162,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'pricing',
|
||||
title: 'Contract Price and Payment Terms',
|
||||
body: 'Rates are set out in the Rate Schedule below.\nPayments 100% in advance.',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
title: 'Duration',
|
||||
body: 'Valid until August 31, {{contractYear}}.',
|
||||
order: 2,
|
||||
order: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -175,6 +192,16 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
expect(html).toContain('#1b9e7a');
|
||||
});
|
||||
|
||||
it('renders the live rate schedule lane under the pricing article', () => {
|
||||
const html = renderer.render(dynamicView());
|
||||
expect(html).toContain('Rate Schedule');
|
||||
// Base freight lane pulled from the rate config
|
||||
expect(html).toContain('Nagad → Galaan Multipurpose Port');
|
||||
expect(html).toContain('USD 100 per wagon');
|
||||
// Additional-service group
|
||||
expect(html).toContain('First-mile pickup by truck');
|
||||
});
|
||||
|
||||
it('keeps the generic layout when no dynamic template is attached', () => {
|
||||
const view = dynamicView();
|
||||
delete view.dynamicTemplate;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../modules/rule-engine/services/rates.service';
|
||||
import { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||
import {
|
||||
ContractDirection,
|
||||
ContractFreight,
|
||||
} from './contract-template.types';
|
||||
|
||||
/** One priced line in the contract's rate schedule. */
|
||||
export interface RateScheduleRow {
|
||||
/** "Negad → Mojo Dry Port" for base freight, service name otherwise. */
|
||||
route: string;
|
||||
/** "40ft GP", "Wheat", or "—" when the rate is not scoped to a type. */
|
||||
cargo: string;
|
||||
currency: string;
|
||||
/** Pre-formatted amount, e.g. "200" (grouped, no trailing zeros). */
|
||||
amount: string;
|
||||
/** Human unit, e.g. "per container", "per wagon", "per ton". */
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin → destination rate schedule shown in a generated contract's
|
||||
* pricing article. Grouped so the reader sees rail freight lanes first, then
|
||||
* pickup/delivery legs, then trigger-based surcharges and demurrage.
|
||||
*/
|
||||
export interface RateSchedule {
|
||||
/** Base rail freight lanes matching this contract's direction + freight. */
|
||||
freightLanes: RateScheduleRow[];
|
||||
/** First-mile / last-mile truck legs (route-agnostic). */
|
||||
additionalServices: RateScheduleRow[];
|
||||
/** Hazard, reefer, overweight, demurrage, customs, etc. */
|
||||
surcharges: RateScheduleRow[];
|
||||
/** True when every group is empty — the template falls back to prose. */
|
||||
isEmpty: boolean;
|
||||
/** Currencies present across the schedule, e.g. "USD" or "USD, ETB". */
|
||||
currencyLabel: string;
|
||||
}
|
||||
|
||||
const UNIT_LABELS: Record<string, string> = {
|
||||
PER_WAGON: 'per wagon',
|
||||
PER_TON: 'per ton',
|
||||
PER_CONTAINER: 'per container',
|
||||
PER_KM: 'per km',
|
||||
PER_INVOICE: 'per invoice',
|
||||
FLAT: 'flat',
|
||||
};
|
||||
|
||||
const SERVICE_ROUTE_LABELS: Partial<Record<Rate['appliesTo'], string>> = {
|
||||
FIRST_MILE: 'First-mile pickup by truck',
|
||||
LAST_MILE: 'Last-mile delivery by truck',
|
||||
};
|
||||
|
||||
/** Friendly wording for the trigger-based charges shown in the surcharge group. */
|
||||
const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
||||
HAZARDOUS: 'Hazardous cargo surcharge',
|
||||
OVERWEIGHT: 'Overweight surcharge',
|
||||
REEFER: 'Reefer (refrigerated) surcharge',
|
||||
WITH_RETURN: 'Empty-container return service',
|
||||
SHIPPING_LINE: 'Shipping line handling',
|
||||
CONSOLIDATION: 'Container consolidation (extra document)',
|
||||
LASHING: 'Cargo lashing and securing',
|
||||
CANCELLATION: 'Booking cancellation fee',
|
||||
DEMURRAGE: 'Demurrage / wagon detention',
|
||||
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
|
||||
CUSTOMS_CLEARANCE: 'Customs clearance service',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ContractRateScheduleBuilder {
|
||||
constructor(private readonly ratesService: RatesService) {}
|
||||
|
||||
/**
|
||||
* Build the rate schedule for a contract of the given direction + freight.
|
||||
* Base-freight lanes are filtered to the matching trade direction / freight
|
||||
* kind so an import container contract shows import container lanes only;
|
||||
* additional services and surcharges are route-agnostic and always shown.
|
||||
*/
|
||||
async build(
|
||||
direction: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
): Promise<RateSchedule> {
|
||||
const rates = await this.ratesService.findLiveRatesDetailed();
|
||||
|
||||
const freightLanes: RateScheduleRow[] = [];
|
||||
const additionalServices: RateScheduleRow[] = [];
|
||||
const surcharges: RateScheduleRow[] = [];
|
||||
|
||||
for (const rate of rates) {
|
||||
if (this.isBaseFreight(rate)) {
|
||||
if (this.baseFreightMatches(rate, direction, freight)) {
|
||||
freightLanes.push(this.laneRow(rate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rate.appliesTo === 'FIRST_MILE' || rate.appliesTo === 'LAST_MILE') {
|
||||
additionalServices.push(this.serviceRow(rate));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
||||
surcharges.push(this.surchargeRow(rate));
|
||||
}
|
||||
|
||||
const currencyLabel = this.currencyLabel([
|
||||
...freightLanes,
|
||||
...additionalServices,
|
||||
...surcharges,
|
||||
]);
|
||||
|
||||
return {
|
||||
freightLanes,
|
||||
additionalServices,
|
||||
surcharges,
|
||||
isEmpty:
|
||||
freightLanes.length === 0 &&
|
||||
additionalServices.length === 0 &&
|
||||
surcharges.length === 0,
|
||||
currencyLabel,
|
||||
};
|
||||
}
|
||||
|
||||
private isBaseFreight(rate: Rate): boolean {
|
||||
return (
|
||||
rate.trigger === 'ALWAYS' &&
|
||||
(rate.appliesTo === 'BULK' ||
|
||||
rate.appliesTo === 'CONTAINER' ||
|
||||
rate.appliesTo === 'INTERCITY')
|
||||
);
|
||||
}
|
||||
|
||||
private baseFreightMatches(
|
||||
rate: Rate,
|
||||
direction: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
): boolean {
|
||||
// Domestic contracts price off intercity rates; the freight kind is carried
|
||||
// in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER).
|
||||
if (direction === 'DOM') {
|
||||
if (rate.appliesTo !== 'INTERCITY') return false;
|
||||
return freight === 'BULK'
|
||||
? rate.rateType === 'INTERCITY_BULK'
|
||||
: rate.rateType === 'INTERCITY_CONTAINER';
|
||||
}
|
||||
|
||||
// Import / export price off BULK or CONTAINER rates matching the direction.
|
||||
const wantAppliesTo = freight === 'BULK' ? 'BULK' : 'CONTAINER';
|
||||
if (rate.appliesTo !== wantAppliesTo) return false;
|
||||
const wantDirection = direction === 'IMP' ? 'IMPORT' : 'EXPORT';
|
||||
return rate.tradeDirection === wantDirection;
|
||||
}
|
||||
|
||||
private laneRow(rate: Rate): RateScheduleRow {
|
||||
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
|
||||
const destination =
|
||||
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
|
||||
return {
|
||||
route: `${origin} → ${destination}`,
|
||||
cargo: this.cargoLabel(rate),
|
||||
currency: rate.currency,
|
||||
amount: this.formatAmount(rate.rateValue),
|
||||
unit: this.unitLabel(rate.rateUnit),
|
||||
};
|
||||
}
|
||||
|
||||
private serviceRow(rate: Rate): RateScheduleRow {
|
||||
return {
|
||||
route: SERVICE_ROUTE_LABELS[rate.appliesTo] ?? rate.appliesTo,
|
||||
cargo: this.cargoLabel(rate),
|
||||
currency: rate.currency,
|
||||
amount: this.formatAmount(rate.rateValue),
|
||||
unit: this.unitLabel(rate.rateUnit),
|
||||
};
|
||||
}
|
||||
|
||||
private surchargeRow(rate: Rate): RateScheduleRow {
|
||||
return {
|
||||
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),
|
||||
cargo: this.cargoLabel(rate),
|
||||
currency: rate.currency,
|
||||
amount: this.formatAmount(rate.rateValue),
|
||||
unit: this.unitLabel(rate.rateUnit),
|
||||
};
|
||||
}
|
||||
|
||||
/** The type a rate is scoped to (container/cargo), or a dash when unscoped. */
|
||||
private cargoLabel(rate: Rate): string {
|
||||
return (
|
||||
rate.containerType?.label ??
|
||||
rate.containerType?.code ??
|
||||
rate.cargoType?.cargoTypeName ??
|
||||
'—'
|
||||
);
|
||||
}
|
||||
|
||||
private unitLabel(unit: Rate['rateUnit']): string {
|
||||
return UNIT_LABELS[unit] ?? unit.toLowerCase().replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/** Group thousands and drop the DB's trailing zeros: "200.0000" → "200". */
|
||||
private formatAmount(value: number | string): string {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return String(value);
|
||||
return num.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
private currencyLabel(rows: RateScheduleRow[]): string {
|
||||
const seen: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (!seen.includes(row.currency)) seen.push(row.currency);
|
||||
}
|
||||
return seen.join(', ') || 'USD';
|
||||
}
|
||||
|
||||
private titleCase(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,15 @@ describe('ContractRendererService', () => {
|
||||
destinationLabel: 'Modjo',
|
||||
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
|
||||
},
|
||||
rateSchedule: {
|
||||
freightLanes: [
|
||||
{ route: 'SGTD → Modjo', cargo: '40ft GP', currency: 'USD', amount: '200', unit: 'per container' },
|
||||
],
|
||||
additionalServices: [],
|
||||
surcharges: [],
|
||||
isEmpty: false,
|
||||
currencyLabel: 'USD',
|
||||
},
|
||||
signatures: [],
|
||||
canSignCustomer: true,
|
||||
canSignStaff: false,
|
||||
|
||||
@@ -57,6 +57,7 @@ export class ContractRendererService implements OnModuleInit {
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((article, index) => ({
|
||||
number: index + 1,
|
||||
id: article.id,
|
||||
title: interpolateTemplateText(article.title, view),
|
||||
...parseArticleBody(interpolateTemplateText(article.body, view)),
|
||||
}));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ContractSignerRole,
|
||||
} from '../modules/bookings/entities/booking-contract-signature.entity';
|
||||
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
|
||||
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
|
||||
|
||||
@@ -74,6 +75,12 @@ export interface ContractViewModel {
|
||||
lastMileDeliveryAddress: string;
|
||||
};
|
||||
pricing: PricingSchedule;
|
||||
/**
|
||||
* The live origin → destination rate schedule (base freight lanes + services
|
||||
* + surcharges) matching this contract's direction and freight kind. Drives
|
||||
* the pricing article's rate table so the contract mirrors the rate config.
|
||||
*/
|
||||
rateSchedule: RateSchedule;
|
||||
signatures: ContractSignatureView[];
|
||||
canSignCustomer: boolean;
|
||||
canSignStaff: boolean;
|
||||
@@ -89,6 +96,7 @@ export class ContractViewModelBuilder {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly templateResolver: ContractTemplateResolver,
|
||||
private readonly pricingBuilder: ContractPricingScheduleBuilder,
|
||||
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
|
||||
) {}
|
||||
|
||||
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
|
||||
@@ -101,6 +109,10 @@ export class ContractViewModelBuilder {
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
|
||||
const template = getTemplateMeta(templateKey);
|
||||
const pricing = await this.pricingBuilder.build(booking);
|
||||
const rateSchedule = await this.rateScheduleBuilder.build(
|
||||
template.direction,
|
||||
template.freight,
|
||||
);
|
||||
const signatures = await this.loadSignatures(bookingId);
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
@@ -143,6 +155,7 @@ export class ContractViewModelBuilder {
|
||||
},
|
||||
schedule: this.buildSchedule(booking),
|
||||
pricing,
|
||||
rateSchedule,
|
||||
signatures,
|
||||
canSignCustomer:
|
||||
booking.status === 'CONTRACT_READY' && !hasCustomer,
|
||||
|
||||
@@ -25,25 +25,9 @@
|
||||
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if pricing.unitRates}}
|
||||
<h3>Unit Rate Schedule</h3>
|
||||
<p>
|
||||
The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting
|
||||
totals are determined per shipment at booking time; no total contract value is fixed at this stage.
|
||||
</p>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Unit price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.unitRates}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{#unless rateSchedule.isEmpty}}
|
||||
<h3>Rate Schedule</h3>
|
||||
{{> rate_schedule}}
|
||||
{{else}}
|
||||
<h3>Charges</h3>
|
||||
<table class="schedule">
|
||||
@@ -76,7 +60,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
<h3>Terms of payment</h3>
|
||||
<p>
|
||||
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
||||
|
||||
@@ -20,5 +20,9 @@
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{/if}}
|
||||
{{#if (eq id "pricing")}}
|
||||
<h3>Rate Schedule</h3>
|
||||
{{> rate_schedule}}
|
||||
{{/if}}
|
||||
</section>
|
||||
{{/each}}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{{#if rateSchedule.isEmpty}}
|
||||
<p class="muted-note">
|
||||
No published rate schedule is currently on file for this corridor. Applicable charges will be quoted
|
||||
by the Service Provider per shipment in accordance with the prevailing EDR tariff.
|
||||
</p>
|
||||
{{else}}
|
||||
<p>
|
||||
The charges below are the current published railway tariff for this contract's trade direction and
|
||||
freight type, expressed as unit prices per origin → destination lane. Quantities and the resulting
|
||||
totals are determined per shipment at booking time.
|
||||
</p>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route / Service</th>
|
||||
<th>Cargo / Equipment</th>
|
||||
<th>Unit price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if rateSchedule.freightLanes.length}}
|
||||
<tr><th colspan="3">Railway Freight — Origin → Destination</th></tr>
|
||||
{{#each rateSchedule.freightLanes}}
|
||||
<tr>
|
||||
<td>{{route}}</td>
|
||||
<td>{{cargo}}</td>
|
||||
<td>{{currency}} {{amount}} {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if rateSchedule.additionalServices.length}}
|
||||
<tr><th colspan="3">Additional Services</th></tr>
|
||||
{{#each rateSchedule.additionalServices}}
|
||||
<tr>
|
||||
<td>{{route}}</td>
|
||||
<td>{{cargo}}</td>
|
||||
<td>{{currency}} {{amount}} {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if rateSchedule.surcharges.length}}
|
||||
<tr><th colspan="3">Surcharges, Demurrage & Fees</th></tr>
|
||||
{{#each rateSchedule.surcharges}}
|
||||
<tr>
|
||||
<td>{{route}}</td>
|
||||
<td>{{cargo}}</td>
|
||||
<td>{{currency}} {{amount}} {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
@@ -134,26 +134,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{#if pricing.unitRates.length}}
|
||||
<h3>Agreed Unit Rates</h3>
|
||||
<p class="muted-note">
|
||||
The rates below are the frozen unit prices applicable to this contract. Quantities and resulting
|
||||
totals are determined per shipment at booking time.
|
||||
</p>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Unit price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.unitRates}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
<h3>Published Rate Schedule</h3>
|
||||
{{> rate_schedule}}
|
||||
</section>
|
||||
|
||||
{{!-- ────────────────────────── Signatures ───────────────────────────── --}}
|
||||
|
||||
Reference in New Issue
Block a user