Merge branch 'main' into feature/screen-management
This commit is contained in:
@@ -55,7 +55,8 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
|
||||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import warehouseRoutes from "./routes/warehouseRoutes"; // 창고 관리
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||
import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
@@ -205,7 +206,8 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/warehouse", warehouseRoutes); // 창고 관리
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||
app.use("/api/materials", materialRoutes); // 자재 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
@@ -235,7 +237,7 @@ app.listen(PORT, HOST, async () => {
|
||||
|
||||
// 대시보드 마이그레이션 실행
|
||||
try {
|
||||
const { runDashboardMigration } = await import('./database/runMigration');
|
||||
const { runDashboardMigration } = await import("./database/runMigration");
|
||||
await runDashboardMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 대시보드 마이그레이션 실패:`, error);
|
||||
|
||||
68
backend-node/src/controllers/MaterialController.ts
Normal file
68
backend-node/src/controllers/MaterialController.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Request, Response } from "express";
|
||||
import MaterialService from "../services/MaterialService";
|
||||
|
||||
export class MaterialController {
|
||||
// 임시 자재 마스터 목록 조회
|
||||
async getTempMaterials(req: Request, res: Response) {
|
||||
try {
|
||||
const { search, category, page, limit } = req.query;
|
||||
|
||||
const result = await MaterialService.getTempMaterials({
|
||||
search: search as string,
|
||||
category: category as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 20,
|
||||
});
|
||||
|
||||
return res.json({ success: true, ...result });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching temp materials:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 자재 상세 조회
|
||||
async getTempMaterialByCode(req: Request, res: Response) {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const material = await MaterialService.getTempMaterialByCode(code);
|
||||
|
||||
if (!material) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "자재를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: material });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching temp material:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 목록 조회
|
||||
async getCategories(req: Request, res: Response) {
|
||||
try {
|
||||
const categories = await MaterialService.getCategories();
|
||||
return res.json({ success: true, data: categories });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching categories:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MaterialController();
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Request, Response } from "express";
|
||||
import { WarehouseService } from "../services/WarehouseService";
|
||||
|
||||
export class WarehouseController {
|
||||
private warehouseService: WarehouseService;
|
||||
|
||||
constructor() {
|
||||
this.warehouseService = new WarehouseService();
|
||||
}
|
||||
|
||||
// 창고 및 자재 데이터 조회
|
||||
getWarehouseData = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await this.warehouseService.getWarehouseData();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
warehouses: data.warehouses,
|
||||
materials: data.materials,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("창고 데이터 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 데이터를 불러오는데 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 창고 정보 조회
|
||||
getWarehouseById = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const warehouse = await this.warehouseService.getWarehouseById(id);
|
||||
|
||||
if (!warehouse) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "창고를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: warehouse,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("창고 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 정보를 불러오는데 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 창고별 자재 목록 조회
|
||||
getMaterialsByWarehouse = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { warehouseId } = req.params;
|
||||
const materials =
|
||||
await this.warehouseService.getMaterialsByWarehouse(warehouseId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: materials,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("자재 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 목록을 불러오는데 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 창고 통계 조회
|
||||
getWarehouseStats = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await this.warehouseService.getWarehouseStats();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("창고 통계 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 통계를 불러오는데 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
299
backend-node/src/controllers/YardLayoutController.ts
Normal file
299
backend-node/src/controllers/YardLayoutController.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { Request, Response } from "express";
|
||||
import YardLayoutService from "../services/YardLayoutService";
|
||||
|
||||
export class YardLayoutController {
|
||||
// 모든 야드 레이아웃 목록 조회
|
||||
async getAllLayouts(req: Request, res: Response) {
|
||||
try {
|
||||
const layouts = await YardLayoutService.getAllLayouts();
|
||||
res.json({ success: true, data: layouts });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layouts:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드 레이아웃 상세 조회
|
||||
async getLayoutById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.getLayoutById(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 새 야드 레이아웃 생성
|
||||
async createLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const created_by = (req as any).user?.userId || "system";
|
||||
const layout = await YardLayoutService.createLayout({
|
||||
name,
|
||||
description,
|
||||
created_by,
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error creating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 수정
|
||||
async updateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const layout = await YardLayoutService.updateLayout(parseInt(id), {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 삭제
|
||||
async deleteLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const layout = await YardLayoutService.deleteLayout(parseInt(id));
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "야드 레이아웃이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 야드의 모든 배치 자재 조회
|
||||
async getPlacementsByLayoutId(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placements = await YardLayoutService.getPlacementsByLayoutId(
|
||||
parseInt(id)
|
||||
);
|
||||
|
||||
res.json({ success: true, data: placements });
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching placements:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 자재 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드에 자재 배치 추가
|
||||
async addMaterialPlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
if (!placementData.external_material_id || !placementData.material_code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "자재 정보가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const placement = await YardLayoutService.addMaterialPlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error adding material placement:", error);
|
||||
|
||||
if (error.code === "23505") {
|
||||
// 유니크 제약 조건 위반
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 배치된 자재입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 배치 추가 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 정보 수정
|
||||
async updatePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placementData = req.body;
|
||||
|
||||
const placement = await YardLayoutService.updatePlacement(
|
||||
parseInt(id),
|
||||
placementData
|
||||
);
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: placement });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 정보 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 해제
|
||||
async removePlacement(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const placement = await YardLayoutService.removePlacement(parseInt(id));
|
||||
|
||||
if (!placement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: "배치가 해제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("Error removing placement:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 해제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 여러 배치 일괄 업데이트
|
||||
async batchUpdatePlacements(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { placements } = req.body;
|
||||
|
||||
if (!Array.isArray(placements) || placements.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "배치 목록이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedPlacements = await YardLayoutService.batchUpdatePlacements(
|
||||
parseInt(id),
|
||||
placements
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: updatedPlacements });
|
||||
} catch (error: any) {
|
||||
console.error("Error batch updating placements:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 일괄 업데이트 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 복제
|
||||
async duplicateLayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "새 야드 이름은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const layout = await YardLayoutService.duplicateLayout(
|
||||
parseInt(id),
|
||||
name
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: layout });
|
||||
} catch (error: any) {
|
||||
console.error("Error duplicating yard layout:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "야드 레이아웃 복제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new YardLayoutController();
|
||||
@@ -17,19 +17,54 @@ export class OpenApiProxyController {
|
||||
|
||||
console.log(`🌤️ 날씨 조회 요청: ${city}`);
|
||||
|
||||
// 기상청 API Hub 키 확인
|
||||
// 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트)
|
||||
const openWeatherKey = process.env.OPENWEATHER_API_KEY;
|
||||
if (openWeatherKey) {
|
||||
try {
|
||||
console.log(`🌍 OpenWeatherMap API 호출: ${city}`);
|
||||
const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', {
|
||||
params: {
|
||||
q: `${city},KR`,
|
||||
appid: openWeatherKey,
|
||||
units: 'metric',
|
||||
lang: 'kr',
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const weatherData = {
|
||||
city: data.name,
|
||||
country: data.sys.country,
|
||||
temperature: Math.round(data.main.temp),
|
||||
feelsLike: Math.round(data.main.feels_like),
|
||||
humidity: data.main.humidity,
|
||||
pressure: data.main.pressure,
|
||||
weatherMain: data.weather[0].main,
|
||||
weatherDescription: data.weather[0].description,
|
||||
weatherIcon: data.weather[0].icon,
|
||||
windSpeed: Math.round(data.wind.speed * 10) / 10,
|
||||
clouds: data.clouds.all,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`);
|
||||
res.json({ success: true, data: weatherData });
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 기상청 API Hub (매시간 정시 데이터)
|
||||
const apiKey = process.env.KMA_API_KEY;
|
||||
|
||||
// API 키가 없으면 테스트 모드로 실시간 날씨 제공
|
||||
// API 키가 없으면 오류 반환
|
||||
if (!apiKey) {
|
||||
console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.');
|
||||
|
||||
const regionCode = getKMARegionCode(city as string);
|
||||
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: weatherData,
|
||||
console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.');
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -48,32 +83,39 @@ export class OpenApiProxyController {
|
||||
// 기상청 API Hub 사용 (apihub.kma.go.kr)
|
||||
const now = new Date();
|
||||
|
||||
// 기상청 데이터는 매시간 정시(XX:00)에 발표되고 약 10분 후 조회 가능
|
||||
// 현재 시각이 XX:10 이전이면 이전 시간 데이터 조회
|
||||
const minute = now.getMinutes();
|
||||
let targetTime = new Date(now);
|
||||
// 한국 시간(KST = UTC+9)으로 변환
|
||||
const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로
|
||||
const kstNow = new Date(now.getTime() + kstOffset);
|
||||
|
||||
if (minute < 10) {
|
||||
// 아직 이번 시간 데이터가 업데이트되지 않음 → 이전 시간으로
|
||||
targetTime = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
}
|
||||
// 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표
|
||||
// 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정
|
||||
const targetTime = new Date(kstNow);
|
||||
|
||||
// tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회)
|
||||
const year = targetTime.getFullYear();
|
||||
const month = String(targetTime.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(targetTime.getDate()).padStart(2, '0');
|
||||
const hour = String(targetTime.getHours()).padStart(2, '0');
|
||||
const year = targetTime.getUTCFullYear();
|
||||
const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(targetTime.getUTCDate()).padStart(2, '0');
|
||||
const hour = String(targetTime.getUTCHours()).padStart(2, '0');
|
||||
const tm = `${year}${month}${day}${hour}00`;
|
||||
|
||||
console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`);
|
||||
|
||||
// 기상청 API Hub - 지상관측시간자료
|
||||
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php';
|
||||
// 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보)
|
||||
// sfctm3: 시간 범위 조회 가능 (tm1~tm2)
|
||||
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php';
|
||||
|
||||
// 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준
|
||||
const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전
|
||||
const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`;
|
||||
const tm2 = tm; // 현재 시간
|
||||
|
||||
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 시간: ${tm})`);
|
||||
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`);
|
||||
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
tm: tm,
|
||||
stn: 0, // 0 = 전체 관측소 데이터 조회
|
||||
tm1: tm1,
|
||||
tm2: tm2,
|
||||
stn: regionCode.stnId, // 특정 관측소만 조회
|
||||
authKey: apiKey,
|
||||
help: 0,
|
||||
disp: 1,
|
||||
@@ -95,30 +137,36 @@ export class OpenApiProxyController {
|
||||
} catch (error: unknown) {
|
||||
console.error('❌ 날씨 조회 실패:', error);
|
||||
|
||||
// API 호출 실패 시 자동으로 테스트 모드로 전환
|
||||
// API 호출 실패 시 명확한 오류 메시지 반환
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
|
||||
// 모든 오류 → 테스트 데이터 반환
|
||||
console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.');
|
||||
const { city = '서울' } = req.query;
|
||||
const regionCode = getKMARegionCode(city as string);
|
||||
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: weatherData,
|
||||
});
|
||||
if (status === 401 || status === 403) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.',
|
||||
});
|
||||
} else if (status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: '기상청 API에서 데이터를 찾을 수 없습니다.',
|
||||
});
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
res.status(504).json({
|
||||
success: false,
|
||||
message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '기상청 API 호출 중 오류가 발생했습니다.',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 예상치 못한 오류 → 테스트 데이터 반환
|
||||
console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.');
|
||||
const { city = '서울' } = req.query;
|
||||
const regionCode = getKMARegionCode(city as string);
|
||||
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: weatherData,
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -169,15 +217,19 @@ export class OpenApiProxyController {
|
||||
} catch (error: unknown) {
|
||||
console.error('❌ 환율 조회 실패:', error);
|
||||
|
||||
// API 호출 실패 시 실제 근사값 반환
|
||||
console.log('⚠️ API 오류 발생. 근사값을 반환합니다.');
|
||||
const { base = 'KRW', target = 'USD' } = req.query;
|
||||
const approximateRate = generateRealisticExchangeRate(base as string, target as string);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: approximateRate,
|
||||
});
|
||||
// API 호출 실패 시 명확한 오류 메시지 반환
|
||||
if (axios.isAxiosError(error)) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '환율 정보를 가져오는 중 오류가 발생했습니다.',
|
||||
error: error.message,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,19 +657,26 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st
|
||||
throw new Error('날씨 데이터를 파싱할 수 없습니다.');
|
||||
}
|
||||
|
||||
// 요청한 관측소(stnId)의 데이터 찾기
|
||||
const targetLine = lines.find((line: string) => {
|
||||
// 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨)
|
||||
const targetLines = lines.filter((line: string) => {
|
||||
const cols = line.trim().split(/\s+/);
|
||||
return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1)
|
||||
});
|
||||
|
||||
if (!targetLine) {
|
||||
if (targetLines.length === 0) {
|
||||
throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
// 가장 최근 데이터 선택 (마지막 줄)
|
||||
const targetLine = targetLines[targetLines.length - 1];
|
||||
|
||||
// 데이터 라인 파싱 (공백으로 구분)
|
||||
const values = targetLine.trim().split(/\s+/);
|
||||
|
||||
// 관측 시각 로깅
|
||||
const obsTime = values[0]; // YYMMDDHHMI
|
||||
console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`);
|
||||
|
||||
// 기상청 API Hub 데이터 형식 (실제 응답 기준):
|
||||
// [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ...
|
||||
const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11)
|
||||
|
||||
15
backend-node/src/routes/materialRoutes.ts
Normal file
15
backend-node/src/routes/materialRoutes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from "express";
|
||||
import MaterialController from "../controllers/MaterialController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 임시 자재 마스터 관리
|
||||
router.get("/temp", MaterialController.getTempMaterials);
|
||||
router.get("/temp/categories", MaterialController.getCategories);
|
||||
router.get("/temp/:code", MaterialController.getTempMaterialByCode);
|
||||
|
||||
export default router;
|
||||
52
backend-node/src/routes/vehicleRoutes.ts
Normal file
52
backend-node/src/routes/vehicleRoutes.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import express from "express";
|
||||
import { query } from "../database/db";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* 차량 위치 자동 업데이트 API
|
||||
* - 모든 active/warning 상태 차량의 위치를 랜덤하게 조금씩 이동
|
||||
*/
|
||||
router.post("/move", async (req, res) => {
|
||||
try {
|
||||
// move_vehicles() 함수 실행
|
||||
await query("SELECT move_vehicles()");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "차량 위치가 업데이트되었습니다"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("차량 위치 업데이트 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "차량 위치 업데이트 실패"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 차량 위치 목록 조회
|
||||
*/
|
||||
router.get("/locations", async (req, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT * FROM vehicle_locations
|
||||
ORDER BY last_update DESC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("차량 위치 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "차량 위치 조회 실패"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Router } from "express";
|
||||
import { WarehouseController } from "../controllers/WarehouseController";
|
||||
|
||||
const router = Router();
|
||||
const warehouseController = new WarehouseController();
|
||||
|
||||
// 창고 및 자재 데이터 조회
|
||||
router.get("/data", warehouseController.getWarehouseData);
|
||||
|
||||
// 특정 창고 정보 조회
|
||||
router.get("/:id", warehouseController.getWarehouseById);
|
||||
|
||||
// 창고별 자재 목록 조회
|
||||
router.get(
|
||||
"/:warehouseId/materials",
|
||||
warehouseController.getMaterialsByWarehouse
|
||||
);
|
||||
|
||||
// 창고 통계 조회
|
||||
router.get("/stats/summary", warehouseController.getWarehouseStats);
|
||||
|
||||
export default router;
|
||||
27
backend-node/src/routes/yardLayoutRoutes.ts
Normal file
27
backend-node/src/routes/yardLayoutRoutes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import express from "express";
|
||||
import YardLayoutController from "../controllers/YardLayoutController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 야드 레이아웃 관리
|
||||
router.get("/", YardLayoutController.getAllLayouts);
|
||||
router.get("/:id", YardLayoutController.getLayoutById);
|
||||
router.post("/", YardLayoutController.createLayout);
|
||||
router.put("/:id", YardLayoutController.updateLayout);
|
||||
router.delete("/:id", YardLayoutController.deleteLayout);
|
||||
router.post("/:id/duplicate", YardLayoutController.duplicateLayout);
|
||||
|
||||
// 자재 배치 관리
|
||||
router.get("/:id/placements", YardLayoutController.getPlacementsByLayoutId);
|
||||
router.post("/:id/placements", YardLayoutController.addMaterialPlacement);
|
||||
router.put("/:id/placements/batch", YardLayoutController.batchUpdatePlacements);
|
||||
|
||||
// 개별 배치 관리 (별도 경로)
|
||||
router.put("/placements/:id", YardLayoutController.updatePlacement);
|
||||
router.delete("/placements/:id", YardLayoutController.removePlacement);
|
||||
|
||||
export default router;
|
||||
@@ -61,8 +61,9 @@ export class DashboardService {
|
||||
id, dashboard_id, element_type, element_subtype,
|
||||
position_x, position_y, width, height,
|
||||
title, custom_title, show_header, content, data_source_config, chart_config,
|
||||
list_config, yard_config,
|
||||
display_order, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
`,
|
||||
[
|
||||
elementId,
|
||||
@@ -79,6 +80,8 @@ export class DashboardService {
|
||||
element.content || null,
|
||||
JSON.stringify(element.dataSource || {}),
|
||||
JSON.stringify(element.chartConfig || {}),
|
||||
JSON.stringify(element.listConfig || null),
|
||||
JSON.stringify(element.yardConfig || null),
|
||||
i,
|
||||
now,
|
||||
now,
|
||||
@@ -342,6 +345,16 @@ export class DashboardService {
|
||||
content: row.content,
|
||||
dataSource: JSON.parse(row.data_source_config || "{}"),
|
||||
chartConfig: JSON.parse(row.chart_config || "{}"),
|
||||
listConfig: row.list_config
|
||||
? typeof row.list_config === "string"
|
||||
? JSON.parse(row.list_config)
|
||||
: row.list_config
|
||||
: undefined,
|
||||
yardConfig: row.yard_config
|
||||
? typeof row.yard_config === "string"
|
||||
? JSON.parse(row.yard_config)
|
||||
: row.yard_config
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -465,8 +478,9 @@ export class DashboardService {
|
||||
id, dashboard_id, element_type, element_subtype,
|
||||
position_x, position_y, width, height,
|
||||
title, custom_title, show_header, content, data_source_config, chart_config,
|
||||
list_config, yard_config,
|
||||
display_order, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
`,
|
||||
[
|
||||
elementId,
|
||||
@@ -483,6 +497,8 @@ export class DashboardService {
|
||||
element.content || null,
|
||||
JSON.stringify(element.dataSource || {}),
|
||||
JSON.stringify(element.chartConfig || {}),
|
||||
JSON.stringify(element.listConfig || null),
|
||||
JSON.stringify(element.yardConfig || null),
|
||||
i,
|
||||
now,
|
||||
now,
|
||||
|
||||
111
backend-node/src/services/MaterialService.ts
Normal file
111
backend-node/src/services/MaterialService.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { getPool } from "../database/db";
|
||||
|
||||
export class MaterialService {
|
||||
// 임시 자재 마스터 목록 조회
|
||||
async getTempMaterials(params: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { search, category, page = 1, limit = 20 } = params;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let whereConditions: string[] = ["is_active = true"];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (search) {
|
||||
whereConditions.push(
|
||||
`(material_code ILIKE $${paramIndex} OR material_name ILIKE $${paramIndex})`
|
||||
);
|
||||
queryParams.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereConditions.push(`category = $${paramIndex}`);
|
||||
queryParams.push(category);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 전체 개수 조회
|
||||
const countQuery = `SELECT COUNT(*) as total FROM temp_material_master ${whereClause}`;
|
||||
const countResult = await pool.query(countQuery, queryParams);
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
// 데이터 조회
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
id,
|
||||
material_code,
|
||||
material_name,
|
||||
category,
|
||||
unit,
|
||||
default_color,
|
||||
description,
|
||||
created_at
|
||||
FROM temp_material_master
|
||||
${whereClause}
|
||||
ORDER BY material_code ASC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
queryParams.push(limit, offset);
|
||||
const dataResult = await pool.query(dataQuery, queryParams);
|
||||
|
||||
return {
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 특정 자재 상세 조회
|
||||
async getTempMaterialByCode(materialCode: string) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
material_code,
|
||||
material_name,
|
||||
category,
|
||||
unit,
|
||||
default_color,
|
||||
description,
|
||||
created_at
|
||||
FROM temp_material_master
|
||||
WHERE material_code = $1 AND is_active = true
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [materialCode]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 카테고리 목록 조회
|
||||
async getCategories() {
|
||||
const query = `
|
||||
SELECT DISTINCT category
|
||||
FROM temp_material_master
|
||||
WHERE is_active = true AND category IS NOT NULL
|
||||
ORDER BY category ASC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query);
|
||||
return result.rows.map((row) => row.category);
|
||||
}
|
||||
}
|
||||
|
||||
export default new MaterialService();
|
||||
@@ -1,170 +0,0 @@
|
||||
import pool from "../database/db";
|
||||
|
||||
export class WarehouseService {
|
||||
// 창고 및 자재 데이터 조회
|
||||
async getWarehouseData() {
|
||||
try {
|
||||
// 창고 목록 조회
|
||||
const warehousesResult = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
capacity,
|
||||
current_usage,
|
||||
status,
|
||||
description,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM warehouse
|
||||
WHERE status = 'active'
|
||||
ORDER BY id
|
||||
`);
|
||||
|
||||
// 자재 목록 조회
|
||||
const materialsResult = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
warehouse_id,
|
||||
name,
|
||||
material_code,
|
||||
quantity,
|
||||
unit,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
status,
|
||||
last_updated,
|
||||
created_at
|
||||
FROM warehouse_material
|
||||
ORDER BY warehouse_id, id
|
||||
`);
|
||||
|
||||
return {
|
||||
warehouses: warehousesResult,
|
||||
materials: materialsResult,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 창고 정보 조회
|
||||
async getWarehouseById(id: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
capacity,
|
||||
current_usage,
|
||||
status,
|
||||
description,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM warehouse
|
||||
WHERE id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result[0] || null;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 창고별 자재 목록 조회
|
||||
async getMaterialsByWarehouse(warehouseId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
warehouse_id,
|
||||
name,
|
||||
material_code,
|
||||
quantity,
|
||||
unit,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
status,
|
||||
last_updated,
|
||||
created_at
|
||||
FROM warehouse_material
|
||||
WHERE warehouse_id = $1
|
||||
ORDER BY id
|
||||
`,
|
||||
[warehouseId]
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 창고 통계 조회
|
||||
async getWarehouseStats() {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT w.id) as total_warehouses,
|
||||
COUNT(m.id) as total_materials,
|
||||
SUM(w.capacity) as total_capacity,
|
||||
SUM(w.current_usage) as total_usage,
|
||||
ROUND(AVG((w.current_usage::numeric / NULLIF(w.capacity, 0)) * 100), 2) as avg_usage_percent
|
||||
FROM warehouse w
|
||||
LEFT JOIN warehouse_material m ON w.id = m.warehouse_id
|
||||
WHERE w.status = 'active'
|
||||
`);
|
||||
|
||||
// 상태별 자재 수
|
||||
const statusResult = await pool.query(`
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) as count
|
||||
FROM warehouse_material
|
||||
GROUP BY status
|
||||
`);
|
||||
|
||||
const statusCounts = statusResult.reduce(
|
||||
(acc: Record<string, number>, row: any) => {
|
||||
acc[row.status] = parseInt(row.count);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
return {
|
||||
...result[0],
|
||||
materialsByStatus: statusCounts,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
337
backend-node/src/services/YardLayoutService.ts
Normal file
337
backend-node/src/services/YardLayoutService.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { getPool } from "../database/db";
|
||||
|
||||
export class YardLayoutService {
|
||||
// 모든 야드 레이아웃 목록 조회
|
||||
async getAllLayouts() {
|
||||
const query = `
|
||||
SELECT
|
||||
yl.id,
|
||||
yl.name,
|
||||
yl.description,
|
||||
yl.created_by,
|
||||
yl.created_at,
|
||||
yl.updated_at,
|
||||
COUNT(ymp.id) as placement_count
|
||||
FROM yard_layout yl
|
||||
LEFT JOIN yard_material_placement ymp ON yl.id = ymp.yard_layout_id
|
||||
GROUP BY yl.id
|
||||
ORDER BY yl.updated_at DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// 특정 야드 레이아웃 상세 조회
|
||||
async getLayoutById(id: number) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM yard_layout
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 새 야드 레이아웃 생성
|
||||
async createLayout(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
created_by?: string;
|
||||
}) {
|
||||
const query = `
|
||||
INSERT INTO yard_layout (name, description, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [
|
||||
data.name,
|
||||
data.description || null,
|
||||
data.created_by || null,
|
||||
]);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// 야드 레이아웃 수정 (이름, 설명만)
|
||||
async updateLayout(
|
||||
id: number,
|
||||
data: { name?: string; description?: string }
|
||||
) {
|
||||
const query = `
|
||||
UPDATE yard_layout
|
||||
SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [
|
||||
data.name || null,
|
||||
data.description || null,
|
||||
id,
|
||||
]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 야드 레이아웃 삭제
|
||||
async deleteLayout(id: number) {
|
||||
const query = `DELETE FROM yard_layout WHERE id = $1 RETURNING *`;
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 특정 야드의 모든 배치 자재 조회
|
||||
async getPlacementsByLayoutId(layoutId: number) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
yard_layout_id,
|
||||
external_material_id,
|
||||
material_code,
|
||||
material_name,
|
||||
quantity,
|
||||
unit,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
memo,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM yard_material_placement
|
||||
WHERE yard_layout_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [layoutId]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// 야드에 자재 배치 추가
|
||||
async addMaterialPlacement(layoutId: number, data: any) {
|
||||
const query = `
|
||||
INSERT INTO yard_material_placement (
|
||||
yard_layout_id,
|
||||
external_material_id,
|
||||
material_code,
|
||||
material_name,
|
||||
quantity,
|
||||
unit,
|
||||
position_x,
|
||||
position_y,
|
||||
position_z,
|
||||
size_x,
|
||||
size_y,
|
||||
size_z,
|
||||
color,
|
||||
memo
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [
|
||||
layoutId,
|
||||
data.external_material_id,
|
||||
data.material_code,
|
||||
data.material_name,
|
||||
data.quantity,
|
||||
data.unit,
|
||||
data.position_x || 0,
|
||||
data.position_y || 0,
|
||||
data.position_z || 0,
|
||||
data.size_x || 5,
|
||||
data.size_y || 5,
|
||||
data.size_z || 5,
|
||||
data.color || "#3b82f6",
|
||||
data.memo || null,
|
||||
]);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// 배치 정보 수정 (위치, 크기, 색상, 메모만)
|
||||
async updatePlacement(placementId: number, data: any) {
|
||||
const query = `
|
||||
UPDATE yard_material_placement
|
||||
SET
|
||||
position_x = COALESCE($1, position_x),
|
||||
position_y = COALESCE($2, position_y),
|
||||
position_z = COALESCE($3, position_z),
|
||||
size_x = COALESCE($4, size_x),
|
||||
size_y = COALESCE($5, size_y),
|
||||
size_z = COALESCE($6, size_z),
|
||||
color = COALESCE($7, color),
|
||||
memo = COALESCE($8, memo),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $9
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [
|
||||
data.position_x,
|
||||
data.position_y,
|
||||
data.position_z,
|
||||
data.size_x,
|
||||
data.size_y,
|
||||
data.size_z,
|
||||
data.color,
|
||||
data.memo,
|
||||
placementId,
|
||||
]);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 배치 해제 (자재는 삭제되지 않음)
|
||||
async removePlacement(placementId: number) {
|
||||
const query = `DELETE FROM yard_material_placement WHERE id = $1 RETURNING *`;
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, [placementId]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
// 여러 배치 일괄 업데이트
|
||||
async batchUpdatePlacements(layoutId: number, placements: any[]) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const results = [];
|
||||
for (const placement of placements) {
|
||||
const query = `
|
||||
UPDATE yard_material_placement
|
||||
SET
|
||||
position_x = $1,
|
||||
position_y = $2,
|
||||
position_z = $3,
|
||||
size_x = $4,
|
||||
size_y = $5,
|
||||
size_z = $6,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7 AND yard_layout_id = $8
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await client.query(query, [
|
||||
placement.position_x,
|
||||
placement.position_y,
|
||||
placement.position_z,
|
||||
placement.size_x,
|
||||
placement.size_y,
|
||||
placement.size_z,
|
||||
placement.id,
|
||||
layoutId,
|
||||
]);
|
||||
|
||||
if (result.rows[0]) {
|
||||
results.push(result.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return results;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 야드 레이아웃 복제
|
||||
async duplicateLayout(id: number, newName: string) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 원본 레이아웃 조회
|
||||
const layoutQuery = `SELECT * FROM yard_layout WHERE id = $1`;
|
||||
const layoutResult = await client.query(layoutQuery, [id]);
|
||||
const originalLayout = layoutResult.rows[0];
|
||||
|
||||
if (!originalLayout) {
|
||||
throw new Error("Layout not found");
|
||||
}
|
||||
|
||||
// 새 레이아웃 생성
|
||||
const newLayoutQuery = `
|
||||
INSERT INTO yard_layout (name, description, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
const newLayoutResult = await client.query(newLayoutQuery, [
|
||||
newName,
|
||||
originalLayout.description,
|
||||
originalLayout.created_by,
|
||||
]);
|
||||
const newLayout = newLayoutResult.rows[0];
|
||||
|
||||
// 배치 자재 복사
|
||||
const placementsQuery = `SELECT * FROM yard_material_placement WHERE yard_layout_id = $1`;
|
||||
const placementsResult = await client.query(placementsQuery, [id]);
|
||||
|
||||
for (const placement of placementsResult.rows) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO yard_material_placement (
|
||||
yard_layout_id, external_material_id, material_code, material_name,
|
||||
quantity, unit, position_x, position_y, position_z,
|
||||
size_x, size_y, size_z, color, memo
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
`,
|
||||
[
|
||||
newLayout.id,
|
||||
placement.external_material_id,
|
||||
placement.material_code,
|
||||
placement.material_name,
|
||||
placement.quantity,
|
||||
placement.unit,
|
||||
placement.position_x,
|
||||
placement.position_y,
|
||||
placement.position_z,
|
||||
placement.size_x,
|
||||
placement.size_y,
|
||||
placement.size_z,
|
||||
placement.color,
|
||||
placement.memo,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return newLayout;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new YardLayoutService();
|
||||
@@ -25,8 +25,8 @@ export class RiskAlertService {
|
||||
const apiKey = process.env.KMA_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.');
|
||||
return this.generateDummyWeatherAlerts();
|
||||
console.log('⚠️ 기상청 API 키가 없습니다. 빈 데이터를 반환합니다.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const alerts: Alert[] = [];
|
||||
@@ -109,7 +109,7 @@ export class RiskAlertService {
|
||||
console.log(`✅ 총 ${alerts.length}건의 기상특보 감지`);
|
||||
} catch (warningError: any) {
|
||||
console.error('❌ 기상청 특보 API 오류:', warningError.message);
|
||||
return this.generateDummyWeatherAlerts();
|
||||
return [];
|
||||
}
|
||||
|
||||
// 특보가 없으면 빈 배열 반환 (0건)
|
||||
@@ -120,8 +120,8 @@ export class RiskAlertService {
|
||||
return alerts;
|
||||
} catch (error: any) {
|
||||
console.error('❌ 기상청 특보 API 오류:', error.message);
|
||||
// API 오류 시 더미 데이터 반환
|
||||
return this.generateDummyWeatherAlerts();
|
||||
// API 오류 시 빈 배열 반환
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +237,9 @@ export class RiskAlertService {
|
||||
console.error('❌ 한국도로공사 API 오류:', error.message);
|
||||
}
|
||||
|
||||
// 모든 API 실패 시 더미 데이터
|
||||
console.log('ℹ️ 모든 교통사고 API 실패. 더미 데이터를 반환합니다.');
|
||||
return this.generateDummyAccidentAlerts();
|
||||
// 모든 API 실패 시 빈 배열
|
||||
console.log('ℹ️ 모든 교통사고 API 실패. 빈 배열을 반환합니다.');
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,9 +356,9 @@ export class RiskAlertService {
|
||||
console.error('❌ 한국도로공사 API 오류:', error.message);
|
||||
}
|
||||
|
||||
// 모든 API 실패 시 더미 데이터
|
||||
console.log('ℹ️ 모든 도로공사 API 실패. 더미 데이터를 반환합니다.');
|
||||
return this.generateDummyRoadworkAlerts();
|
||||
// 모든 API 실패 시 빈 배열
|
||||
console.log('ℹ️ 모든 도로공사 API 실패. 빈 배열을 반환합니다.');
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -467,82 +467,5 @@ export class RiskAlertService {
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 날씨 특보 더미 데이터
|
||||
*/
|
||||
private generateDummyWeatherAlerts(): Alert[] {
|
||||
return [
|
||||
{
|
||||
id: `weather-${Date.now()}-1`,
|
||||
type: 'weather',
|
||||
severity: 'high',
|
||||
title: '대설특보',
|
||||
location: '강원 영동지역',
|
||||
description: '시간당 2cm 이상 폭설. 차량 운행 주의',
|
||||
timestamp: new Date(Date.now() - 30 * 60000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: `weather-${Date.now()}-2`,
|
||||
type: 'weather',
|
||||
severity: 'medium',
|
||||
title: '강풍특보',
|
||||
location: '남해안 전 지역',
|
||||
description: '순간 풍속 20m/s 이상. 고속도로 주행 주의',
|
||||
timestamp: new Date(Date.now() - 90 * 60000).toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 교통사고 더미 데이터
|
||||
*/
|
||||
private generateDummyAccidentAlerts(): Alert[] {
|
||||
return [
|
||||
{
|
||||
id: `accident-${Date.now()}-1`,
|
||||
type: 'accident',
|
||||
severity: 'high',
|
||||
title: '교통사고 발생',
|
||||
location: '경부고속도로 서울방향 189km',
|
||||
description: '3중 추돌사고로 2차로 통제 중. 우회 권장',
|
||||
timestamp: new Date(Date.now() - 10 * 60000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: `accident-${Date.now()}-2`,
|
||||
type: 'accident',
|
||||
severity: 'medium',
|
||||
title: '사고 다발 지역',
|
||||
location: '영동고속도로 강릉방향 160km',
|
||||
description: '안개로 인한 가시거리 50m 이하. 서행 운전',
|
||||
timestamp: new Date(Date.now() - 60 * 60000).toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 도로공사 더미 데이터
|
||||
*/
|
||||
private generateDummyRoadworkAlerts(): Alert[] {
|
||||
return [
|
||||
{
|
||||
id: `construction-${Date.now()}-1`,
|
||||
type: 'construction',
|
||||
severity: 'medium',
|
||||
title: '도로 공사',
|
||||
location: '서울외곽순환 목동IC~화곡IC',
|
||||
description: '야간 공사로 1차로 통제 (22:00~06:00)',
|
||||
timestamp: new Date(Date.now() - 45 * 60000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: `construction-${Date.now()}-2`,
|
||||
type: 'construction',
|
||||
severity: 'low',
|
||||
title: '도로 통제',
|
||||
location: '중부내륙고속도로 김천JC~현풍IC',
|
||||
description: '도로 유지보수 작업. 차량 속도 제한 60km/h',
|
||||
timestamp: new Date(Date.now() - 120 * 60000).toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,16 @@ export interface DashboardElement {
|
||||
title?: string;
|
||||
showLegend?: boolean;
|
||||
};
|
||||
listConfig?: {
|
||||
columns?: any[];
|
||||
pagination?: any;
|
||||
viewMode?: string;
|
||||
cardColumns?: number;
|
||||
};
|
||||
yardConfig?: {
|
||||
layoutId: number;
|
||||
layoutName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
|
||||
Reference in New Issue
Block a user