Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
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 driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
@@ -240,6 +241,7 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1428,10 +1428,51 @@ export async function deleteMenu(
|
||||
}
|
||||
}
|
||||
|
||||
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
|
||||
const menuObjid = Number(menuId);
|
||||
|
||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 3. code_info에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 5. rel_menu_auth에서 관련 권한 삭제
|
||||
await query(
|
||||
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 6. screen_menu_assignments에서 관련 할당 삭제
|
||||
await query(
|
||||
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
|
||||
|
||||
// Raw Query를 사용한 메뉴 삭제
|
||||
const [deletedMenu] = await query<any>(
|
||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||
[Number(menuId)]
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||
|
||||
@@ -32,10 +32,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
// 검색 필드 파싱
|
||||
const fields = searchFields
|
||||
const requestedFields = searchFields
|
||||
? (searchFields as string).split(",").map((f) => f.trim())
|
||||
: [];
|
||||
|
||||
// 🆕 테이블의 실제 컬럼 목록 조회
|
||||
const pool = getPool();
|
||||
const columnsResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// 🆕 존재하는 컬럼만 필터링
|
||||
const fields = requestedFields.filter((field) => {
|
||||
if (existingColumns.has(field)) {
|
||||
return true;
|
||||
} else {
|
||||
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const existingColumnsArray = Array.from(existingColumns);
|
||||
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
|
||||
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -43,32 +65,57 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
// 멀티테넌시 필터링
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
// 🆕 company_code 컬럼이 있는 경우에만 필터링
|
||||
if (existingColumns.has("company_code")) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 조건
|
||||
if (searchText && fields.length > 0) {
|
||||
const searchConditions = fields.map((field) => {
|
||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||
paramIndex++;
|
||||
return condition;
|
||||
});
|
||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||
if (searchText) {
|
||||
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
|
||||
let searchableFields = fields;
|
||||
if (searchableFields.length === 0) {
|
||||
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
|
||||
const defaultSearchColumns = [
|
||||
'name', 'code', 'description', 'title', 'label',
|
||||
'item_name', 'item_code', 'item_number',
|
||||
'equipment_name', 'equipment_code',
|
||||
'inspection_item', 'consumable_name', // 소모품명 추가
|
||||
'supplier_name', 'customer_name', 'product_name',
|
||||
];
|
||||
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
|
||||
|
||||
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
|
||||
}
|
||||
|
||||
if (searchableFields.length > 0) {
|
||||
const searchConditions = searchableFields.map((field) => {
|
||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||
paramIndex++;
|
||||
return condition;
|
||||
});
|
||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||
|
||||
// 검색어 파라미터 추가
|
||||
fields.forEach(() => {
|
||||
params.push(`%${searchText}%`);
|
||||
});
|
||||
// 검색어 파라미터 추가
|
||||
searchableFields.forEach(() => {
|
||||
params.push(`%${searchText}%`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 필터 조건
|
||||
// 추가 필터 조건 (존재하는 컬럼만)
|
||||
const additionalFilter = JSON.parse(filterCondition as string);
|
||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||
whereConditions.push(`${key} = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
if (existingColumns.has(key)) {
|
||||
whereConditions.push(`${key} = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
} else {
|
||||
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
|
||||
}
|
||||
}
|
||||
|
||||
// 페이징
|
||||
@@ -78,8 +125,7 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 쿼리 실행
|
||||
const pool = getPool();
|
||||
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
SELECT * FROM ${tableName} ${whereClause}
|
||||
|
||||
@@ -32,8 +32,17 @@ export class FlowController {
|
||||
*/
|
||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description, tableName, dbSourceType, dbConnectionId } =
|
||||
req.body;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
// REST API 관련 필드
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
} = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
@@ -43,6 +52,9 @@ export class FlowController {
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
userCompanyCode,
|
||||
});
|
||||
|
||||
@@ -54,8 +66,12 @@ export class FlowController {
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블 이름이 제공된 경우에만 존재 확인
|
||||
if (tableName) {
|
||||
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
|
||||
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
|
||||
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
|
||||
|
||||
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
|
||||
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
|
||||
const tableExists =
|
||||
await this.flowDefinitionService.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
@@ -68,7 +84,17 @@ export class FlowController {
|
||||
}
|
||||
|
||||
const flowDef = await this.flowDefinitionService.create(
|
||||
{ name, description, tableName, dbSourceType, dbConnectionId },
|
||||
{
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
||||
},
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
@@ -148,11 +148,42 @@ export const updateScreenInfo = async (
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { screenName, tableName, description, isActive } = req.body;
|
||||
const {
|
||||
screenName,
|
||||
tableName,
|
||||
description,
|
||||
isActive,
|
||||
// REST API 관련 필드 추가
|
||||
dataSourceType,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
} = req.body;
|
||||
|
||||
console.log("화면 정보 수정 요청:", {
|
||||
screenId: id,
|
||||
dataSourceType,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
});
|
||||
|
||||
await screenManagementService.updateScreenInfo(
|
||||
parseInt(id),
|
||||
{ screenName, tableName, description, isActive },
|
||||
{
|
||||
screenName,
|
||||
tableName,
|
||||
description,
|
||||
isActive,
|
||||
dataSourceType,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
},
|
||||
companyCode
|
||||
);
|
||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||
@@ -294,6 +325,53 @@ export const getDeletedScreens = async (
|
||||
}
|
||||
};
|
||||
|
||||
// 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
export const bulkDeleteScreens = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { screenIds, deleteReason, force } = req.body;
|
||||
|
||||
if (!Array.isArray(screenIds) || screenIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "삭제할 화면 ID 목록이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.bulkDeleteScreens(
|
||||
screenIds,
|
||||
companyCode,
|
||||
userId,
|
||||
deleteReason,
|
||||
force || false
|
||||
);
|
||||
|
||||
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
|
||||
if (result.skippedCount > 0) {
|
||||
message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message,
|
||||
result: {
|
||||
deletedCount: result.deletedCount,
|
||||
skippedCount: result.skippedCount,
|
||||
errors: result.errors,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("활성 화면 일괄 삭제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "일괄 삭제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 휴지통 화면 일괄 영구 삭제
|
||||
export const bulkPermanentDeleteScreens = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
||||
@@ -870,6 +870,17 @@ export async function addTableData(
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (companyCode && !data.company_code) {
|
||||
// 테이블에 company_code 컬럼이 있는지 확인
|
||||
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||
if (hasCompanyCodeColumn) {
|
||||
data.company_code = companyCode;
|
||||
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 추가
|
||||
await tableManagementService.addTableData(tableName, data);
|
||||
|
||||
|
||||
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 || "운행 취소에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
updateScreen,
|
||||
updateScreenInfo,
|
||||
deleteScreen,
|
||||
bulkDeleteScreens,
|
||||
checkScreenDependencies,
|
||||
restoreScreen,
|
||||
permanentDeleteScreen,
|
||||
@@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen);
|
||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||
|
||||
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;
|
||||
|
||||
@@ -1094,4 +1094,150 @@ export class ExternalRestApiConnectionService {
|
||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 REST API 데이터 조회 및 병합
|
||||
* 여러 REST API의 응답을 병합하여 하나의 데이터셋으로 반환
|
||||
*/
|
||||
static async fetchMultipleData(
|
||||
configs: Array<{
|
||||
connectionId: number;
|
||||
endpoint: string;
|
||||
jsonPath: string;
|
||||
alias: string;
|
||||
}>,
|
||||
userCompanyCode?: string
|
||||
): Promise<ApiResponse<{
|
||||
rows: any[];
|
||||
columns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }>;
|
||||
total: number;
|
||||
sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>;
|
||||
}>> {
|
||||
try {
|
||||
logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`);
|
||||
|
||||
// 각 API에서 데이터 조회
|
||||
const results = await Promise.all(
|
||||
configs.map(async (config) => {
|
||||
try {
|
||||
const result = await this.fetchData(
|
||||
config.connectionId,
|
||||
config.endpoint,
|
||||
config.jsonPath,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
connectionId: config.connectionId,
|
||||
connectionName: result.data.connectionInfo.connectionName,
|
||||
alias: config.alias,
|
||||
rows: result.data.rows,
|
||||
columns: result.data.columns,
|
||||
};
|
||||
} else {
|
||||
logger.warn(`API ${config.connectionId} 조회 실패:`, result.message);
|
||||
return {
|
||||
success: false,
|
||||
connectionId: config.connectionId,
|
||||
connectionName: "",
|
||||
alias: config.alias,
|
||||
rows: [],
|
||||
columns: [],
|
||||
error: result.message,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`API ${config.connectionId} 조회 오류:`, error);
|
||||
return {
|
||||
success: false,
|
||||
connectionId: config.connectionId,
|
||||
connectionName: "",
|
||||
alias: config.alias,
|
||||
rows: [],
|
||||
columns: [],
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 성공한 결과만 필터링
|
||||
const successfulResults = results.filter(r => r.success);
|
||||
|
||||
if (successfulResults.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "모든 REST API 조회에 실패했습니다.",
|
||||
error: {
|
||||
code: "ALL_APIS_FAILED",
|
||||
details: results.map(r => ({ connectionId: r.connectionId, error: r.error })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 컬럼 병합 (별칭 적용)
|
||||
const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = [];
|
||||
|
||||
for (const result of successfulResults) {
|
||||
for (const col of result.columns) {
|
||||
const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName;
|
||||
mergedColumns.push({
|
||||
columnName: prefixedColumnName,
|
||||
columnLabel: `${col.columnLabel} (${result.connectionName})`,
|
||||
dataType: col.dataType,
|
||||
sourceApi: result.connectionName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합)
|
||||
// 참고: 실제 사용 시에는 조인 키가 필요할 수 있음
|
||||
const maxRows = Math.max(...successfulResults.map(r => r.rows.length));
|
||||
const mergedRows: any[] = [];
|
||||
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
const mergedRow: any = {};
|
||||
|
||||
for (const result of successfulResults) {
|
||||
const row = result.rows[i] || {};
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const prefixedKey = result.alias ? `${result.alias}${key}` : key;
|
||||
mergedRow[prefixedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
mergedRows.push(mergedRow);
|
||||
}
|
||||
|
||||
logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
rows: mergedRows,
|
||||
columns: mergedColumns,
|
||||
total: mergedRows.length,
|
||||
sources: successfulResults.map(r => ({
|
||||
connectionId: r.connectionId,
|
||||
connectionName: r.connectionName,
|
||||
rowCount: r.rows.length,
|
||||
})),
|
||||
},
|
||||
message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("다중 REST API 데이터 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "다중 REST API 데이터 조회에 실패했습니다.",
|
||||
error: {
|
||||
code: "MULTI_FETCH_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,21 @@ export class FlowDefinitionService {
|
||||
tableName: request.tableName,
|
||||
dbSourceType: request.dbSourceType,
|
||||
dbConnectionId: request.dbConnectionId,
|
||||
restApiConnectionId: request.restApiConnectionId,
|
||||
restApiEndpoint: request.restApiEndpoint,
|
||||
restApiJsonPath: request.restApiJsonPath,
|
||||
restApiConnections: request.restApiConnections,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
const query = `
|
||||
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO flow_definition (
|
||||
name, description, table_name, db_source_type, db_connection_id,
|
||||
rest_api_connection_id, rest_api_endpoint, rest_api_json_path,
|
||||
rest_api_connections, company_code, created_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
@@ -43,6 +51,10 @@ export class FlowDefinitionService {
|
||||
request.tableName || null,
|
||||
request.dbSourceType || "internal",
|
||||
request.dbConnectionId || null,
|
||||
request.restApiConnectionId || null,
|
||||
request.restApiEndpoint || null,
|
||||
request.restApiJsonPath || "response",
|
||||
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
|
||||
companyCode,
|
||||
userId,
|
||||
];
|
||||
@@ -199,6 +211,19 @@ export class FlowDefinitionService {
|
||||
* DB 행을 FlowDefinition 객체로 변환
|
||||
*/
|
||||
private mapToFlowDefinition(row: any): FlowDefinition {
|
||||
// rest_api_connections 파싱 (JSONB → 배열)
|
||||
let restApiConnections = undefined;
|
||||
if (row.rest_api_connections) {
|
||||
try {
|
||||
restApiConnections = typeof row.rest_api_connections === 'string'
|
||||
? JSON.parse(row.rest_api_connections)
|
||||
: row.rest_api_connections;
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse rest_api_connections:", e);
|
||||
restApiConnections = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -206,6 +231,12 @@ export class FlowDefinitionService {
|
||||
tableName: row.table_name,
|
||||
dbSourceType: row.db_source_type || "internal",
|
||||
dbConnectionId: row.db_connection_id,
|
||||
// REST API 관련 필드 (단일)
|
||||
restApiConnectionId: row.rest_api_connection_id,
|
||||
restApiEndpoint: row.rest_api_endpoint,
|
||||
restApiJsonPath: row.rest_api_json_path,
|
||||
// 다중 REST API 관련 필드
|
||||
restApiConnections: restApiConnections,
|
||||
companyCode: row.company_code || "*",
|
||||
isActive: row.is_active,
|
||||
createdBy: row.created_by,
|
||||
|
||||
@@ -53,6 +53,7 @@ interface ScreenDefinition {
|
||||
layout_metadata: any;
|
||||
db_source_type: string | null;
|
||||
db_connection_id: number | null;
|
||||
source_screen_id: number | null; // 원본 화면 ID (복사 추적용)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +235,27 @@ export class MenuCopyService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId)
|
||||
if (props?.componentConfig?.leftScreenId) {
|
||||
const leftScreenId = props.componentConfig.leftScreenId;
|
||||
const numId =
|
||||
typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId);
|
||||
if (!isNaN(numId) && numId > 0) {
|
||||
referenced.push(numId);
|
||||
logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (props?.componentConfig?.rightScreenId) {
|
||||
const rightScreenId = props.componentConfig.rightScreenId;
|
||||
const numId =
|
||||
typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId);
|
||||
if (!isNaN(numId) && numId > 0) {
|
||||
referenced.push(numId);
|
||||
logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return referenced;
|
||||
@@ -431,14 +453,16 @@ export class MenuCopyService {
|
||||
const value = obj[key];
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
|
||||
// screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열)
|
||||
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열)
|
||||
if (
|
||||
key === "screen_id" ||
|
||||
key === "screenId" ||
|
||||
key === "targetScreenId"
|
||||
key === "targetScreenId" ||
|
||||
key === "leftScreenId" ||
|
||||
key === "rightScreenId"
|
||||
) {
|
||||
const numValue = typeof value === "number" ? value : parseInt(value);
|
||||
if (!isNaN(numValue)) {
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
const newId = screenIdMap.get(numValue);
|
||||
if (newId) {
|
||||
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
||||
@@ -856,7 +880,10 @@ export class MenuCopyService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 복사
|
||||
* 화면 복사 (업데이트 또는 신규 생성)
|
||||
* - source_screen_id로 기존 복사본 찾기
|
||||
* - 변경된 내용이 있으면 업데이트
|
||||
* - 없으면 새로 복사
|
||||
*/
|
||||
private async copyScreens(
|
||||
screenIds: Set<number>,
|
||||
@@ -876,18 +903,19 @@ export class MenuCopyService {
|
||||
return screenIdMap;
|
||||
}
|
||||
|
||||
logger.info(`📄 화면 복사 중: ${screenIds.size}개`);
|
||||
logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`);
|
||||
|
||||
// === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) ===
|
||||
// === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) ===
|
||||
const screenDefsToProcess: Array<{
|
||||
originalScreenId: number;
|
||||
newScreenId: number;
|
||||
targetScreenId: number;
|
||||
screenDef: ScreenDefinition;
|
||||
isUpdate: boolean; // 업데이트인지 신규 생성인지
|
||||
}> = [];
|
||||
|
||||
for (const originalScreenId of screenIds) {
|
||||
try {
|
||||
// 1) screen_definitions 조회
|
||||
// 1) 원본 screen_definitions 조회
|
||||
const screenDefResult = await client.query<ScreenDefinition>(
|
||||
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
|
||||
[originalScreenId]
|
||||
@@ -900,122 +928,198 @@ export class MenuCopyService {
|
||||
|
||||
const screenDef = screenDefResult.rows[0];
|
||||
|
||||
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
|
||||
const existingScreenResult = await client.query<{ screen_id: number }>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
// 2) 기존 복사본 찾기: source_screen_id로 검색
|
||||
const existingCopyResult = await client.query<{
|
||||
screen_id: number;
|
||||
screen_name: string;
|
||||
updated_date: Date;
|
||||
}>(
|
||||
`SELECT screen_id, screen_name, updated_date
|
||||
FROM screen_definitions
|
||||
WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
LIMIT 1`,
|
||||
[screenDef.screen_code, targetCompanyCode]
|
||||
[originalScreenId, targetCompanyCode]
|
||||
);
|
||||
|
||||
if (existingScreenResult.rows.length > 0) {
|
||||
// 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑
|
||||
const existingScreenId = existingScreenResult.rows[0].screen_id;
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
logger.info(
|
||||
` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})`
|
||||
);
|
||||
continue; // 레이아웃 복사도 스킵
|
||||
}
|
||||
|
||||
// 3) 새 screen_code 생성
|
||||
const newScreenCode = await this.generateUniqueScreenCode(
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
// 4) 화면명 변환 적용
|
||||
// 3) 화면명 변환 적용
|
||||
let transformedScreenName = screenDef.screen_name;
|
||||
if (screenNameConfig) {
|
||||
// 1. 제거할 텍스트 제거
|
||||
if (screenNameConfig.removeText?.trim()) {
|
||||
transformedScreenName = transformedScreenName.replace(
|
||||
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
||||
""
|
||||
);
|
||||
transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거
|
||||
transformedScreenName = transformedScreenName.trim();
|
||||
}
|
||||
|
||||
// 2. 접두사 추가
|
||||
if (screenNameConfig.addPrefix?.trim()) {
|
||||
transformedScreenName =
|
||||
screenNameConfig.addPrefix.trim() + " " + transformedScreenName;
|
||||
}
|
||||
}
|
||||
|
||||
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||
const newScreenResult = await client.query<{ screen_id: number }>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code,
|
||||
description, is_active, layout_metadata,
|
||||
db_source_type, db_connection_id, created_by,
|
||||
deleted_date, deleted_by, delete_reason
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING screen_id`,
|
||||
[
|
||||
transformedScreenName, // 변환된 화면명
|
||||
newScreenCode, // 새 화면 코드
|
||||
screenDef.table_name,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
screenDef.description,
|
||||
screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화
|
||||
screenDef.layout_metadata,
|
||||
screenDef.db_source_type,
|
||||
screenDef.db_connection_id,
|
||||
userId,
|
||||
null, // deleted_date: NULL (새 화면은 삭제되지 않음)
|
||||
null, // deleted_by: NULL
|
||||
null, // delete_reason: NULL
|
||||
]
|
||||
);
|
||||
if (existingCopyResult.rows.length > 0) {
|
||||
// === 기존 복사본이 있는 경우: 업데이트 ===
|
||||
const existingScreen = existingCopyResult.rows[0];
|
||||
const existingScreenId = existingScreen.screen_id;
|
||||
|
||||
const newScreenId = newScreenResult.rows[0].screen_id;
|
||||
screenIdMap.set(originalScreenId, newScreenId);
|
||||
// 원본 레이아웃 조회
|
||||
const sourceLayoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})`
|
||||
);
|
||||
// 대상 레이아웃 조회
|
||||
const targetLayoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
[existingScreenId]
|
||||
);
|
||||
|
||||
// 저장해서 2단계에서 처리
|
||||
screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef });
|
||||
// 변경 여부 확인 (레이아웃 개수 또는 내용 비교)
|
||||
const hasChanges = this.hasLayoutChanges(
|
||||
sourceLayoutsResult.rows,
|
||||
targetLayoutsResult.rows
|
||||
);
|
||||
|
||||
if (hasChanges) {
|
||||
// 변경 사항이 있으면 업데이트
|
||||
logger.info(
|
||||
` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})`
|
||||
);
|
||||
|
||||
// screen_definitions 업데이트
|
||||
await client.query(
|
||||
`UPDATE screen_definitions SET
|
||||
screen_name = $1,
|
||||
table_name = $2,
|
||||
description = $3,
|
||||
is_active = $4,
|
||||
layout_metadata = $5,
|
||||
db_source_type = $6,
|
||||
db_connection_id = $7,
|
||||
updated_by = $8,
|
||||
updated_date = NOW()
|
||||
WHERE screen_id = $9`,
|
||||
[
|
||||
transformedScreenName,
|
||||
screenDef.table_name,
|
||||
screenDef.description,
|
||||
screenDef.is_active === "D" ? "Y" : screenDef.is_active,
|
||||
screenDef.layout_metadata,
|
||||
screenDef.db_source_type,
|
||||
screenDef.db_connection_id,
|
||||
userId,
|
||||
existingScreenId,
|
||||
]
|
||||
);
|
||||
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
screenDefsToProcess.push({
|
||||
originalScreenId,
|
||||
targetScreenId: existingScreenId,
|
||||
screenDef,
|
||||
isUpdate: true,
|
||||
});
|
||||
} else {
|
||||
// 변경 사항이 없으면 스킵
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
logger.info(
|
||||
` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// === 기존 복사본이 없는 경우: 신규 생성 ===
|
||||
const newScreenCode = await this.generateUniqueScreenCode(
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
const newScreenResult = await client.query<{ screen_id: number }>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code,
|
||||
description, is_active, layout_metadata,
|
||||
db_source_type, db_connection_id, created_by,
|
||||
deleted_date, deleted_by, delete_reason, source_screen_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING screen_id`,
|
||||
[
|
||||
transformedScreenName,
|
||||
newScreenCode,
|
||||
screenDef.table_name,
|
||||
targetCompanyCode,
|
||||
screenDef.description,
|
||||
screenDef.is_active === "D" ? "Y" : screenDef.is_active,
|
||||
screenDef.layout_metadata,
|
||||
screenDef.db_source_type,
|
||||
screenDef.db_connection_id,
|
||||
userId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
originalScreenId, // source_screen_id 저장
|
||||
]
|
||||
);
|
||||
|
||||
const newScreenId = newScreenResult.rows[0].screen_id;
|
||||
screenIdMap.set(originalScreenId, newScreenId);
|
||||
|
||||
logger.info(
|
||||
` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})`
|
||||
);
|
||||
|
||||
screenDefsToProcess.push({
|
||||
originalScreenId,
|
||||
targetScreenId: newScreenId,
|
||||
screenDef,
|
||||
isUpdate: false,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`,
|
||||
`❌ 화면 처리 실패: screen_id=${originalScreenId}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) ===
|
||||
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
|
||||
logger.info(
|
||||
`\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
);
|
||||
|
||||
for (const {
|
||||
originalScreenId,
|
||||
newScreenId,
|
||||
targetScreenId,
|
||||
screenDef,
|
||||
isUpdate,
|
||||
} of screenDefsToProcess) {
|
||||
try {
|
||||
// screen_layouts 복사
|
||||
// 원본 레이아웃 조회
|
||||
const layoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
// 1단계: component_id 매핑 생성 (원본 → 새 ID)
|
||||
if (isUpdate) {
|
||||
// 업데이트: 기존 레이아웃 삭제 후 새로 삽입
|
||||
await client.query(
|
||||
`DELETE FROM screen_layouts WHERE screen_id = $1`,
|
||||
[targetScreenId]
|
||||
);
|
||||
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
|
||||
}
|
||||
|
||||
// component_id 매핑 생성 (원본 → 새 ID)
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const layout of layoutsResult.rows) {
|
||||
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
componentIdMap.set(layout.component_id, newComponentId);
|
||||
}
|
||||
|
||||
// 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑)
|
||||
// 레이아웃 삽입
|
||||
for (const layout of layoutsResult.rows) {
|
||||
const newComponentId = componentIdMap.get(layout.component_id)!;
|
||||
|
||||
// parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우)
|
||||
const newParentId = layout.parent_id
|
||||
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
||||
: null;
|
||||
@@ -1023,7 +1127,6 @@ export class MenuCopyService {
|
||||
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
||||
: null;
|
||||
|
||||
// properties 내부 참조 업데이트
|
||||
const updatedProperties = this.updateReferencesInProperties(
|
||||
layout.properties,
|
||||
screenIdMap,
|
||||
@@ -1037,38 +1140,94 @@ export class MenuCopyService {
|
||||
display_order, layout_type, layout_config, zones_config, zone_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||
[
|
||||
newScreenId, // 새 화면 ID
|
||||
targetScreenId,
|
||||
layout.component_type,
|
||||
newComponentId, // 새 컴포넌트 ID
|
||||
newParentId, // 매핑된 parent_id
|
||||
newComponentId,
|
||||
newParentId,
|
||||
layout.position_x,
|
||||
layout.position_y,
|
||||
layout.width,
|
||||
layout.height,
|
||||
updatedProperties, // 업데이트된 속성
|
||||
updatedProperties,
|
||||
layout.display_order,
|
||||
layout.layout_type,
|
||||
layout.layout_config,
|
||||
layout.zones_config,
|
||||
newZoneId, // 매핑된 zone_id
|
||||
newZoneId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`);
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`);
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`,
|
||||
`❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`);
|
||||
// 통계 출력
|
||||
const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length;
|
||||
const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length;
|
||||
const skipCount = screenIds.size - screenDefsToProcess.length;
|
||||
|
||||
logger.info(`
|
||||
✅ 화면 처리 완료:
|
||||
- 신규 복사: ${newCount}개
|
||||
- 업데이트: ${updateCount}개
|
||||
- 스킵 (변경 없음): ${skipCount}개
|
||||
- 총 매핑: ${screenIdMap.size}개
|
||||
`);
|
||||
|
||||
return screenIdMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 변경 여부 확인
|
||||
*/
|
||||
private hasLayoutChanges(
|
||||
sourceLayouts: ScreenLayout[],
|
||||
targetLayouts: ScreenLayout[]
|
||||
): boolean {
|
||||
// 1. 레이아웃 개수가 다르면 변경됨
|
||||
if (sourceLayouts.length !== targetLayouts.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 각 레이아웃의 주요 속성 비교
|
||||
for (let i = 0; i < sourceLayouts.length; i++) {
|
||||
const source = sourceLayouts[i];
|
||||
const target = targetLayouts[i];
|
||||
|
||||
// component_type이 다르면 변경됨
|
||||
if (source.component_type !== target.component_type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 위치/크기가 다르면 변경됨
|
||||
if (
|
||||
source.position_x !== target.position_x ||
|
||||
source.position_y !== target.position_y ||
|
||||
source.width !== target.width ||
|
||||
source.height !== target.height
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// properties의 JSON 문자열 비교 (깊은 비교)
|
||||
const sourceProps = JSON.stringify(source.properties || {});
|
||||
const targetProps = JSON.stringify(target.properties || {});
|
||||
if (sourceProps !== targetProps) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 위상 정렬 (부모 먼저)
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -326,7 +326,19 @@ export class ScreenManagementService {
|
||||
*/
|
||||
async updateScreenInfo(
|
||||
screenId: number,
|
||||
updateData: { screenName: string; tableName?: string; description?: string; isActive: string },
|
||||
updateData: {
|
||||
screenName: string;
|
||||
tableName?: string;
|
||||
description?: string;
|
||||
isActive: string;
|
||||
// REST API 관련 필드 추가
|
||||
dataSourceType?: string;
|
||||
dbSourceType?: string;
|
||||
dbConnectionId?: number;
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
},
|
||||
userCompanyCode: string
|
||||
): Promise<void> {
|
||||
// 권한 확인
|
||||
@@ -348,24 +360,43 @@ export class ScreenManagementService {
|
||||
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 화면 정보 업데이트 (tableName 포함)
|
||||
// 화면 정보 업데이트 (REST API 필드 포함)
|
||||
await query(
|
||||
`UPDATE screen_definitions
|
||||
SET screen_name = $1,
|
||||
table_name = $2,
|
||||
description = $3,
|
||||
is_active = $4,
|
||||
updated_date = $5
|
||||
WHERE screen_id = $6`,
|
||||
updated_date = $5,
|
||||
data_source_type = $6,
|
||||
db_source_type = $7,
|
||||
db_connection_id = $8,
|
||||
rest_api_connection_id = $9,
|
||||
rest_api_endpoint = $10,
|
||||
rest_api_json_path = $11
|
||||
WHERE screen_id = $12`,
|
||||
[
|
||||
updateData.screenName,
|
||||
updateData.tableName || null,
|
||||
updateData.description || null,
|
||||
updateData.isActive,
|
||||
new Date(),
|
||||
updateData.dataSourceType || "database",
|
||||
updateData.dbSourceType || "internal",
|
||||
updateData.dbConnectionId || null,
|
||||
updateData.restApiConnectionId || null,
|
||||
updateData.restApiEndpoint || null,
|
||||
updateData.restApiJsonPath || null,
|
||||
screenId,
|
||||
]
|
||||
);
|
||||
|
||||
console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, {
|
||||
dataSourceType: updateData.dataSourceType,
|
||||
restApiConnectionId: updateData.restApiConnectionId,
|
||||
restApiEndpoint: updateData.restApiEndpoint,
|
||||
restApiJsonPath: updateData.restApiJsonPath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -861,6 +892,134 @@ export class ScreenManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
*/
|
||||
async bulkDeleteScreens(
|
||||
screenIds: number[],
|
||||
userCompanyCode: string,
|
||||
deletedBy: string,
|
||||
deleteReason?: string,
|
||||
force: boolean = false
|
||||
): Promise<{
|
||||
deletedCount: number;
|
||||
skippedCount: number;
|
||||
errors: Array<{ screenId: number; error: string }>;
|
||||
}> {
|
||||
if (screenIds.length === 0) {
|
||||
throw new Error("삭제할 화면을 선택해주세요.");
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
let skippedCount = 0;
|
||||
const errors: Array<{ screenId: number; error: string }> = [];
|
||||
|
||||
// 각 화면을 개별적으로 삭제 처리
|
||||
for (const screenId of screenIds) {
|
||||
try {
|
||||
// 권한 확인 (Raw Query)
|
||||
const existingResult = await query<{
|
||||
company_code: string | null;
|
||||
is_active: string;
|
||||
screen_name: string;
|
||||
}>(
|
||||
`SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
if (existingResult.length === 0) {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: "화면을 찾을 수 없습니다.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingScreen = existingResult[0];
|
||||
|
||||
// 권한 확인
|
||||
if (
|
||||
userCompanyCode !== "*" &&
|
||||
existingScreen.company_code !== userCompanyCode
|
||||
) {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: "이 화면을 삭제할 권한이 없습니다.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 삭제된 화면인지 확인
|
||||
if (existingScreen.is_active === "D") {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: "이미 삭제된 화면입니다.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 강제 삭제가 아닌 경우 의존성 체크
|
||||
if (!force) {
|
||||
const dependencyCheck = await this.checkScreenDependencies(
|
||||
screenId,
|
||||
userCompanyCode
|
||||
);
|
||||
if (dependencyCheck.hasDependencies) {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리
|
||||
await transaction(async (client) => {
|
||||
const now = new Date();
|
||||
|
||||
// 소프트 삭제 (휴지통으로 이동)
|
||||
await client.query(
|
||||
`UPDATE screen_definitions
|
||||
SET is_active = 'D',
|
||||
deleted_date = $1,
|
||||
deleted_by = $2,
|
||||
delete_reason = $3,
|
||||
updated_date = $4,
|
||||
updated_by = $5
|
||||
WHERE screen_id = $6`,
|
||||
[now, deletedBy, deleteReason || null, now, deletedBy, screenId]
|
||||
);
|
||||
|
||||
// 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거)
|
||||
await client.query(
|
||||
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
|
||||
[screenId]
|
||||
);
|
||||
});
|
||||
|
||||
deletedCount++;
|
||||
logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`);
|
||||
} catch (error) {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
logger.error(`화면 삭제 실패: ${screenId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개`
|
||||
);
|
||||
|
||||
return { deletedCount, skippedCount, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통 화면 일괄 영구 삭제
|
||||
*/
|
||||
@@ -1486,11 +1645,23 @@ export class ScreenManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
// 🔥 최신 inputType 정보 조회 (table_type_columns에서)
|
||||
const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode);
|
||||
|
||||
const components: ComponentData[] = componentLayouts.map((layout) => {
|
||||
const properties = layout.properties as any;
|
||||
|
||||
// 🔥 최신 inputType으로 widgetType 및 componentType 업데이트
|
||||
const tableName = properties?.tableName;
|
||||
const columnName = properties?.columnName;
|
||||
const latestTypeInfo = tableName && columnName
|
||||
? inputTypeMap.get(`${tableName}.${columnName}`)
|
||||
: null;
|
||||
|
||||
const component = {
|
||||
id: layout.component_id,
|
||||
type: layout.component_type as any,
|
||||
// 🔥 최신 componentType이 있으면 type 덮어쓰기
|
||||
type: latestTypeInfo?.componentType || layout.component_type as any,
|
||||
position: {
|
||||
x: layout.position_x,
|
||||
y: layout.position_y,
|
||||
@@ -1499,6 +1670,17 @@ export class ScreenManagementService {
|
||||
size: { width: layout.width, height: layout.height },
|
||||
parentId: layout.parent_id,
|
||||
...properties,
|
||||
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기
|
||||
...(latestTypeInfo && {
|
||||
widgetType: latestTypeInfo.inputType,
|
||||
inputType: latestTypeInfo.inputType,
|
||||
componentType: latestTypeInfo.componentType,
|
||||
componentConfig: {
|
||||
...properties?.componentConfig,
|
||||
type: latestTypeInfo.componentType,
|
||||
inputType: latestTypeInfo.inputType,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(`로드된 컴포넌트:`, {
|
||||
@@ -1508,6 +1690,9 @@ export class ScreenManagementService {
|
||||
size: component.size,
|
||||
parentId: component.parentId,
|
||||
title: (component as any).title,
|
||||
widgetType: (component as any).widgetType,
|
||||
componentType: (component as any).componentType,
|
||||
latestTypeInfo,
|
||||
});
|
||||
|
||||
return component;
|
||||
@@ -1527,6 +1712,112 @@ export class ScreenManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 타입에 해당하는 컴포넌트 ID 반환
|
||||
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
|
||||
*/
|
||||
private getComponentIdFromInputType(inputType: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
// 텍스트 입력
|
||||
text: "text-input",
|
||||
email: "text-input",
|
||||
password: "text-input",
|
||||
tel: "text-input",
|
||||
// 숫자 입력
|
||||
number: "number-input",
|
||||
decimal: "number-input",
|
||||
// 날짜/시간
|
||||
date: "date-input",
|
||||
datetime: "date-input",
|
||||
time: "date-input",
|
||||
// 텍스트 영역
|
||||
textarea: "textarea-basic",
|
||||
// 선택
|
||||
select: "select-basic",
|
||||
dropdown: "select-basic",
|
||||
// 체크박스/라디오
|
||||
checkbox: "checkbox-basic",
|
||||
radio: "radio-basic",
|
||||
boolean: "toggle-switch",
|
||||
// 파일
|
||||
file: "file-upload",
|
||||
// 이미지
|
||||
image: "image-widget",
|
||||
img: "image-widget",
|
||||
picture: "image-widget",
|
||||
photo: "image-widget",
|
||||
// 버튼
|
||||
button: "button-primary",
|
||||
// 기타
|
||||
label: "text-display",
|
||||
code: "select-basic",
|
||||
entity: "select-basic",
|
||||
category: "select-basic",
|
||||
};
|
||||
|
||||
return mapping[inputType] || "text-input";
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트들의 최신 inputType 정보 조회
|
||||
* @param layouts - 레이아웃 목록
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns Map<"tableName.columnName", { inputType, componentType }>
|
||||
*/
|
||||
private async getLatestInputTypes(
|
||||
layouts: any[],
|
||||
companyCode: string
|
||||
): Promise<Map<string, { inputType: string; componentType: string }>> {
|
||||
const inputTypeMap = new Map<string, { inputType: string; componentType: string }>();
|
||||
|
||||
// tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출
|
||||
const tableColumnPairs = new Set<string>();
|
||||
for (const layout of layouts) {
|
||||
const properties = layout.properties as any;
|
||||
if (properties?.tableName && properties?.columnName) {
|
||||
tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tableColumnPairs.size === 0) {
|
||||
return inputTypeMap;
|
||||
}
|
||||
|
||||
// 각 테이블-컬럼 조합에 대해 최신 inputType 조회
|
||||
const pairs = Array.from(tableColumnPairs).map(pair => {
|
||||
const [tableName, columnName] = pair.split('|');
|
||||
return { tableName, columnName };
|
||||
});
|
||||
|
||||
// 배치 쿼리로 한 번에 조회
|
||||
const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ');
|
||||
const params = pairs.flatMap(p => [p.tableName, p.columnName]);
|
||||
|
||||
try {
|
||||
const results = await query<{ table_name: string; column_name: string; input_type: string }>(
|
||||
`SELECT table_name, column_name, input_type
|
||||
FROM table_type_columns
|
||||
WHERE (table_name, column_name) IN (${placeholders})
|
||||
AND company_code = $${params.length + 1}`,
|
||||
[...params, companyCode]
|
||||
);
|
||||
|
||||
for (const row of results) {
|
||||
const componentType = this.getComponentIdFromInputType(row.input_type);
|
||||
inputTypeMap.set(`${row.table_name}.${row.column_name}`, {
|
||||
inputType: row.input_type,
|
||||
componentType: componentType,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`최신 inputType 조회 완료: ${results.length}개`);
|
||||
} catch (error) {
|
||||
console.warn(`최신 inputType 조회 실패 (무시됨):`, error);
|
||||
}
|
||||
|
||||
return inputTypeMap;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 템플릿 관리
|
||||
// ========================================
|
||||
@@ -2016,37 +2307,40 @@ export class ScreenManagementService {
|
||||
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
|
||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||
|
||||
// 해당 회사의 기존 화면 코드들 조회
|
||||
// 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지)
|
||||
// LIMIT 제거하고 숫자 추출하여 최대값 찾기
|
||||
const existingScreens = await client.query<{ screen_code: string }>(
|
||||
`SELECT screen_code FROM screen_definitions
|
||||
WHERE company_code = $1 AND screen_code LIKE $2
|
||||
ORDER BY screen_code DESC
|
||||
LIMIT 10`,
|
||||
[companyCode, `${companyCode}%`]
|
||||
WHERE screen_code LIKE $1
|
||||
ORDER BY screen_code DESC`,
|
||||
[`${companyCode}_%`]
|
||||
);
|
||||
|
||||
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
||||
let maxNumber = 0;
|
||||
const pattern = new RegExp(
|
||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$`
|
||||
);
|
||||
|
||||
console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`);
|
||||
console.log(`🔍 패턴: ${pattern}`);
|
||||
|
||||
for (const screen of existingScreens.rows) {
|
||||
const match = screen.screen_code.match(pattern);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
||||
// 다음 순번으로 화면 코드 생성
|
||||
const nextNumber = maxNumber + 1;
|
||||
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||
|
||||
const newCode = `${companyCode}_${paddedNumber}`;
|
||||
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`);
|
||||
// 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩
|
||||
const newCode = `${companyCode}_${nextNumber}`;
|
||||
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`);
|
||||
|
||||
return newCode;
|
||||
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
|
||||
|
||||
@@ -797,6 +797,9 @@ export class TableManagementService {
|
||||
]
|
||||
);
|
||||
|
||||
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
|
||||
await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode);
|
||||
|
||||
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
||||
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
||||
cache.delete(cacheKeyPattern);
|
||||
@@ -816,6 +819,135 @@ export class TableManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 타입에 해당하는 컴포넌트 ID 반환
|
||||
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
|
||||
*/
|
||||
private getComponentIdFromInputType(inputType: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
// 텍스트 입력
|
||||
text: "text-input",
|
||||
email: "text-input",
|
||||
password: "text-input",
|
||||
tel: "text-input",
|
||||
// 숫자 입력
|
||||
number: "number-input",
|
||||
decimal: "number-input",
|
||||
// 날짜/시간
|
||||
date: "date-input",
|
||||
datetime: "date-input",
|
||||
time: "date-input",
|
||||
// 텍스트 영역
|
||||
textarea: "textarea-basic",
|
||||
// 선택
|
||||
select: "select-basic",
|
||||
dropdown: "select-basic",
|
||||
// 체크박스/라디오
|
||||
checkbox: "checkbox-basic",
|
||||
radio: "radio-basic",
|
||||
boolean: "toggle-switch",
|
||||
// 파일
|
||||
file: "file-upload",
|
||||
// 이미지
|
||||
image: "image-widget",
|
||||
img: "image-widget",
|
||||
picture: "image-widget",
|
||||
photo: "image-widget",
|
||||
// 버튼
|
||||
button: "button-primary",
|
||||
// 기타
|
||||
label: "text-display",
|
||||
code: "select-basic",
|
||||
entity: "select-basic",
|
||||
category: "select-basic",
|
||||
};
|
||||
|
||||
return mapping[inputType] || "text-input";
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화
|
||||
* @param tableName - 테이블명
|
||||
* @param columnName - 컬럼명
|
||||
* @param inputType - 새로운 입력 타입
|
||||
* @param companyCode - 회사 코드
|
||||
*/
|
||||
private async syncScreenLayoutsInputType(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
inputType: string,
|
||||
companyCode: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 해당 컬럼을 사용하는 화면 레이아웃 조회
|
||||
const affectedLayouts = await query<{
|
||||
layout_id: number;
|
||||
screen_id: number;
|
||||
component_id: string;
|
||||
component_type: string;
|
||||
properties: any;
|
||||
}>(
|
||||
`SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties
|
||||
FROM screen_layouts sl
|
||||
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
|
||||
WHERE sl.properties->>'tableName' = $1
|
||||
AND sl.properties->>'columnName' = $2
|
||||
AND (sd.company_code = $3 OR $3 = '*')`,
|
||||
[tableName, columnName, companyCode]
|
||||
);
|
||||
|
||||
if (affectedLayouts.length === 0) {
|
||||
logger.info(
|
||||
`화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견`
|
||||
);
|
||||
|
||||
// 새로운 componentType 계산
|
||||
const newComponentType = this.getComponentIdFromInputType(inputType);
|
||||
|
||||
// 각 레이아웃의 widgetType, componentType 업데이트
|
||||
for (const layout of affectedLayouts) {
|
||||
const updatedProperties = {
|
||||
...layout.properties,
|
||||
widgetType: inputType,
|
||||
inputType: inputType,
|
||||
// componentConfig 내부의 type도 업데이트
|
||||
componentConfig: {
|
||||
...layout.properties?.componentConfig,
|
||||
type: newComponentType,
|
||||
inputType: inputType,
|
||||
},
|
||||
};
|
||||
|
||||
await query(
|
||||
`UPDATE screen_layouts
|
||||
SET properties = $1, component_type = $2
|
||||
WHERE layout_id = $3`,
|
||||
[JSON.stringify(updatedProperties), newComponentType, layout.layout_id]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨`
|
||||
);
|
||||
} catch (error) {
|
||||
// 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행
|
||||
logger.warn(
|
||||
`화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 타입별 기본 상세 설정 생성
|
||||
*/
|
||||
@@ -1516,6 +1648,26 @@ export class TableManagementService {
|
||||
columnName
|
||||
);
|
||||
|
||||
// 🆕 배열 처리: IN 절 사용
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
// 빈 배열이면 항상 false 조건
|
||||
return {
|
||||
whereClause: `1 = 0`,
|
||||
values: [],
|
||||
paramCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// IN 절로 여러 값 검색
|
||||
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||
return {
|
||||
whereClause: `${columnName} IN (${placeholders})`,
|
||||
values: value,
|
||||
paramCount: value.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
|
||||
// 엔티티 타입이 아니면 기본 검색
|
||||
return {
|
||||
@@ -4070,4 +4222,22 @@ export class TableManagementService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에 특정 컬럼이 존재하는지 확인
|
||||
*/
|
||||
async hasColumn(tableName: string, columnName: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await query<any>(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = $2`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
return result.length > 0;
|
||||
} catch (error) {
|
||||
logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -2,14 +2,38 @@
|
||||
* 플로우 관리 시스템 타입 정의
|
||||
*/
|
||||
|
||||
// 다중 REST API 연결 설정
|
||||
export interface RestApiConnectionConfig {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
endpoint: string;
|
||||
jsonPath: string;
|
||||
alias: string; // 컬럼 접두어 (예: "api1_")
|
||||
}
|
||||
|
||||
// 다중 외부 DB 연결 설정
|
||||
export interface ExternalDbConnectionConfig {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
dbType: string;
|
||||
tableName: string;
|
||||
alias: string; // 컬럼 접두어 (예: "db1_")
|
||||
}
|
||||
|
||||
// 플로우 정의
|
||||
export interface FlowDefinition {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
tableName: string;
|
||||
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
|
||||
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
|
||||
dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우)
|
||||
// REST API 관련 필드 (단일)
|
||||
restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우)
|
||||
restApiEndpoint?: string; // REST API 엔드포인트
|
||||
restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data)
|
||||
// 다중 REST API 관련 필드
|
||||
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열
|
||||
companyCode: string; // 회사 코드 (* = 공통)
|
||||
isActive: boolean;
|
||||
createdBy?: string;
|
||||
@@ -22,8 +46,14 @@ export interface CreateFlowDefinitionRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
tableName: string;
|
||||
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
|
||||
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
|
||||
dbConnectionId?: number; // 외부 DB 연결 ID
|
||||
// REST API 관련 필드 (단일)
|
||||
restApiConnectionId?: number; // REST API 연결 ID
|
||||
restApiEndpoint?: string; // REST API 엔드포인트
|
||||
restApiJsonPath?: string; // JSON 응답에서 데이터 경로
|
||||
// 다중 REST API 관련 필드
|
||||
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열
|
||||
companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용)
|
||||
}
|
||||
|
||||
|
||||
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