Backend: - Add Prisma soft-delete middleware for automatic deletedAt filtering (#10) - Split 2758-line copilot.service.ts into focused sub-services (#14): - copilot-schedule.service.ts (schedule/event tools) - copilot-reports.service.ts (reporting/analytics tools) - copilot-fleet.service.ts (vehicle/driver tools) - copilot-vip.service.ts (VIP management tools) - copilot.service.ts now thin orchestrator - Remove manual deletedAt: null from 50+ queries Frontend: - Create SortableHeader component for reusable table sorting (#16) - Create useListPage hook for shared search/filter/sort state (#16) - Update VipList, DriverList, EventList to use shared infrastructure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotFleetService {
|
||||
private readonly logger = new Logger(CopilotFleetService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAvailableVehicles(filters: Record<string, any>): Promise<ToolResult> {
|
||||
const where: any = { deletedAt: null, status: 'AVAILABLE' };
|
||||
|
||||
if (filters.type) {
|
||||
where.type = filters.type;
|
||||
}
|
||||
|
||||
if (filters.minSeats) {
|
||||
where.seatCapacity = { gte: filters.minSeats };
|
||||
}
|
||||
|
||||
const vehicles = await this.prisma.vehicle.findMany({
|
||||
where,
|
||||
orderBy: [{ type: 'asc' }, { seatCapacity: 'desc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: vehicles,
|
||||
message: `Found ${vehicles.length} available vehicle(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
async assignVehicleToEvent(eventId: string, vehicleId: string): Promise<ToolResult> {
|
||||
const event = await this.prisma.scheduleEvent.findFirst({
|
||||
where: { id: eventId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: `Event with ID ${eventId} not found.` };
|
||||
}
|
||||
|
||||
// If vehicleId is null, we're unassigning
|
||||
if (vehicleId === null || vehicleId === 'null') {
|
||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { vehicleId: null },
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedEvent,
|
||||
message: `Vehicle unassigned from event "${updatedEvent.title}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify vehicle exists
|
||||
const vehicle = await this.prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
return { success: false, error: `Vehicle with ID ${vehicleId} not found.` };
|
||||
}
|
||||
|
||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { vehicleId },
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedEvent,
|
||||
message: `Vehicle ${vehicle.name} assigned to event "${updatedEvent.title}"`,
|
||||
};
|
||||
}
|
||||
|
||||
async suggestVehicleForEvent(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { eventId } = input;
|
||||
|
||||
const event = await this.prisma.scheduleEvent.findFirst({
|
||||
where: { id: eventId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: `Event with ID ${eventId} not found.` };
|
||||
}
|
||||
|
||||
// Fetch VIP info to determine party size
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds } },
|
||||
select: { id: true, name: true, partySize: true },
|
||||
});
|
||||
|
||||
// Determine required capacity based on total party size
|
||||
const requiredSeats = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
|
||||
// Find vehicles not in use during this event time
|
||||
const busyVehicleIds = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
id: { not: eventId },
|
||||
status: { not: 'CANCELLED' },
|
||||
vehicleId: { not: null },
|
||||
OR: [
|
||||
{
|
||||
startTime: { lte: event.startTime },
|
||||
endTime: { gt: event.startTime },
|
||||
},
|
||||
{
|
||||
startTime: { lt: event.endTime },
|
||||
endTime: { gte: event.endTime },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { vehicleId: true },
|
||||
});
|
||||
|
||||
const busyIds = busyVehicleIds.map((e) => e.vehicleId).filter((id): id is string => id !== null);
|
||||
|
||||
// Find available vehicles with sufficient capacity
|
||||
const suitableVehicles = await this.prisma.vehicle.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: 'AVAILABLE',
|
||||
seatCapacity: { gte: requiredSeats },
|
||||
id: { notIn: busyIds },
|
||||
},
|
||||
orderBy: [
|
||||
{ seatCapacity: 'asc' }, // Prefer smallest suitable vehicle
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
eventId,
|
||||
eventTitle: event.title,
|
||||
vipNames: vips.map((v) => v.name),
|
||||
requiredSeats,
|
||||
suggestions: suitableVehicles.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
seatCapacity: v.seatCapacity,
|
||||
})),
|
||||
},
|
||||
message:
|
||||
suitableVehicles.length > 0
|
||||
? `Found ${suitableVehicles.length} suitable vehicle(s) for this event (requires ${requiredSeats} seat(s)).`
|
||||
: `No available vehicles found with capacity for ${requiredSeats} passenger(s) during this time.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getVehicleSchedule(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { vehicleName, vehicleId, startDate, endDate } = input;
|
||||
|
||||
let vehicle;
|
||||
|
||||
if (vehicleId) {
|
||||
vehicle = await this.prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, deletedAt: null },
|
||||
});
|
||||
} else if (vehicleName) {
|
||||
const vehicles = await this.prisma.vehicle.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
name: { contains: vehicleName, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
|
||||
if (vehicles.length === 0) {
|
||||
return { success: false, error: `No vehicle found matching "${vehicleName}".` };
|
||||
}
|
||||
|
||||
if (vehicles.length > 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Multiple vehicles match "${vehicleName}": ${vehicles.map((v) => v.name).join(', ')}. Please be more specific.`,
|
||||
};
|
||||
}
|
||||
|
||||
vehicle = vehicles[0];
|
||||
} else {
|
||||
return { success: false, error: 'Either vehicleName or vehicleId is required.' };
|
||||
}
|
||||
|
||||
if (!vehicle) {
|
||||
return { success: false, error: 'Vehicle not found.' };
|
||||
}
|
||||
|
||||
const dateStart = startOfDay(new Date(startDate));
|
||||
const dateEnd = new Date(endDate);
|
||||
dateEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
vehicleId: vehicle.id,
|
||||
startTime: { gte: dateStart, lte: dateEnd },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Fetch VIP names for all events
|
||||
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: uniqueVipIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||
|
||||
const totalHours =
|
||||
events.reduce((sum, e) => {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}, 0) / 3600000;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
vehicle: {
|
||||
id: vehicle.id,
|
||||
name: vehicle.name,
|
||||
type: vehicle.type,
|
||||
seatCapacity: vehicle.seatCapacity,
|
||||
status: vehicle.status,
|
||||
},
|
||||
dateRange: {
|
||||
start: toDateString(dateStart),
|
||||
end: toDateString(dateEnd),
|
||||
},
|
||||
eventCount: events.length,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
events: events.map((e) => ({
|
||||
eventId: e.id,
|
||||
title: e.title,
|
||||
type: e.type,
|
||||
startTime: e.startTime,
|
||||
endTime: e.endTime,
|
||||
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
driverName: e.driver?.name || null,
|
||||
pickupLocation: e.pickupLocation,
|
||||
dropoffLocation: e.dropoffLocation,
|
||||
location: e.location,
|
||||
})),
|
||||
},
|
||||
message: `Vehicle ${vehicle.name} has ${events.length} scheduled event(s) (${Math.round(totalHours * 10) / 10} hours total).`,
|
||||
};
|
||||
}
|
||||
|
||||
async searchDrivers(filters: Record<string, any>): Promise<ToolResult> {
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (filters.name) {
|
||||
where.name = { contains: filters.name, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
if (filters.department) {
|
||||
where.department = filters.department;
|
||||
}
|
||||
|
||||
if (filters.availableOnly) {
|
||||
where.isAvailable = true;
|
||||
}
|
||||
|
||||
const drivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: drivers,
|
||||
message: `Found ${drivers.length} driver(s) matching the criteria.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getDriverSchedule(
|
||||
driverId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
): Promise<ToolResult> {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
return { success: false, error: `Driver with ID ${driverId} not found.` };
|
||||
}
|
||||
|
||||
const where: any = {
|
||||
deletedAt: null,
|
||||
driverId,
|
||||
status: { not: 'CANCELLED' },
|
||||
};
|
||||
|
||||
if (startDate) {
|
||||
where.startTime = { gte: new Date(startDate) };
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
where.endTime = { lte: new Date(endDate) };
|
||||
}
|
||||
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where,
|
||||
include: {
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Fetch VIP names for all events
|
||||
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: uniqueVipIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||
|
||||
const eventsWithVipNames = events.map((event) => ({
|
||||
...event,
|
||||
vipNames: event.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
driver,
|
||||
events: eventsWithVipNames,
|
||||
eventCount: events.length,
|
||||
},
|
||||
message: `Driver ${driver.name} has ${events.length} scheduled event(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
async listAllDrivers(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { includeUnavailable = true } = input;
|
||||
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (!includeUnavailable) {
|
||||
where.isAvailable = true;
|
||||
}
|
||||
|
||||
const drivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
department: true,
|
||||
isAvailable: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: drivers,
|
||||
message: `Found ${drivers.length} driver(s) in the system.`,
|
||||
};
|
||||
}
|
||||
|
||||
async findAvailableDriversForTimerange(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { startTime, endTime, preferredDepartment } = input;
|
||||
|
||||
// Get all drivers
|
||||
const where: any = { deletedAt: null, isAvailable: true };
|
||||
|
||||
if (preferredDepartment) {
|
||||
where.department = preferredDepartment;
|
||||
}
|
||||
|
||||
const allDrivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
});
|
||||
|
||||
// Find drivers with conflicting events
|
||||
const busyDriverIds = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
driverId: { not: null },
|
||||
status: { not: 'CANCELLED' },
|
||||
OR: [
|
||||
{
|
||||
startTime: { lte: new Date(startTime) },
|
||||
endTime: { gt: new Date(startTime) },
|
||||
},
|
||||
{
|
||||
startTime: { lt: new Date(endTime) },
|
||||
endTime: { gte: new Date(endTime) },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { driverId: true },
|
||||
});
|
||||
|
||||
const busyIds = new Set(busyDriverIds.map((e) => e.driverId));
|
||||
|
||||
const availableDrivers = allDrivers.filter((d) => !busyIds.has(d.id));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: availableDrivers,
|
||||
message: `Found ${availableDrivers.length} available driver(s) for the specified time range.`,
|
||||
};
|
||||
}
|
||||
|
||||
async updateDriver(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { driverId, ...updates } = input;
|
||||
|
||||
const existingDriver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!existingDriver) {
|
||||
return { success: false, error: `Driver with ID ${driverId} not found.` };
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.phone !== undefined) updateData.phone = updates.phone;
|
||||
if (updates.department !== undefined) updateData.department = updates.department;
|
||||
if (updates.isAvailable !== undefined) updateData.isAvailable = updates.isAvailable;
|
||||
if (updates.shiftStartTime !== undefined) updateData.shiftStartTime = updates.shiftStartTime;
|
||||
if (updates.shiftEndTime !== undefined) updateData.shiftEndTime = updates.shiftEndTime;
|
||||
|
||||
const driver = await this.prisma.driver.update({
|
||||
where: { id: driverId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
this.logger.log(`Driver updated: ${driverId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: driver,
|
||||
message: `Driver ${driver.name} updated successfully.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user