mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 08:58:21 +00:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => {
|
||||
rate?: Rate;
|
||||
pending?: RateChangeRequest | null;
|
||||
applyThrows?: Error;
|
||||
/** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */
|
||||
derived?: Partial<Rate>;
|
||||
previewThrows?: Error;
|
||||
} = {}) => {
|
||||
const rate = opts.rate ?? liveRate();
|
||||
const saved: RateChangeRequest[] = [];
|
||||
@@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => {
|
||||
const rates = {
|
||||
findById: jest.fn(async () => rate),
|
||||
assertUpdateValid: jest.fn(async () => undefined),
|
||||
// Stands in for buildUpdate: it resolves a patch into the full column
|
||||
// set, including columns the form never posts (rateType and friends).
|
||||
previewUpdate: jest.fn(async (_id: string, dto: Record<string, unknown>) => {
|
||||
if (opts.previewThrows) throw opts.previewThrows;
|
||||
return { ...dto, ...(opts.derived ?? {}) } as Partial<Rate>;
|
||||
}),
|
||||
applyApprovedUpdate: jest.fn(async () => {
|
||||
if (opts.applyThrows) throw opts.applyThrows;
|
||||
return rate;
|
||||
@@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => {
|
||||
});
|
||||
|
||||
it('validates up front so the requester hears about a bad patch, not the approver', async () => {
|
||||
const { service, rates } = build();
|
||||
rates.assertUpdateValid.mockRejectedValueOnce(
|
||||
new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
|
||||
);
|
||||
// Resolving the patch IS the validation — buildUpdate throws on a bad
|
||||
// unit, so previewUpdate surfaces it at submit time.
|
||||
const { service } = build({
|
||||
previewThrows: new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
|
||||
});
|
||||
await expect(
|
||||
service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
|
||||
).rejects.toThrow(/not valid for this rate/);
|
||||
});
|
||||
|
||||
it('shows the approver a bulk switch, which only exists as a derived column', async () => {
|
||||
// The form posts intercityKind: BULK — never stored. The real edit lands
|
||||
// on rateType (+ the cargo/container swap), so that is what the approver
|
||||
// must see. Diffing the raw patch showed an empty change list.
|
||||
const { service } = build({
|
||||
rate: liveRate({
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
appliesTo: 'INTERCITY',
|
||||
containerTypeId: 'ct-1',
|
||||
}),
|
||||
derived: {
|
||||
rateType: 'INTERCITY_BULK',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: 'cargo-9',
|
||||
} as Partial<Rate>,
|
||||
});
|
||||
|
||||
const request = await service.submit({
|
||||
rateId: 'rate-1',
|
||||
update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never,
|
||||
});
|
||||
|
||||
expect(request.payload).toMatchObject({
|
||||
rateType: 'INTERCITY_BULK',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: 'cargo-9',
|
||||
});
|
||||
expect(request.previousValues).toMatchObject({
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
containerTypeId: 'ct-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
|
||||
@@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
/** Backoffice page where both the queue and the rates live. */
|
||||
const RATES_LINK = '/dashboard/rules/rates';
|
||||
|
||||
/** Fields a change request may carry — anything else in the patch is ignored. */
|
||||
/**
|
||||
* Persisted columns an approver is shown a before→after for.
|
||||
*
|
||||
* These are RESOLVED entity columns, not raw form fields: the diff runs
|
||||
* against `RatesService.previewUpdate`, so a change the form expresses through
|
||||
* a non-stored selector still shows up here as the column it actually moves
|
||||
* (a flip to bulk lands on `rateType` + the container/cargo swap).
|
||||
*/
|
||||
const DIFFABLE_FIELDS = [
|
||||
'rateValue',
|
||||
'currency',
|
||||
@@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [
|
||||
'tradeDirection',
|
||||
'containerTypeId',
|
||||
'cargoTypeId',
|
||||
// The container-vs-bulk shape of the rate. Missing here, switching a LIVE
|
||||
// rate to bulk showed the approver an empty change list — the only column
|
||||
// that records the kind is rateType, and the form never posts it directly.
|
||||
'rateType',
|
||||
// Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping
|
||||
// line diffed to nothing.
|
||||
'shippingLineCompanyId',
|
||||
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
|
||||
// diffed to nothing and the submit was refused as "nothing changed".
|
||||
'originYardId',
|
||||
@@ -82,7 +96,11 @@ export class RateChangeRequestsService {
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.changedFieldsOnly(rate, dto.update);
|
||||
// Diff the RESOLVED columns, not the raw patch: the form's cargoKind /
|
||||
// intercityKind selectors are never stored, so a bulk switch only shows up
|
||||
// once the patch is resolved into the columns it moves.
|
||||
const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto);
|
||||
const payload = this.changedFieldsOnly(rate, resolved);
|
||||
if (Object.keys(payload).length === 0) {
|
||||
throw new BadRequestException('Nothing changed — the proposed values match the live rate.');
|
||||
}
|
||||
@@ -98,7 +116,9 @@ export class RateChangeRequestsService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto);
|
||||
// previewUpdate above already ran the full validation (it IS buildUpdate),
|
||||
// so re-validating here would only repeat it — and the trimmed payload is
|
||||
// resolved columns, not a form patch, so it is not the right input for it.
|
||||
|
||||
const request = await this.repo.save(
|
||||
this.repo.create({
|
||||
@@ -186,13 +206,14 @@ export class RateChangeRequestsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only fields the requester actually changed. A form posts every field
|
||||
* back, so without this the diff would list untouched values as changes.
|
||||
* Keep only columns the edit actually moves. `buildUpdate` returns a full
|
||||
* resolved column set (it re-derives scope on every patch), so without this
|
||||
* the diff would list every untouched column as a change.
|
||||
*/
|
||||
private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record<string, unknown> {
|
||||
private changedFieldsOnly(rate: Rate, resolved: Partial<Rate>): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const field of DIFFABLE_FIELDS) {
|
||||
const proposed = (update as Record<string, unknown>)[field];
|
||||
const proposed = (resolved as Record<string, unknown>)[field];
|
||||
if (proposed === undefined) continue;
|
||||
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
|
||||
patch[field] = proposed;
|
||||
|
||||
@@ -753,6 +753,19 @@ export class RatesService {
|
||||
await this.buildUpdate(await this.findById(id), dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact column changes applying this patch would make, without writing.
|
||||
*
|
||||
* A change request diffs against THIS rather than the raw patch: the form
|
||||
* posts selectors that are never stored (`cargoKind`, `intercityKind`), and
|
||||
* the real edit they encode lands on derived columns — flipping a rate to
|
||||
* bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing
|
||||
* the raw patch missed all of it, so the approver saw an empty change list.
|
||||
*/
|
||||
async previewUpdate(id: string, dto: UpdateRateDto): Promise<Partial<Rate>> {
|
||||
return this.buildUpdate(await this.findById(id), dto);
|
||||
}
|
||||
|
||||
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
|
||||
const updates = await this.buildUpdate(existing, dto);
|
||||
const updated = await this.repository.update(existing.id, updates);
|
||||
|
||||
@@ -867,6 +867,27 @@ export class TrainSchedulingController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/wagons/export")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
|
||||
})
|
||||
async scheduleWagonListExport(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } =
|
||||
await this.trainSchedulingService.scheduleWagonListWorkbook(id);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/export/load-list/document")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Download printable export marshalling / load list PDF" })
|
||||
|
||||
@@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
||||
import { TabularExportService } from '../../exports/tabular-export.service';
|
||||
|
||||
/** One line of the schedule wagon-list export (raw SQL projection). */
|
||||
interface ScheduleWagonListRow {
|
||||
sequenceNo: number | null;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
containerNumber: string | null;
|
||||
containerSizeFt: number | null;
|
||||
loadType: string | null;
|
||||
status: string | null;
|
||||
bulkCargoDescription: string | null;
|
||||
/** numeric columns arrive as strings from pg. */
|
||||
vgmTons: string | null;
|
||||
originLabel: string | null;
|
||||
destinationLabel: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||||
@@ -427,6 +446,9 @@ export class TrainSchedulingService {
|
||||
// Per-wagon history ledger (global module). @Optional keeps the positional
|
||||
// spec constructors working; production always has it.
|
||||
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
||||
// Trailing + @Optional so the positional constructors in the existing specs
|
||||
// keep working; production always resolves it from ExportsModule.
|
||||
@Optional() private readonly tabularExport?: TabularExportService,
|
||||
) {}
|
||||
|
||||
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
||||
@@ -3747,6 +3769,132 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule detail page's wagon-list Excel export.
|
||||
*
|
||||
* One row per container (a wagon carrying two boxes yields two rows, repeating
|
||||
* the wagon number) so each container's own VGM is present and totals footable.
|
||||
* Bulk wagons, having no containers, yield a single row carrying the bulk
|
||||
* description and the allocated tonnage as the VGM figure.
|
||||
*
|
||||
* Only wagon slots that actually carry an allocation are listed — empty slots
|
||||
* on the consist are omitted.
|
||||
*/
|
||||
async scheduleWagonListWorkbook(
|
||||
scheduleId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.tabularExport) {
|
||||
throw new BadRequestException('Tabular export service is unavailable');
|
||||
}
|
||||
|
||||
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
|
||||
// container-less) allocation as one row. `booking_container_units` is joined
|
||||
// on BOTH container number and its booking_container line — container
|
||||
// numbers repeat across bookings, so number alone would multiply rows.
|
||||
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
||||
ci.container_number AS "containerNumber",
|
||||
cit.size_ft AS "containerSizeFt",
|
||||
a.load_type AS "loadType",
|
||||
a.status AS "status",
|
||||
bl.cargo_description AS "bulkCargoDescription",
|
||||
COALESCE(
|
||||
ci.gross_weight_tons,
|
||||
bcu.vgm_tons,
|
||||
bc.vgm_per_unit_tons,
|
||||
a.allocated_weight_tons
|
||||
) AS "vgmTons",
|
||||
COALESCE(by_.label, so.label) AS "originLabel",
|
||||
COALESCE(ay.label, sd.label) AS "destinationLabel",
|
||||
b.reference AS "bookingReference",
|
||||
COALESCE(
|
||||
slc.name,
|
||||
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
||||
c.name
|
||||
) AS "customerName"
|
||||
FROM freight.train_schedules s
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.wagon_booking_allocations a
|
||||
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
LEFT JOIN freight.bookings b ON b.id = a.booking_id
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
|
||||
LEFT JOIN freight.booking_container bc
|
||||
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container_units bcu
|
||||
ON bcu.container_number = ci.container_number
|
||||
AND bcu.booking_container_id = bc.id
|
||||
AND bcu.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
|
||||
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
|
||||
WHERE s.id = $1 AND s.deleted_at IS NULL
|
||||
ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`,
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
// "number" is the printed line number of the sheet, not the wagon sequence —
|
||||
// a two-container wagon occupies two lines, and the reader counts lines.
|
||||
const sheetRows = rows.map((row, index) => ({
|
||||
number: index + 1,
|
||||
wagonNumber: row.wagonNumber ?? '—',
|
||||
containerNumber:
|
||||
row.containerNumber ??
|
||||
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
|
||||
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
|
||||
originLabel: row.originLabel ?? '—',
|
||||
destinationLabel: row.destinationLabel ?? '—',
|
||||
customerName: row.customerName ?? '—',
|
||||
}));
|
||||
|
||||
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
|
||||
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
|
||||
|
||||
const buffer = await this.tabularExport.toXlsx({
|
||||
title: `Wagons ${reference}`.slice(0, 31),
|
||||
description: `Wagon list for train ${reference}`,
|
||||
label: 'train-schedule:wagon-list',
|
||||
kpis: [
|
||||
{ label: 'Lines', value: sheetRows.length },
|
||||
{
|
||||
label: 'Wagons',
|
||||
value: new Set(rows.map((r) => r.sequenceNo)).size,
|
||||
},
|
||||
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'number', label: 'No.', type: 'number' },
|
||||
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
|
||||
{ key: 'containerNumber', label: 'Container number', type: 'string' },
|
||||
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
|
||||
{ key: 'originLabel', label: 'Origin', type: 'string' },
|
||||
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
|
||||
{ key: 'customerName', label: 'Customer', type: 'string' },
|
||||
],
|
||||
rows: sheetRows,
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module';
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { ExportsModule } from '../exports/exports.module';
|
||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FacilityHandlingService } from './facility-handling.service';
|
||||
@@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
UserTradeAccessModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
ExportsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
|
||||
Reference in New Issue
Block a user