공차관련수정사항들
This commit is contained in:
@@ -72,6 +72,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
@@ -238,6 +239,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
||||
@@ -708,6 +708,12 @@ export class DashboardController {
|
||||
});
|
||||
}
|
||||
|
||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||
const isKmaApi = urlObj.hostname.includes('kma.go.kr');
|
||||
if (isKmaApi) {
|
||||
requestConfig.responseType = 'arraybuffer';
|
||||
}
|
||||
|
||||
const response = await axios(requestConfig);
|
||||
|
||||
if (response.status >= 400) {
|
||||
@@ -719,8 +725,24 @@ export class DashboardController {
|
||||
let data = response.data;
|
||||
const contentType = response.headers["content-type"];
|
||||
|
||||
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||
const iconv = require('iconv-lite');
|
||||
const buffer = Buffer.from(data);
|
||||
const utf8Text = buffer.toString('utf-8');
|
||||
|
||||
// UTF-8로 정상 디코딩되었는지 확인
|
||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||
data = { text: utf8Text, contentType, encoding: 'utf-8' };
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
const eucKrText = iconv.decode(buffer, 'EUC-KR');
|
||||
data = { text: eucKrText, contentType, encoding: 'euc-kr' };
|
||||
}
|
||||
}
|
||||
// 텍스트 응답인 경우 포맷팅
|
||||
if (typeof data === "string") {
|
||||
else if (typeof data === "string") {
|
||||
data = { text: data, contentType };
|
||||
}
|
||||
|
||||
|
||||
206
backend-node/src/controllers/vehicleReportController.ts
Normal file
206
backend-node/src/controllers/vehicleReportController.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 차량 운행 리포트 컨트롤러
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { vehicleReportService } from "../services/vehicleReportService";
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
* GET /api/vehicle/reports/daily
|
||||
*/
|
||||
export const getDailyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getDailyReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getDailyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "일별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 주별 통계 조회
|
||||
* GET /api/vehicle/reports/weekly
|
||||
*/
|
||||
export const getWeeklyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { year, month, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month });
|
||||
|
||||
const result = await vehicleReportService.getWeeklyReport(companyCode, {
|
||||
year: year ? parseInt(year as string) : new Date().getFullYear(),
|
||||
month: month ? parseInt(month as string) : new Date().getMonth() + 1,
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getWeeklyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "주별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
* GET /api/vehicle/reports/monthly
|
||||
*/
|
||||
export const getMonthlyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { year, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getMonthlyReport] 요청:", { companyCode, year });
|
||||
|
||||
const result = await vehicleReportService.getMonthlyReport(companyCode, {
|
||||
year: year ? parseInt(year as string) : new Date().getFullYear(),
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getMonthlyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "월별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 요약 통계 조회 (대시보드용)
|
||||
* GET /api/vehicle/reports/summary
|
||||
*/
|
||||
export const getSummaryReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { period } = req.query; // today, week, month, year
|
||||
|
||||
console.log("📊 [getSummaryReport] 요청:", { companyCode, period });
|
||||
|
||||
const result = await vehicleReportService.getSummaryReport(
|
||||
companyCode,
|
||||
(period as string) || "today"
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getSummaryReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "요약 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운전자별 통계 조회
|
||||
* GET /api/vehicle/reports/by-driver
|
||||
*/
|
||||
export const getDriverReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getDriverReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getDriverReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운전자별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 구간별 통계 조회
|
||||
* GET /api/vehicle/reports/by-route
|
||||
*/
|
||||
export const getRouteReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getRouteReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getRouteReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "구간별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
301
backend-node/src/controllers/vehicleTripController.ts
Normal file
301
backend-node/src/controllers/vehicleTripController.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 차량 운행 이력 컨트롤러
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { vehicleTripService } from "../services/vehicleTripService";
|
||||
|
||||
/**
|
||||
* 운행 시작
|
||||
* POST /api/vehicle/trip/start
|
||||
*/
|
||||
export const startTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body;
|
||||
|
||||
console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival });
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.startTrip({
|
||||
userId,
|
||||
companyCode,
|
||||
vehicleId,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
console.log("✅ [startTrip] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "운행이 시작되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [startTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 시작에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 종료
|
||||
* POST /api/vehicle/trip/end
|
||||
*/
|
||||
export const endTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tripId, latitude, longitude } = req.body;
|
||||
|
||||
console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId });
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.endTrip({
|
||||
tripId,
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
console.log("✅ [endTrip] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "운행이 종료되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [endTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 종료에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 기록 추가 (연속 추적)
|
||||
* POST /api/vehicle/trip/location
|
||||
*/
|
||||
export const addTripLocation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tripId, latitude, longitude, accuracy, speed } = req.body;
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.addLocation({
|
||||
tripId,
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
speed,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [addTripLocation] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 기록에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 이력 목록 조회
|
||||
* GET /api/vehicle/trips
|
||||
*/
|
||||
export const getTripList = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query;
|
||||
|
||||
console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate });
|
||||
|
||||
const result = await vehicleTripService.getTripList(companyCode, {
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
status: status as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
departure: departure as string,
|
||||
arrival: arrival as string,
|
||||
limit: limit ? parseInt(limit as string) : 50,
|
||||
offset: offset ? parseInt(offset as string) : 0,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getTripList] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 이력 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 상세 조회 (경로 포함)
|
||||
* GET /api/vehicle/trips/:tripId
|
||||
*/
|
||||
export const getTripDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.params;
|
||||
|
||||
console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId });
|
||||
|
||||
const result = await vehicleTripService.getTripDetail(tripId, companyCode);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "운행 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getTripDetail] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 상세 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 활성 운행 조회 (현재 진행 중)
|
||||
* GET /api/vehicle/trip/active
|
||||
*/
|
||||
export const getActiveTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
|
||||
const result = await vehicleTripService.getActiveTrip(userId, companyCode);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
hasActiveTrip: !!result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getActiveTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "활성 운행 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 취소
|
||||
* POST /api/vehicle/trip/cancel
|
||||
*/
|
||||
export const cancelTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.body;
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.cancelTrip(tripId, companyCode);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "취소할 운행을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "운행이 취소되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [cancelTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 취소에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
71
backend-node/src/routes/vehicleTripRoutes.ts
Normal file
71
backend-node/src/routes/vehicleTripRoutes.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 차량 운행 이력 및 리포트 라우트
|
||||
*/
|
||||
import { Router } from "express";
|
||||
import {
|
||||
startTrip,
|
||||
endTrip,
|
||||
addTripLocation,
|
||||
getTripList,
|
||||
getTripDetail,
|
||||
getActiveTrip,
|
||||
cancelTrip,
|
||||
} from "../controllers/vehicleTripController";
|
||||
import {
|
||||
getDailyReport,
|
||||
getWeeklyReport,
|
||||
getMonthlyReport,
|
||||
getSummaryReport,
|
||||
getDriverReport,
|
||||
getRouteReport,
|
||||
} from "../controllers/vehicleReportController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// === 운행 관리 ===
|
||||
// 운행 시작
|
||||
router.post("/trip/start", startTrip);
|
||||
|
||||
// 운행 종료
|
||||
router.post("/trip/end", endTrip);
|
||||
|
||||
// 위치 기록 추가 (연속 추적)
|
||||
router.post("/trip/location", addTripLocation);
|
||||
|
||||
// 활성 운행 조회 (현재 진행 중)
|
||||
router.get("/trip/active", getActiveTrip);
|
||||
|
||||
// 운행 취소
|
||||
router.post("/trip/cancel", cancelTrip);
|
||||
|
||||
// 운행 이력 목록 조회
|
||||
router.get("/trips", getTripList);
|
||||
|
||||
// 운행 상세 조회 (경로 포함)
|
||||
router.get("/trips/:tripId", getTripDetail);
|
||||
|
||||
// === 리포트 ===
|
||||
// 요약 통계 (대시보드용)
|
||||
router.get("/reports/summary", getSummaryReport);
|
||||
|
||||
// 일별 통계
|
||||
router.get("/reports/daily", getDailyReport);
|
||||
|
||||
// 주별 통계
|
||||
router.get("/reports/weekly", getWeeklyReport);
|
||||
|
||||
// 월별 통계
|
||||
router.get("/reports/monthly", getMonthlyReport);
|
||||
|
||||
// 운전자별 통계
|
||||
router.get("/reports/by-driver", getDriverReport);
|
||||
|
||||
// 구간별 통계
|
||||
router.get("/reports/by-route", getRouteReport);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -47,9 +47,24 @@ export class RiskAlertService {
|
||||
|
||||
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
|
||||
|
||||
// 텍스트 응답 파싱 (EUC-KR 인코딩)
|
||||
// 텍스트 응답 파싱 (인코딩 자동 감지)
|
||||
const iconv = require('iconv-lite');
|
||||
const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR');
|
||||
const buffer = Buffer.from(warningResponse.data);
|
||||
|
||||
// UTF-8 먼저 시도, 실패하면 EUC-KR 시도
|
||||
let responseText: string;
|
||||
const utf8Text = buffer.toString('utf-8');
|
||||
|
||||
// UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지)
|
||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||
responseText = utf8Text;
|
||||
console.log('📝 UTF-8 인코딩으로 디코딩');
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
responseText = iconv.decode(buffer, 'EUC-KR');
|
||||
console.log('📝 EUC-KR 인코딩으로 디코딩');
|
||||
}
|
||||
|
||||
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
|
||||
const lines = responseText.split('\n');
|
||||
|
||||
403
backend-node/src/services/vehicleReportService.ts
Normal file
403
backend-node/src/services/vehicleReportService.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 차량 운행 리포트 서비스
|
||||
*/
|
||||
import { getPool } from "../database/db";
|
||||
|
||||
interface DailyReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface WeeklyReportFilters {
|
||||
year: number;
|
||||
month: number;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface MonthlyReportFilters {
|
||||
year: number;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface DriverReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface RouteReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class VehicleReportService {
|
||||
private get pool() {
|
||||
return getPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
*/
|
||||
async getDailyReport(companyCode: string, filters: DailyReportFilters) {
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 기본값: 최근 30일
|
||||
const endDate = filters.endDate || new Date().toISOString().split("T")[0];
|
||||
const startDate =
|
||||
filters.startDate ||
|
||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
|
||||
conditions.push(`DATE(start_time) >= $${paramIndex++}`);
|
||||
params.push(startDate);
|
||||
conditions.push(`DATE(start_time) <= $${paramIndex++}`);
|
||||
params.push(endDate);
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
|
||||
if (filters.vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(filters.vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
DATE(start_time) as date,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY DATE(start_time)
|
||||
ORDER BY DATE(start_time) DESC
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
data: result.rows.map((row) => ({
|
||||
date: row.date,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
cancelledCount: parseInt(row.cancelled_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 주별 통계 조회
|
||||
*/
|
||||
async getWeeklyReport(companyCode: string, filters: WeeklyReportFilters) {
|
||||
const { year, month, userId, vehicleId } = filters;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`);
|
||||
params.push(year);
|
||||
conditions.push(`EXTRACT(MONTH FROM start_time) = $${paramIndex++}`);
|
||||
params.push(month);
|
||||
|
||||
if (userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
EXTRACT(WEEK FROM start_time) as week_number,
|
||||
MIN(DATE(start_time)) as week_start,
|
||||
MAX(DATE(start_time)) as week_end,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY EXTRACT(WEEK FROM start_time)
|
||||
ORDER BY week_number
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
data: result.rows.map((row) => ({
|
||||
weekNumber: parseInt(row.week_number),
|
||||
weekStart: row.week_start,
|
||||
weekEnd: row.week_end,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
async getMonthlyReport(companyCode: string, filters: MonthlyReportFilters) {
|
||||
const { year, userId, vehicleId } = filters;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`);
|
||||
params.push(year);
|
||||
|
||||
if (userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM start_time) as month,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration,
|
||||
COUNT(DISTINCT user_id) as driver_count
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY EXTRACT(MONTH FROM start_time)
|
||||
ORDER BY month
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
year,
|
||||
data: result.rows.map((row) => ({
|
||||
month: parseInt(row.month),
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
cancelledCount: parseInt(row.cancelled_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
driverCount: parseInt(row.driver_count),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 조회 (대시보드용)
|
||||
*/
|
||||
async getSummaryReport(companyCode: string, period: string) {
|
||||
let dateCondition = "";
|
||||
|
||||
switch (period) {
|
||||
case "today":
|
||||
dateCondition = "DATE(start_time) = CURRENT_DATE";
|
||||
break;
|
||||
case "week":
|
||||
dateCondition = "start_time >= CURRENT_DATE - INTERVAL '7 days'";
|
||||
break;
|
||||
case "month":
|
||||
dateCondition = "start_time >= CURRENT_DATE - INTERVAL '30 days'";
|
||||
break;
|
||||
case "year":
|
||||
dateCondition = "EXTRACT(YEAR FROM start_time) = EXTRACT(YEAR FROM CURRENT_DATE)";
|
||||
break;
|
||||
default:
|
||||
dateCondition = "DATE(start_time) = CURRENT_DATE";
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total_trips,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips,
|
||||
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_trips,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration,
|
||||
COUNT(DISTINCT user_id) as active_drivers
|
||||
FROM vehicle_trip_summary
|
||||
WHERE company_code = $1 AND ${dateCondition}
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, [companyCode]);
|
||||
const row = result.rows[0];
|
||||
|
||||
// 완료율 계산
|
||||
const totalTrips = parseInt(row.total_trips) || 0;
|
||||
const completedTrips = parseInt(row.completed_trips) || 0;
|
||||
const completionRate = totalTrips > 0 ? (completedTrips / totalTrips) * 100 : 0;
|
||||
|
||||
return {
|
||||
period,
|
||||
totalTrips,
|
||||
completedTrips,
|
||||
activeTrips: parseInt(row.active_trips) || 0,
|
||||
cancelledTrips: parseInt(row.cancelled_trips) || 0,
|
||||
completionRate: parseFloat(completionRate.toFixed(1)),
|
||||
totalDistance: parseFloat(row.total_distance) || 0,
|
||||
totalDuration: parseInt(row.total_duration) || 0,
|
||||
avgDistance: parseFloat(row.avg_distance) || 0,
|
||||
avgDuration: parseFloat(row.avg_duration) || 0,
|
||||
activeDrivers: parseInt(row.active_drivers) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운전자별 통계 조회
|
||||
*/
|
||||
async getDriverReport(companyCode: string, filters: DriverReportFilters) {
|
||||
const conditions: string[] = ["vts.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push(`DATE(vts.start_time) >= $${paramIndex++}`);
|
||||
params.push(filters.startDate);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(`DATE(vts.start_time) <= $${paramIndex++}`);
|
||||
params.push(filters.endDate);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const limit = filters.limit || 10;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
vts.user_id,
|
||||
ui.user_name,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN vts.status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN vts.status = 'completed' THEN vts.total_distance END), 0) as avg_distance
|
||||
FROM vehicle_trip_summary vts
|
||||
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY vts.user_id, ui.user_name
|
||||
ORDER BY total_distance DESC
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit);
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
userId: row.user_id,
|
||||
userName: row.user_name || row.user_id,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간별 통계 조회
|
||||
*/
|
||||
async getRouteReport(companyCode: string, filters: RouteReportFilters) {
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push(`DATE(start_time) >= $${paramIndex++}`);
|
||||
params.push(filters.startDate);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(`DATE(start_time) <= $${paramIndex++}`);
|
||||
params.push(filters.endDate);
|
||||
}
|
||||
|
||||
// 출발지/도착지가 있는 것만
|
||||
conditions.push("departure IS NOT NULL");
|
||||
conditions.push("arrival IS NOT NULL");
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const limit = filters.limit || 10;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
departure,
|
||||
arrival,
|
||||
departure_name,
|
||||
destination_name,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY departure, arrival, departure_name, destination_name
|
||||
ORDER BY trip_count DESC
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit);
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
departure: row.departure,
|
||||
arrival: row.arrival,
|
||||
departureName: row.departure_name || row.departure,
|
||||
destinationName: row.destination_name || row.arrival,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const vehicleReportService = new VehicleReportService();
|
||||
|
||||
456
backend-node/src/services/vehicleTripService.ts
Normal file
456
backend-node/src/services/vehicleTripService.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 차량 운행 이력 서비스
|
||||
*/
|
||||
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();
|
||||
176
backend-node/src/utils/geoUtils.ts
Normal file
176
backend-node/src/utils/geoUtils.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 지리 좌표 관련 유틸리티 함수
|
||||
*/
|
||||
|
||||
/**
|
||||
* Haversine 공식을 사용하여 두 좌표 간의 거리 계산 (km)
|
||||
*
|
||||
* @param lat1 - 첫 번째 지점의 위도
|
||||
* @param lon1 - 첫 번째 지점의 경도
|
||||
* @param lat2 - 두 번째 지점의 위도
|
||||
* @param lon2 - 두 번째 지점의 경도
|
||||
* @returns 두 지점 간의 거리 (km)
|
||||
*/
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371; // 지구 반경 (km)
|
||||
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRadians(lat1)) *
|
||||
Math.cos(toRadians(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 각도를 라디안으로 변환
|
||||
*/
|
||||
function toRadians(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* 라디안을 각도로 변환
|
||||
*/
|
||||
export function toDegrees(radians: number): number {
|
||||
return radians * (180 / Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열에서 총 거리 계산
|
||||
*
|
||||
* @param coordinates - { latitude, longitude }[] 형태의 좌표 배열
|
||||
* @returns 총 거리 (km)
|
||||
*/
|
||||
export function calculateTotalDistance(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): number {
|
||||
let totalDistance = 0;
|
||||
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const prev = coordinates[i - 1];
|
||||
const curr = coordinates[i];
|
||||
totalDistance += calculateDistance(
|
||||
prev.latitude,
|
||||
prev.longitude,
|
||||
curr.latitude,
|
||||
curr.longitude
|
||||
);
|
||||
}
|
||||
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표가 특정 반경 내에 있는지 확인
|
||||
*
|
||||
* @param centerLat - 중심점 위도
|
||||
* @param centerLon - 중심점 경도
|
||||
* @param pointLat - 확인할 지점의 위도
|
||||
* @param pointLon - 확인할 지점의 경도
|
||||
* @param radiusKm - 반경 (km)
|
||||
* @returns 반경 내에 있으면 true
|
||||
*/
|
||||
export function isWithinRadius(
|
||||
centerLat: number,
|
||||
centerLon: number,
|
||||
pointLat: number,
|
||||
pointLon: number,
|
||||
radiusKm: number
|
||||
): boolean {
|
||||
const distance = calculateDistance(centerLat, centerLon, pointLat, pointLon);
|
||||
return distance <= radiusKm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 좌표 사이의 방위각(bearing) 계산
|
||||
*
|
||||
* @param lat1 - 시작점 위도
|
||||
* @param lon1 - 시작점 경도
|
||||
* @param lat2 - 도착점 위도
|
||||
* @param lon2 - 도착점 경도
|
||||
* @returns 방위각 (0-360도)
|
||||
*/
|
||||
export function calculateBearing(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
const lat1Rad = toRadians(lat1);
|
||||
const lat2Rad = toRadians(lat2);
|
||||
|
||||
const x = Math.sin(dLon) * Math.cos(lat2Rad);
|
||||
const y =
|
||||
Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
||||
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
|
||||
|
||||
let bearing = toDegrees(Math.atan2(x, y));
|
||||
bearing = (bearing + 360) % 360; // 0-360 범위로 정규화
|
||||
|
||||
return bearing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열의 경계 상자(bounding box) 계산
|
||||
*
|
||||
* @param coordinates - 좌표 배열
|
||||
* @returns { minLat, maxLat, minLon, maxLon }
|
||||
*/
|
||||
export function getBoundingBox(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): { minLat: number; maxLat: number; minLon: number; maxLon: number } | null {
|
||||
if (coordinates.length === 0) return null;
|
||||
|
||||
let minLat = coordinates[0].latitude;
|
||||
let maxLat = coordinates[0].latitude;
|
||||
let minLon = coordinates[0].longitude;
|
||||
let maxLon = coordinates[0].longitude;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
minLat = Math.min(minLat, coord.latitude);
|
||||
maxLat = Math.max(maxLat, coord.latitude);
|
||||
minLon = Math.min(minLon, coord.longitude);
|
||||
maxLon = Math.max(maxLon, coord.longitude);
|
||||
}
|
||||
|
||||
return { minLat, maxLat, minLon, maxLon };
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열의 중심점 계산
|
||||
*
|
||||
* @param coordinates - 좌표 배열
|
||||
* @returns { latitude, longitude } 중심점
|
||||
*/
|
||||
export function getCenterPoint(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): { latitude: number; longitude: number } | null {
|
||||
if (coordinates.length === 0) return null;
|
||||
|
||||
let sumLat = 0;
|
||||
let sumLon = 0;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
sumLat += coord.latitude;
|
||||
sumLon += coord.longitude;
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: sumLat / coordinates.length,
|
||||
longitude: sumLon / coordinates.length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user