Files
TK-FB-Project/api.hyungi.net/services/mProjectService.js
Hyungi Ahn c42c9f4fa3 fix: 부적합 제출 버그 수정 및 UI 개선
- 부적합 API 호출 형식 수정 (카테고리/아이템 추가 시)
- 부적합 저장 시 내부 플래그 제거 후 백엔드 전송
- 기본 부적합 객체 구조 수정 (category_id, item_id 추가)
- 날씨 API 시간대 수정 (UTC → KST 변환)
- 신고 카테고리 관리 페이지 추가 (/pages/admin/issue-categories.html)
- 부적합 입력 UI 개선 (대분류→소분류 캐스케이딩 선택)
- 저장된 부적합 분리 표시 및 수정/삭제 기능
- 디버깅 로그 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:31:26 +09:00

384 lines
10 KiB
JavaScript

/**
* M-Project API 연동 서비스
*
* TK-FB-Project의 부적합 신고를 M-Project로 전송하는 서비스
*
* @author TK-FB-Project
* @since 2026-02-03
*/
const logger = require('../utils/logger');
// M-Project API 설정
const M_PROJECT_CONFIG = {
baseUrl: process.env.M_PROJECT_API_URL || 'http://localhost:16080',
username: process.env.M_PROJECT_USERNAME || 'api_service',
password: process.env.M_PROJECT_PASSWORD || '',
defaultProjectId: parseInt(process.env.M_PROJECT_DEFAULT_PROJECT_ID) || 1,
};
// 캐시된 인증 토큰
let cachedToken = null;
let tokenExpiry = null;
/**
* TK-FB 카테고리를 M-Project 카테고리로 매핑
* @param {string} tkCategory - TK-FB-Project 카테고리
* @returns {string} M-Project 카테고리
*/
function mapCategoryToMProject(tkCategory) {
const categoryMap = {
// TK-FB 카테고리 → M-Project 카테고리
'자재 누락': 'material_missing',
'자재누락': 'material_missing',
'자재 관련': 'material_missing',
'설계 오류': 'design_error',
'설계미스': 'design_error',
'설계 관련': 'design_error',
'입고 불량': 'incoming_defect',
'입고자재 불량': 'incoming_defect',
'입고 관련': 'incoming_defect',
'검사 미스': 'inspection_miss',
'검사미스': 'inspection_miss',
'검사 관련': 'inspection_miss',
'기타': 'etc',
};
// 정확히 매칭되는 경우
if (categoryMap[tkCategory]) {
return categoryMap[tkCategory];
}
// 부분 매칭 시도
const lowerCategory = tkCategory?.toLowerCase() || '';
if (lowerCategory.includes('자재') || lowerCategory.includes('material')) {
return 'material_missing';
}
if (lowerCategory.includes('설계') || lowerCategory.includes('design')) {
return 'design_error';
}
if (lowerCategory.includes('입고') || lowerCategory.includes('incoming')) {
return 'incoming_defect';
}
if (lowerCategory.includes('검사') || lowerCategory.includes('inspection')) {
return 'inspection_miss';
}
// 기본값
return 'etc';
}
/**
* M-Project API 인증 토큰 획득
* @returns {Promise<string|null>} JWT 토큰
*/
async function getAuthToken() {
// 캐시된 토큰이 유효하면 재사용
if (cachedToken && tokenExpiry && new Date() < tokenExpiry) {
return cachedToken;
}
try {
const formData = new URLSearchParams();
formData.append('username', M_PROJECT_CONFIG.username);
formData.append('password', M_PROJECT_CONFIG.password);
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('M-Project 인증 실패', {
status: response.status,
error: errorText
});
return null;
}
const data = await response.json();
cachedToken = data.access_token;
// 토큰 만료 시간 설정 (기본 6일, M-Project는 7일 유효)
tokenExpiry = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000);
logger.info('M-Project 인증 성공');
return cachedToken;
} catch (error) {
logger.error('M-Project 인증 요청 오류', { error: error.message });
return null;
}
}
/**
* 이미지 URL을 Base64로 변환
* @param {string} imageUrl - 이미지 URL 또는 경로
* @returns {Promise<string|null>} Base64 인코딩된 이미지
*/
async function convertImageToBase64(imageUrl) {
if (!imageUrl) return null;
try {
// 이미 Base64인 경우 그대로 반환
if (imageUrl.startsWith('data:image')) {
return imageUrl;
}
// URL인 경우 fetch
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
const response = await fetch(imageUrl);
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
const contentType = response.headers.get('content-type') || 'image/jpeg';
return `data:${contentType};base64,${base64}`;
}
// 로컬 파일 경로인 경우
const fs = require('fs').promises;
const path = require('path');
const filePath = path.join(process.cwd(), 'uploads', imageUrl);
try {
const fileBuffer = await fs.readFile(filePath);
const base64 = fileBuffer.toString('base64');
const ext = path.extname(imageUrl).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
const contentType = mimeTypes[ext] || 'image/jpeg';
return `data:${contentType};base64,${base64}`;
} catch {
return null;
}
} catch (error) {
logger.warn('이미지 Base64 변환 실패', { imageUrl, error: error.message });
return null;
}
}
/**
* TK-FB-Project 부적합 신고를 M-Project로 전송
*
* @param {Object} issueData - 부적합 신고 데이터
* @param {string} issueData.category - 카테고리 이름
* @param {string} issueData.description - 신고 내용
* @param {string} issueData.reporter_name - 신고자 이름
* @param {string} issueData.project_name - 프로젝트 이름 (옵션)
* @param {number} issueData.tk_issue_id - TK-FB-Project 이슈 ID
* @param {string[]} issueData.photos - 사진 URL 배열 (최대 5개)
* @returns {Promise<{success: boolean, mProjectId?: number, error?: string}>}
*/
async function sendToMProject(issueData) {
const {
category,
description,
reporter_name,
project_name,
tk_issue_id,
photos = [],
} = issueData;
logger.info('M-Project 연동 시작', { tk_issue_id, category });
// 인증 토큰 획득
const token = await getAuthToken();
if (!token) {
return { success: false, error: 'M-Project 인증 실패' };
}
try {
// 카테고리 매핑
const mProjectCategory = mapCategoryToMProject(category);
// 설명에 TK-FB 정보 추가
const enhancedDescription = [
description,
'',
'---',
`[TK-FB-Project 연동]`,
`- 원본 이슈 ID: ${tk_issue_id}`,
`- 신고자: ${reporter_name || '미상'}`,
project_name ? `- 프로젝트: ${project_name}` : null,
].filter(Boolean).join('\n');
// 사진 변환 (최대 5개)
const photoPromises = photos.slice(0, 5).map(convertImageToBase64);
const base64Photos = await Promise.all(photoPromises);
// 요청 본문 구성
const requestBody = {
category: mProjectCategory,
description: enhancedDescription,
project_id: M_PROJECT_CONFIG.defaultProjectId,
};
// 사진 추가
base64Photos.forEach((photo, index) => {
if (photo) {
const fieldName = index === 0 ? 'photo' : `photo${index + 1}`;
requestBody[fieldName] = photo;
}
});
// M-Project API 호출
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/issues`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('M-Project 이슈 생성 실패', {
status: response.status,
error: errorText,
tk_issue_id
});
return {
success: false,
error: `M-Project API 오류: ${response.status}`
};
}
const createdIssue = await response.json();
logger.info('M-Project 이슈 생성 성공', {
tk_issue_id,
m_project_id: createdIssue.id
});
return {
success: true,
mProjectId: createdIssue.id
};
} catch (error) {
logger.error('M-Project 연동 오류', {
tk_issue_id,
error: error.message
});
return {
success: false,
error: error.message
};
}
}
/**
* M-Project에서 이슈 상태 조회
*
* @param {number} mProjectId - M-Project 이슈 ID
* @returns {Promise<{success: boolean, status?: string, data?: Object, error?: string}>}
*/
async function getIssueStatus(mProjectId) {
const token = await getAuthToken();
if (!token) {
return { success: false, error: 'M-Project 인증 실패' };
}
try {
const response = await fetch(
`${M_PROJECT_CONFIG.baseUrl}/api/issues/${mProjectId}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
}
);
if (!response.ok) {
return {
success: false,
error: `M-Project API 오류: ${response.status}`
};
}
const data = await response.json();
return {
success: true,
status: data.review_status || data.status,
data
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* M-Project 상태를 TK-FB 상태로 매핑
* @param {string} mProjectStatus - M-Project 상태
* @returns {string} TK-FB-Project 상태
*/
function mapStatusFromMProject(mProjectStatus) {
const statusMap = {
'pending_review': 'received',
'in_progress': 'in_progress',
'completed': 'completed',
'disposed': 'closed',
};
return statusMap[mProjectStatus] || 'received';
}
/**
* M-Project 연결 테스트
* @returns {Promise<{success: boolean, message: string}>}
*/
async function testConnection() {
try {
const token = await getAuthToken();
if (!token) {
return { success: false, message: 'M-Project 인증 실패' };
}
// /api/auth/me로 연결 테스트
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const user = await response.json();
return {
success: true,
message: `M-Project 연결 성공 (사용자: ${user.username})`
};
} else {
return {
success: false,
message: `M-Project 연결 실패: ${response.status}`
};
}
} catch (error) {
return {
success: false,
message: `M-Project 연결 오류: ${error.message}`
};
}
}
module.exports = {
sendToMProject,
getIssueStatus,
mapStatusFromMProject,
testConnection,
mapCategoryToMProject,
};