Compare commits

...

2 Commits

Author SHA1 Message Date
Hyungi Ahn
1ceeef2a65 refactor(tkeg): print→logging 교체 + 레거시 파일 정리 (-5,447줄)
- 6개 파일 디버그 print문 102건 → logger.info/warning/error 교체
- DashboardPage.old.jsx 삭제 (미사용)
- NewMaterialsPage.jsx/css 삭제 (dead import)
- spool_manager_v2.py 삭제 (미사용)
- App.jsx dead import 제거
- main.py 구 주석 블록 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:41:39 +09:00
Hyungi Ahn
d6dd03a52f feat(schedule): 공정표 제품유형 + 표준공정 자동생성 백엔드
- product_types 참조 테이블 + projects.product_type_id FK (tkuser 마이그레이션)
- schedule_entries에 work_type_id, risk_assessment_id, source 컬럼 추가
- schedule_phases에 product_type_id 추가 (phase 오염 방지)
- generateFromTemplate: tksafety 템플릿 기반 공정 자동 생성 (트랜잭션)
- phase 매칭 3단계 우선순위 (전용→범용→신규)
- 간트 데이터 NULL 날짜 guard 추가
- system1 startup 마이그레이션 러너 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:39:12 +09:00
22 changed files with 326 additions and 5474 deletions

View File

@@ -209,6 +209,37 @@ const ScheduleController = {
}
},
// === 제품유형 ===
getProductTypes: async (req, res) => {
try {
const rows = await ScheduleModel.getProductTypes();
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getProductTypes error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 표준공정 자동 생성 ===
generateFromTemplate: async (req, res) => {
try {
const { project_id, product_type_code } = req.body;
if (!project_id || !product_type_code) {
return res.status(400).json({ success: false, message: '프로젝트와 제품유형을 선택해주세요.' });
}
const result = await ScheduleModel.generateFromTemplate(
project_id, product_type_code, req.user.user_id || req.user.id
);
if (result.error) {
return res.status(409).json({ success: false, message: result.error });
}
res.status(201).json({ success: true, data: result, message: `${result.created}개 표준공정이 생성되었습니다.` });
} catch (err) {
logger.error('Schedule generateFromTemplate error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 부적합 연동 ===
getNonconformance: async (req, res) => {
try {

View File

@@ -0,0 +1,22 @@
-- schedule_entries 확장: 작업보고서 매핑 + 위험성평가 연결 + 생성 출처
ALTER TABLE schedule_entries ADD COLUMN work_type_id INT NULL COMMENT 'work_types FK (작업보고서 매핑)';
ALTER TABLE schedule_entries ADD COLUMN risk_assessment_id INT NULL COMMENT 'risk_projects FK';
ALTER TABLE schedule_entries ADD COLUMN source VARCHAR(20) DEFAULT 'manual' COMMENT '생성 출처 (manual/template)';
-- schedule_phases 확장: 제품유형별 phase 구분
ALTER TABLE schedule_phases ADD COLUMN product_type_id INT NULL COMMENT 'NULL=범용, 값=해당 제품유형 전용';
-- FK는 product_types 테이블 존재 시에만 생성 (tkuser 마이그레이션 의존)
-- work_type_id FK
ALTER TABLE schedule_entries ADD CONSTRAINT fk_entry_work_type
FOREIGN KEY (work_type_id) REFERENCES work_types(id) ON DELETE SET NULL;
-- risk_assessment_id FK (같은 DB, 물리 FK)
ALTER TABLE schedule_entries ADD CONSTRAINT fk_entry_risk_assessment
FOREIGN KEY (risk_assessment_id) REFERENCES risk_projects(id) ON DELETE SET NULL;
-- schedule_phases.product_type_id FK
ALTER TABLE schedule_phases ADD CONSTRAINT fk_phase_product_type
FOREIGN KEY (product_type_id) REFERENCES product_types(id) ON DELETE SET NULL

View File

@@ -41,26 +41,59 @@ app.use((req, res) => {
});
});
// 서버 시작
const server = app.listen(PORT, () => {
logger.info(`서버 시작 완료`, {
port: PORT,
env: process.env.NODE_ENV || 'development',
nodeVersion: process.version
// Startup: 마이그레이션 후 서버 시작
async function runStartupMigrations() {
try {
const { getDb } = require('./dbPool');
const fs = require('fs');
const path = require('path');
const db = await getDb();
const migrationFiles = ['20260326_schedule_extensions.sql'];
for (const file of migrationFiles) {
const sqlPath = path.join(__dirname, 'db', 'migrations', file);
if (!fs.existsSync(sqlPath)) continue;
const sql = fs.readFileSync(sqlPath, 'utf8');
const stmts = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
for (const stmt of stmts) {
try { await db.query(stmt); } catch (err) {
if (['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME', 'ER_FK_DUP_NAME'].includes(err.code)) {
// 이미 적용됨 — 무시
} else if (err.code === 'ER_NO_REFERENCED_ROW_2' || err.message.includes('Cannot add foreign key')) {
// product_types 테이블 미존재 (tkuser 미시작) — skip, 재시작 시 retry
logger.warn(`Migration FK skip (dependency not ready): ${err.message}`);
} else {
throw err;
}
}
}
logger.info(`[system1] Migration ${file} completed`);
}
} catch (err) {
logger.error('Migration error:', err.message);
}
}
let server;
runStartupMigrations().then(() => {
server = app.listen(PORT, () => {
logger.info(`서버 시작 완료`, {
port: PORT,
env: process.env.NODE_ENV || 'development',
nodeVersion: process.version
});
});
});
// Graceful Shutdown
const gracefulShutdown = (signal) => {
logger.info(`${signal} 신호 수신 - 서버 종료 시작`);
if (!server) return process.exit(0);
server.close(async () => {
logger.info('HTTP 서버 종료 완료');
// 리소스 정리
try {
// DB 연결 종료는 각 요청에서 pool을 사용하므로 불필요
// Redis 종료 (사용 중인 경우)
if (cache.redis) {
await cache.redis.quit();
logger.info('캐시 시스템 종료 완료');
@@ -72,15 +105,12 @@ const gracefulShutdown = (signal) => {
process.exit(0);
});
// 30초 후 강제 종료
setTimeout(() => {
logger.error('강제 종료 - 정상 종료 시간 초과');
console.error(' 정상 종료 실패, 강제 종료합니다.');
process.exit(1);
}, 30000);
};
// 시그널 핸들러 등록
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View File

@@ -49,11 +49,12 @@ const ScheduleModel = {
const db = await getDb();
let sql = `
SELECT e.*, p.phase_name, p.color AS phase_color, pr.project_name, pr.job_no AS project_code,
su.name AS created_by_name
su.name AS created_by_name, wt.name AS work_type_name
FROM schedule_entries e
JOIN schedule_phases p ON e.phase_id = p.phase_id
JOIN projects pr ON e.project_id = pr.project_id
LEFT JOIN sso_users su ON e.created_by = su.user_id
LEFT JOIN work_types wt ON e.work_type_id = wt.id
WHERE 1=1
`;
const params = [];
@@ -80,7 +81,8 @@ const ScheduleModel = {
FROM schedule_entries e
JOIN schedule_phases p ON e.phase_id = p.phase_id
JOIN projects pr ON e.project_id = pr.project_id
WHERE (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?)
WHERE e.start_date IS NOT NULL AND e.end_date IS NOT NULL
AND (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?)
AND e.status != 'cancelled'
ORDER BY pr.job_no, p.display_order, e.display_order
`, [year, year]);
@@ -112,11 +114,12 @@ const ScheduleModel = {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO schedule_entries
(project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by, source, work_type_id, risk_assessment_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[data.project_id, data.phase_id, data.task_name, data.start_date, data.end_date,
data.progress || 0, data.status || 'planned', data.assignee || null,
data.notes || null, data.display_order || 0, data.created_by || null]
data.notes || null, data.display_order || 0, data.created_by || null,
data.source || 'manual', data.work_type_id || null, data.risk_assessment_id || null]
);
return result.insertId;
},
@@ -141,7 +144,7 @@ const ScheduleModel = {
const db = await getDb();
const fields = [];
const params = [];
const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id'];
const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id', 'work_type_id', 'risk_assessment_id'];
for (const key of allowed) {
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
}
@@ -247,6 +250,100 @@ const ScheduleModel = {
await db.query('DELETE FROM schedule_milestones WHERE milestone_id = ?', [milestoneId]);
},
// === 제품유형 ===
async getProductTypes() {
const db = await getDb();
const [rows] = await db.query(
'SELECT * FROM product_types WHERE is_active = TRUE ORDER BY display_order'
);
return rows;
},
// === 표준공정 자동 생성 ===
async generateFromTemplate(projectId, productTypeCode, createdBy) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// 1. 중복 체크
const [existing] = await conn.query(
"SELECT COUNT(*) AS cnt FROM schedule_entries WHERE project_id = ? AND source = 'template'",
[projectId]
);
if (existing[0].cnt > 0) {
await conn.rollback();
return { error: '이미 표준공정이 생성되었습니다' };
}
// 2. product_type_id 조회
const [ptRows] = await conn.query(
'SELECT id FROM product_types WHERE code = ?', [productTypeCode]
);
if (ptRows.length === 0) {
await conn.rollback();
return { error: '존재하지 않는 제품유형입니다' };
}
const productTypeId = ptRows[0].id;
// 3. tksafety risk_process_templates 조회
const [templates] = await conn.query(
'SELECT * FROM risk_process_templates WHERE product_type = ? ORDER BY display_order',
[productTypeCode]
);
if (templates.length === 0) {
await conn.rollback();
return { error: '해당 제품유형의 공정 템플릿이 없습니다' };
}
// 4. 각 템플릿 → phase 매칭/생성 → entry 생성
let createdCount = 0;
for (const tmpl of templates) {
// phase 매칭: 1순위 전용, 2순위 범용, 3순위 신규
const [specificPhase] = await conn.query(
'SELECT phase_id FROM schedule_phases WHERE phase_name = ? AND product_type_id = ?',
[tmpl.process_name, productTypeId]
);
let phaseId;
if (specificPhase.length > 0) {
phaseId = specificPhase[0].phase_id;
} else {
const [genericPhase] = await conn.query(
'SELECT phase_id FROM schedule_phases WHERE phase_name = ? AND product_type_id IS NULL',
[tmpl.process_name]
);
if (genericPhase.length > 0) {
phaseId = genericPhase[0].phase_id;
} else {
// 신규 phase 생성 (제품유형 전용)
const [newPhase] = await conn.query(
'INSERT INTO schedule_phases (phase_name, display_order, product_type_id) VALUES (?, ?, ?)',
[tmpl.process_name, tmpl.display_order, productTypeId]
);
phaseId = newPhase.insertId;
}
}
// entry 생성 (날짜 NULL — 관리자가 나중에 입력)
await conn.query(
`INSERT INTO schedule_entries
(project_id, phase_id, task_name, start_date, end_date, status, progress, source, display_order, created_by)
VALUES (?, ?, ?, NULL, NULL, 'planned', 0, 'template', ?, ?)`,
[projectId, phaseId, tmpl.process_name, tmpl.display_order, createdBy]
);
createdCount++;
}
await conn.commit();
return { created: createdCount };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
},
// === 부적합 연동 (격리 함수) ===
// 향후 System3 API 호출로 전환 시 이 함수만 수정
async getNonconformanceByProject(projectId) {

View File

@@ -3,6 +3,12 @@ const router = express.Router();
const ctrl = require('../controllers/scheduleController');
const { requireMinLevel } = require('../middlewares/auth');
// 제품유형
router.get('/product-types', ctrl.getProductTypes);
// 표준공정 자동 생성
router.post('/generate-from-template', requireMinLevel('support_team'), ctrl.generateFromTemplate);
// 공정 단계
router.get('/phases', ctrl.getPhases);
router.post('/phases', requireMinLevel('admin'), ctrl.createPhase);

View File

@@ -104,13 +104,6 @@ try:
except ImportError:
logger.warning("dashboard 라우터를 찾을 수 없습니다")
# 리비전 관리 라우터 (임시 비활성화)
# try:
# from .routers import revision_management
# app.include_router(revision_management.router, tags=["revision-management"])
# except ImportError:
# logger.warning("revision_management 라우터를 찾을 수 없습니다")
try:
from .routers import tubing
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])

View File

@@ -201,26 +201,26 @@ async def upload_file(
# 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화
# 🔍 디버깅: 업로드 파라미터 로깅
print(f"🔍 [UPLOAD] job_no: {job_no}, revision: {revision}, parent_file_id: {parent_file_id}")
print(f"🔍 [UPLOAD] bom_name: {bom_name}, filename: {file.filename}")
print(f"🔍 [UPLOAD] revision != 'Rev.0': {revision != 'Rev.0'}")
logger.info(f"[UPLOAD] job_no: {job_no}, revision: {revision}, parent_file_id: {parent_file_id}")
logger.info(f"[UPLOAD] bom_name: {bom_name}, filename: {file.filename}")
logger.info(f"[UPLOAD] revision != 'Rev.0': {revision != 'Rev.0'}")
try:
# 1. 현재 트랜잭션 완전 롤백
db.rollback()
print("🔄 1단계: 이전 트랜잭션 롤백 완료")
logger.info("1단계: 이전 트랜잭션 롤백 완료")
# 2. 세션 상태 초기화
db.close()
print("🔄 2단계: 세션 닫기 완료")
logger.info("2단계: 세션 닫기 완료")
# 3. 새 세션 생성
from ..database import get_db
db = next(get_db())
print("🔄 3단계: 새 세션 생성 완료")
logger.info("3단계: 새 세션 생성 완료")
except Exception as e:
print(f"⚠️ 트랜잭션 초기화 중 오류: {e}")
logger.warning(f"트랜잭션 초기화 중 오류: {e}")
# 오류 발생 시에도 계속 진행
# [변경] BOMParser 사용하여 확장자 검증
@@ -245,10 +245,10 @@ async def upload_file(
try:
# [변경] BOMParser 사용하여 파일 파싱 (자동 양식 감지 포함)
print(f"🚀 파일 파싱 시작: {file_path}")
logger.info(f"파일 파싱 시작: {file_path}")
materials_data = BOMParser.parse_file(str(file_path))
parsed_count = len(materials_data)
print(f"파싱 완료: {parsed_count}개 자재 추출됨")
logger.info(f"파싱 완료: {parsed_count}개 자재 추출됨")
# 신규 자재 카운트 초기화
new_materials_count = 0
@@ -256,7 +256,7 @@ async def upload_file(
# 리비전 업로드인 경우만 자동 리비전 생성 및 기존 자재 조회
if parent_file_id is not None:
print(f"🔄 리비전 업로드 감지: parent_file_id={parent_file_id}")
logger.info(f"리비전 업로드 감지: parent_file_id={parent_file_id}")
# 부모 파일의 정보 조회
parent_query = text("""
SELECT original_filename, revision, bom_name FROM files
@@ -299,10 +299,10 @@ async def upload_file(
else:
revision = "Rev.1"
print(f"리비전 업로드: {latest_rev} {revision}")
logger.info(f"리비전 업로드: {latest_rev} -> {revision}")
else:
revision = "Rev.1"
print(f"첫 번째 리비전: {revision}")
logger.info(f"첫 번째 리비전: {revision}")
# 모든 이전 리비전의 누적 자재 목록 조회 (리비전 0부터 현재까지)
existing_materials_query = text("""
@@ -327,24 +327,24 @@ async def upload_file(
existing_materials_descriptions.add(key)
existing_materials_with_quantity[key] = float(row.total_quantity or 0)
print(f"📊 누적 자재 수 (Rev.0~현재): {len(existing_materials_descriptions)}")
print(f"📊 누적 자재 총 수량: {sum(existing_materials_with_quantity.values())}")
logger.info(f"누적 자재 수 (Rev.0~현재): {len(existing_materials_descriptions)}")
logger.info(f"누적 자재 총 수량: {sum(existing_materials_with_quantity.values())}")
if len(existing_materials_descriptions) > 0:
print(f"📝 기존 자재 샘플 (처음 3개): {list(existing_materials_descriptions)[:3]}")
logger.info(f"기존 자재 샘플 (처음 3개): {list(existing_materials_descriptions)[:3]}")
# 수량이 있는 자재들 확인
quantity_samples = [(k, v) for k, v in list(existing_materials_with_quantity.items())[:3]]
print(f"📊 기존 자재 수량 샘플: {quantity_samples}")
logger.info(f"기존 자재 수량 샘플: {quantity_samples}")
else:
print(f"⚠️ 기존 자재가 없습니다! parent_file_id={parent_file_id}의 materials 테이블을 확인하세요.")
logger.warning(f"기존 자재가 없습니다! parent_file_id={parent_file_id}의 materials 테이블을 확인하세요.")
# 파일명을 부모와 동일하게 유지
file.filename = parent_file[0]
else:
# 일반 업로드 (새 BOM)
print(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)")
logger.info(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)")
# 파일 정보 저장 (사용자 정보 포함)
print("DB 저장 시작")
logger.info("DB 저장 시작")
username = current_user.get('username', 'unknown')
user_id = current_user.get('user_id')
@@ -370,7 +370,7 @@ async def upload_file(
file_id = file_result.fetchone()[0]
db.commit() # 파일 레코드 즉시 커밋
print(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}")
logger.info(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}")
# 🔄 리비전 비교 수행 (RULES.md 코딩 컨벤션 준수)
revision_comparison = None
@@ -378,23 +378,23 @@ async def upload_file(
purchased_materials_map = {} # 구매확정된 자재 매핑 (키 -> 구매확정 정보)
if revision != "Rev.0": # 리비전 업로드인 경우만 비교
print(f"🔍 [DEBUG] 리비전 비교 시작 - revision: {revision}, parent_file_id: {parent_file_id}")
logger.info(f"[DEBUG] 리비전 비교 시작 - revision: {revision}, parent_file_id: {parent_file_id}")
try:
# 간단한 리비전 비교 로직 (purchase_confirmed 기반)
print(f"🔍 [DEBUG] perform_simple_revision_comparison 호출 중...")
logger.info(f"[DEBUG] perform_simple_revision_comparison 호출 중...")
revision_comparison = perform_simple_revision_comparison(db, job_no, parent_file_id, materials_data)
print(f"🔍 [DEBUG] 리비전 비교 완료: {revision_comparison.keys() if revision_comparison else 'None'}")
logger.info(f"[DEBUG] 리비전 비교 완료: {revision_comparison.keys() if revision_comparison else 'None'}")
if revision_comparison.get("has_purchased_materials", False):
print(f"📊 간단한 리비전 비교 결과:")
print(f" - 구매확정된 자재: {revision_comparison.get('purchased_count', 0)}")
print(f" - 미구매 자재: {revision_comparison.get('unpurchased_count', 0)}")
print(f" - 신규 자재: {revision_comparison.get('new_count', 0)}")
print(f" - 제외된 구매확정 자재: {revision_comparison.get('excluded_purchased_count', 0)}")
logger.info(f"간단한 리비전 비교 결과:")
logger.info(f" - 구매확정된 자재: {revision_comparison.get('purchased_count', 0)}")
logger.info(f" - 미구매 자재: {revision_comparison.get('unpurchased_count', 0)}")
logger.info(f" - 신규 자재: {revision_comparison.get('new_count', 0)}")
logger.info(f" - 제외된 구매확정 자재: {revision_comparison.get('excluded_purchased_count', 0)}")
# 신규 및 변경된 자재만 분류
materials_to_classify = revision_comparison.get("materials_to_classify", [])
print(f" - 분류 필요: {len(materials_to_classify)}")
logger.info(f" - 분류 필요: {len(materials_to_classify)}")
# 🔥 구매확정된 자재 매핑 정보 저장
purchased_materials_map = revision_comparison.get("purchased_materials_map", {})

View File

@@ -5,6 +5,8 @@
- 발주 상태 관리
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
@@ -14,6 +16,8 @@ from datetime import datetime
from ..database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/materials", tags=["material-comparison"])
@router.post("/compare-revisions")
@@ -452,7 +456,7 @@ async def perform_material_comparison(
previous_total = previous_item["pipe_details"]["total_length_mm"]
length_change = current_total - previous_total
modified_item["length_change"] = length_change
print(f"🔢 실제 길이 변화: {current_item['description'][:50]} - 이전:{previous_total:.0f}mm 현재:{current_total:.0f}mm (변화:{length_change:+.0f}mm)")
logger.info(f"실제 길이 변화: {current_item['description'][:50]} - 이전:{previous_total:.0f}mm -> 현재:{current_total:.0f}mm (변화:{length_change:+.0f}mm)")
modified_items.append(modified_item)
@@ -587,7 +591,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE')
pipe_with_details = sum(1 for data in materials_dict.values()
if data.get('category') == 'PIPE' and 'pipe_details' in data)
print(f"자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count}개 (길이정보: {pipe_with_details}개)")
logger.info(f"자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count}개 (길이정보: {pipe_with_details}개)")
return materials_dict

View File

@@ -49,7 +49,7 @@ async def create_purchase_request(
if request_data.material_ids:
logger.info(f"🔍 material_ids 샘플: {request_data.material_ids[:5]}")
print(f"🔍 [DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}")
logger.info(f"[DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}")
# 구매신청 번호 생성
today = datetime.now().strftime('%Y%m%d')
count_query = text("""
@@ -160,8 +160,8 @@ async def create_purchase_request(
# 🔥 중요: materials 테이블의 purchase_confirmed 업데이트
if request_data.material_ids:
print(f"🔥 [PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재")
print(f"🔥 [PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그
logger.info(f"[PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재")
logger.info(f"[PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그
update_materials_query = text("""
UPDATE materials
@@ -176,10 +176,10 @@ async def create_purchase_request(
"confirmed_by": current_user.get("username", "system")
})
print(f"🔥 [PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨")
logger.info(f"[PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨")
logger.info(f"{len(request_data.material_ids)}개 자재의 purchase_confirmed를 true로 업데이트")
else:
print(f"⚠️ [PURCHASE] material_ids가 비어있음!")
logger.warning(f"[PURCHASE] material_ids가 비어있음!")
db.commit()
@@ -330,7 +330,7 @@ async def get_request_materials(
data = json.load(f)
grouped_materials = data.get("grouped_materials", [])
except Exception as e:
print(f"⚠️ JSON 파일 읽기 오류 (무시): {e}")
logger.warning(f"JSON 파일 읽기 오류 (무시): {e}")
grouped_materials = []
# 개별 자재 정보 조회 (기존 코드)

View File

@@ -1,3 +1,4 @@
import logging
import pandas as pd
import re
@@ -6,6 +7,8 @@ import uuid
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
# 허용된 확장자
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
@@ -72,7 +75,7 @@ class BOMParser:
# 양식 감지
format_type = cls.detect_format(df)
print(f"📋 감지된 BOM 양식: {format_type}")
logger.info(f"감지된 BOM 양식: {format_type}")
if format_type == 'INVENTOR':
return cls._parse_inventor_bom(df)
@@ -109,7 +112,7 @@ class BOMParser:
mapped_columns[standard_col] = possible_upper
break
print(f"📋 [Standard] 컬럼 매핑 결과: {mapped_columns}")
logger.info(f"[Standard] 컬럼 매핑 결과: {mapped_columns}")
materials = []
for index, row in df.iterrows():
@@ -190,7 +193,7 @@ class BOMParser:
헤더: NO., NAME, Q'ty, LENGTH & THICKNESS, WEIGHT, DESCIPTION, REMARK
특징: Size 컬럼 부재, NAME에 주요 정보 포함
"""
print("⚠️ [Inventor] 인벤터 양식 파서를 사용합니다.")
logger.warning("[Inventor] 인벤터 양식 파서를 사용합니다.")
# 컬럼명 전처리 (좌우 공백 제거 및 대문자화)
df.columns = df.columns.str.strip().str.upper()

View File

@@ -3,9 +3,12 @@
원본 설명에서 완전한 재질명을 추출하여 축약되지 않은 형태로 제공
"""
import logging
import re
from typing import Optional, Dict
logger = logging.getLogger(__name__)
def extract_full_material_grade(description: str) -> str:
"""
원본 설명에서 전체 재질명 추출
@@ -196,7 +199,7 @@ def update_full_material_grades(db, batch_size: int = 1000) -> Dict:
count_query = text("SELECT COUNT(*) FROM materials WHERE full_material_grade IS NULL OR full_material_grade = ''")
total_count = db.execute(count_query).scalar()
print(f"📊 업데이트 대상 자재: {total_count}")
logger.info(f"업데이트 대상 자재: {total_count}")
updated_count = 0
processed_count = 0
@@ -244,7 +247,7 @@ def update_full_material_grades(db, batch_size: int = 1000) -> Dict:
db.commit()
offset += batch_size
print(f"📈 진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)")
logger.info(f"진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)")
return {
"total_processed": processed_count,
@@ -254,7 +257,7 @@ def update_full_material_grades(db, batch_size: int = 1000) -> Dict:
except Exception as e:
db.rollback()
print(f"업데이트 실패: {str(e)}")
logger.error(f"업데이트 실패: {str(e)}")
return {
"total_processed": 0,
"updated_count": 0,

View File

@@ -1,3 +1,4 @@
import logging
from sqlalchemy.orm import Session
from sqlalchemy import text
@@ -18,6 +19,8 @@ from app.services.structural_classifier import classify_structural
from app.services.pipe_classifier import classify_pipe_for_purchase, extract_end_preparation_info
from app.services.material_grade_extractor import extract_full_material_grade
logger = logging.getLogger(__name__)
class MaterialService:
"""자재 처리 및 저장을 담당하는 서비스"""
@@ -70,7 +73,7 @@ class MaterialService:
if revision_comparison and revision_comparison.get("materials_to_classify"):
materials_to_classify = revision_comparison.get("materials_to_classify")
print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}")
logger.info(f"자재 분류 및 저장 시작: {len(materials_to_classify)}")
for material_data in materials_to_classify:
MaterialService._classify_and_save_single_material(
@@ -116,7 +119,7 @@ class MaterialService:
new_keys.add(new_key)
except Exception as e:
print(f"변경사항 분석 실패: {e}")
logger.error(f"변경사항 분석 실패: {e}")
@staticmethod
def _generate_material_key(dwg, line, desc, size, grade):
@@ -332,7 +335,7 @@ class MaterialService:
def inherit_purchase_requests(db: Session, current_file_id: int, parent_file_id: int):
"""이전 리비전의 구매신청 정보를 상속합니다."""
try:
print(f"🔄 구매신청 정보 상속 처리 시작...")
logger.info(f"구매신청 정보 상속 처리 시작...")
# 1. 이전 리비전에서 그룹별 구매신청 수량 집계
prev_purchase_summary = text("""
@@ -396,14 +399,14 @@ class MaterialService:
inherited_count = len(new_materials)
if inherited_count > 0:
print(f" {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) {inherited_count}/{purchased_count}개 상속")
logger.info(f" {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) -> {inherited_count}/{purchased_count}개 상속")
# 커밋은 호출하는 쪽에서 일괄 처리하거나 여기서 처리
# db.commit()
print(f"구매신청 정보 상속 완료")
logger.info(f"구매신청 정보 상속 완료")
except Exception as e:
print(f"구매신청 정보 상속 실패: {str(e)}")
logger.error(f"구매신청 정보 상속 실패: {str(e)}")
# 상속 실패는 전체 프로세스를 중단하지 않음
@staticmethod

View File

@@ -1,229 +0,0 @@
"""
수정된 스풀 관리 시스템
도면별 스풀 넘버링 + 에리어는 별도 관리
"""
import re
from typing import Dict, List, Optional, Tuple
from datetime import datetime
# ========== 스풀 넘버링 규칙 ==========
SPOOL_NUMBERING_RULES = {
"SPOOL_NUMBER": {
"sequence": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
"U", "V", "W", "X", "Y", "Z"],
"description": "도면별 스풀 넘버"
},
"AREA_NUMBER": {
"pattern": r"#(\d{2})", # #01, #02, #03...
"format": "#{:02d}", # 2자리 숫자
"range": (1, 99), # 01~99
"description": "물리적 구역 넘버 (별도 관리)"
}
}
class SpoolManagerV2:
"""수정된 스풀 관리 클래스"""
def __init__(self, project_id: int = None):
self.project_id = project_id
def generate_spool_identifier(self, dwg_name: str, spool_number: str) -> str:
"""
스풀 식별자 생성 (도면명 + 스풀넘버)
Args:
dwg_name: 도면명 (예: "A-1", "B-3")
spool_number: 스풀넘버 (예: "A", "B")
Returns:
스풀 식별자 (예: "A-1-A", "B-3-B")
"""
# 스풀 넘버 포맷 검증
spool_formatted = self.format_spool_number(spool_number)
# 조합: {도면명}-{스풀넘버}
return f"{dwg_name}-{spool_formatted}"
def parse_spool_identifier(self, spool_id: str) -> Dict:
"""스풀 식별자 파싱"""
# 패턴: DWG_NAME-SPOOL_NUMBER
# 예: A-1-A, B-3-B, 1-IAR-3B1D0-0129-N-A
# 마지막 '-' 기준으로 분리
parts = spool_id.rsplit('-', 1)
if len(parts) == 2:
dwg_base = parts[0]
spool_number = parts[1]
return {
"original_id": spool_id,
"dwg_name": dwg_base,
"spool_number": spool_number,
"is_valid": self.validate_spool_number(spool_number),
"format": "CORRECT"
}
else:
return {
"original_id": spool_id,
"dwg_name": None,
"spool_number": None,
"is_valid": False,
"format": "INVALID"
}
def format_spool_number(self, spool_input: str) -> str:
"""스풀 넘버 포맷팅 및 검증"""
spool_clean = spool_input.upper().strip()
# 유효한 스풀 넘버인지 확인 (A-Z 단일 문자)
if re.match(r'^[A-Z]$', spool_clean):
return spool_clean
raise ValueError(f"유효하지 않은 스풀 넘버: {spool_input} (A-Z 단일 문자만 가능)")
def validate_spool_number(self, spool_number: str) -> bool:
"""스풀 넘버 유효성 검증"""
return bool(re.match(r'^[A-Z]$', spool_number))
def get_next_spool_number(self, dwg_name: str, existing_spools: List[str] = None) -> str:
"""해당 도면의 다음 사용 가능한 스풀 넘버 추천"""
if not existing_spools:
return "A" # 첫 번째 스풀
# 해당 도면의 기존 스풀들 파싱
used_spools = set()
for spool_id in existing_spools:
parsed = self.parse_spool_identifier(spool_id)
if parsed["dwg_name"] == dwg_name and parsed["is_valid"]:
used_spools.add(parsed["spool_number"])
# 다음 사용 가능한 넘버 찾기
sequence = SPOOL_NUMBERING_RULES["SPOOL_NUMBER"]["sequence"]
for spool_num in sequence:
if spool_num not in used_spools:
return spool_num
raise ValueError(f"도면 {dwg_name}에서 사용 가능한 스풀 넘버가 없습니다")
def validate_spool_identifier(self, spool_id: str) -> Dict:
"""스풀 식별자 전체 유효성 검증"""
parsed = self.parse_spool_identifier(spool_id)
validation_result = {
"is_valid": True,
"errors": [],
"warnings": [],
"parsed": parsed
}
# 도면명 확인
if not parsed["dwg_name"]:
validation_result["is_valid"] = False
validation_result["errors"].append("도면명이 없습니다")
# 스풀 넘버 확인
if not parsed["spool_number"]:
validation_result["is_valid"] = False
validation_result["errors"].append("스풀 넘버가 없습니다")
elif not self.validate_spool_number(parsed["spool_number"]):
validation_result["is_valid"] = False
validation_result["errors"].append("스풀 넘버 형식이 잘못되었습니다 (A-Z 단일 문자)")
return validation_result
# ========== 에리어 관리 (별도 시스템) ==========
class AreaManager:
"""에리어 관리 클래스 (물리적 구역)"""
def __init__(self, project_id: int = None):
self.project_id = project_id
def format_area_number(self, area_input: str) -> str:
"""에리어 넘버 포맷팅"""
# 숫자만 추출
numbers = re.findall(r'\d+', area_input)
if numbers:
area_num = int(numbers[0])
if 1 <= area_num <= 99:
return f"#{area_num:02d}"
raise ValueError(f"유효하지 않은 에리어 넘버: {area_input} (#01-#99)")
def assign_drawings_to_area(self, area_number: str, drawing_names: List[str]) -> Dict:
"""도면들을 에리어에 할당"""
area_formatted = self.format_area_number(area_number)
return {
"area_number": area_formatted,
"assigned_drawings": drawing_names,
"assignment_count": len(drawing_names),
"assignment_date": datetime.now().isoformat()
}
def classify_pipe_with_corrected_spool(dat_file: str, description: str, main_nom: str,
length: float = None, dwg_name: str = None,
spool_number: str = None, area_number: str = None) -> Dict:
"""파이프 분류 + 수정된 스풀 정보"""
# 기본 파이프 분류
from .pipe_classifier import classify_pipe
pipe_result = classify_pipe(dat_file, description, main_nom, length)
# 스풀 관리자 생성
spool_manager = SpoolManagerV2()
area_manager = AreaManager()
# 스풀 정보 처리
spool_info = {
"dwg_name": dwg_name,
"spool_number": None,
"spool_identifier": None,
"area_number": None, # 별도 관리
"manual_input_required": True,
"validation": None
}
# 스풀 넘버가 제공된 경우
if dwg_name and spool_number:
try:
spool_identifier = spool_manager.generate_spool_identifier(dwg_name, spool_number)
spool_info.update({
"spool_number": spool_manager.format_spool_number(spool_number),
"spool_identifier": spool_identifier,
"manual_input_required": False,
"validation": {"is_valid": True, "errors": []}
})
except ValueError as e:
spool_info["validation"] = {
"is_valid": False,
"errors": [str(e)]
}
# 에리어 정보 처리 (별도)
if area_number:
try:
area_formatted = area_manager.format_area_number(area_number)
spool_info["area_number"] = area_formatted
except ValueError as e:
spool_info["area_validation"] = {
"is_valid": False,
"errors": [str(e)]
}
# 기존 결과에 스풀 정보 추가
pipe_result["spool_info"] = spool_info
return pipe_result

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import DashboardPage from './pages/dashboard/DashboardPage';
import { UserMenu, ErrorBoundary } from './components/common';
import NewMaterialsPage from './pages/NewMaterialsPage';
import BOMManagementPage from './pages/BOMManagementPage';
import UnifiedBOMPage from './pages/UnifiedBOMPage';
import SystemSettingsPage from './pages/SystemSettingsPage';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -80,4 +80,13 @@ async function remove(req, res, next) {
}
}
module.exports = { getAll, getActive, getById, create, update, remove };
async function getProductTypes(req, res, next) {
try {
const types = await projectModel.getProductTypes();
res.json({ success: true, data: types });
} catch (err) {
next(err);
}
}
module.exports = { getAll, getActive, getById, create, update, remove, getProductTypes };

View File

@@ -93,6 +93,7 @@ async function start() {
const { runMigration, runGenericMigration } = require('./models/vacationSettingsModel');
await runMigration();
await runGenericMigration('20260323_add_resigned_date.sql');
await runGenericMigration('20260326_add_product_types.sql');
} catch (err) {
if (!['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME'].includes(err.code)) {
console.error('Fatal migration error:', err.message);

View File

@@ -10,7 +10,10 @@ const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects ORDER BY project_id DESC'
`SELECT p.*, pt.code AS product_type_code, pt.name AS product_type_name
FROM projects p
LEFT JOIN product_types pt ON p.product_type_id = pt.id
ORDER BY p.project_id DESC`
);
return rows;
}
@@ -18,7 +21,10 @@ async function getAll() {
async function getActive() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects WHERE is_active = TRUE ORDER BY project_name ASC'
`SELECT p.*, pt.code AS product_type_code, pt.name AS product_type_name
FROM projects p
LEFT JOIN product_types pt ON p.product_type_id = pt.id
WHERE p.is_active = TRUE ORDER BY p.project_name ASC`
);
return rows;
}
@@ -26,18 +32,29 @@ async function getActive() {
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects WHERE project_id = ?',
`SELECT p.*, pt.code AS product_type_code, pt.name AS product_type_name
FROM projects p
LEFT JOIN product_types pt ON p.product_type_id = pt.id
WHERE p.project_id = ?`,
[id]
);
return rows[0] || null;
}
async function create({ job_no, project_name, contract_date, due_date, delivery_method, site, pm }) {
async function getProductTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM product_types WHERE is_active = TRUE ORDER BY display_order'
);
return rows;
}
async function create({ job_no, project_name, contract_date, due_date, delivery_method, site, pm, product_type_id }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO projects (job_no, project_name, contract_date, due_date, delivery_method, site, pm)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[job_no, project_name, contract_date || null, due_date || null, delivery_method || null, site || null, pm || null]
`INSERT INTO projects (job_no, project_name, contract_date, due_date, delivery_method, site, pm, product_type_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[job_no, project_name, contract_date || null, due_date || null, delivery_method || null, site || null, pm || null, product_type_id || null]
);
return getById(result.insertId);
}
@@ -57,6 +74,7 @@ async function update(id, data) {
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.project_status !== undefined) { fields.push('project_status = ?'); values.push(data.project_status); }
if (data.completed_date !== undefined) { fields.push('completed_date = ?'); values.push(data.completed_date || null); }
if (data.product_type_id !== undefined) { fields.push('product_type_id = ?'); values.push(data.product_type_id || null); }
if (fields.length === 0) return getById(id);
@@ -76,4 +94,4 @@ async function deactivate(id) {
);
}
module.exports = { getAll, getActive, getById, create, update, deactivate };
module.exports = { getAll, getActive, getById, create, update, deactivate, getProductTypes };

View File

@@ -9,6 +9,7 @@ const { requireAuth, requireAdminOrPermission } = require('../middleware/auth');
const projectPerm = requireAdminOrPermission('tkuser.projects');
router.get('/product-types', requireAuth, projectController.getProductTypes);
router.get('/', requireAuth, projectController.getAll);
router.get('/active', requireAuth, projectController.getActive);
router.get('/:id', requireAuth, projectController.getById);

View File

@@ -0,0 +1,22 @@
-- 제품유형 참조 테이블
CREATE TABLE IF NOT EXISTS product_types (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
display_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 초기 데이터
INSERT IGNORE INTO product_types (code, name, display_order) VALUES
('PKG', 'Package', 1),
('VESSEL', '압력용기', 2),
('HX', '열교환기', 3),
('SKID', 'Skid', 4);
-- projects에 product_type_id FK 추가
ALTER TABLE projects ADD COLUMN product_type_id INT NULL;
ALTER TABLE projects ADD CONSTRAINT fk_project_product_type
FOREIGN KEY (product_type_id) REFERENCES product_types(id)