Merge branch 'main' into feature/screen-management

This commit is contained in:
kjs
2025-10-17 17:15:47 +09:00
60 changed files with 5901 additions and 1253 deletions

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

View File

@@ -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,
});
}
};
}

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

View File

@@ -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)