feat: add wagon usage computation and maintenance logging features

- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

@@ -0,0 +1,96 @@
import { computeScheduleWagonUsage } from './schedule-wagon-usage.util';
/** A coupled slot; `allocated` = a booking actually sits on it. */
const slot = (allocated = false) => ({ allocations: allocated ? [{}] : [] });
const booking = (wagonsRequired: number | null) => ({ booking: { wagonsRequired } });
describe('computeScheduleWagonUsage', () => {
it('reports allocated slots as used, not the coupled consist size', () => {
// The reported bug: a 37-wagon consist carrying 3 allocated bookings read
// "37 wgn used" in the list while the detail page read "3 in use".
const slots = [...Array(34).fill(slot(false)), ...Array(3).fill(slot(true))];
const usage = computeScheduleWagonUsage({
wagonSlots: slots,
storedWagonCount: 37,
scheduleBookings: [],
});
expect(usage.wagonsUsed).toBe(3);
expect(usage.wagonsTotal).toBe(37);
});
it('counts a built train with no bookings as 0 used', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: Array(40).fill(slot(false)),
storedWagonCount: 40,
scheduleBookings: [],
});
expect(usage.wagonsUsed).toBe(0);
expect(usage.wagonsRemaining).toBe(40);
});
it('treats wagons of an unpaid booking as reserved, so they are not bookable', () => {
// Booking claims 5 wagons but has no wagon plan yet: 0 used, still only 5
// bookable on a 10-wagon train — the reservation is not free space.
const usage = computeScheduleWagonUsage({
wagonSlots: Array(10).fill(slot(false)),
storedWagonCount: 10,
scheduleBookings: [booking(5)],
});
expect(usage.wagonsUsed).toBe(0);
expect(usage.wagonsReserved).toBe(5);
expect(usage.wagonsRemaining).toBe(5);
});
it('does not double-count a booking that is both reserved and allocated', () => {
// 3 allocated slots for a booking that reserved 3 wagons: 7 remain, not 4.
const usage = computeScheduleWagonUsage({
wagonSlots: [...Array(7).fill(slot(false)), ...Array(3).fill(slot(true))],
storedWagonCount: 10,
scheduleBookings: [booking(3)],
});
expect(usage.wagonsUsed).toBe(3);
expect(usage.wagonsReserved).toBe(3);
expect(usage.wagonsRemaining).toBe(7);
});
it('never reports negative remaining when claims exceed the consist', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: Array(2).fill(slot(false)),
storedWagonCount: 2,
scheduleBookings: [booking(5)],
});
expect(usage.wagonsRemaining).toBe(0);
});
it('falls back to the stored counter when slot rows were not loaded', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: [],
storedWagonCount: 12,
scheduleBookings: [],
});
expect(usage.wagonsTotal).toBe(12);
expect(usage.wagonsUsed).toBe(0);
});
it('tolerates missing relations and null wagonsRequired', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: null,
storedWagonCount: null,
scheduleBookings: [booking(null)],
});
expect(usage).toEqual({
wagonsUsed: 0,
wagonsTotal: 0,
wagonsReserved: 0,
wagonsRemaining: 0,
});
});
});

View File

@@ -0,0 +1,58 @@
/**
* Wagon figures for a train-schedule list row.
*
* The list used to report `trainSet.wagonCount` — the COUPLED CONSIST SIZE —
* under the label "wgn used", so a 37-wagon train carrying 3 allocated bookings
* read "37 wgn used" in the list while its detail page (WagonPlanGrid) read
* "37 wagons · 3 in use". These helpers make the list agree with the detail
* page, which is the figure staff trust.
*/
/** The shape this math needs — a slot counts as used when it has allocations. */
export interface WagonSlotLike {
allocations?: unknown[] | null;
}
export interface ScheduleBookingLike {
booking?: { wagonsRequired?: number | null } | null;
}
export interface ScheduleWagonUsage {
/** Coupled slots carrying at least one booking allocation. */
wagonsUsed: number;
/** Coupled consist size — the denominator of `wagonsUsed`. */
wagonsTotal: number;
/** Wagons claimed by bookings, including bookings that have not paid. */
wagonsReserved: number;
/** Consist minus what bookings have claimed — what is still bookable. */
wagonsRemaining: number;
}
export function computeScheduleWagonUsage(input: {
wagonSlots?: WagonSlotLike[] | null;
/** Stored counter; used only when the slot rows were not loaded. */
storedWagonCount?: number | null;
scheduleBookings?: ScheduleBookingLike[] | null;
}): ScheduleWagonUsage {
const slots = input.wagonSlots ?? [];
// Same predicate as the detail page's WagonPlanGrid: a slot is in use only
// when a booking is actually allocated onto it.
const wagonsUsed = slots.filter((slot) => (slot.allocations?.length ?? 0) > 0).length;
// Prefer live slot rows; the stored counter drifts when a consist is edited
// without a recompute, which is why the list and detail disagreed on totals.
const wagonsTotal = slots.length || (input.storedWagonCount ?? 0);
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
const wagonsReserved = (input.scheduleBookings ?? []).reduce(
(sum, link) => sum + (link.booking?.wagonsRequired ?? 0),
0,
);
// Reserved subsumes allocated — an allocated booking still counts its wagons —
// so remaining subtracts whichever claim is larger, never both.
const wagonsRemaining = Math.max(0, wagonsTotal - Math.max(wagonsUsed, wagonsReserved));
return { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining };
}