ERP-node/backend-node/src/services/vehicleTripService.ts

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();