Refactor code structure for improved readability and maintainability

This commit is contained in:
marshalyordanos
2026-09-03 22:27:00 +03:00
parent 9c57aa1c0c
commit 380ba4c4c9
11 changed files with 407 additions and 66 deletions

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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" })

View File

@@ -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) {

View File

@@ -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,