Support schedule trip variants
This commit is contained in:
26
backend/migrations/006_schedule_trip_variants.sql
Normal file
26
backend/migrations/006_schedule_trip_variants.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Per-departure metadata for partial, via-stop, and calendar-specific trips.
|
||||||
|
-- Existing clients can continue to use departure_times; trips is the richer source.
|
||||||
|
ALTER TABLE schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS trips JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
UPDATE schedules
|
||||||
|
SET trips = (
|
||||||
|
SELECT COALESCE(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'departureTime', to_char(departure_time, 'HH24:MI:SS'),
|
||||||
|
'startStopId', NULL,
|
||||||
|
'endStopId', NULL,
|
||||||
|
'viaStopIds', '[]'::jsonb,
|
||||||
|
'daysOfWeek', '[]'::jsonb,
|
||||||
|
'exceptDaysOfWeek', '[]'::jsonb,
|
||||||
|
'note', ''
|
||||||
|
)
|
||||||
|
ORDER BY departure_time
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
FROM unnest(departure_times) AS expanded_departures(departure_time)
|
||||||
|
)
|
||||||
|
WHERE trips = '[]'::jsonb
|
||||||
|
AND departure_times IS NOT NULL;
|
||||||
@@ -23,7 +23,7 @@ class EtaController {
|
|||||||
|
|
||||||
// Get schedule for this route
|
// Get schedule for this route
|
||||||
let scheduleResult = await pool.query(`
|
let scheduleResult = await pool.query(`
|
||||||
SELECT departure_times
|
SELECT departure_times, trips
|
||||||
FROM schedules
|
FROM schedules
|
||||||
WHERE route_id = $1
|
WHERE route_id = $1
|
||||||
AND day_type = $2
|
AND day_type = $2
|
||||||
@@ -36,7 +36,7 @@ class EtaController {
|
|||||||
if (scheduleResult.rows.length === 0 && scheduleType === 'holiday') {
|
if (scheduleResult.rows.length === 0 && scheduleType === 'holiday') {
|
||||||
scheduleType = 'sunday';
|
scheduleType = 'sunday';
|
||||||
scheduleResult = await pool.query(`
|
scheduleResult = await pool.query(`
|
||||||
SELECT departure_times
|
SELECT departure_times, trips
|
||||||
FROM schedules
|
FROM schedules
|
||||||
WHERE route_id = $1
|
WHERE route_id = $1
|
||||||
AND day_type = 'sunday'
|
AND day_type = 'sunday'
|
||||||
@@ -54,60 +54,65 @@ class EtaController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const departureTimes = scheduleResult.rows[0].departure_times;
|
// Get stops for this route so partial trips can be filtered by sequence.
|
||||||
|
const stopsResult = await pool.query(`
|
||||||
// Get time offset for this stop
|
SELECT stop_id, sequence, time_offset_minutes
|
||||||
const stopResult = await pool.query(`
|
|
||||||
SELECT time_offset_minutes
|
|
||||||
FROM route_stops
|
FROM route_stops
|
||||||
WHERE route_id = $1 AND stop_id = $2
|
WHERE route_id = $1
|
||||||
`, [routeId, stopId]);
|
ORDER BY sequence
|
||||||
|
`, [routeId]);
|
||||||
|
|
||||||
if (stopResult.rows.length === 0) {
|
const routeStops = stopsResult.rows;
|
||||||
|
const targetStop = routeStops.find(stop => Number(stop.stop_id) === Number(stopId));
|
||||||
|
|
||||||
|
if (!targetStop) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: 'Stop not found on route',
|
error: 'Stop not found on route',
|
||||||
message: 'This stop is not part of this route'
|
message: 'This stop is not part of this route'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeOffset = stopResult.rows[0].time_offset_minutes;
|
const stopsById = this.buildStopsById(routeStops);
|
||||||
|
const trips = this.getScheduleTrips(scheduleResult.rows[0]);
|
||||||
|
const activeTrips = trips.filter(trip => (
|
||||||
|
this.isTripActiveOnDate(trip, currentTime) &&
|
||||||
|
this.tripServesStop(trip, targetStop, stopsById)
|
||||||
|
));
|
||||||
|
|
||||||
// Calculate ETAs
|
// Calculate ETAs
|
||||||
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
|
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
|
||||||
const etas = [];
|
const etas = [];
|
||||||
|
|
||||||
for (const departureTime of departureTimes) {
|
for (const trip of activeTrips) {
|
||||||
if (departureTime > currentTimeStr) {
|
const arrival = this.getTripArrivalTime(trip, targetStop, stopsById);
|
||||||
// Add time offset to get arrival time at this stop
|
if (!arrival) continue;
|
||||||
const arrival = this.addMinutesToTime(departureTime, timeOffset);
|
|
||||||
|
|
||||||
// Get historical delay for this hour and day type
|
|
||||||
const delay = await this.getHistoricalDelay(
|
|
||||||
routeId,
|
|
||||||
stopId,
|
|
||||||
currentTime.getHours(),
|
|
||||||
scheduleType === 'holiday' ? 'sunday' : scheduleType
|
|
||||||
);
|
|
||||||
|
|
||||||
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
const delay = await this.getHistoricalDelay(
|
||||||
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
routeId,
|
||||||
|
stopId,
|
||||||
|
currentTime.getHours(),
|
||||||
|
scheduleType === 'holiday' ? 'sunday' : scheduleType
|
||||||
|
);
|
||||||
|
|
||||||
etas.push({
|
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
||||||
scheduledTime: arrival,
|
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
||||||
estimatedTime: adjustedArrival,
|
if (minutesUntil < 0) continue;
|
||||||
minutesUntil: minutesUntil,
|
|
||||||
delay: delay
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return next 3 arrivals
|
etas.push({
|
||||||
if (etas.length >= 3) break;
|
scheduledTime: arrival,
|
||||||
}
|
estimatedTime: adjustedArrival,
|
||||||
|
minutesUntil: minutesUntil,
|
||||||
|
delay: delay,
|
||||||
|
trip: this.publicTripInfo(trip)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
etas.sort((a, b) => a.minutesUntil - b.minutesUntil);
|
||||||
|
|
||||||
if (etas.length === 0) {
|
if (etas.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
message: 'No more arrivals today',
|
message: 'No more arrivals today',
|
||||||
nextDay: departureTimes[0]
|
nextDay: activeTrips[0]?.departureTime || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +121,7 @@ class EtaController {
|
|||||||
stopId,
|
stopId,
|
||||||
currentTime: currentTimeStr,
|
currentTime: currentTimeStr,
|
||||||
dayType: scheduleType,
|
dayType: scheduleType,
|
||||||
arrivals: etas
|
arrivals: etas.slice(0, 3)
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -170,10 +175,19 @@ class EtaController {
|
|||||||
// Helper: Calculate ETA for a specific route (internal use)
|
// Helper: Calculate ETA for a specific route (internal use)
|
||||||
async calculateEtaForRoute(stopId, routeId) {
|
async calculateEtaForRoute(stopId, routeId) {
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
const dayType = this.getDayType(currentTime);
|
let dayType = this.getDayType(currentTime);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const typeResult = await pool.query(
|
||||||
|
'SELECT get_schedule_type(CURRENT_DATE) as schedule_type'
|
||||||
|
);
|
||||||
|
dayType = typeResult.rows[0].schedule_type;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching SQL schedule type:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleResult = await pool.query(`
|
let scheduleResult = await pool.query(`
|
||||||
SELECT departure_times
|
SELECT departure_times, trips
|
||||||
FROM schedules
|
FROM schedules
|
||||||
WHERE route_id = $1 AND day_type = $2
|
WHERE route_id = $1 AND day_type = $2
|
||||||
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||||
@@ -181,47 +195,66 @@ class EtaController {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, [routeId, dayType]);
|
`, [routeId, dayType]);
|
||||||
|
|
||||||
|
if (scheduleResult.rows.length === 0 && dayType === 'holiday') {
|
||||||
|
dayType = 'sunday';
|
||||||
|
scheduleResult = await pool.query(`
|
||||||
|
SELECT departure_times, trips
|
||||||
|
FROM schedules
|
||||||
|
WHERE route_id = $1 AND day_type = 'sunday'
|
||||||
|
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||||
|
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||||
|
LIMIT 1
|
||||||
|
`, [routeId]);
|
||||||
|
}
|
||||||
|
|
||||||
if (scheduleResult.rows.length === 0) return [];
|
if (scheduleResult.rows.length === 0) return [];
|
||||||
|
|
||||||
const departureTimes = scheduleResult.rows[0].departure_times;
|
const stopsResult = await pool.query(`
|
||||||
|
SELECT stop_id, sequence, time_offset_minutes
|
||||||
const stopResult = await pool.query(`
|
|
||||||
SELECT time_offset_minutes
|
|
||||||
FROM route_stops
|
FROM route_stops
|
||||||
WHERE route_id = $1 AND stop_id = $2
|
WHERE route_id = $1
|
||||||
`, [routeId, stopId]);
|
ORDER BY sequence
|
||||||
|
`, [routeId]);
|
||||||
|
|
||||||
if (stopResult.rows.length === 0) return [];
|
const routeStops = stopsResult.rows;
|
||||||
|
const targetStop = routeStops.find(stop => Number(stop.stop_id) === Number(stopId));
|
||||||
|
if (!targetStop) return [];
|
||||||
|
|
||||||
|
const stopsById = this.buildStopsById(routeStops);
|
||||||
|
const trips = this.getScheduleTrips(scheduleResult.rows[0]);
|
||||||
|
const activeTrips = trips.filter(trip => (
|
||||||
|
this.isTripActiveOnDate(trip, currentTime) &&
|
||||||
|
this.tripServesStop(trip, targetStop, stopsById)
|
||||||
|
));
|
||||||
|
|
||||||
const timeOffset = stopResult.rows[0].time_offset_minutes;
|
|
||||||
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
|
const currentTimeStr = currentTime.toTimeString().split(' ')[0];
|
||||||
const etas = [];
|
const etas = [];
|
||||||
|
|
||||||
for (const departureTime of departureTimes) {
|
for (const trip of activeTrips) {
|
||||||
if (departureTime > currentTimeStr) {
|
const arrival = this.getTripArrivalTime(trip, targetStop, stopsById);
|
||||||
const arrival = this.addMinutesToTime(departureTime, timeOffset);
|
if (!arrival) continue;
|
||||||
const delay = await this.getHistoricalDelay(
|
|
||||||
routeId,
|
|
||||||
stopId,
|
|
||||||
currentTime.getHours(),
|
|
||||||
dayType
|
|
||||||
);
|
|
||||||
|
|
||||||
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
const delay = await this.getHistoricalDelay(
|
||||||
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
routeId,
|
||||||
|
stopId,
|
||||||
|
currentTime.getHours(),
|
||||||
|
dayType
|
||||||
|
);
|
||||||
|
|
||||||
etas.push({
|
const adjustedArrival = this.addMinutesToTime(arrival, delay);
|
||||||
scheduledTime: arrival,
|
const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival);
|
||||||
estimatedTime: adjustedArrival,
|
if (minutesUntil < 0) continue;
|
||||||
minutesUntil: minutesUntil,
|
|
||||||
delay: delay
|
|
||||||
});
|
|
||||||
|
|
||||||
if (etas.length >= 3) break;
|
etas.push({
|
||||||
}
|
scheduledTime: arrival,
|
||||||
|
estimatedTime: adjustedArrival,
|
||||||
|
minutesUntil: minutesUntil,
|
||||||
|
delay: delay,
|
||||||
|
trip: this.publicTripInfo(trip)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return etas;
|
return etas.sort((a, b) => a.minutesUntil - b.minutesUntil).slice(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Get historical delay
|
// Helper: Get historical delay
|
||||||
@@ -255,8 +288,9 @@ class EtaController {
|
|||||||
addMinutesToTime(timeStr, minutes) {
|
addMinutesToTime(timeStr, minutes) {
|
||||||
const [hours, mins, secs] = timeStr.split(':').map(Number);
|
const [hours, mins, secs] = timeStr.split(':').map(Number);
|
||||||
const totalMins = hours * 60 + mins + minutes;
|
const totalMins = hours * 60 + mins + minutes;
|
||||||
const newHours = Math.floor(totalMins / 60) % 24;
|
const normalizedMins = ((totalMins % 1440) + 1440) % 1440;
|
||||||
const newMins = totalMins % 60;
|
const newHours = Math.floor(normalizedMins / 60);
|
||||||
|
const newMins = normalizedMins % 60;
|
||||||
return `${String(newHours).padStart(2, '0')}:${String(newMins).padStart(2, '0')}:${String(secs || 0).padStart(2, '0')}`;
|
return `${String(newHours).padStart(2, '0')}:${String(newMins).padStart(2, '0')}:${String(secs || 0).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +300,101 @@ class EtaController {
|
|||||||
const [h2, m2] = time2.split(':').map(Number);
|
const [h2, m2] = time2.split(':').map(Number);
|
||||||
return (h2 * 60 + m2) - (h1 * 60 + m1);
|
return (h2 * 60 + m2) - (h1 * 60 + m1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getScheduleTrips(schedule) {
|
||||||
|
const trips = this.parseTrips(schedule.trips);
|
||||||
|
if (trips.length > 0) return trips;
|
||||||
|
|
||||||
|
return (schedule.departure_times || []).map(departureTime => ({
|
||||||
|
departureTime,
|
||||||
|
startStopId: null,
|
||||||
|
endStopId: null,
|
||||||
|
viaStopIds: [],
|
||||||
|
daysOfWeek: [],
|
||||||
|
exceptDaysOfWeek: [],
|
||||||
|
note: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
parseTrips(trips) {
|
||||||
|
if (Array.isArray(trips)) return trips;
|
||||||
|
if (typeof trips !== 'string') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedTrips = JSON.parse(trips);
|
||||||
|
return Array.isArray(parsedTrips) ? parsedTrips : [];
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildStopsById(routeStops) {
|
||||||
|
return routeStops.reduce((stopsById, stop) => {
|
||||||
|
stopsById[Number(stop.stop_id)] = {
|
||||||
|
...stop,
|
||||||
|
stop_id: Number(stop.stop_id),
|
||||||
|
sequence: Number(stop.sequence),
|
||||||
|
time_offset_minutes: Number(stop.time_offset_minutes || 0)
|
||||||
|
};
|
||||||
|
return stopsById;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
tripServesStop(trip, targetStop, stopsById) {
|
||||||
|
const targetSequence = Number(targetStop.sequence);
|
||||||
|
const startStop = trip.startStopId ? stopsById[Number(trip.startStopId)] : null;
|
||||||
|
const endStop = trip.endStopId ? stopsById[Number(trip.endStopId)] : null;
|
||||||
|
|
||||||
|
if (startStop && endStop && startStop.sequence > endStop.sequence) {
|
||||||
|
return targetSequence <= startStop.sequence && targetSequence >= endStop.sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startStop && targetSequence < startStop.sequence) return false;
|
||||||
|
if (endStop && targetSequence > endStop.sequence) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTripArrivalTime(trip, targetStop, stopsById) {
|
||||||
|
if (!trip.departureTime) return null;
|
||||||
|
|
||||||
|
const startStop = trip.startStopId ? stopsById[Number(trip.startStopId)] : null;
|
||||||
|
const targetOffset = Number(targetStop.time_offset_minutes || 0);
|
||||||
|
const startOffset = startStop ? Number(startStop.time_offset_minutes || 0) : 0;
|
||||||
|
const offsetDelta = startStop && Number(startStop.sequence) > Number(targetStop.sequence)
|
||||||
|
? startOffset - targetOffset
|
||||||
|
: targetOffset - startOffset;
|
||||||
|
|
||||||
|
return this.addMinutesToTime(trip.departureTime, offsetDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
isTripActiveOnDate(trip, date) {
|
||||||
|
const isoWeekday = this.getIsoWeekday(date);
|
||||||
|
const daysOfWeek = Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek.map(Number) : [];
|
||||||
|
const exceptDaysOfWeek = Array.isArray(trip.exceptDaysOfWeek)
|
||||||
|
? trip.exceptDaysOfWeek.map(Number)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (daysOfWeek.length > 0 && !daysOfWeek.includes(isoWeekday)) return false;
|
||||||
|
if (exceptDaysOfWeek.includes(isoWeekday)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIsoWeekday(date) {
|
||||||
|
const day = date.getDay();
|
||||||
|
return day === 0 ? 7 : day;
|
||||||
|
}
|
||||||
|
|
||||||
|
publicTripInfo(trip) {
|
||||||
|
return {
|
||||||
|
departureTime: trip.departureTime,
|
||||||
|
startStopId: trip.startStopId || null,
|
||||||
|
endStopId: trip.endStopId || null,
|
||||||
|
viaStopIds: Array.isArray(trip.viaStopIds) ? trip.viaStopIds : [],
|
||||||
|
daysOfWeek: Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek : [],
|
||||||
|
exceptDaysOfWeek: Array.isArray(trip.exceptDaysOfWeek) ? trip.exceptDaysOfWeek : [],
|
||||||
|
note: trip.note || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new EtaController();
|
module.exports = new EtaController();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const pool = require('../config/database');
|
|||||||
const redisClient = require('../config/redis');
|
const redisClient = require('../config/redis');
|
||||||
const logger = require('../config/logger');
|
const logger = require('../config/logger');
|
||||||
|
|
||||||
|
const VALID_DAY_TYPES = ['weekday', 'saturday', 'sunday', 'holiday'];
|
||||||
|
|
||||||
class SchedulesController {
|
class SchedulesController {
|
||||||
// Get all schedules for a route
|
// Get all schedules for a route
|
||||||
async getRouteSchedules(req, res) {
|
async getRouteSchedules(req, res) {
|
||||||
@@ -25,7 +27,7 @@ class SchedulesController {
|
|||||||
END
|
END
|
||||||
`, [routeId]);
|
`, [routeId]);
|
||||||
|
|
||||||
res.json(result.rows);
|
res.json(result.rows.map(schedule => this.withScheduleTrips(schedule)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching route schedules:', error);
|
logger.error('Error fetching route schedules:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch schedules' });
|
res.status(500).json({ error: 'Failed to fetch schedules' });
|
||||||
@@ -51,7 +53,7 @@ class SchedulesController {
|
|||||||
return res.status(404).json({ error: 'Schedule not found' });
|
return res.status(404).json({ error: 'Schedule not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
res.json(this.withScheduleTrips(result.rows[0]));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching schedule:', error);
|
logger.error('Error fetching schedule:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch schedule' });
|
res.status(500).json({ error: 'Failed to fetch schedule' });
|
||||||
@@ -60,30 +62,28 @@ class SchedulesController {
|
|||||||
|
|
||||||
// Create schedule
|
// Create schedule
|
||||||
async createSchedule(req, res) {
|
async createSchedule(req, res) {
|
||||||
const { routeId, dayType, departureTimes, validFrom, validUntil } = req.body;
|
const { routeId, dayType, departureTimes, trips, validFrom, validUntil } = req.body;
|
||||||
|
|
||||||
if (!routeId || !dayType || !departureTimes) {
|
if (!routeId || !dayType || (!departureTimes && !trips)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Missing required fields',
|
error: 'Missing required fields',
|
||||||
required: ['routeId', 'dayType', 'departureTimes']
|
required: ['routeId', 'dayType', 'departureTimes or trips']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate day type
|
// Validate day type
|
||||||
const validDayTypes = ['weekday', 'saturday', 'sunday', 'holiday'];
|
if (!VALID_DAY_TYPES.includes(dayType)) {
|
||||||
if (!validDayTypes.includes(dayType)) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid day type',
|
error: 'Invalid day type',
|
||||||
valid: validDayTypes
|
valid: VALID_DAY_TYPES
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and sort departure times
|
const normalizedSchedule = this.normalizeScheduleInput({ departureTimes, trips });
|
||||||
const sortedTimes = this.validateAndSortTimes(departureTimes);
|
if (normalizedSchedule.error) {
|
||||||
if (!sortedTimes) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid departure times format',
|
error: normalizedSchedule.error,
|
||||||
expected: 'Array of times in HH:MM or HH:MM:SS format'
|
expected: normalizedSchedule.expected
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,16 +104,23 @@ class SchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
INSERT INTO schedules (route_id, day_type, departure_times, valid_from, valid_until)
|
INSERT INTO schedules (route_id, day_type, departure_times, trips, valid_from, valid_until)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`, [routeId, dayType, sortedTimes, validFrom || null, validUntil || null]);
|
`, [
|
||||||
|
routeId,
|
||||||
|
dayType,
|
||||||
|
normalizedSchedule.departureTimes,
|
||||||
|
JSON.stringify(normalizedSchedule.trips),
|
||||||
|
validFrom || null,
|
||||||
|
validUntil || null
|
||||||
|
]);
|
||||||
|
|
||||||
// Invalidate cache
|
// Invalidate cache
|
||||||
await redisClient.del(`route:${routeId}`);
|
await redisClient.del(`route:${routeId}`);
|
||||||
|
|
||||||
logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`);
|
logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`);
|
||||||
res.status(201).json(result.rows[0]);
|
res.status(201).json(this.withScheduleTrips(result.rows[0]));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating schedule:', error);
|
logger.error('Error creating schedule:', error);
|
||||||
res.status(500).json({ error: 'Failed to create schedule' });
|
res.status(500).json({ error: 'Failed to create schedule' });
|
||||||
@@ -123,7 +130,7 @@ class SchedulesController {
|
|||||||
// Update schedule
|
// Update schedule
|
||||||
async updateSchedule(req, res) {
|
async updateSchedule(req, res) {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { departureTimes, validFrom, validUntil } = req.body;
|
const { departureTimes, trips, validFrom, validUntil } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current schedule to find route_id
|
// Get current schedule to find route_id
|
||||||
@@ -132,12 +139,13 @@ class SchedulesController {
|
|||||||
return res.status(404).json({ error: 'Schedule not found' });
|
return res.status(404).json({ error: 'Schedule not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let sortedTimes = null;
|
let normalizedSchedule = null;
|
||||||
if (departureTimes) {
|
if (departureTimes !== undefined || trips !== undefined) {
|
||||||
sortedTimes = this.validateAndSortTimes(departureTimes);
|
normalizedSchedule = this.normalizeScheduleInput({ departureTimes, trips });
|
||||||
if (!sortedTimes) {
|
if (normalizedSchedule.error) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid departure times format'
|
error: normalizedSchedule.error,
|
||||||
|
expected: normalizedSchedule.expected
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,18 +154,25 @@ class SchedulesController {
|
|||||||
UPDATE schedules
|
UPDATE schedules
|
||||||
SET
|
SET
|
||||||
departure_times = COALESCE($1, departure_times),
|
departure_times = COALESCE($1, departure_times),
|
||||||
valid_from = COALESCE($2, valid_from),
|
trips = COALESCE($2::jsonb, trips),
|
||||||
valid_until = COALESCE($3, valid_until),
|
valid_from = COALESCE($3, valid_from),
|
||||||
|
valid_until = COALESCE($4, valid_until),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $4
|
WHERE id = $5
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`, [sortedTimes, validFrom, validUntil, id]);
|
`, [
|
||||||
|
normalizedSchedule?.departureTimes || null,
|
||||||
|
normalizedSchedule ? JSON.stringify(normalizedSchedule.trips) : null,
|
||||||
|
validFrom,
|
||||||
|
validUntil,
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
|
||||||
// Invalidate cache
|
// Invalidate cache
|
||||||
await redisClient.del(`route:${current.rows[0].route_id}`);
|
await redisClient.del(`route:${current.rows[0].route_id}`);
|
||||||
|
|
||||||
logger.info(`Schedule ${id} updated`);
|
logger.info(`Schedule ${id} updated`);
|
||||||
res.json(result.rows[0]);
|
res.json(this.withScheduleTrips(result.rows[0]));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating schedule:', error);
|
logger.error('Error updating schedule:', error);
|
||||||
res.status(500).json({ error: 'Failed to update schedule' });
|
res.status(500).json({ error: 'Failed to update schedule' });
|
||||||
@@ -251,7 +266,7 @@ class SchedulesController {
|
|||||||
scheduleType: scheduleType,
|
scheduleType: scheduleType,
|
||||||
isHoliday: holidayCheck.rows.length > 0,
|
isHoliday: holidayCheck.rows.length > 0,
|
||||||
holidayName: holidayCheck.rows[0]?.name || null,
|
holidayName: holidayCheck.rows[0]?.name || null,
|
||||||
schedule: result.rows[0]
|
schedule: this.withScheduleTrips(result.rows[0])
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching today schedule:', error);
|
logger.error('Error fetching today schedule:', error);
|
||||||
@@ -274,7 +289,7 @@ class SchedulesController {
|
|||||||
try {
|
try {
|
||||||
// Get source schedule
|
// Get source schedule
|
||||||
const source = await pool.query(`
|
const source = await pool.query(`
|
||||||
SELECT departure_times FROM schedules
|
SELECT departure_times, trips FROM schedules
|
||||||
WHERE route_id = $1 AND day_type = $2
|
WHERE route_id = $1 AND day_type = $2
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -286,6 +301,8 @@ class SchedulesController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceSchedule = this.withScheduleTrips(source.rows[0]);
|
||||||
|
|
||||||
// Check if target already exists
|
// Check if target already exists
|
||||||
const existing = await pool.query(`
|
const existing = await pool.query(`
|
||||||
SELECT id FROM schedules
|
SELECT id FROM schedules
|
||||||
@@ -296,21 +313,31 @@ class SchedulesController {
|
|||||||
// Update existing
|
// Update existing
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
UPDATE schedules
|
UPDATE schedules
|
||||||
SET departure_times = $1, updated_at = CURRENT_TIMESTAMP
|
SET departure_times = $1, trips = $2::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE route_id = $2 AND day_type = $3
|
WHERE route_id = $3 AND day_type = $4
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`, [source.rows[0].departure_times, routeId, toDayType]);
|
`, [
|
||||||
|
sourceSchedule.departure_times,
|
||||||
|
JSON.stringify(sourceSchedule.trips),
|
||||||
|
routeId,
|
||||||
|
toDayType
|
||||||
|
]);
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
res.json(this.withScheduleTrips(result.rows[0]));
|
||||||
} else {
|
} else {
|
||||||
// Create new
|
// Create new
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
INSERT INTO schedules (route_id, day_type, departure_times)
|
INSERT INTO schedules (route_id, day_type, departure_times, trips)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4::jsonb)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`, [routeId, toDayType, source.rows[0].departure_times]);
|
`, [
|
||||||
|
routeId,
|
||||||
|
toDayType,
|
||||||
|
sourceSchedule.departure_times,
|
||||||
|
JSON.stringify(sourceSchedule.trips)
|
||||||
|
]);
|
||||||
|
|
||||||
res.status(201).json(result.rows[0]);
|
res.status(201).json(this.withScheduleTrips(result.rows[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache
|
// Invalidate cache
|
||||||
@@ -327,15 +354,11 @@ class SchedulesController {
|
|||||||
validateAndSortTimes(times) {
|
validateAndSortTimes(times) {
|
||||||
if (!Array.isArray(times)) return null;
|
if (!Array.isArray(times)) return null;
|
||||||
|
|
||||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(:[0-5][0-9])?$/;
|
|
||||||
const validTimes = [];
|
const validTimes = [];
|
||||||
|
|
||||||
for (const time of times) {
|
for (const time of times) {
|
||||||
if (!timeRegex.test(time)) return null;
|
const normalized = this.normalizeTime(time);
|
||||||
|
if (!normalized) return null;
|
||||||
// Normalize to HH:MM:SS format
|
|
||||||
const parts = time.split(':');
|
|
||||||
const normalized = `${parts[0].padStart(2, '0')}:${parts[1]}:${parts[2] || '00'}`;
|
|
||||||
validTimes.push(normalized);
|
validTimes.push(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +366,191 @@ class SchedulesController {
|
|||||||
validTimes.sort();
|
validTimes.sort();
|
||||||
return validTimes;
|
return validTimes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeScheduleInput({ departureTimes, trips }) {
|
||||||
|
if (trips !== undefined) {
|
||||||
|
return this.normalizeTrips(trips);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTimes = this.validateAndSortTimes(departureTimes);
|
||||||
|
if (!sortedTimes || sortedTimes.length === 0) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid departure times format',
|
||||||
|
expected: 'Non-empty array of times in HH:MM or HH:MM:SS format'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
departureTimes: sortedTimes,
|
||||||
|
trips: sortedTimes.map(departureTime => this.createTrip({ departureTime }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeTrips(trips) {
|
||||||
|
if (!Array.isArray(trips) || trips.length === 0) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid trips format',
|
||||||
|
expected: 'Non-empty array of trip objects'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTrips = [];
|
||||||
|
|
||||||
|
for (const trip of trips) {
|
||||||
|
if (!trip || typeof trip !== 'object') {
|
||||||
|
return {
|
||||||
|
error: 'Invalid trip item',
|
||||||
|
expected: 'Each trip must be an object'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const departureTime = this.normalizeTime(
|
||||||
|
trip.departureTime || trip.departure_time || trip.time
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!departureTime) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid trip departure time',
|
||||||
|
expected: 'Trip departureTime must be HH:MM or HH:MM:SS'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysOfWeek = this.normalizeWeekdays(trip.daysOfWeek || trip.days_of_week || []);
|
||||||
|
const exceptDaysOfWeek = this.normalizeWeekdays(
|
||||||
|
trip.exceptDaysOfWeek || trip.except_days_of_week || []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!daysOfWeek || !exceptDaysOfWeek) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid trip day constraints',
|
||||||
|
expected: 'daysOfWeek and exceptDaysOfWeek must contain ISO weekdays 1-7'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlappingDay = daysOfWeek.find(day => exceptDaysOfWeek.includes(day));
|
||||||
|
if (overlappingDay) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid trip day constraints',
|
||||||
|
expected: 'A weekday cannot be both included and excluded'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const viaStopIds = this.normalizeStopIdArray(trip.viaStopIds || trip.via_stop_ids || []);
|
||||||
|
if (!viaStopIds) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid via stop ids',
|
||||||
|
expected: 'viaStopIds must be an array of positive integers'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedTrips.push(this.createTrip({
|
||||||
|
departureTime,
|
||||||
|
startStopId: this.normalizeOptionalId(trip.startStopId || trip.start_stop_id),
|
||||||
|
endStopId: this.normalizeOptionalId(trip.endStopId || trip.end_stop_id),
|
||||||
|
viaStopIds,
|
||||||
|
daysOfWeek,
|
||||||
|
exceptDaysOfWeek,
|
||||||
|
note: typeof trip.note === 'string' ? trip.note.trim().slice(0, 200) : ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedTrips.sort((a, b) => a.departureTime.localeCompare(b.departureTime));
|
||||||
|
|
||||||
|
return {
|
||||||
|
departureTimes: normalizedTrips.map(trip => trip.departureTime),
|
||||||
|
trips: normalizedTrips
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createTrip({
|
||||||
|
departureTime,
|
||||||
|
startStopId = null,
|
||||||
|
endStopId = null,
|
||||||
|
viaStopIds = [],
|
||||||
|
daysOfWeek = [],
|
||||||
|
exceptDaysOfWeek = [],
|
||||||
|
note = ''
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
departureTime,
|
||||||
|
startStopId,
|
||||||
|
endStopId,
|
||||||
|
viaStopIds,
|
||||||
|
daysOfWeek,
|
||||||
|
exceptDaysOfWeek,
|
||||||
|
note
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
withScheduleTrips(schedule) {
|
||||||
|
const trips = this.parseTrips(schedule.trips);
|
||||||
|
|
||||||
|
if (trips.length > 0) {
|
||||||
|
return { ...schedule, trips };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTrips = (schedule.departure_times || []).map(departureTime => (
|
||||||
|
this.createTrip({ departureTime: this.normalizeTime(departureTime) })
|
||||||
|
));
|
||||||
|
|
||||||
|
return { ...schedule, trips: fallbackTrips };
|
||||||
|
}
|
||||||
|
|
||||||
|
parseTrips(trips) {
|
||||||
|
if (Array.isArray(trips)) return trips;
|
||||||
|
if (typeof trips !== 'string') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedTrips = JSON.parse(trips);
|
||||||
|
return Array.isArray(parsedTrips) ? parsedTrips : [];
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeTime(time) {
|
||||||
|
if (typeof time !== 'string') return null;
|
||||||
|
|
||||||
|
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(:[0-5][0-9])?$/;
|
||||||
|
if (!timeRegex.test(time)) return null;
|
||||||
|
|
||||||
|
const parts = time.split(':');
|
||||||
|
return `${parts[0].padStart(2, '0')}:${parts[1]}:${parts[2] || '00'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeOptionalId(value) {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeStopIdArray(value) {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
|
||||||
|
const ids = [];
|
||||||
|
for (const item of value) {
|
||||||
|
const id = this.normalizeOptionalId(item);
|
||||||
|
if (!id) return null;
|
||||||
|
if (!ids.includes(id)) ids.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeWeekdays(value) {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
|
||||||
|
const weekdays = [];
|
||||||
|
for (const item of value) {
|
||||||
|
const day = Number(item);
|
||||||
|
if (!Number.isInteger(day) || day < 1 || day > 7) return null;
|
||||||
|
if (!weekdays.includes(day)) weekdays.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
return weekdays.sort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new SchedulesController();
|
module.exports = new SchedulesController();
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class SyncController {
|
|||||||
const syncData = {
|
const syncData = {
|
||||||
syncType: 'full',
|
syncType: 'full',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
version: '1.0',
|
version: '1.1',
|
||||||
data: {
|
data: {
|
||||||
routes: routesResult.rows,
|
routes: routesResult.rows,
|
||||||
stops: stopsResult.rows,
|
stops: stopsResult.rows,
|
||||||
@@ -247,4 +247,4 @@ class SyncController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new SyncController();
|
module.exports = new SyncController();
|
||||||
|
|||||||
@@ -160,9 +160,10 @@
|
|||||||
### Schedule
|
### Schedule
|
||||||
|
|
||||||
- Таблица: `schedules`
|
- Таблица: `schedules`
|
||||||
- Поля: `id`, `route_id`, `day_type`, `departure_times`, `valid_from`, `valid_until`, `created_at`, `updated_at`
|
- Поля: `id`, `route_id`, `day_type`, `departure_times`, `trips`, `valid_from`, `valid_until`, `created_at`, `updated_at`
|
||||||
- Enum `day_type`: `weekday`, `saturday`, `sunday`, `holiday`
|
- Enum `day_type`: `weekday`, `saturday`, `sunday`, `holiday`
|
||||||
- `departure_times` хранится как массив `TIME[]`
|
- `departure_times` хранится как массив `TIME[]`
|
||||||
|
- `trips` хранит расширенное описание каждого рейса: `departureTime`, `startStopId`, `endStopId`, `viaStopIds`, `daysOfWeek`, `exceptDaysOfWeek`, `note`. Это покрывает рейсы “с/до остановки”, “через остановку”, “по пятницам” и “кроме пятницы”.
|
||||||
|
|
||||||
### Holiday
|
### Holiday
|
||||||
|
|
||||||
@@ -242,8 +243,9 @@
|
|||||||
|
|
||||||
### Schedule DTO
|
### Schedule DTO
|
||||||
|
|
||||||
- `CreateScheduleRequest`: `{ routeId: number, dayType: ScheduleDayType, departureTimes: string[], validFrom?: string|null, validUntil?: string|null }`
|
- `ScheduleTrip`: `{ departureTime: string, startStopId?: number|null, endStopId?: number|null, viaStopIds?: number[], daysOfWeek?: number[], exceptDaysOfWeek?: number[], note?: string }`, где дни недели — ISO `1..7`, `1 = Monday`, `7 = Sunday`
|
||||||
- `UpdateScheduleRequest`: `{ departureTimes?: string[], validFrom?: string|null, validUntil?: string|null }`
|
- `CreateScheduleRequest`: `{ routeId: number, dayType: ScheduleDayType, departureTimes?: string[], trips?: ScheduleTrip[], validFrom?: string|null, validUntil?: string|null }`
|
||||||
|
- `UpdateScheduleRequest`: `{ departureTimes?: string[], trips?: ScheduleTrip[], validFrom?: string|null, validUntil?: string|null }`
|
||||||
- `CopyScheduleRequest`: `{ fromDayType: ScheduleDayType, toDayType: ScheduleDayType }`
|
- `CopyScheduleRequest`: `{ fromDayType: ScheduleDayType, toDayType: ScheduleDayType }`
|
||||||
|
|
||||||
### Holiday DTO
|
### Holiday DTO
|
||||||
@@ -715,7 +717,7 @@
|
|||||||
|
|
||||||
### [POST] /api/v1/routes/:routeId/schedules/copy
|
### [POST] /api/v1/routes/:routeId/schedules/copy
|
||||||
|
|
||||||
- Описание: копирует departure times из одного day type в другой
|
- Описание: копирует `departure_times` и расширенные `trips` из одного day type в другой
|
||||||
- Авторизация: Bearer JWT или `X-API-Key`
|
- Авторизация: Bearer JWT или `X-API-Key`
|
||||||
- Параметры path: `routeId`
|
- Параметры path: `routeId`
|
||||||
- Query-параметры: нет
|
- Query-параметры: нет
|
||||||
@@ -756,9 +758,9 @@
|
|||||||
- Request Body: `CreateScheduleRequest`
|
- Request Body: `CreateScheduleRequest`
|
||||||
- Response 200/201: созданный `Schedule`
|
- Response 200/201: созданный `Schedule`
|
||||||
- Ошибки:
|
- Ошибки:
|
||||||
- `400` — нет `routeId`, `dayType` или `departureTimes`
|
- `400` — нет `routeId`, `dayType` или одного из полей `departureTimes`/`trips`
|
||||||
- `400` — invalid `dayType`
|
- `400` — invalid `dayType`
|
||||||
- `400` — invalid departure time format, ожидается `HH:MM` или `HH:MM:SS`
|
- `400` — invalid departure time/trip format, ожидается `HH:MM` или `HH:MM:SS`, для `trips` также валидируются ISO weekdays `1..7`
|
||||||
- `409` — уже есть активное расписание на этот `routeId + dayType`
|
- `409` — уже есть активное расписание на этот `routeId + dayType`
|
||||||
- `401`/`403`/`429`
|
- `401`/`403`/`429`
|
||||||
- `500` — `Failed to create schedule`
|
- `500` — `Failed to create schedule`
|
||||||
@@ -767,7 +769,7 @@
|
|||||||
|
|
||||||
### [PUT] /api/v1/schedules/:id
|
### [PUT] /api/v1/schedules/:id
|
||||||
|
|
||||||
- Описание: обновляет departure times и/или validity window
|
- Описание: обновляет departure times, trips и/или validity window
|
||||||
- Авторизация: Bearer JWT или `X-API-Key`
|
- Авторизация: Bearer JWT или `X-API-Key`
|
||||||
- Параметры path: `id`
|
- Параметры path: `id`
|
||||||
- Query-параметры: нет
|
- Query-параметры: нет
|
||||||
@@ -776,7 +778,7 @@
|
|||||||
- Response 200/201: обновленный `Schedule`
|
- Response 200/201: обновленный `Schedule`
|
||||||
- Ошибки:
|
- Ошибки:
|
||||||
- `404` — расписание не найдено
|
- `404` — расписание не найдено
|
||||||
- `400` — invalid departure time format
|
- `400` — invalid departure time/trip format
|
||||||
- `401`/`403`/`429`
|
- `401`/`403`/`429`
|
||||||
- `500` — `Failed to update schedule`
|
- `500` — `Failed to update schedule`
|
||||||
- Связанные сущности: `Schedule`
|
- Связанные сущности: `Schedule`
|
||||||
|
|||||||
@@ -1960,6 +1960,43 @@ components:
|
|||||||
ScheduleDayType:
|
ScheduleDayType:
|
||||||
type: string
|
type: string
|
||||||
enum: [weekday, saturday, sunday, holiday]
|
enum: [weekday, saturday, sunday, holiday]
|
||||||
|
ScheduleTrip:
|
||||||
|
type: object
|
||||||
|
required: [departureTime]
|
||||||
|
properties:
|
||||||
|
departureTime:
|
||||||
|
type: string
|
||||||
|
description: Departure time in HH:MM or HH:MM:SS format.
|
||||||
|
startStopId:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Optional stop id where a partial trip starts.
|
||||||
|
endStopId:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Optional stop id where a partial trip ends.
|
||||||
|
viaStopIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
description: Optional via-stop markers for display and route variants.
|
||||||
|
daysOfWeek:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 7
|
||||||
|
description: ISO weekdays when this trip runs, 1=Monday, 7=Sunday.
|
||||||
|
exceptDaysOfWeek:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 7
|
||||||
|
description: ISO weekdays when this trip does not run.
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
description: Human-readable note such as "по пятницам" or "через Мариненко".
|
||||||
AlertType:
|
AlertType:
|
||||||
type: string
|
type: string
|
||||||
enum: [delay, cancellation, detour, info]
|
enum: [delay, cancellation, detour, info]
|
||||||
@@ -2132,6 +2169,10 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
trips:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScheduleTrip'
|
||||||
valid_from:
|
valid_from:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
@@ -2720,7 +2761,7 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
CreateScheduleRequest:
|
CreateScheduleRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [routeId, dayType, departureTimes]
|
required: [routeId, dayType]
|
||||||
properties:
|
properties:
|
||||||
routeId:
|
routeId:
|
||||||
type: integer
|
type: integer
|
||||||
@@ -2730,6 +2771,11 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
description: Legacy flat departure list. If trips is supplied, departureTimes is derived from trips.
|
||||||
|
trips:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScheduleTrip'
|
||||||
validFrom:
|
validFrom:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
@@ -2745,6 +2791,11 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
description: Legacy flat departure list. If trips is supplied, departureTimes is derived from trips.
|
||||||
|
trips:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScheduleTrip'
|
||||||
validFrom:
|
validFrom:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Calendar, Plus, Edit, Trash2, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
|
||||||
import { Calendar, Clock, Plus, Edit, Trash2, Copy, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
|
import { routesApi, routeStopsApi, schedulesApi, holidaysApi } from '../services/api';
|
||||||
import { routesApi, schedulesApi, holidaysApi } from '../services/api';
|
|
||||||
import { PageLoader } from '../components/AnimatedComponents';
|
import { PageLoader } from '../components/AnimatedComponents';
|
||||||
|
|
||||||
const DAY_TYPES = [
|
const DAY_TYPES = [
|
||||||
@@ -11,9 +10,20 @@ const DAY_TYPES = [
|
|||||||
{ value: 'holiday', label: 'Праздники', description: 'По расписанию воскресенья' }
|
{ value: 'holiday', label: 'Праздники', description: 'По расписанию воскресенья' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const WEEKDAYS = [
|
||||||
|
{ value: 1, label: 'Пн' },
|
||||||
|
{ value: 2, label: 'Вт' },
|
||||||
|
{ value: 3, label: 'Ср' },
|
||||||
|
{ value: 4, label: 'Чт' },
|
||||||
|
{ value: 5, label: 'Пт' },
|
||||||
|
{ value: 6, label: 'Сб' },
|
||||||
|
{ value: 7, label: 'Вс' }
|
||||||
|
];
|
||||||
|
|
||||||
export default function SchedulesPage() {
|
export default function SchedulesPage() {
|
||||||
const [routes, setRoutes] = useState([]);
|
const [routes, setRoutes] = useState([]);
|
||||||
const [selectedRoute, setSelectedRoute] = useState(null);
|
const [selectedRoute, setSelectedRoute] = useState(null);
|
||||||
|
const [routeStops, setRouteStops] = useState([]);
|
||||||
const [schedules, setSchedules] = useState([]);
|
const [schedules, setSchedules] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showEditor, setShowEditor] = useState(false);
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
@@ -29,6 +39,7 @@ export default function SchedulesPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRoute) {
|
if (selectedRoute) {
|
||||||
loadSchedules(selectedRoute.id);
|
loadSchedules(selectedRoute.id);
|
||||||
|
loadRouteStops(selectedRoute.id);
|
||||||
}
|
}
|
||||||
}, [selectedRoute]);
|
}, [selectedRoute]);
|
||||||
|
|
||||||
@@ -56,6 +67,16 @@ export default function SchedulesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadRouteStops = async (routeId) => {
|
||||||
|
try {
|
||||||
|
const response = await routeStopsApi.getRouteStops(routeId);
|
||||||
|
setRouteStops(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading route stops:', error);
|
||||||
|
setRouteStops([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadUpcomingHolidays = async () => {
|
const loadUpcomingHolidays = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await holidaysApi.getUpcoming(5);
|
const response = await holidaysApi.getUpcoming(5);
|
||||||
@@ -69,7 +90,8 @@ export default function SchedulesPage() {
|
|||||||
setEditingSchedule({
|
setEditingSchedule({
|
||||||
routeId: selectedRoute.id,
|
routeId: selectedRoute.id,
|
||||||
dayType: dayType,
|
dayType: dayType,
|
||||||
departureTimes: []
|
departureTimes: [],
|
||||||
|
trips: []
|
||||||
});
|
});
|
||||||
setShowEditor(true);
|
setShowEditor(true);
|
||||||
};
|
};
|
||||||
@@ -241,7 +263,7 @@ export default function SchedulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
{schedule ? (
|
{schedule ? (
|
||||||
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
|
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
|
||||||
{schedule.departure_times?.length || 0} рейсов
|
{getScheduleTrips(schedule).length} рейсов
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">
|
<span className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">
|
||||||
@@ -266,15 +288,25 @@ export default function SchedulesPage() {
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg max-h-48 overflow-y-auto animate-[fadeSlideIn_0.2s_ease-out]">
|
<div className="mt-2 p-3 bg-gray-50 rounded-lg max-h-48 overflow-y-auto animate-[fadeSlideIn_0.2s_ease-out]">
|
||||||
<div className="grid grid-cols-4 gap-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
{schedule.departure_times?.map((time, idx) => (
|
{getScheduleTrips(schedule).map((trip, idx) => (
|
||||||
<span
|
<div
|
||||||
key={idx}
|
key={`${trip.departureTime}-${idx}`}
|
||||||
className="text-gray-700 list-item-animated"
|
className="flex flex-wrap items-center gap-1 text-gray-700 list-item-animated"
|
||||||
style={{ animationDelay: `${idx * 10}ms` }}
|
style={{ animationDelay: `${idx * 10}ms` }}
|
||||||
>
|
>
|
||||||
{time.substring(0, 5)}
|
<span className="font-mono font-medium w-12">
|
||||||
</span>
|
{formatScheduleTime(trip.departureTime)}
|
||||||
|
</span>
|
||||||
|
{getTripLabels(trip, routeStops).map((label) => (
|
||||||
|
<span
|
||||||
|
key={label}
|
||||||
|
className="rounded bg-white px-1.5 py-0.5 text-xs text-gray-600 border border-gray-200"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,6 +377,7 @@ export default function SchedulesPage() {
|
|||||||
<ScheduleEditor
|
<ScheduleEditor
|
||||||
schedule={editingSchedule}
|
schedule={editingSchedule}
|
||||||
routeName={selectedRoute?.name}
|
routeName={selectedRoute?.name}
|
||||||
|
routeStops={routeStops}
|
||||||
onSave={handleSaveSchedule}
|
onSave={handleSaveSchedule}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowEditor(false);
|
setShowEditor(false);
|
||||||
@@ -356,10 +389,8 @@ export default function SchedulesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleEditor({ schedule, routeName, onSave, onCancel }) {
|
function ScheduleEditor({ schedule, routeName, routeStops, onSave, onCancel }) {
|
||||||
const [times, setTimes] = useState(
|
const [trips, setTrips] = useState(() => normalizeTripsForEditor(schedule));
|
||||||
schedule.departure_times?.map(t => t.substring(0, 5)) || []
|
|
||||||
);
|
|
||||||
const [newTime, setNewTime] = useState('');
|
const [newTime, setNewTime] = useState('');
|
||||||
const [bulkInput, setBulkInput] = useState('');
|
const [bulkInput, setBulkInput] = useState('');
|
||||||
const [showBulkInput, setShowBulkInput] = useState(false);
|
const [showBulkInput, setShowBulkInput] = useState(false);
|
||||||
@@ -375,83 +406,92 @@ function ScheduleEditor({ schedule, routeName, onSave, onCancel }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (times.includes(newTime)) {
|
setTrips(sortTrips([...trips, createEditorTrip(newTime)]));
|
||||||
alert('Это время уже добавлено');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTimes = [...times, newTime].sort();
|
|
||||||
setTimes(newTimes);
|
|
||||||
setNewTime('');
|
setNewTime('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveTime = (index) => {
|
const handleRemoveTrip = (index) => {
|
||||||
const newTimes = times.filter((_, i) => i !== index);
|
setTrips(trips.filter((_, i) => i !== index));
|
||||||
setTimes(newTimes);
|
};
|
||||||
|
|
||||||
|
const handleUpdateTrip = (index, patch) => {
|
||||||
|
setTrips(trips.map((trip, i) => (
|
||||||
|
i === index ? { ...trip, ...patch } : trip
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleDay = (index, field, day) => {
|
||||||
|
const trip = trips[index];
|
||||||
|
const current = trip[field] || [];
|
||||||
|
const next = current.includes(day)
|
||||||
|
? current.filter(value => value !== day)
|
||||||
|
: [...current, day].sort();
|
||||||
|
|
||||||
|
const oppositeField = field === 'daysOfWeek' ? 'exceptDaysOfWeek' : 'daysOfWeek';
|
||||||
|
handleUpdateTrip(index, {
|
||||||
|
[field]: next,
|
||||||
|
[oppositeField]: (trip[oppositeField] || []).filter(value => value !== day)
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkAdd = () => {
|
const handleBulkAdd = () => {
|
||||||
// Parse bulk input (comma, space, or newline separated)
|
const inputTrips = bulkInput
|
||||||
const inputTimes = bulkInput
|
.split(/\s*\|\s*|[,;\n]+/)
|
||||||
.split(/[,\s\n]+/)
|
|
||||||
.map(t => t.trim())
|
.map(t => t.trim())
|
||||||
.filter(t => t);
|
.filter(t => t);
|
||||||
|
|
||||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$/;
|
const validTrips = [];
|
||||||
const validTimes = [];
|
const invalidItems = [];
|
||||||
const invalidTimes = [];
|
|
||||||
|
|
||||||
for (const time of inputTimes) {
|
for (const item of inputTrips) {
|
||||||
if (timeRegex.test(time)) {
|
const parsedTrip = parseBulkTrip(item, routeStops);
|
||||||
// Normalize time format
|
if (parsedTrip) {
|
||||||
const parts = time.split(':');
|
validTrips.push(parsedTrip);
|
||||||
const normalized = `${parts[0].padStart(2, '0')}:${parts[1]}`;
|
|
||||||
if (!times.includes(normalized) && !validTimes.includes(normalized)) {
|
|
||||||
validTimes.push(normalized);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
invalidTimes.push(time);
|
invalidItems.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invalidTimes.length > 0) {
|
if (invalidItems.length > 0) {
|
||||||
alert(`Неверный формат: ${invalidTimes.join(', ')}`);
|
alert(`Неверный формат: ${invalidItems.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validTimes.length > 0) {
|
if (validTrips.length > 0) {
|
||||||
const newTimes = [...times, ...validTimes].sort();
|
setTrips(sortTrips([...trips, ...validTrips]));
|
||||||
setTimes(newTimes);
|
|
||||||
setBulkInput('');
|
setBulkInput('');
|
||||||
setShowBulkInput(false);
|
setShowBulkInput(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (times.length === 0) {
|
if (trips.length === 0) {
|
||||||
alert('Добавьте хотя бы одно время отправления');
|
alert('Добавьте хотя бы одно время отправления');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiTrips = sortTrips(trips).map(toApiTrip);
|
||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
routeId: schedule.routeId || schedule.route_id,
|
routeId: schedule.routeId || schedule.route_id,
|
||||||
dayType: schedule.dayType || schedule.day_type,
|
dayType: schedule.dayType || schedule.day_type,
|
||||||
departureTimes: times.map(t => `${t}:00`)
|
departureTimes: apiTrips.map(trip => trip.departureTime),
|
||||||
|
trips: apiTrips
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate quick add times
|
// Generate quick add times
|
||||||
const generateQuickTimes = (startHour, endHour, interval) => {
|
const generateQuickTimes = (startHour, endHour, interval) => {
|
||||||
const generatedTimes = [];
|
const existingTimes = new Set(trips.map(trip => trip.departureTime));
|
||||||
|
const generatedTrips = [];
|
||||||
for (let h = startHour; h <= endHour; h++) {
|
for (let h = startHour; h <= endHour; h++) {
|
||||||
for (let m = 0; m < 60; m += interval) {
|
for (let m = 0; m < 60; m += interval) {
|
||||||
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
if (!times.includes(time)) {
|
if (!existingTimes.has(time)) {
|
||||||
generatedTimes.push(time);
|
generatedTrips.push(createEditorTrip(time));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newTimes = [...times, ...generatedTimes].sort();
|
setTrips(sortTrips([...trips, ...generatedTrips]));
|
||||||
setTimes(newTimes);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -490,7 +530,7 @@ function ScheduleEditor({ schedule, routeName, onSave, onCancel }) {
|
|||||||
07-20 × 60мин
|
07-20 × 60мин
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTimes([])}
|
onClick={() => setTrips([])}
|
||||||
className="text-xs bg-red-50 text-red-600 border border-red-200 px-2 sm:px-3 py-1.5 rounded hover:bg-red-100"
|
className="text-xs bg-red-50 text-red-600 border border-red-200 px-2 sm:px-3 py-1.5 rounded hover:bg-red-100"
|
||||||
>
|
>
|
||||||
Очистить
|
Очистить
|
||||||
@@ -526,7 +566,7 @@ function ScheduleEditor({ schedule, routeName, onSave, onCancel }) {
|
|||||||
<div className="mb-4 p-3 sm:p-4 bg-blue-50 rounded-lg">
|
<div className="mb-4 p-3 sm:p-4 bg-blue-50 rounded-lg">
|
||||||
<label className="label">Массовый ввод</label>
|
<label className="label">Массовый ввод</label>
|
||||||
<p className="text-xs text-gray-600 mb-2">
|
<p className="text-xs text-gray-600 mb-2">
|
||||||
Введите время через запятую (ЧЧ:ММ)
|
Введите рейсы через | или с новой строки: 15:10 по пятницам, 18:00 кроме пятницы, 06:05 через Мариненко
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
value={bulkInput}
|
value={bulkInput}
|
||||||
@@ -541,25 +581,134 @@ function ScheduleEditor({ schedule, routeName, onSave, onCancel }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Times Grid */}
|
{/* Trips List */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
Время отправления ({times.length})
|
Рейсы ({trips.length})
|
||||||
</label>
|
</label>
|
||||||
{times.length > 0 ? (
|
{trips.length > 0 ? (
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1.5 sm:gap-2 max-h-48 sm:max-h-64 overflow-y-auto p-2 bg-gray-50 rounded-lg">
|
<div className="space-y-3 max-h-[42vh] overflow-y-auto p-2 bg-gray-50 rounded-lg">
|
||||||
{times.map((time, index) => (
|
{trips.map((trip, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={`${trip.departureTime}-${index}`}
|
||||||
className="flex items-center justify-between bg-white border border-gray-200 rounded px-1.5 sm:px-2 py-1"
|
className="bg-white border border-gray-200 rounded-lg p-3"
|
||||||
>
|
>
|
||||||
<span className="text-xs sm:text-sm font-mono">{time}</span>
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<button
|
<input
|
||||||
onClick={() => handleRemoveTime(index)}
|
type="time"
|
||||||
className="text-red-500 hover:text-red-700 ml-1"
|
value={trip.departureTime}
|
||||||
>
|
onChange={(e) => handleUpdateTrip(index, { departureTime: e.target.value })}
|
||||||
×
|
className="input max-w-[130px]"
|
||||||
</button>
|
/>
|
||||||
|
<div className="flex-1 flex flex-wrap gap-1">
|
||||||
|
{getTripLabels(toApiTrip(trip), routeStops).map(label => (
|
||||||
|
<span key={label} className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveTrip(index)}
|
||||||
|
className="text-red-500 hover:text-red-700 px-2 py-1 text-lg leading-none"
|
||||||
|
title="Удалить рейс"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
С остановки
|
||||||
|
<select
|
||||||
|
value={trip.startStopId || ''}
|
||||||
|
onChange={(e) => handleUpdateTrip(index, { startStopId: e.target.value ? Number(e.target.value) : null })}
|
||||||
|
className="input mt-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">От начала маршрута</option>
|
||||||
|
{routeStops.map(stop => (
|
||||||
|
<option key={stop.stop_id} value={stop.stop_id}>{stop.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
До остановки
|
||||||
|
<select
|
||||||
|
value={trip.endStopId || ''}
|
||||||
|
onChange={(e) => handleUpdateTrip(index, { endStopId: e.target.value ? Number(e.target.value) : null })}
|
||||||
|
className="input mt-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">До конца маршрута</option>
|
||||||
|
{routeStops.map(stop => (
|
||||||
|
<option key={stop.stop_id} value={stop.stop_id}>{stop.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
Через остановку
|
||||||
|
<select
|
||||||
|
value={trip.viaStopIds?.[0] || ''}
|
||||||
|
onChange={(e) => handleUpdateTrip(index, { viaStopIds: e.target.value ? [Number(e.target.value)] : [] })}
|
||||||
|
className="input mt-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Без отметки</option>
|
||||||
|
{routeStops.map(stop => (
|
||||||
|
<option key={stop.stop_id} value={stop.stop_id}>{stop.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-600 mb-1">Только по дням</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{WEEKDAYS.map(day => (
|
||||||
|
<button
|
||||||
|
key={day.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleDay(index, 'daysOfWeek', day.value)}
|
||||||
|
className={`px-2 py-1 rounded text-xs border ${
|
||||||
|
trip.daysOfWeek?.includes(day.value)
|
||||||
|
? 'bg-primary-600 text-white border-primary-600'
|
||||||
|
: 'bg-white text-gray-700 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-600 mb-1">Кроме дней</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{WEEKDAYS.map(day => (
|
||||||
|
<button
|
||||||
|
key={day.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleDay(index, 'exceptDaysOfWeek', day.value)}
|
||||||
|
className={`px-2 py-1 rounded text-xs border ${
|
||||||
|
trip.exceptDaysOfWeek?.includes(day.value)
|
||||||
|
? 'bg-red-600 text-white border-red-600'
|
||||||
|
: 'bg-white text-gray-700 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="text-xs text-gray-600 block mt-3">
|
||||||
|
Примечание
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={trip.note || ''}
|
||||||
|
onChange={(e) => handleUpdateTrip(index, { note: e.target.value })}
|
||||||
|
className="input mt-1 text-sm"
|
||||||
|
placeholder="например: по пятницам, через Мариненко"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -582,4 +731,165 @@ function ScheduleEditor({ schedule, routeName, onSave, onCancel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getScheduleTrips(schedule) {
|
||||||
|
if (Array.isArray(schedule.trips) && schedule.trips.length > 0) {
|
||||||
|
return schedule.trips;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (schedule.departure_times || []).map(departureTime => ({
|
||||||
|
departureTime,
|
||||||
|
startStopId: null,
|
||||||
|
endStopId: null,
|
||||||
|
viaStopIds: [],
|
||||||
|
daysOfWeek: [],
|
||||||
|
exceptDaysOfWeek: [],
|
||||||
|
note: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTripsForEditor(schedule) {
|
||||||
|
return getScheduleTrips(schedule).map(trip => ({
|
||||||
|
departureTime: formatScheduleTime(trip.departureTime),
|
||||||
|
startStopId: trip.startStopId || null,
|
||||||
|
endStopId: trip.endStopId || null,
|
||||||
|
viaStopIds: Array.isArray(trip.viaStopIds) ? trip.viaStopIds : [],
|
||||||
|
daysOfWeek: Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek.map(Number) : [],
|
||||||
|
exceptDaysOfWeek: Array.isArray(trip.exceptDaysOfWeek) ? trip.exceptDaysOfWeek.map(Number) : [],
|
||||||
|
note: trip.note || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEditorTrip(departureTime) {
|
||||||
|
return {
|
||||||
|
departureTime,
|
||||||
|
startStopId: null,
|
||||||
|
endStopId: null,
|
||||||
|
viaStopIds: [],
|
||||||
|
daysOfWeek: [],
|
||||||
|
exceptDaysOfWeek: [],
|
||||||
|
note: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiTrip(trip) {
|
||||||
|
return {
|
||||||
|
departureTime: `${formatScheduleTime(trip.departureTime)}:00`,
|
||||||
|
startStopId: trip.startStopId || null,
|
||||||
|
endStopId: trip.endStopId || null,
|
||||||
|
viaStopIds: Array.isArray(trip.viaStopIds) ? trip.viaStopIds.filter(Boolean).map(Number) : [],
|
||||||
|
daysOfWeek: Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek.filter(Boolean).map(Number) : [],
|
||||||
|
exceptDaysOfWeek: Array.isArray(trip.exceptDaysOfWeek) ? trip.exceptDaysOfWeek.filter(Boolean).map(Number) : [],
|
||||||
|
note: (trip.note || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTrips(items) {
|
||||||
|
return [...items].sort((a, b) => a.departureTime.localeCompare(b.departureTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScheduleTime(time) {
|
||||||
|
return (time || '').substring(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTripLabels(trip, routeStops) {
|
||||||
|
const labels = [];
|
||||||
|
const startStop = findRouteStop(routeStops, trip.startStopId);
|
||||||
|
const endStop = findRouteStop(routeStops, trip.endStopId);
|
||||||
|
const viaStops = (trip.viaStopIds || [])
|
||||||
|
.map(stopId => findRouteStop(routeStops, stopId))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (startStop) labels.push(`с ${startStop.name}`);
|
||||||
|
if (endStop) labels.push(`до ${endStop.name}`);
|
||||||
|
viaStops.forEach(stop => labels.push(`через ${stop.name}`));
|
||||||
|
|
||||||
|
if (trip.daysOfWeek?.length > 0) {
|
||||||
|
labels.push(`по ${trip.daysOfWeek.map(getWeekdayLabel).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trip.exceptDaysOfWeek?.length > 0) {
|
||||||
|
labels.push(`кроме ${trip.exceptDaysOfWeek.map(getWeekdayLabel).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trip.note) labels.push(trip.note);
|
||||||
|
return [...new Set(labels)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekdayLabel(value) {
|
||||||
|
return WEEKDAYS.find(day => day.value === Number(value))?.label || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRouteStop(routeStops, stopId) {
|
||||||
|
if (!stopId) return null;
|
||||||
|
return routeStops.find(stop => Number(stop.stop_id) === Number(stopId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBulkTrip(value, routeStops) {
|
||||||
|
const match = value.match(/^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(?:\s+(.+))?$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const trip = createEditorTrip(`${match[1].padStart(2, '0')}:${match[2]}`);
|
||||||
|
const note = (match[3] || '').trim();
|
||||||
|
if (!note) return trip;
|
||||||
|
|
||||||
|
const normalizedNote = normalizeText(note);
|
||||||
|
|
||||||
|
if (/по\s+пятниц/.test(normalizedNote)) {
|
||||||
|
trip.daysOfWeek = [5];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/кроме\s+пятниц/.test(normalizedNote)) {
|
||||||
|
trip.exceptDaysOfWeek = [5];
|
||||||
|
}
|
||||||
|
|
||||||
|
const viaStopId = findStopIdInText(extractStopText(normalizedNote, 'через'), routeStops);
|
||||||
|
const endStopId = findStopIdInText(extractStopText(normalizedNote, 'до'), routeStops);
|
||||||
|
const startStopId = findStopIdInText(extractStopText(normalizedNote, 'с'), routeStops);
|
||||||
|
|
||||||
|
if (viaStopId) trip.viaStopIds = [viaStopId];
|
||||||
|
if (endStopId) trip.endStopId = endStopId;
|
||||||
|
if (startStopId) trip.startStopId = startStopId;
|
||||||
|
|
||||||
|
trip.note = note;
|
||||||
|
return trip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractStopText(text, marker) {
|
||||||
|
const match = text.match(new RegExp(`\\b${marker}\\s+(.+?)(?=\\s+(?:по|кроме)\\s+|$)`));
|
||||||
|
return match?.[1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStopIdInText(value, routeStops) {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalizedValue = normalizeText(value);
|
||||||
|
const looseValue = normalizeStopLookup(normalizedValue);
|
||||||
|
|
||||||
|
const match = routeStops.find(stop => {
|
||||||
|
const stopName = normalizeText(stop.name);
|
||||||
|
const looseStopName = normalizeStopLookup(stopName);
|
||||||
|
return normalizedValue.includes(stopName) ||
|
||||||
|
stopName.includes(normalizedValue) ||
|
||||||
|
looseValue.includes(looseStopName) ||
|
||||||
|
looseStopName.includes(looseValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return match?.stop_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStopLookup(value) {
|
||||||
|
return value
|
||||||
|
.split(/\s+/)
|
||||||
|
.map(word => word.replace(/[аяеуыиюь]$/u, ''))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[«»"']/g, '')
|
||||||
|
.replace(/ё/g, 'е')
|
||||||
|
.replace(/[.,]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user