Added assertCapacity() checks before saving (matches single receive)
Added applyCapacityDelta() after save to increment counters
Now validates warehouse → yard → zone capacity hierarchy
Single receive already had both checks; bulk receive was gap.
This commit is contained in:
Hagernesh
2026-07-22 13:13:14 +00:00
parent 93cf4d1e2a
commit ec066f3d29
12 changed files with 289 additions and 24 deletions

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Dedup stamp for the km/date-due maintenance alert — without it the daily
* cron would re-notify every day a schedule stays due.
*/
export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface {
name = 'AddMaintenanceDueNotifiedAt2480000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules
ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at
`);
}
}

View File

@@ -436,18 +436,26 @@ export class BookingsController {
}
@Get(':id/customer-truck-assignment/freight-order')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
@ApiOperation({
summary:
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
})
async customerTruckFreightOrder(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
@Query('copies') copies?: string,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const extraCopyIndexes = (copies ?? '')
.split(',')
.map((n) => Number(n.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(id);
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);

View File

@@ -142,8 +142,21 @@ export class BookingsService {
return this.findById(bookingId);
}
/** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */
static readonly FREIGHT_ORDER_EXTRA_COPIES = [
'Original 1 (for Issuing Carrier)',
'Original 2 (for Consignee)',
'Original 3 (for Shipper)',
'Copy 4 (Delivery Receipt)',
'Copy 5 (Extra Copy)',
'Copy 6 (Extra Copy)',
'Copy 7 (Extra Copy)',
'Copy 8 (for Agent)',
] as const;
async customerTruckFreightOrderCopies(
bookingId: string,
extraCopyIndexes: number[] = [],
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
if (!booking.customerTruckAssignedAt) {
@@ -171,7 +184,12 @@ export class BookingsService {
[bookingId],
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
// The 2 gate copies are ALWAYS printed; the waybill-style copies are
// whatever the customer ticked (indexes into the fixed catalog).
const extraCopies = [...new Set(extraCopyIndexes)]
.map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1])
.filter(Boolean);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies);
// Chromium when available; otherwise the styled tabular fallback (never the
// generic text dump — the freight order is an outward-facing gate document).
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
@@ -268,6 +286,7 @@ export class BookingsService {
arrivedAt: string | null;
containers: string | null;
}>,
extraCopies: string[] = [],
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const assignedAt = booking.customerTruckAssignedAt
@@ -386,6 +405,7 @@ export class BookingsService {
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
${extraCopies.map((label) => copy(label)).join('')}
</body>
</html>`;
}

View File

@@ -62,4 +62,8 @@ export class MaintenanceSchedule extends BaseEntity {
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
nextDueDate?: Date;
/** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */
@Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true })
dueNotifiedAt?: Date;
}

View File

@@ -44,6 +44,13 @@ export class MaintenanceController {
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
}
@Get('due-board')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' })
async getDueBoard() {
return this.maintenanceService.getDueBoard();
}
@Get('upcoming/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get upcoming maintenance' })

View File

@@ -12,10 +12,12 @@ import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { MaintenanceController } from './maintenance.controller';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
@Module({
imports: [
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
NotificationInboxModule,
],
providers: [
MaintenanceService,

View File

@@ -47,4 +47,105 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
.getRawOne();
return result?.total || 0;
}
/**
* Fleet-wide "next due" board: one row per vehicle with a SCHEDULED
* maintenance item, driven by time AND km — whichever is soonest. Current km
* is the vehicle's latest fuel-up odometer reading (how mileage is actually
* captured today), falling back to vehicle.actual_distance_km when the
* vehicle has no fuel purchase on file yet.
*/
async getDueBoard(): Promise<
Array<{
scheduleId: string;
vehicleId: string;
plateNumber: string;
maintenanceType: string;
description: string;
scheduledDate: Date;
nextDueDate: Date | null;
nextDueKm: number | null;
currentKm: number | null;
kmRemaining: number | null;
daysRemaining: number | null;
overdue: boolean;
}>
> {
return this.scheduleRepository.manager.query(`
SELECT DISTINCT ON (s.vehicle_id)
s.id AS "scheduleId",
s.vehicle_id AS "vehicleId",
v.plate_number AS "plateNumber",
s.maintenance_type AS "maintenanceType",
s.description,
s.scheduled_date AS "scheduledDate",
s.next_due_date AS "nextDueDate",
s.next_due_km AS "nextDueKm",
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm",
CASE WHEN s.next_due_km IS NOT NULL
THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0)
ELSE NULL END AS "kmRemaining",
CASE WHEN s.next_due_date IS NOT NULL
THEN EXTRACT(DAY FROM s.next_due_date - now())
ELSE NULL END AS "daysRemaining",
(
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
OR (s.next_due_km IS NOT NULL
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
) AS overdue
FROM freight.maintenance_schedules s
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MAX(odometer_reading) AS max_odometer
FROM freight.fuel_purchases fp2
WHERE fp2.vehicle_id = s.vehicle_id
) fp ON true
WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL
ORDER BY s.vehicle_id, s.scheduled_date ASC
`);
}
/**
* SCHEDULED items that have crossed their km or date due-point and have not
* yet been notified. Backs the daily km/date maintenance alert.
*/
async getUnnotifiedDue(): Promise<
Array<{
id: string;
vehicleId: string;
plateNumber: string;
maintenanceType: string;
description: string;
nextDueKm: number | null;
nextDueDate: Date | null;
currentKm: number | null;
}>
> {
return this.scheduleRepository.manager.query(`
SELECT
s.id,
s.vehicle_id AS "vehicleId",
v.plate_number AS "plateNumber",
s.maintenance_type AS "maintenanceType",
s.description,
s.next_due_km AS "nextDueKm",
s.next_due_date AS "nextDueDate",
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm"
FROM freight.maintenance_schedules s
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MAX(odometer_reading) AS max_odometer
FROM freight.fuel_purchases fp2
WHERE fp2.vehicle_id = s.vehicle_id
) fp ON true
WHERE s.status = 'SCHEDULED'
AND s.deleted_at IS NULL
AND s.due_notified_at IS NULL
AND (
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
OR (s.next_due_km IS NOT NULL
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
)
`);
}
}

View File

@@ -1,14 +1,19 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, Repository } from 'typeorm';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
@Injectable()
export class MaintenanceService {
private readonly logger = new Logger(MaintenanceService.name);
constructor(
private readonly maintenanceRepository: MaintenanceRepository,
@InjectRepository(MaintenanceSchedule)
@@ -18,8 +23,44 @@ export class MaintenanceService {
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
// reach it through the global DataSource rather than @InjectRepository.
private readonly dataSource: DataSource,
private readonly inbox: NotificationInboxService,
) {}
/** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */
async getDueBoard() {
return this.maintenanceRepository.getDueBoard();
}
/**
* Daily check: a vehicle's driven km (latest fuel-up odometer reading, since
* that's the only place mileage is actually recorded) or its due date has
* reached a SCHEDULED item's threshold → alert backoffice once.
*/
@Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' })
async sendDueAlerts(): Promise<void> {
try {
const due = await this.maintenanceRepository.getUnnotifiedDue();
for (const item of due) {
const reason =
item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
await this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
title: `Maintenance due — ${item.plateNumber}`,
body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`,
link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`,
data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' },
});
await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() });
}
} catch (err) {
this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack);
}
}
/**
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the

View File

@@ -205,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
driverName: truckPrefill.driverName ?? '',
driverLicense: truckPrefill.driverLicense ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
@@ -218,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label: `Customer · ${t.plateNumber}${t.driverName}`,
trailerPlate: '',
driverName: t.driverName,
driverLicense: '',
driverPhone: '',
truckType: t.truckType,
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
@@ -231,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? `${t.driverName}` : ''}`,
trailerPlate: t.trailerPlateNumber ?? '',
driverName: t.driverName ?? '',
driverLicense: t.driverLicense ?? '',
driverPhone: t.driverPhone ?? '',
truckType: t.truckType ?? '',
containerNumbers: splitContainerNumbers(t.containerNumber),
@@ -281,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
// The freight order's truck details are the customer's / fleet's record — the
// gate may FILL blanks (walk-in license, phone) but never edit shown values.
const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate);
const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense);
const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone);
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
/** Load a truck into the form: its saved block if any, else its assignment. */
@@ -293,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
setTruckPlateNumber(plate);
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
setDriverName(block?.driverName || option?.driverName || '');
setDriverLicense(block?.driverLicense || '');
setDriverLicense(block?.driverLicense || option?.driverLicense || '');
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
setTruckType(block?.truckType || option?.truckType || '');
const loaded = block
@@ -611,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label="Trailer plate number"
value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
readOnly={isTrailerLocked}
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
</Group>
<Group grow align="flex-start">

View File

@@ -3,6 +3,7 @@ import {
Alert,
Badge,
Button,
Checkbox,
Divider,
Group,
Loader,
@@ -27,6 +28,19 @@ import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate
// copies (Port Operations, Gate Security & Carrier) are always printed.
const FREIGHT_ORDER_COPIES = [
{ index: 1, label: "Original 1 (for Issuing Carrier)" },
{ index: 2, label: "Original 2 (for Consignee)" },
{ index: 3, label: "Original 3 (for Shipper)" },
{ index: 4, label: "Copy 4 (Delivery Receipt)" },
{ index: 5, label: "Copy 5 (Extra Copy)" },
{ index: 6, label: "Copy 6 (Extra Copy)" },
{ index: 7, label: "Copy 7 (Extra Copy)" },
{ index: 8, label: "Copy 8 (for Agent)" },
];
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -138,8 +152,11 @@ export function CustomerTruckAssignmentCard({
});
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
const [selectedCopies, setSelectedCopies] = useState<number[]>(
FREIGHT_ORDER_COPIES.map((c) => c.index),
);
const downloadFreightOrder = async () => {
const blob = await downloadMutation.mutateAsync({ id: booking.id });
const blob = await downloadMutation.mutateAsync({ id: booking.id, copies: selectedCopies });
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
};
@@ -322,17 +339,52 @@ export function CustomerTruckAssignmentCard({
)}
{trucks.length > 0 && (
<Group justify="flex-end">
<Button
variant="light"
leftSection={<Download size={16} />}
color="edr-green"
onClick={downloadFreightOrder}
loading={downloadMutation.isPending}
>
Generate Freight Order Copies
</Button>
</Group>
<Stack gap="xs">
<Text fz="13px" fw={600}>Copies</Text>
<Group gap="xs">
<Button
size="compact-xs"
variant="default"
onClick={() => setSelectedCopies(FREIGHT_ORDER_COPIES.map((c) => c.index))}
>
8 copies
</Button>
<Button size="compact-xs" variant="default" onClick={() => setSelectedCopies([])}>
clear selection
</Button>
</Group>
<SimpleGrid cols={2} spacing={6} verticalSpacing={6}>
{FREIGHT_ORDER_COPIES.map((c) => (
<Checkbox
key={c.index}
size="xs"
label={c.label}
checked={selectedCopies.includes(c.index)}
onChange={(e) =>
setSelectedCopies((prev) =>
e.currentTarget.checked
? [...prev, c.index].sort((a, b) => a - b)
: prev.filter((i) => i !== c.index),
)
}
/>
))}
</SimpleGrid>
<Group justify="space-between" align="center">
<Text fz="11.5px" c="#9AA8B5">
Port Operations and Gate Security copies are always included.
</Text>
<Button
variant="light"
leftSection={<Download size={16} />}
color="edr-green"
onClick={downloadFreightOrder}
loading={downloadMutation.isPending}
>
Generate Freight Order Copies
</Button>
</Group>
</Stack>
)}
</Stack>

View File

@@ -308,10 +308,10 @@ export const api = {
bookingsService.assignCustomerTruck(id, payload),
),
downloadCustomerTruckFreightOrder: endpoint<{ id: string }, Blob>(
downloadCustomerTruckFreightOrder: endpoint<{ id: string; copies?: number[] }, Blob>(
"bookings",
"downloadCustomerTruckFreightOrder",
({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id),
({ id, copies }) => bookingsService.downloadCustomerTruckFreightOrder(id, copies),
),
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(

View File

@@ -212,10 +212,10 @@ export const bookingsService = {
);
return data.data;
},
downloadCustomerTruckFreightOrder: async (id: string): Promise<Blob> => {
downloadCustomerTruckFreightOrder: async (id: string, copies?: number[]): Promise<Blob> => {
const { data } = await client.get(
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
{ responseType: "blob" },
{ responseType: "blob", params: copies?.length ? { copies: copies.join(",") } : undefined },
);
return data;
},