enhance contract document rendering with detailed cargo information

- Updated ContractDocumentViewModelBuilder to include cargoTypeName, containerType, and cargoSummary in the schedule.
- Modified contract dynamic template tests to validate the new cargo fields.
- Enhanced contract renderer service tests to reflect changes in cargo data structure.
- Updated contract view model interface to include new cargo-related fields.
- Improved dynamic template rendering to display cargo type and container type.
- Refactored exchange settings controller and service to streamline error handling and feed status management.
- Introduced article HTML conversion functions to support Quill editor integration for structured article editing.
- Added tests for article HTML conversion to ensure correct round-trip processing of clauses and bullets.
This commit is contained in:
Marshal
2026-08-04 13:03:39 +00:00
parent 45216c5624
commit d0040a6851
14 changed files with 472 additions and 224 deletions

View File

@@ -166,6 +166,8 @@ export class ContractDocumentViewModelBuilder {
year: 'numeric',
}),
contractYear: new Date().getFullYear(),
contractStartDate: this.formatDate(contract.contractValidFrom),
contractEndDate: this.formatDate(contract.contractValidUntil),
client: {
companyName: contract.company?.name ?? 'Client',
companyAddress: this.valueOrDash(contract.company?.address),
@@ -281,7 +283,8 @@ export class ContractDocumentViewModelBuilder {
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
const firstRoute = this.firstRoute(contract);
const cargoScope = (contract.cargoScope ?? [])[0];
const scope = contract.cargoScope ?? [];
const cargoScope = scope[0];
const cargoName =
cargoScope?.cargoType?.cargoTypeName ||
cargoScope?.cargoFreeText ||
@@ -289,6 +292,28 @@ export class ContractDocumentViewModelBuilder {
? `${cargoScope.containerSize} container`
: 'Container cargo');
// A contract's scope can list several cargo lines (e.g. coffee in 20ft and
// 40ft); name each distinctly rather than collapsing to the first.
const containerType = [
...new Set(scope.map((s) => s.containerSize ?? '').filter(Boolean)),
].join(', ');
const cargoTypeName = [
...new Set(
scope
.map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
.filter(Boolean),
),
].join(', ');
const cargoSummary = scope
.map((s) => {
const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
const size = s.containerSize ? `(${s.containerSize})` : null;
const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
return [name, size, cap].filter(Boolean).join(' ');
})
.filter(Boolean)
.join('; ');
return {
originLabel: this.yardLabel(firstRoute?.originYard),
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
@@ -302,6 +327,9 @@ export class ContractDocumentViewModelBuilder {
scheduledDate: this.formatDate(null),
contractType: this.valueOrDash(contract.contractType),
cargoDescription: this.valueOrDash(cargoName),
cargoTypeName: this.valueOrDash(cargoTypeName),
containerType: this.valueOrDash(containerType),
cargoSummary: this.valueOrDash(cargoSummary),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
// A hazardous contract names the declared class + UN number on the

View File

@@ -117,6 +117,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
scheduledDate: '—',
contractType: 'GENERAL',
cargoDescription: 'Steel billets',
cargoTypeName: 'Steel billets',
containerType: '—',
cargoSummary: 'Steel billets × 2,800',
totalWeightVgm: '—',
equipmentReturn: '—',
hazardousLabel: 'No',
@@ -192,6 +195,24 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
expect(html).toContain('#1b9e7a');
});
it('shows cargo type and container type in the commercial schedule annex', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Cargo type');
expect(html).toContain('Container type');
expect(html).toContain('Cargo scope');
expect(html).toContain('Steel billets × 2,800');
});
it('interpolates the cargo/container placeholders inside article text', () => {
const view = dynamicView();
const body =
'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
'({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
expect(interpolateTemplateText(body, view)).toBe(
'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
);
});
it('renders the live rate schedule lane under the pricing article', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Rate Schedule');

View File

@@ -43,6 +43,9 @@ describe('ContractRendererService', () => {
scheduledDate: '1 January 2026',
contractType: 'NEW',
cargoDescription: 'Container cargo',
cargoTypeName: 'Coffee',
containerType: '40ft',
cargoSummary: 'Coffee (40ft) × 12',
totalWeightVgm: '24 tons',
equipmentReturn: 'RETURN',
hazardousLabel: 'No',

View File

@@ -41,6 +41,13 @@ export interface ContractViewModel {
template: ContractTemplateMeta;
contractDate: string;
contractYear: number;
/**
* The contract's validity window (`contract_valid_from` / `_until`). Distinct
* from `contractDate`, which is the day the document is generated — these are
* the dates the contract is actually in force between. "—" when unset.
*/
contractStartDate: string;
contractEndDate: string;
client: {
companyName: string;
companyAddress: string;
@@ -68,6 +75,16 @@ export interface ContractViewModel {
scheduledDate: string;
contractType: string;
cargoDescription: string;
/**
* The named cargo type on its own (e.g. "Coffee"), separate from
* `cargoDescription` which folds in free text and a container fallback.
* Lets a clause name the commodity without the surrounding prose.
*/
cargoTypeName: string;
/** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
containerType: string;
/** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
cargoSummary: string;
totalWeightVgm: string;
equipmentReturn: string;
hazardousLabel: string;
@@ -195,6 +212,21 @@ export class ContractViewModelBuilder {
'Bulk commodity'
: booking.cargoType?.cargoTypeName || 'Container cargo';
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
// A booking may carry both sizes; name each one once, in the order booked.
const containerType = [
...new Set(
(booking.bookingContainers ?? [])
.map(
(line) =>
line.containerType?.label ??
(line.containerType?.sizeFt
? `${line.containerType.sizeFt}ft`
: line.containerSize) ??
'',
)
.filter(Boolean),
),
].join(', ');
return {
originLabel: this.yardLabel(booking.originYard),
@@ -207,6 +239,13 @@ export class ContractViewModelBuilder {
scheduledDate: this.formatDate(booking.scheduledDate),
contractType: this.valueOrDash(booking.contractType),
cargoDescription: this.valueOrDash(cargoName),
cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
containerType: this.valueOrDash(containerType),
cargoSummary: this.valueOrDash(
[cargoName, containerType ? `(${containerType})` : null]
.filter(Boolean)
.join(' '),
),
totalWeightVgm:
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
equipmentReturn: this.valueOrDash(booking.equipmentReturn),

View File

@@ -125,6 +125,18 @@
<th>Hazardous cargo</th>
<td>{{schedule.hazardousLabel}}</td>
</tr>
<tr>
<th>Cargo type</th>
<td>{{schedule.cargoTypeName}}</td>
<th>Container type</th>
<td>{{schedule.containerType}}</td>
</tr>
<tr>
<th>Cargo scope</th>
<td>{{schedule.cargoSummary}}</td>
<th>Freight type</th>
<td>{{schedule.freightType}}</td>
</tr>
<tr>
<th>Equipment return</th>
<td>{{schedule.equipmentReturn}}</td>

View File

@@ -239,6 +239,11 @@ export class ContractTemplatesService {
scheduledDate: "—",
contractType: "GENERAL",
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
cargoTypeName: isBulk ? "Steel billets" : "Coffee",
containerType: isBulk ? "—" : "40ft",
cargoSummary: isBulk
? "Steel billets × 2,800"
: "Coffee (40ft) × 12; Sesame (20ft) × 6",
totalWeightVgm: "—",
equipmentReturn: isBulk ? "—" : "With empty return",
hazardousLabel: "No",

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser, ExchangeService } from "@edr/api-common";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { FreightAdmin } from "../../common/booking-guards";
@@ -11,10 +11,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service";
@ApiBearerAuth()
@Controller("exchange-settings")
export class ExchangeSettingsController {
constructor(
private readonly service: ExchangeSettingsService,
private readonly exchangeService: ExchangeService,
) {}
constructor(private readonly service: ExchangeSettingsService) {}
@Get()
@FreightAdmin()
@@ -22,24 +19,15 @@ export class ExchangeSettingsController {
summary: "Current USD→ETB fallback rate and CBE feed health",
})
async get() {
const [setting, status] = [
await this.service.get(),
this.exchangeService.getProviderStatus(),
];
const setting = await this.service.get();
const status = this.service.getFeedStatus();
return {
fallbackRate: setting.fallbackRate,
fallbackSource: setting.fallbackSource,
lastSyncedAt: setting.lastSyncedAt,
updatedById: setting.updatedById,
feed: {
rate: status.rate,
source: status.source,
lastSuccessAt: status.lastSuccessAt
? new Date(status.lastSuccessAt).toISOString()
: null,
lastError: status.lastError,
},
feed: status,
};
}

View File

@@ -10,6 +10,18 @@ import { ExchangeSetting } from "./entities/exchange-setting.entity";
*/
const SEED_FALLBACK_RATE = 162.4165;
/** Health of the CBE feed, as surfaced to the backoffice. */
export interface ExchangeFeedStatus {
/** Rate most recently observed, whatever its source. */
rate: number | null;
/** `live` means CBE answered; `stored`/`default` mean it is failing. */
source: "live" | "stored" | null;
/** ISO timestamp of the last successful fetch. */
lastSuccessAt: string | null;
/** Message from the most recent failure, cleared on success. */
lastError: string | null;
}
/**
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
* CBE endpoint is unreachable.
@@ -22,11 +34,30 @@ const SEED_FALLBACK_RATE = 162.4165;
export class ExchangeSettingsService {
private readonly logger = new Logger(ExchangeSettingsService.name);
/**
* Feed health, recorded from the exchange provider's callbacks rather than
* read off an injected `ExchangeService`. The provider is registered several
* times (bookings, contracts, warehouses), so no single instance sees every
* fetch — and injecting one here would be circular, since those
* registrations inject *this* service.
*/
private feed: ExchangeFeedStatus = {
rate: null,
source: null,
lastSuccessAt: null,
lastError: null,
};
constructor(
@InjectRepository(ExchangeSetting)
private readonly repository: Repository<ExchangeSetting>,
) {}
/** Health of the CBE feed as last observed by any provider instance. */
getFeedStatus(): ExchangeFeedStatus {
return { ...this.feed };
}
/** The settings row, created at the seed rate on first access. */
async get(): Promise<ExchangeSetting> {
const existing = await this.repository.findOne({ where: {} });
@@ -47,15 +78,22 @@ export class ExchangeSettingsService {
* than propagating a database error into a pricing call.
*/
async loadFallbackRate(): Promise<number | null> {
// Only reached when the live fetch failed, so this call is itself the
// signal that the feed is down.
try {
const { fallbackRate } = await this.get();
return Number.isFinite(fallbackRate) && fallbackRate > 0
? fallbackRate
: null;
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
this.feed = {
...this.feed,
rate: usable ? fallbackRate : this.feed.rate,
source: "stored",
lastError: this.feed.lastError ?? "CBE endpoint unreachable",
};
return usable ? fallbackRate : null;
} catch (err) {
this.logger.warn(
`Could not read stored exchange fallback: ${(err as Error).message}`,
);
const message = (err as Error).message;
this.feed = { ...this.feed, source: "stored", lastError: message };
this.logger.warn(`Could not read stored exchange fallback: ${message}`);
return null;
}
}
@@ -66,6 +104,14 @@ export class ExchangeSettingsService {
* down, so a working CBE feed takes precedence again.
*/
async saveFallbackRate(rate: number): Promise<void> {
// Only called after a successful fetch, so the feed is confirmed healthy.
this.feed = {
rate,
source: "live",
lastSuccessAt: new Date().toISOString(),
lastError: null,
};
const current = await this.get();
await this.repository.update(current.id, {
fallbackRate: rate,