457 lines
12 KiB
TypeScript
457 lines
12 KiB
TypeScript
/**
|
|
* 차량 운행 이력 서비스
|
|
*/
|
|
import { getPool } from "../database/db";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { calculateDistance } from "../utils/geoUtils";
|
|
|
|
interface StartTripParams {
|
|
userId: string;
|
|
companyCode: string;
|
|
vehicleId?: number;
|
|
departure?: string;
|
|
arrival?: string;
|
|
departureName?: string;
|
|
destinationName?: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
interface EndTripParams {
|
|
tripId: string;
|
|
userId: string;
|
|
companyCode: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
interface AddLocationParams {
|
|
tripId: string;
|
|
userId: string;
|
|
companyCode: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
accuracy?: number;
|
|
speed?: number;
|
|
}
|
|
|
|
interface TripListFilters {
|
|
userId?: string;
|
|
vehicleId?: number;
|
|
status?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
departure?: string;
|
|
arrival?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
class VehicleTripService {
|
|
private get pool() {
|
|
return getPool();
|
|
}
|
|
|
|
/**
|
|
* 운행 시작
|
|
*/
|
|
async startTrip(params: StartTripParams) {
|
|
const {
|
|
userId,
|
|
companyCode,
|
|
vehicleId,
|
|
departure,
|
|
arrival,
|
|
departureName,
|
|
destinationName,
|
|
latitude,
|
|
longitude,
|
|
} = params;
|
|
|
|
const tripId = `TRIP-${Date.now()}-${uuidv4().substring(0, 8)}`;
|
|
|
|
// 1. vehicle_trip_summary에 운행 기록 생성
|
|
const summaryQuery = `
|
|
INSERT INTO vehicle_trip_summary (
|
|
trip_id, user_id, vehicle_id, departure, arrival,
|
|
departure_name, destination_name, start_time, status, company_code
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'active', $8)
|
|
RETURNING *
|
|
`;
|
|
|
|
const summaryResult = await this.pool.query(summaryQuery, [
|
|
tripId,
|
|
userId,
|
|
vehicleId || null,
|
|
departure || null,
|
|
arrival || null,
|
|
departureName || null,
|
|
destinationName || null,
|
|
companyCode,
|
|
]);
|
|
|
|
// 2. 시작 위치 기록
|
|
const locationQuery = `
|
|
INSERT INTO vehicle_location_history (
|
|
trip_id, user_id, vehicle_id, latitude, longitude,
|
|
trip_status, departure, arrival, departure_name, destination_name,
|
|
recorded_at, company_code
|
|
) VALUES ($1, $2, $3, $4, $5, 'start', $6, $7, $8, $9, NOW(), $10)
|
|
RETURNING id
|
|
`;
|
|
|
|
await this.pool.query(locationQuery, [
|
|
tripId,
|
|
userId,
|
|
vehicleId || null,
|
|
latitude,
|
|
longitude,
|
|
departure || null,
|
|
arrival || null,
|
|
departureName || null,
|
|
destinationName || null,
|
|
companyCode,
|
|
]);
|
|
|
|
return {
|
|
tripId,
|
|
summary: summaryResult.rows[0],
|
|
startLocation: { latitude, longitude },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 운행 종료
|
|
*/
|
|
async endTrip(params: EndTripParams) {
|
|
const { tripId, userId, companyCode, latitude, longitude } = params;
|
|
|
|
// 1. 운행 정보 조회
|
|
const tripQuery = `
|
|
SELECT * FROM vehicle_trip_summary
|
|
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
|
`;
|
|
const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]);
|
|
|
|
if (tripResult.rows.length === 0) {
|
|
throw new Error("활성 운행을 찾을 수 없습니다.");
|
|
}
|
|
|
|
const trip = tripResult.rows[0];
|
|
|
|
// 2. 마지막 위치 기록
|
|
const locationQuery = `
|
|
INSERT INTO vehicle_location_history (
|
|
trip_id, user_id, vehicle_id, latitude, longitude,
|
|
trip_status, departure, arrival, departure_name, destination_name,
|
|
recorded_at, company_code
|
|
) VALUES ($1, $2, $3, $4, $5, 'end', $6, $7, $8, $9, NOW(), $10)
|
|
RETURNING id
|
|
`;
|
|
|
|
await this.pool.query(locationQuery, [
|
|
tripId,
|
|
userId,
|
|
trip.vehicle_id,
|
|
latitude,
|
|
longitude,
|
|
trip.departure,
|
|
trip.arrival,
|
|
trip.departure_name,
|
|
trip.destination_name,
|
|
companyCode,
|
|
]);
|
|
|
|
// 3. 총 거리 및 위치 수 계산
|
|
const statsQuery = `
|
|
SELECT
|
|
COUNT(*) as location_count,
|
|
MIN(recorded_at) as start_time,
|
|
MAX(recorded_at) as end_time
|
|
FROM vehicle_location_history
|
|
WHERE trip_id = $1 AND company_code = $2
|
|
`;
|
|
const statsResult = await this.pool.query(statsQuery, [tripId, companyCode]);
|
|
const stats = statsResult.rows[0];
|
|
|
|
// 4. 모든 위치 데이터로 총 거리 계산
|
|
const locationsQuery = `
|
|
SELECT latitude, longitude
|
|
FROM vehicle_location_history
|
|
WHERE trip_id = $1 AND company_code = $2
|
|
ORDER BY recorded_at ASC
|
|
`;
|
|
const locationsResult = await this.pool.query(locationsQuery, [tripId, companyCode]);
|
|
|
|
let totalDistance = 0;
|
|
const locations = locationsResult.rows;
|
|
for (let i = 1; i < locations.length; i++) {
|
|
const prev = locations[i - 1];
|
|
const curr = locations[i];
|
|
totalDistance += calculateDistance(
|
|
prev.latitude,
|
|
prev.longitude,
|
|
curr.latitude,
|
|
curr.longitude
|
|
);
|
|
}
|
|
|
|
// 5. 운행 시간 계산 (분)
|
|
const startTime = new Date(stats.start_time);
|
|
const endTime = new Date(stats.end_time);
|
|
const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
|
|
|
|
// 6. 운행 요약 업데이트
|
|
const updateQuery = `
|
|
UPDATE vehicle_trip_summary
|
|
SET
|
|
end_time = NOW(),
|
|
total_distance = $1,
|
|
duration_minutes = $2,
|
|
location_count = $3,
|
|
status = 'completed'
|
|
WHERE trip_id = $4 AND company_code = $5
|
|
RETURNING *
|
|
`;
|
|
|
|
const updateResult = await this.pool.query(updateQuery, [
|
|
totalDistance.toFixed(3),
|
|
durationMinutes,
|
|
stats.location_count,
|
|
tripId,
|
|
companyCode,
|
|
]);
|
|
|
|
return {
|
|
tripId,
|
|
summary: updateResult.rows[0],
|
|
totalDistance: parseFloat(totalDistance.toFixed(3)),
|
|
durationMinutes,
|
|
locationCount: parseInt(stats.location_count),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 위치 기록 추가 (연속 추적)
|
|
*/
|
|
async addLocation(params: AddLocationParams) {
|
|
const { tripId, userId, companyCode, latitude, longitude, accuracy, speed } = params;
|
|
|
|
// 1. 운행 정보 조회
|
|
const tripQuery = `
|
|
SELECT * FROM vehicle_trip_summary
|
|
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
|
`;
|
|
const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]);
|
|
|
|
if (tripResult.rows.length === 0) {
|
|
throw new Error("활성 운행을 찾을 수 없습니다.");
|
|
}
|
|
|
|
const trip = tripResult.rows[0];
|
|
|
|
// 2. 이전 위치 조회 (거리 계산용)
|
|
const prevLocationQuery = `
|
|
SELECT latitude, longitude
|
|
FROM vehicle_location_history
|
|
WHERE trip_id = $1 AND company_code = $2
|
|
ORDER BY recorded_at DESC
|
|
LIMIT 1
|
|
`;
|
|
const prevResult = await this.pool.query(prevLocationQuery, [tripId, companyCode]);
|
|
|
|
let distanceFromPrev = 0;
|
|
if (prevResult.rows.length > 0) {
|
|
const prev = prevResult.rows[0];
|
|
distanceFromPrev = calculateDistance(
|
|
prev.latitude,
|
|
prev.longitude,
|
|
latitude,
|
|
longitude
|
|
);
|
|
}
|
|
|
|
// 3. 위치 기록 추가
|
|
const locationQuery = `
|
|
INSERT INTO vehicle_location_history (
|
|
trip_id, user_id, vehicle_id, latitude, longitude,
|
|
accuracy, speed, distance_from_prev,
|
|
trip_status, departure, arrival, departure_name, destination_name,
|
|
recorded_at, company_code
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'tracking', $9, $10, $11, $12, NOW(), $13)
|
|
RETURNING id
|
|
`;
|
|
|
|
const result = await this.pool.query(locationQuery, [
|
|
tripId,
|
|
userId,
|
|
trip.vehicle_id,
|
|
latitude,
|
|
longitude,
|
|
accuracy || null,
|
|
speed || null,
|
|
distanceFromPrev > 0 ? distanceFromPrev.toFixed(3) : null,
|
|
trip.departure,
|
|
trip.arrival,
|
|
trip.departure_name,
|
|
trip.destination_name,
|
|
companyCode,
|
|
]);
|
|
|
|
// 4. 운행 요약의 위치 수 업데이트
|
|
await this.pool.query(
|
|
`UPDATE vehicle_trip_summary SET location_count = location_count + 1 WHERE trip_id = $1`,
|
|
[tripId]
|
|
);
|
|
|
|
return {
|
|
locationId: result.rows[0].id,
|
|
distanceFromPrev: parseFloat(distanceFromPrev.toFixed(3)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 운행 이력 목록 조회
|
|
*/
|
|
async getTripList(companyCode: string, filters: TripListFilters) {
|
|
const conditions: string[] = ["company_code = $1"];
|
|
const params: any[] = [companyCode];
|
|
let paramIndex = 2;
|
|
|
|
if (filters.userId) {
|
|
conditions.push(`user_id = $${paramIndex++}`);
|
|
params.push(filters.userId);
|
|
}
|
|
|
|
if (filters.vehicleId) {
|
|
conditions.push(`vehicle_id = $${paramIndex++}`);
|
|
params.push(filters.vehicleId);
|
|
}
|
|
|
|
if (filters.status) {
|
|
conditions.push(`status = $${paramIndex++}`);
|
|
params.push(filters.status);
|
|
}
|
|
|
|
if (filters.startDate) {
|
|
conditions.push(`start_time >= $${paramIndex++}`);
|
|
params.push(filters.startDate);
|
|
}
|
|
|
|
if (filters.endDate) {
|
|
conditions.push(`start_time <= $${paramIndex++}`);
|
|
params.push(filters.endDate + " 23:59:59");
|
|
}
|
|
|
|
if (filters.departure) {
|
|
conditions.push(`departure = $${paramIndex++}`);
|
|
params.push(filters.departure);
|
|
}
|
|
|
|
if (filters.arrival) {
|
|
conditions.push(`arrival = $${paramIndex++}`);
|
|
params.push(filters.arrival);
|
|
}
|
|
|
|
const whereClause = conditions.join(" AND ");
|
|
|
|
// 총 개수 조회
|
|
const countQuery = `SELECT COUNT(*) as total FROM vehicle_trip_summary WHERE ${whereClause}`;
|
|
const countResult = await this.pool.query(countQuery, params);
|
|
const total = parseInt(countResult.rows[0].total);
|
|
|
|
// 목록 조회
|
|
const limit = filters.limit || 50;
|
|
const offset = filters.offset || 0;
|
|
|
|
const listQuery = `
|
|
SELECT
|
|
vts.*,
|
|
ui.user_name,
|
|
v.vehicle_number
|
|
FROM vehicle_trip_summary vts
|
|
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
|
LEFT JOIN vehicles v ON vts.vehicle_id = v.id
|
|
WHERE ${whereClause}
|
|
ORDER BY vts.start_time DESC
|
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
|
`;
|
|
|
|
params.push(limit, offset);
|
|
const listResult = await this.pool.query(listQuery, params);
|
|
|
|
return {
|
|
data: listResult.rows,
|
|
total,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 운행 상세 조회 (경로 포함)
|
|
*/
|
|
async getTripDetail(tripId: string, companyCode: string) {
|
|
// 1. 운행 요약 조회
|
|
const summaryQuery = `
|
|
SELECT
|
|
vts.*,
|
|
ui.user_name,
|
|
v.vehicle_number
|
|
FROM vehicle_trip_summary vts
|
|
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
|
LEFT JOIN vehicles v ON vts.vehicle_id = v.id
|
|
WHERE vts.trip_id = $1 AND vts.company_code = $2
|
|
`;
|
|
const summaryResult = await this.pool.query(summaryQuery, [tripId, companyCode]);
|
|
|
|
if (summaryResult.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// 2. 경로 데이터 조회
|
|
const routeQuery = `
|
|
SELECT
|
|
id, latitude, longitude, accuracy, speed,
|
|
distance_from_prev, trip_status, recorded_at
|
|
FROM vehicle_location_history
|
|
WHERE trip_id = $1 AND company_code = $2
|
|
ORDER BY recorded_at ASC
|
|
`;
|
|
const routeResult = await this.pool.query(routeQuery, [tripId, companyCode]);
|
|
|
|
return {
|
|
summary: summaryResult.rows[0],
|
|
route: routeResult.rows,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 활성 운행 조회
|
|
*/
|
|
async getActiveTrip(userId: string, companyCode: string) {
|
|
const query = `
|
|
SELECT * FROM vehicle_trip_summary
|
|
WHERE user_id = $1 AND company_code = $2 AND status = 'active'
|
|
ORDER BY start_time DESC
|
|
LIMIT 1
|
|
`;
|
|
const result = await this.pool.query(query, [userId, companyCode]);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
/**
|
|
* 운행 취소
|
|
*/
|
|
async cancelTrip(tripId: string, companyCode: string) {
|
|
const query = `
|
|
UPDATE vehicle_trip_summary
|
|
SET status = 'cancelled', end_time = NOW()
|
|
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
|
RETURNING *
|
|
`;
|
|
const result = await this.pool.query(query, [tripId, companyCode]);
|
|
return result.rows[0] || null;
|
|
}
|
|
}
|
|
|
|
export const vehicleTripService = new VehicleTripService();
|