Files
TK-BOM-Project/PIPE_DEVELOPMENT_GUIDE.md
Hyungi Ahn 8f42a1054e
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
🔧 완전한 스키마 자동화 시스템 구축
 주요 기능:
- 완전한 데이터베이스 스키마 분석 및 자동 마이그레이션 시스템
- 44개 테이블 완전 지원 (운영 서버 43개 + 1개 추가)
- 누락된 테이블/컬럼 자동 감지 및 생성

🔧 해결된 스키마 문제:
- users.status 컬럼 누락 → 자동 추가
- files 테이블 4개 컬럼 누락 → 자동 추가
- materials 테이블 22개 컬럼 누락 → 자동 추가
- support_details, purchase_requests, purchase_request_items 테이블 누락 → 자동 생성
- material_purchase_tracking.description, purchase_status 컬럼 누락 → 자동 추가

🚀 자동화 도구:
- schema_analyzer.py: 코드와 DB 스키마 비교 분석
- auto_migrator.py: 자동 마이그레이션 실행
- docker_migrator.py: Docker 환경용 간편 마이그레이션
- schema_monitor.py: 실시간 스키마 모니터링

📋 리비전 관리 시스템:
- 8개 카테고리별 리비전 페이지 구현
- PIPE Cutting Plan 관리 시스템
- PIPE Issue Management 시스템
- 완전한 리비전 비교 및 추적 기능

🎯 사용법:
docker exec tk-mp-backend python3 scripts/docker_migrator.py

앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
2025-10-21 10:34:45 +09:00

40 KiB
Raw Blame History

🔧 PIPE Cutting Plan & 이슈 관리 시스템 개발 가이드

📋 목차

  1. 시스템 개요
  2. 전체 워크플로우
  3. 데이터베이스 설계
  4. 페이지별 상세 설계
  5. API 엔드포인트
  6. 리비전 관리 로직
  7. 개발 우선순위
  8. 기술 스택

🎯 시스템 개요

핵심 목적

  • PIPE 자재의 체계적인 Cutting Plan 관리
  • 구역별/도면별 단관 정보 관리
  • 리비전 시 변경사항 추적 및 비교
  • 현장 이슈 및 문제점 체계적 기록

주요 특징

  • 2단계 워크플로우: 구역 할당 → 라인번호 입력
  • 스마트 리비전: 기존 데이터와 신규 BOM 자동 비교
  • 현장 이슈 관리: 도면별/단관별 문제점 추적
  • Excel 연동: 완전한 데이터 내보내기 지원

🔄 전체 워크플로우

graph TD
    A[BOM 업로드] --> B{PIPE 데이터 추출}
    B --> C[1단계: 구역별 도면 할당]
    C --> D[2단계: 라인번호 입력]
    D --> E[3단계: 단관 정보 확인]
    E --> F[Cutting Plan 저장]
    
    F --> G{새로운 리비전?}
    G -->|Yes| H[4단계: 리비전 비교]
    G -->|No| I[이슈 관리 페이지]
    
    H --> J[변경사항 확인/수정]
    J --> K[5단계: 변경 도면 요약]
    K --> L[파이프 구매량 재계산]
    L --> I
    
    I --> M[현장 이슈 입력]
    M --> N[Excel 내보내기]

🗄️ 데이터베이스 설계

1. pipe_cutting_plans (단관 정보)

CREATE TABLE pipe_cutting_plans (
    id SERIAL PRIMARY KEY,
    job_no VARCHAR(50) NOT NULL,
    file_id INTEGER REFERENCES files(id),
    
    -- 기본 정보
    area VARCHAR(10),                    -- #01, #02 등
    drawing_name VARCHAR(100) NOT NULL,  -- P&ID-001
    line_no VARCHAR(50) NOT NULL,        -- LINE-A-001
    
    -- 파이프 정보
    material_grade VARCHAR(50),          -- A106 GR.B
    schedule_spec VARCHAR(20),           -- SCH40, SCH80
    nominal_size VARCHAR(50),            -- 4", 6", 8"
    length_mm DECIMAL(10,3) NOT NULL,    -- 1500.0
    
    -- 끝단 정보
    end_preparation VARCHAR(20),         -- 무개선, 한개선, 양개선
    
    -- 메타데이터
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    created_by VARCHAR(100),
    
    -- 인덱스
    INDEX idx_cutting_plans_job (job_no),
    INDEX idx_cutting_plans_area_drawing (area, drawing_name),
    INDEX idx_cutting_plans_material (material_grade, nominal_size)
);

2. pipe_revision_comparisons (리비전 비교)

CREATE TABLE pipe_revision_comparisons (
    id SERIAL PRIMARY KEY,
    job_no VARCHAR(50) NOT NULL,
    current_file_id INTEGER REFERENCES files(id),
    previous_cutting_plan_id INTEGER REFERENCES pipe_cutting_plans(id),
    
    -- 비교 결과
    comparison_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    total_drawings INTEGER,              -- 전체 도면 수
    changed_drawings INTEGER,            -- 변경된 도면 수
    unchanged_drawings INTEGER,          -- 변경되지 않은 도면 수
    
    -- 단관 변경 통계
    total_segments INTEGER,              -- 전체 단관 수
    added_segments INTEGER,              -- 추가된 단관
    removed_segments INTEGER,            -- 삭제된 단관
    modified_segments INTEGER,           -- 수정된 단관
    unchanged_segments INTEGER,          -- 변경되지 않은 단관
    
    -- 메타데이터
    created_by VARCHAR(100),
    is_applied BOOLEAN DEFAULT FALSE,
    applied_at TIMESTAMP,
    applied_by VARCHAR(100)
);

3. pipe_revision_changes (리비전 변경 상세)

CREATE TABLE pipe_revision_changes (
    id SERIAL PRIMARY KEY,
    comparison_id INTEGER REFERENCES pipe_revision_comparisons(id),
    
    -- 변경 정보
    drawing_name VARCHAR(100) NOT NULL,
    change_type VARCHAR(20) NOT NULL,    -- 'added', 'removed', 'modified', 'unchanged'
    
    -- 이전 데이터 (수정/삭제의 경우)
    old_line_no VARCHAR(50),
    old_material_grade VARCHAR(50),
    old_schedule_spec VARCHAR(20),
    old_nominal_size VARCHAR(50),
    old_length_mm DECIMAL(10,3),
    old_end_preparation VARCHAR(20),
    
    -- 새로운 데이터 (추가/수정의 경우)
    new_line_no VARCHAR(50),
    new_material_grade VARCHAR(50),
    new_schedule_spec VARCHAR(20),
    new_nominal_size VARCHAR(50),
    new_length_mm DECIMAL(10,3),
    new_end_preparation VARCHAR(20),
    
    -- 변경 사유
    change_reason TEXT,
    
    -- 메타데이터
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

4. pipe_drawing_issues (도면 전반 이슈)

CREATE TABLE pipe_drawing_issues (
    id SERIAL PRIMARY KEY,
    job_no VARCHAR(50) NOT NULL,
    area VARCHAR(10) NOT NULL,
    drawing_name VARCHAR(100) NOT NULL,
    
    -- 이슈 정보
    issue_description TEXT NOT NULL,
    severity VARCHAR(20) DEFAULT 'medium', -- 'low', 'medium', 'high', 'critical'
    status VARCHAR(20) DEFAULT 'open',     -- 'open', 'in_progress', 'resolved'
    
    -- 해결 정보
    resolution_notes TEXT,
    resolved_by VARCHAR(100),
    resolved_at TIMESTAMP,
    
    -- 메타데이터
    reported_by VARCHAR(100),
    reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

5. pipe_segment_issues (단관별 이슈)

CREATE TABLE pipe_segment_issues (
    id SERIAL PRIMARY KEY,
    job_no VARCHAR(50) NOT NULL,
    area VARCHAR(10) NOT NULL,
    drawing_name VARCHAR(100) NOT NULL,
    line_no VARCHAR(50) NOT NULL,
    
    -- 원본 정보
    original_material_info VARCHAR(100),
    original_length DECIMAL(10,3),
    
    -- 이슈 정보
    issue_description TEXT NOT NULL,
    issue_type VARCHAR(50),              -- 'cutting', 'installation', 'material', 'routing', 'other'
    
    -- 변경사항 (있는 경우)
    length_change DECIMAL(10,3),         -- +/- 변경량
    new_length DECIMAL(10,3),            -- 최종 길이
    material_change VARCHAR(100),        -- 재질 변경 정보
    
    -- 이슈 관리
    severity VARCHAR(20) DEFAULT 'medium',
    status VARCHAR(20) DEFAULT 'open',
    
    -- 해결 정보
    resolution_notes TEXT,
    resolved_by VARCHAR(100),
    resolved_at TIMESTAMP,
    
    -- 메타데이터
    cutting_plan_id INTEGER REFERENCES pipe_cutting_plans(id),
    reported_by VARCHAR(100),
    reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

📄 페이지별 상세 설계

1. PIPE Cutting Plan 메인 페이지

파일: frontend/src/pages/revision/PipeCuttingPlanPage.jsx

1단계: 구역별 도면 할당

// UI 구조
<div className="area-assignment-stage">
    <h3>📂 1단계: 구역별 도면 할당</h3>
    
    {/* 구역 입력 */}
    <div className="area-input">
        <input 
            type="text" 
            placeholder="#01, #02 등 구역 입력"
            value={currentArea}
            onChange={(e) => setCurrentArea(e.target.value)}
        />
        <button onClick={addArea}>구역 추가</button>
    </div>
    
    {/* 도면 선택 */}
    <div className="drawing-selection">
        <h4>📋 사용 가능한 도면 목록</h4>
        {availableDrawings.map(drawing => (
            <label key={drawing.name}>
                <input 
                    type="checkbox"
                    checked={selectedDrawings.includes(drawing.name)}
                    onChange={() => toggleDrawingSelection(drawing.name)}
                />
                {drawing.name} ({drawing.pipe_count} 단관)
            </label>
        ))}
    </div>
    
    {/* 구역별 할당 현황 */}
    <div className="area-assignments">
        {areas.map(area => (
            <div key={area.name} className="area-card">
                <h4>{area.name}</h4>
                <div className="assigned-drawings">
                    {area.drawings.map(drawing => (
                        <span key={drawing} className="drawing-tag">
                            {drawing}
                            <button onClick={() => removeDrawingFromArea(area.name, drawing)}>×</button>
                        </span>
                    ))}
                </div>
            </div>
        ))}
    </div>
    
    <button 
        className="btn-next-stage"
        onClick={proceedToLineNumberInput}
        disabled={!allDrawingsAssigned}
    >
        ➡️ 2단계: 라인번호 입력으로 진행
    </button>
</div>

2단계: 라인번호 입력

<div className="line-number-input-stage">
    <h3>🔢 2단계: 라인번호 입력</h3>
    
    {/* 구역/도면 선택 */}
    <div className="area-drawing-selector">
        <select value={selectedArea} onChange={(e) => setSelectedArea(e.target.value)}>
            <option value="">구역 선택</option>
            {areas.map(area => (
                <option key={area.name} value={area.name}>{area.name}</option>
            ))}
        </select>
        
        <select value={selectedDrawing} onChange={(e) => setSelectedDrawing(e.target.value)}>
            <option value="">도면 선택</option>
            {getDrawingsForArea(selectedArea).map(drawing => (
                <option key={drawing} value={drawing}>{drawing}</option>
            ))}
        </select>
    </div>
    
    {/* 단관 정보 테이블 */}
    <div className="pipe-segments-table">
        <table>
            <thead>
                <tr>
                    <th>라인번호</th>
                    <th>재질</th>
                    <th>규격</th>
                    <th>길이(mm)</th>
                    <th>끝단가공</th>
                    <th>액션</th>
                </tr>
            </thead>
            <tbody>
                {pipeSegments.map((segment, index) => (
                    <tr key={index}>
                        <td>
                            <input 
                                type="text"
                                value={segment.line_no}
                                onChange={(e) => updateSegmentLineNo(index, e.target.value)}
                                placeholder="LINE-A-001"
                            />
                        </td>
                        <td>{segment.material_grade}</td>
                        <td>{segment.schedule_spec} {segment.nominal_size}</td>
                        <td>{segment.length_mm}</td>
                        <td>
                            <select 
                                value={segment.end_preparation}
                                onChange={(e) => updateSegmentEndPrep(index, e.target.value)}
                            >
                                <option value="무개선">무개선</option>
                                <option value="한개선">한개선</option>
                                <option value="양개선">양개선</option>
                            </select>
                        </td>
                        <td>
                            <button onClick={() => removeSegment(index)}>삭제</button>
                        </td>
                    </tr>
                ))}
            </tbody>
        </table>
    </div>
    
    <div className="stage-actions">
        <button onClick={addNewSegment}> 단관 추가</button>
        <button onClick={proceedToConfirmation}>➡️ 3단계: 확인으로 진행</button>
    </div>
</div>

3단계: 단관 정보 확인

<div className="confirmation-stage">
    <h3> 3단계: 단관 정보 확인</h3>
    
    {/* 전체 요약 */}
    <div className="summary-stats">
        <div className="stat-card">
            <h4>📊 전체 현황</h4>
            <p> 구역: {areas.length}</p>
            <p> 도면: {totalDrawings}</p>
            <p> 단관: {totalSegments}</p>
        </div>
    </div>
    
    {/* 구역별 상세 */}
    {areas.map(area => (
        <div key={area.name} className="area-summary">
            <h4>🏗️ {area.name} 구역</h4>
            {area.drawings.map(drawing => (
                <div key={drawing} className="drawing-summary">
                    <h5>📋 {drawing}</h5>
                    <table className="segments-summary">
                        <thead>
                            <tr>
                                <th>라인번호</th>
                                <th>재질/규격</th>
                                <th>길이</th>
                                <th>끝단가공</th>
                            </tr>
                        </thead>
                        <tbody>
                            {getSegmentsForDrawing(area.name, drawing).map(segment => (
                                <tr key={segment.id}>
                                    <td>{segment.line_no}</td>
                                    <td>{segment.material_grade} {segment.schedule_spec} {segment.nominal_size}</td>
                                    <td>{segment.length_mm}mm</td>
                                    <td>{segment.end_preparation}</td>
                                </tr>
                            ))}
                        </tbody>
                    </table>
                </div>
            ))}
        </div>
    ))}
    
    <div className="final-actions">
        <button onClick={exportToExcel}>📊 Excel 내보내기</button>
        <button onClick={saveCuttingPlan} className="btn-primary">
            💾 Cutting Plan 저장
        </button>
    </div>
</div>

2. 리비전 비교 페이지

파일: frontend/src/pages/revision/PipeRevisionComparisonPage.jsx

4단계: 리비전 비교

<div className="revision-comparison-stage">
    <h3>🔄 4단계: 리비전 비교</h3>
    
    {/* 비교 요약 */}
    <div className="comparison-summary">
        <div className="summary-grid">
            <div className="summary-card added">
                <h4> 추가된 단관</h4>
                <span className="count">{comparisonResult.added_segments}</span>
            </div>
            <div className="summary-card removed">
                <h4> 삭제된 단관</h4>
                <span className="count">{comparisonResult.removed_segments}</span>
            </div>
            <div className="summary-card modified">
                <h4>🔧 수정된 단관</h4>
                <span className="count">{comparisonResult.modified_segments}</span>
            </div>
            <div className="summary-card unchanged">
                <h4> 변경없음</h4>
                <span className="count">{comparisonResult.unchanged_segments}</span>
            </div>
        </div>
    </div>
    
    {/* 변경된 도면만 표시 */}
    <div className="changed-drawings">
        <h4>📋 변경된 도면 목록</h4>
        {changedDrawings.map(drawing => (
            <div key={drawing.name} className="drawing-comparison">
                <h5>📄 {drawing.name}</h5>
                <table className="comparison-table">
                    <thead>
                        <tr>
                            <th>상태</th>
                            <th>라인번호</th>
                            <th>재질/규격</th>
                            <th>길이</th>
                            <th>끝단가공</th>
                            <th>변경사항</th>
                        </tr>
                    </thead>
                    <tbody>
                        {drawing.segments.map(segment => (
                            <tr 
                                key={segment.id}
                                className={`segment-row ${segment.change_type}`}
                                style={{
                                    backgroundColor: segment.change_type === 'unchanged' ? '#e8f5e8' : '#ffffff'
                                }}
                            >
                                <td>
                                    <span className={`status-badge ${segment.change_type}`}>
                                        {getStatusLabel(segment.change_type)}
                                    </span>
                                </td>
                                <td>{segment.line_no}</td>
                                <td>{segment.material_info}</td>
                                <td>{segment.length_mm}mm</td>
                                <td>{segment.end_preparation}</td>
                                <td>
                                    {segment.change_type !== 'unchanged' && (
                                        <div className="change-details">
                                            {segment.changes.map(change => (
                                                <div key={change.field} className="change-item">
                                                    <strong>{change.field}:</strong> 
                                                    {change.old_value}  {change.new_value}
                                                </div>
                                            ))}
                                        </div>
                                    )}
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>
        ))}
    </div>
    
    <div className="revision-actions">
        <button onClick={acceptAllChanges}> 모든 변경사항 적용</button>
        <button onClick={proceedToSummary}>➡️ 5단계: 변경 요약으로 진행</button>
    </div>
</div>

5단계: 변경 도면 요약

<div className="change-summary-stage">
    <h3>📊 5단계: 변경 도면 요약</h3>
    
    {/* 변경 도면만 집중 표시 */}
    <div className="changed-drawings-only">
        <h4>⚠️ 검토가 필요한 도면들</h4>
        {changedDrawingsOnly.map(drawing => (
            <div key={drawing.name} className="drawing-change-card">
                <div className="drawing-header">
                    <h5>📄 {drawing.name}</h5>
                    <div className="change-stats">
                        <span className="added">+{drawing.added_count}</span>
                        <span className="removed">-{drawing.removed_count}</span>
                        <span className="modified">~{drawing.modified_count}</span>
                    </div>
                </div>
                
                <div className="change-impact">
                    <h6>📈 파이프 구매량 영향</h6>
                    {drawing.material_impacts.map(impact => (
                        <div key={impact.material} className="material-impact">
                            <span className="material">{impact.material}</span>
                            <span className="length-change">
                                {impact.length_change > 0 ? '+' : ''}{impact.length_change}m
                            </span>
                            <span className="percentage">
                                ({impact.percentage_change > 0 ? '+' : ''}{impact.percentage_change}%)
                            </span>
                        </div>
                    ))}
                </div>
            </div>
        ))}
    </div>
    
    {/* 전체 구매량 재계산 */}
    <div className="purchase-recalculation">
        <h4>💰 파이프 구매량 재계산</h4>
        <div className="purchase-comparison">
            <div className="before-after">
                <div className="before">
                    <h5>이전 구매 계획</h5>
                    {previousPurchasePlan.map(item => (
                        <div key={item.material} className="purchase-item">
                            <span>{item.material}</span>
                            <span>{item.total_length}m</span>
                        </div>
                    ))}
                </div>
                <div className="after">
                    <h5>신규 구매 계획</h5>
                    {newPurchasePlan.map(item => (
                        <div key={item.material} className="purchase-item">
                            <span>{item.material}</span>
                            <span>{item.total_length}m</span>
                            {item.additional_needed > 0 && (
                                <span className="additional">
                                    (+{item.additional_needed}m 추가 필요)
                                </span>
                            )}
                        </div>
                    ))}
                </div>
            </div>
        </div>
    </div>
    
    <div className="final-actions">
        <button onClick={exportRevisionReport}>📊 리비전 리포트 내보내기</button>
        <button onClick={sendToPipeRevisionPage}>
            ➡️ PIPE 리비전 페이지로 추가 구매량 전송
        </button>
    </div>
</div>

3. 단관 이슈 관리 페이지

파일: frontend/src/pages/PipeIssueManagementPage.jsx

<div className="pipe-issue-management">
    <div className="page-header">
        <h1>🛠️ 단관 이슈 관리</h1>
        <p>현장에서 발생하는 파이프 관련 이슈를 체계적으로 관리합니다</p>
    </div>
    
    {/* 검색/필터 섹션 */}
    <div className="search-section">
        <div className="search-controls">
            <select value={selectedArea} onChange={(e) => setSelectedArea(e.target.value)}>
                <option value="">구역 선택</option>
                {areas.map(area => (
                    <option key={area} value={area}>{area}</option>
                ))}
            </select>
            
            <select value={selectedDrawing} onChange={(e) => setSelectedDrawing(e.target.value)}>
                <option value="">도면 선택</option>
                {getDrawingsForArea(selectedArea).map(drawing => (
                    <option key={drawing} value={drawing}>{drawing}</option>
                ))}
            </select>
            
            <button onClick={loadSegments}>🔍 단관 정보 로드</button>
        </div>
    </div>
    
    {/* 도면 전반 이슈 섹션 */}
    <div className="drawing-issues-section">
        <h3>📋 도면 전반 이슈</h3>
        
        {/* 기존 이슈 목록 */}
        <div className="existing-issues">
            {drawingIssues.map(issue => (
                <div key={issue.id} className="issue-card">
                    <div className="issue-header">
                        <span className={`severity-badge ${issue.severity}`}>
                            {issue.severity.toUpperCase()}
                        </span>
                        <span className={`status-badge ${issue.status}`}>
                            {issue.status.toUpperCase()}
                        </span>
                    </div>
                    <div className="issue-content">
                        <p>{issue.issue_description}</p>
                        <div className="issue-meta">
                            <small>보고자: {issue.reported_by} | {issue.reported_at}</small>
                        </div>
                    </div>
                    <div className="issue-actions">
                        <button onClick={() => editDrawingIssue(issue.id)}>수정</button>
                        <button onClick={() => deleteDrawingIssue(issue.id)}>삭제</button>
                        {issue.status === 'open' && (
                            <button onClick={() => resolveDrawingIssue(issue.id)}>해결</button>
                        )}
                    </div>
                </div>
            ))}
        </div>
        
        {/* 새 이슈 추가 */}
        <div className="add-drawing-issue">
            <h4>  도면 이슈 추가</h4>
            <textarea
                placeholder="도면 전반적인 문제점을 입력하세요..."
                value={newDrawingIssue}
                onChange={(e) => setNewDrawingIssue(e.target.value)}
            />
            <div className="issue-controls">
                <select value={newIssueSeverity} onChange={(e) => setNewIssueSeverity(e.target.value)}>
                    <option value="low">낮음</option>
                    <option value="medium">보통</option>
                    <option value="high">높음</option>
                    <option value="critical">심각</option>
                </select>
                <button onClick={addDrawingIssue}>이슈 추가</button>
            </div>
        </div>
    </div>
    
    {/* 단관별 상세 정보 섹션 */}
    <div className="segments-section">
        <h3>🔧 단관별 상세 정보</h3>
        
        {segments.length > 0 && (
            <table className="segments-table">
                <thead>
                    <tr>
                        <th>라인번호</th>
                        <th>재질/규격</th>
                        <th>길이</th>
                        <th>끝단가공</th>
                        <th>이슈 상태</th>
                        <th>액션</th>
                    </tr>
                </thead>
                <tbody>
                    {segments.map(segment => (
                        <tr key={segment.id}>
                            <td>{segment.line_no}</td>
                            <td>{segment.material_grade} {segment.schedule_spec} {segment.nominal_size}</td>
                            <td>{segment.length_mm}mm</td>
                            <td>{segment.end_preparation}</td>
                            <td>
                                {segment.has_issues ? (
                                    <span className="has-issues">⚠️ 이슈있음</span>
                                ) : (
                                    <span className="no-issues"> 정상</span>
                                )}
                            </td>
                            <td>
                                <button onClick={() => openSegmentIssueDialog(segment)}>
                                    {segment.has_issues ? '이슈 수정' : '이슈 입력'}
                                </button>
                            </td>
                        </tr>
                    ))}
                </tbody>
            </table>
        )}
    </div>
    
    {/* 단관 이슈 입력 다이얼로그 */}
    {showSegmentIssueDialog && (
        <div className="modal-overlay">
            <div className="issue-dialog">
                <h4>🔧 {selectedSegment.line_no} 이슈 관리</h4>
                
                <div className="segment-info">
                    <p><strong>구역:</strong> {selectedArea}</p>
                    <p><strong>도면:</strong> {selectedDrawing}</p>
                    <p><strong>재질:</strong> {selectedSegment.material_info}</p>
                    <p><strong>원래 길이:</strong> {selectedSegment.length_mm}mm</p>
                </div>
                
                <div className="issue-input">
                    <label>이슈 설명:</label>
                    <textarea
                        placeholder="예: 설치가 힘들어 30mm 절단함"
                        value={segmentIssueDescription}
                        onChange={(e) => setSegmentIssueDescription(e.target.value)}
                    />
                    
                    <label>이슈 유형:</label>
                    <select value={segmentIssueType} onChange={(e) => setSegmentIssueType(e.target.value)}>
                        <option value="cutting">절단</option>
                        <option value="installation">설치</option>
                        <option value="material">재질</option>
                        <option value="routing">라우팅</option>
                        <option value="other">기타</option>
                    </select>
                    
                    <label>길이 변경 (mm):</label>
                    <input
                        type="number"
                        placeholder="예: -30 (30mm 절단)"
                        value={lengthChange}
                        onChange={(e) => setLengthChange(e.target.value)}
                    />
                    
                    <label>심각도:</label>
                    <select value={segmentIssueSeverity} onChange={(e) => setSegmentIssueSeverity(e.target.value)}>
                        <option value="low">낮음</option>
                        <option value="medium">보통</option>
                        <option value="high">높음</option>
                        <option value="critical">심각</option>
                    </select>
                </div>
                
                <div className="dialog-actions">
                    <button onClick={saveSegmentIssue}>저장</button>
                    <button onClick={closeSegmentIssueDialog}>취소</button>
                </div>
            </div>
        </div>
    )}
    
    {/* 이슈 리포트 섹션 */}
    <div className="issue-report-section">
        <h3>📊 이슈 리포트</h3>
        <div className="report-actions">
            <button onClick={generateIssueReport}>📋 이슈 리포트 생성</button>
            <button onClick={exportIssuesToExcel}>📊 Excel 내보내기</button>
        </div>
    </div>
</div>

🔌 API 엔드포인트

Backend 라우터 구조

backend/app/routers/
├── pipe_cutting_plan.py      # Cutting Plan 관리
├── pipe_revision.py           # 리비전 비교 및 관리
└── pipe_issue_management.py   # 이슈 관리

1. Cutting Plan API

# backend/app/routers/pipe_cutting_plan.py

@router.get("/pipe-cutting-plan/drawings/{job_no}")
async def get_available_drawings(job_no: str):
    """사용 가능한 도면 목록 조회"""
    pass

@router.post("/pipe-cutting-plan/area-assignment")
async def save_area_assignment(assignment_data: AreaAssignmentRequest):
    """구역별 도면 할당 저장"""
    pass

@router.get("/pipe-cutting-plan/segments/{job_no}/{area}/{drawing}")
async def get_pipe_segments(job_no: str, area: str, drawing: str):
    """특정 구역/도면의 단관 정보 조회"""
    pass

@router.post("/pipe-cutting-plan/segments")
async def save_pipe_segments(segments_data: PipeSegmentsRequest):
    """단관 정보 저장"""
    pass

@router.get("/pipe-cutting-plan/export/{job_no}")
async def export_cutting_plan_excel(job_no: str):
    """Cutting Plan Excel 내보내기"""
    pass

2. Revision API

# backend/app/routers/pipe_revision.py

@router.post("/pipe-revision/compare")
async def compare_pipe_revisions(comparison_request: PipeRevisionComparisonRequest):
    """파이프 리비전 비교"""
    pass

@router.get("/pipe-revision/comparison/{comparison_id}")
async def get_revision_comparison(comparison_id: int):
    """리비전 비교 결과 조회"""
    pass

@router.post("/pipe-revision/apply-changes")
async def apply_revision_changes(changes_data: RevisionChangesRequest):
    """리비전 변경사항 적용"""
    pass

@router.get("/pipe-revision/purchase-impact/{comparison_id}")
async def get_purchase_impact(comparison_id: int):
    """구매량 영향 분석"""
    pass

3. Issue Management API

# backend/app/routers/pipe_issue_management.py

@router.get("/pipe-issues/drawings/{job_no}/{area}")
async def get_drawing_issues(job_no: str, area: str):
    """도면 이슈 목록 조회"""
    pass

@router.post("/pipe-issues/drawing")
async def create_drawing_issue(issue_data: DrawingIssueRequest):
    """도면 이슈 생성"""
    pass

@router.get("/pipe-issues/segments/{job_no}/{area}/{drawing}")
async def get_segment_issues(job_no: str, area: str, drawing: str):
    """단관 이슈 목록 조회"""
    pass

@router.post("/pipe-issues/segment")
async def create_segment_issue(issue_data: SegmentIssueRequest):
    """단관 이슈 생성"""
    pass

@router.get("/pipe-issues/report/{job_no}")
async def generate_issue_report(job_no: str):
    """이슈 리포트 생성"""
    pass

⚙️ 리비전 관리 로직

리비전 규칙

1. Cutting Plan 작성 전 리비전

def handle_pre_cutting_plan_revision(job_no: str, new_file_id: int):
    """
    Cutting Plan 작성 버튼을 누르기 전 리비전 처리
    - 기존 단관 정보 전체 삭제
    - 새 BOM 파일 업로드 필수
    """
    # 기존 단관 정보 삭제
    delete_existing_pipe_segments(job_no)
    
    # 새 BOM 데이터 추출 및 저장
    extract_and_save_pipe_data(new_file_id)
    
    return {
        "status": "pre_cutting_plan_revision",
        "message": "기존 단관 정보가 삭제되었습니다. 새로운 Cutting Plan을 작성해주세요.",
        "requires_new_cutting_plan": True
    }

2. Cutting Plan 작성 후 리비전

def handle_post_cutting_plan_revision(job_no: str, new_file_id: int):
    """
    Cutting Plan 저장 후 리비전 처리
    - 기존 저장된 데이터와 신규 BOM 비교
    - 도면별 변경사항 분석
    """
    # 기존 Cutting Plan 조회
    existing_plan = get_existing_cutting_plan(job_no)
    
    # 신규 BOM에서 파이프 데이터 추출
    new_pipe_data = extract_pipe_data_from_bom(new_file_id)
    
    # 도면별 비교 수행
    comparison_result = compare_pipe_data_by_drawing(existing_plan, new_pipe_data)
    
    return {
        "status": "post_cutting_plan_revision",
        "comparison_id": save_comparison_result(comparison_result),
        "changed_drawings": comparison_result.changed_drawings,
        "requires_review": True
    }

3. 단관 비교 로직

def compare_pipe_segments(existing_segments, new_segments):
    """
    단관별 상세 비교
    - 도면명, 길이, 재질, 끝단가공 정보 비교
    - 변경 유형 분류: added, removed, modified, unchanged
    """
    comparison_results = []
    
    for drawing_name in set(existing_segments.keys()) | set(new_segments.keys()):
        existing = existing_segments.get(drawing_name, [])
        new = new_segments.get(drawing_name, [])
        
        # 단관별 매칭 및 비교
        matched_pairs, added, removed = match_segments(existing, new)
        
        for old_segment, new_segment in matched_pairs:
            if segments_are_identical(old_segment, new_segment):
                comparison_results.append({
                    "drawing_name": drawing_name,
                    "change_type": "unchanged",
                    "segment_data": new_segment
                })
            else:
                comparison_results.append({
                    "drawing_name": drawing_name,
                    "change_type": "modified",
                    "old_data": old_segment,
                    "new_data": new_segment,
                    "changes": get_segment_changes(old_segment, new_segment)
                })
        
        # 추가된 단관
        for segment in added:
            comparison_results.append({
                "drawing_name": drawing_name,
                "change_type": "added",
                "segment_data": segment
            })
        
        # 삭제된 단관
        for segment in removed:
            comparison_results.append({
                "drawing_name": drawing_name,
                "change_type": "removed",
                "segment_data": segment
            })
    
    return comparison_results

def segments_are_identical(segment1, segment2):
    """단관 동일성 검사"""
    return (
        segment1.material_grade == segment2.material_grade and
        segment1.schedule_spec == segment2.schedule_spec and
        segment1.nominal_size == segment2.nominal_size and
        segment1.length_mm == segment2.length_mm and
        segment1.end_preparation == segment2.end_preparation
    )

4. 구매량 재계산

def recalculate_pipe_purchase_requirements(comparison_id: int):
    """
    리비전 후 파이프 구매량 재계산
    - 확정된 단관 정보로부터 총 길이 계산
    - 기존 구매 신청량과 비교
    - 추가 구매 필요량 산출
    """
    # 확정된 단관 정보 조회
    confirmed_segments = get_confirmed_segments_from_comparison(comparison_id)
    
    # 재질별 총 길이 계산
    material_requirements = calculate_total_length_by_material(confirmed_segments)
    
    # 기존 PIPE BOM 페이지의 구매 신청 정보 조회
    existing_purchase_requests = get_existing_pipe_purchase_requests(job_no)
    
    # 추가 구매 필요량 계산
    additional_requirements = []
    for material, required_length in material_requirements.items():
        existing_request = existing_purchase_requests.get(material, 0)
        if required_length > existing_request:
            additional_requirements.append({
                "material": material,
                "existing_request": existing_request,
                "total_required": required_length,
                "additional_needed": required_length - existing_request
            })
    
    return additional_requirements

📅 개발 우선순위

Phase 1: 기본 Cutting Plan 시스템 (2주)

  1. PipeCuttingPlanPage.jsx 기본 구조 생성
  2. 🔄 2단계 워크플로우 구현
    • 1단계: 구역별 도면 할당
    • 2단계: 라인번호 입력
    • 3단계: 확인 및 저장
  3. 📊 Excel 내보내기 기능
  4. 🗄️ 기본 DB 테이블 생성 및 마이그레이션

Phase 2: 리비전 비교 시스템 (3주)

  1. 🔄 리비전 감지 및 비교 로직
  2. 📋 4단계: 리비전 비교 페이지
  3. 📊 5단계: 변경 도면 요약 페이지
  4. 💰 구매량 재계산 기능
  5. 🔗 PIPE 리비전 페이지 연동

Phase 3: 이슈 관리 시스템 (2주)

  1. 🛠️ 단관 이슈 관리 페이지
  2. 📋 도면별/단관별 이슈 입력
  3. 📊 이슈 리포트 및 Excel 내보내기
  4. 🔍 이슈 검색 및 필터링

Phase 4: 고도화 및 최적화 (1주)

  1. 🎨 UI/UX 개선
  2. 성능 최적화
  3. 🧪 테스트 코드 작성
  4. 📚 사용자 매뉴얼 작성

🛠️ 기술 스택

Frontend

  • React 18: 컴포넌트 기반 UI
  • CSS Modules: 스타일 관리
  • Axios: API 통신
  • React Hooks: 상태 관리

Backend

  • FastAPI: REST API 서버
  • SQLAlchemy: ORM
  • PostgreSQL: 데이터베이스
  • Pandas: Excel 처리

Database

  • PostgreSQL 14+: 메인 데이터베이스
  • Redis: 캐시 (선택사항)

DevOps

  • Docker: 컨테이너화
  • Docker Compose: 멀티 컨테이너 관리

📝 개발 체크리스트

데이터베이스

  • pipe_cutting_plans 테이블 생성
  • pipe_revision_comparisons 테이블 생성
  • pipe_revision_changes 테이블 생성
  • pipe_drawing_issues 테이블 생성
  • pipe_segment_issues 테이블 생성
  • 인덱스 및 외래키 설정
  • 마이그레이션 스크립트 작성

Backend API

  • pipe_cutting_plan.py 라우터 구현
  • pipe_revision.py 라우터 구현
  • pipe_issue_management.py 라우터 구현
  • 데이터 추출 서비스 구현
  • Excel 내보내기 서비스 구현
  • 리비전 비교 알고리즘 구현

Frontend Pages

  • PipeCuttingPlanPage.jsx 2단계 워크플로우
  • PipeRevisionComparisonPage.jsx 구현
  • PipeIssueManagementPage.jsx 구현
  • 공통 컴포넌트 개발
  • CSS 스타일링

통합 테스트

  • 전체 워크플로우 테스트
  • 리비전 시나리오 테스트
  • Excel 내보내기 테스트
  • 이슈 관리 기능 테스트

🎯 성공 지표

  1. 사용성: 현장 작업자가 직관적으로 사용 가능
  2. 정확성: 리비전 비교 결과의 100% 정확성
  3. 효율성: 기존 수작업 대비 80% 시간 단축
  4. 추적성: 모든 변경사항의 완전한 이력 관리
  5. 확장성: 향후 추가 기능 개발 용이성

이 가이드를 바탕으로 체계적이고 실용적인 PIPE 관리 시스템을 구축할 수 있습니다! 🚀