feat: PIPE 분석 기능 개선 및 자재 확인 페이지 UX 향상
- 자재 확인 페이지에 뒤로가기 버튼 추가 - 상세 목록 탭에 PIPE 분석 섹션 추가 - 재질-외경-스케줄-제작방식별로 그룹화 - 동일 속성 파이프들의 길이 합산 표시 - 총 파이프 길이 및 규격 종류 수 요약 - 파일 삭제 기능 수정 (외래키 제약 조건 해결) - MaterialsPage에서 전체 자재 목록 표시 (limit 10000) - 길이 단위 변환 로직 수정 (mm 단위 유지) - 파싱 로직에 디버그 출력 추가 TODO: MAIN_NOM/RED_NOM 별도 저장을 위한 스키마 개선 필요
This commit is contained in:
@@ -497,6 +497,180 @@ async def upload_file(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"VALVE 상세정보 저장 실패: {e}")
|
print(f"VALVE 상세정보 저장 실패: {e}")
|
||||||
|
|
||||||
|
elif category == 'FLANGE' and confidence >= 0.5:
|
||||||
|
try:
|
||||||
|
flange_info = classification_result
|
||||||
|
|
||||||
|
flange_insert_query = text("""
|
||||||
|
INSERT INTO flange_details (
|
||||||
|
material_id, file_id, flange_type, facing_type,
|
||||||
|
pressure_rating, material_standard, material_grade,
|
||||||
|
size_inches, classification_confidence, additional_info
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
|
||||||
|
:file_id, :flange_type, :facing_type,
|
||||||
|
:pressure_rating, :material_standard, :material_grade,
|
||||||
|
:size_inches, :classification_confidence, :additional_info
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
db.execute(flange_insert_query, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"description": material_data["original_description"],
|
||||||
|
"row_number": material_data["row_number"],
|
||||||
|
"flange_type": flange_info.get('flange_type', {}).get('type', ''),
|
||||||
|
"facing_type": flange_info.get('face_finish', {}).get('finish', ''),
|
||||||
|
"pressure_rating": flange_info.get('pressure_rating', {}).get('rating', ''),
|
||||||
|
"material_standard": flange_info.get('material', {}).get('standard', ''),
|
||||||
|
"material_grade": flange_info.get('material', {}).get('grade', ''),
|
||||||
|
"size_inches": material_data.get('size_spec', ''),
|
||||||
|
"classification_confidence": confidence,
|
||||||
|
"additional_info": json.dumps(flange_info, ensure_ascii=False)
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"FLANGE 상세정보 저장 완료: {material_data['original_description']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FLANGE 상세정보 저장 실패: {e}")
|
||||||
|
|
||||||
|
elif category == 'BOLT' and confidence >= 0.5:
|
||||||
|
try:
|
||||||
|
bolt_info = classification_result
|
||||||
|
|
||||||
|
bolt_insert_query = text("""
|
||||||
|
INSERT INTO bolt_details (
|
||||||
|
material_id, file_id, bolt_type, thread_type,
|
||||||
|
diameter, length, material_standard, material_grade,
|
||||||
|
coating_type, includes_nut, includes_washer,
|
||||||
|
classification_confidence, additional_info
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
|
||||||
|
:file_id, :bolt_type, :thread_type,
|
||||||
|
:diameter, :length, :material_standard, :material_grade,
|
||||||
|
:coating_type, :includes_nut, :includes_washer,
|
||||||
|
:classification_confidence, :additional_info
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# BOLT 분류기 결과 구조에 맞게 데이터 추출
|
||||||
|
bolt_details = bolt_info.get('bolt_details', {})
|
||||||
|
material_info = bolt_info.get('material', {})
|
||||||
|
|
||||||
|
db.execute(bolt_insert_query, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"description": material_data["original_description"],
|
||||||
|
"row_number": material_data["row_number"],
|
||||||
|
"bolt_type": bolt_details.get('type', ''),
|
||||||
|
"thread_type": bolt_details.get('thread_type', ''),
|
||||||
|
"diameter": bolt_details.get('diameter', ''),
|
||||||
|
"length": bolt_details.get('length', ''),
|
||||||
|
"material_standard": material_info.get('standard', ''),
|
||||||
|
"material_grade": material_info.get('grade', ''),
|
||||||
|
"coating_type": material_info.get('coating', ''),
|
||||||
|
"includes_nut": bolt_details.get('includes_nut', False),
|
||||||
|
"includes_washer": bolt_details.get('includes_washer', False),
|
||||||
|
"classification_confidence": confidence,
|
||||||
|
"additional_info": json.dumps(bolt_info, ensure_ascii=False)
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"BOLT 상세정보 저장 완료: {material_data['original_description']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"BOLT 상세정보 저장 실패: {e}")
|
||||||
|
|
||||||
|
elif category == 'GASKET' and confidence >= 0.5:
|
||||||
|
try:
|
||||||
|
gasket_info = classification_result
|
||||||
|
|
||||||
|
gasket_insert_query = text("""
|
||||||
|
INSERT INTO gasket_details (
|
||||||
|
material_id, file_id, gasket_type, gasket_subtype,
|
||||||
|
material_type, size_inches, pressure_rating,
|
||||||
|
thickness, temperature_range, fire_safe,
|
||||||
|
classification_confidence, additional_info
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
|
||||||
|
:file_id, :gasket_type, :gasket_subtype,
|
||||||
|
:material_type, :size_inches, :pressure_rating,
|
||||||
|
:thickness, :temperature_range, :fire_safe,
|
||||||
|
:classification_confidence, :additional_info
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# GASKET 분류기 결과 구조에 맞게 데이터 추출
|
||||||
|
gasket_type_info = gasket_info.get('gasket_type', {})
|
||||||
|
material_info = gasket_info.get('material', {})
|
||||||
|
|
||||||
|
db.execute(gasket_insert_query, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"description": material_data["original_description"],
|
||||||
|
"row_number": material_data["row_number"],
|
||||||
|
"gasket_type": gasket_type_info.get('type', ''),
|
||||||
|
"gasket_subtype": gasket_type_info.get('subtype', ''),
|
||||||
|
"material_type": material_info.get('type', ''),
|
||||||
|
"size_inches": material_data.get('size_spec', ''),
|
||||||
|
"pressure_rating": gasket_info.get('pressure_rating', ''),
|
||||||
|
"thickness": gasket_info.get('thickness', ''),
|
||||||
|
"temperature_range": material_info.get('temperature_range', ''),
|
||||||
|
"fire_safe": gasket_info.get('fire_safe', False),
|
||||||
|
"classification_confidence": confidence,
|
||||||
|
"additional_info": json.dumps(gasket_info, ensure_ascii=False)
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"GASKET 상세정보 저장 완료: {material_data['original_description']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"GASKET 상세정보 저장 실패: {e}")
|
||||||
|
|
||||||
|
elif category == 'INSTRUMENT' and confidence >= 0.5:
|
||||||
|
try:
|
||||||
|
inst_info = classification_result
|
||||||
|
|
||||||
|
inst_insert_query = text("""
|
||||||
|
INSERT INTO instrument_details (
|
||||||
|
material_id, file_id, instrument_type, instrument_subtype,
|
||||||
|
measurement_type, measurement_range, accuracy,
|
||||||
|
connection_type, connection_size, body_material,
|
||||||
|
classification_confidence, additional_info
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
|
||||||
|
:file_id, :instrument_type, :instrument_subtype,
|
||||||
|
:measurement_type, :measurement_range, :accuracy,
|
||||||
|
:connection_type, :connection_size, :body_material,
|
||||||
|
:classification_confidence, :additional_info
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# INSTRUMENT 분류기 결과 구조에 맞게 데이터 추출
|
||||||
|
inst_type_info = inst_info.get('instrument_type', {})
|
||||||
|
measurement_info = inst_info.get('measurement', {})
|
||||||
|
connection_info = inst_info.get('connection', {})
|
||||||
|
|
||||||
|
db.execute(inst_insert_query, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"description": material_data["original_description"],
|
||||||
|
"row_number": material_data["row_number"],
|
||||||
|
"instrument_type": inst_type_info.get('type', ''),
|
||||||
|
"instrument_subtype": inst_type_info.get('subtype', ''),
|
||||||
|
"measurement_type": measurement_info.get('type', ''),
|
||||||
|
"measurement_range": measurement_info.get('range', ''),
|
||||||
|
"accuracy": measurement_info.get('accuracy', ''),
|
||||||
|
"connection_type": connection_info.get('type', ''),
|
||||||
|
"connection_size": connection_info.get('size', ''),
|
||||||
|
"body_material": inst_info.get('material', ''),
|
||||||
|
"classification_confidence": confidence,
|
||||||
|
"additional_info": json.dumps(inst_info, ensure_ascii=False)
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"INSTRUMENT 상세정보 저장 완료: {material_data['original_description']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"INSTRUMENT 상세정보 저장 실패: {e}")
|
||||||
|
|
||||||
materials_inserted += 1
|
materials_inserted += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async def get_files(
|
|||||||
"original_filename": f.original_filename,
|
"original_filename": f.original_filename,
|
||||||
"name": f.original_filename,
|
"name": f.original_filename,
|
||||||
"job_no": f.job_no, # job_no 사용
|
"job_no": f.job_no, # job_no 사용
|
||||||
"bom_name": f.original_filename, # 파일명을 BOM 이름으로 사용
|
"bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명
|
||||||
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
|
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
|
||||||
"status": "active" if f.is_active else "inactive", # is_active 상태
|
"status": "active" if f.is_active else "inactive", # is_active 상태
|
||||||
"file_size": f.file_size,
|
"file_size": f.file_size,
|
||||||
@@ -118,7 +118,26 @@ async def delete_file(
|
|||||||
if not file:
|
if not file:
|
||||||
return {"error": "파일을 찾을 수 없습니다"}
|
return {"error": "파일을 찾을 수 없습니다"}
|
||||||
|
|
||||||
# 관련 자재 데이터 삭제
|
# 먼저 상세 테이블의 데이터 삭제 (외래 키 제약 조건 때문)
|
||||||
|
# 각 자재 타입별 상세 테이블 데이터 삭제
|
||||||
|
detail_tables = [
|
||||||
|
'pipe_details', 'fitting_details', 'valve_details',
|
||||||
|
'flange_details', 'bolt_details', 'gasket_details',
|
||||||
|
'instrument_details'
|
||||||
|
]
|
||||||
|
|
||||||
|
# 해당 파일의 materials ID 조회
|
||||||
|
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
|
||||||
|
material_ids_result = db.execute(material_ids_query, {"file_id": file_id})
|
||||||
|
material_ids = [row[0] for row in material_ids_result]
|
||||||
|
|
||||||
|
if material_ids:
|
||||||
|
# 각 상세 테이블에서 관련 데이터 삭제
|
||||||
|
for table in detail_tables:
|
||||||
|
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
|
||||||
|
db.execute(delete_detail_query, {"material_ids": material_ids})
|
||||||
|
|
||||||
|
# materials 테이블 데이터 삭제
|
||||||
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
|
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
|
||||||
db.execute(materials_query, {"file_id": file_id})
|
db.execute(materials_query, {"file_id": file_id})
|
||||||
|
|
||||||
@@ -267,17 +286,17 @@ async def upload_file(
|
|||||||
file_type = "excel" if file.filename.endswith(('.xls', '.xlsx')) else "csv" if file.filename.endswith('.csv') else "unknown"
|
file_type = "excel" if file.filename.endswith(('.xls', '.xlsx')) else "csv" if file.filename.endswith('.csv') else "unknown"
|
||||||
|
|
||||||
# BOM 종류별 자동 리비전 관리
|
# BOM 종류별 자동 리비전 관리
|
||||||
if bom_type and not parent_bom_id:
|
if bom_name and not parent_bom_id:
|
||||||
# 같은 job_no의 같은 파일명에 대한 최신 리비전 조회
|
# 같은 job_no의 같은 BOM 이름에 대한 최신 리비전 조회
|
||||||
latest_revision_query = text("""
|
latest_revision_query = text("""
|
||||||
SELECT revision FROM files
|
SELECT revision FROM files
|
||||||
WHERE job_no = :job_no AND original_filename = :filename
|
WHERE job_no = :job_no AND bom_name = :bom_name
|
||||||
ORDER BY revision DESC LIMIT 1
|
ORDER BY revision DESC LIMIT 1
|
||||||
""")
|
""")
|
||||||
|
|
||||||
result = db.execute(latest_revision_query, {
|
result = db.execute(latest_revision_query, {
|
||||||
"job_no": job_no,
|
"job_no": job_no,
|
||||||
"filename": file.filename
|
"bom_name": bom_name
|
||||||
})
|
})
|
||||||
|
|
||||||
latest_file = result.fetchone()
|
latest_file = result.fetchone()
|
||||||
@@ -300,10 +319,10 @@ async def upload_file(
|
|||||||
insert_query = text("""
|
insert_query = text("""
|
||||||
INSERT INTO files (
|
INSERT INTO files (
|
||||||
job_no, filename, original_filename, file_path,
|
job_no, filename, original_filename, file_path,
|
||||||
file_size, upload_date, revision, file_type, uploaded_by
|
file_size, upload_date, revision, file_type, uploaded_by, bom_name
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:job_no, :filename, :original_filename, :file_path,
|
:job_no, :filename, :original_filename, :file_path,
|
||||||
:file_size, NOW(), :revision, :file_type, :uploaded_by
|
:file_size, NOW(), :revision, :file_type, :uploaded_by, :bom_name
|
||||||
) RETURNING id
|
) RETURNING id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -315,7 +334,8 @@ async def upload_file(
|
|||||||
"file_size": file_size,
|
"file_size": file_size,
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
"file_type": file_type,
|
"file_type": file_type,
|
||||||
"uploaded_by": "system"
|
"uploaded_by": "system",
|
||||||
|
"bom_name": bom_name
|
||||||
})
|
})
|
||||||
|
|
||||||
file_id = result.fetchone()[0]
|
file_id = result.fetchone()[0]
|
||||||
@@ -372,9 +392,10 @@ async def upload_file(
|
|||||||
:quantity, :unit, :drawing_name, :area_code, :line_no,
|
:quantity, :unit, :drawing_name, :area_code, :line_no,
|
||||||
:classification_confidence, :is_verified, NOW()
|
:classification_confidence, :is_verified, NOW()
|
||||||
)
|
)
|
||||||
|
RETURNING id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
db.execute(insert_material_query, {
|
result = db.execute(insert_material_query, {
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"line_number": material.get("line_number", 0),
|
"line_number": material.get("line_number", 0),
|
||||||
"original_description": material.get("original_description", ""),
|
"original_description": material.get("original_description", ""),
|
||||||
@@ -391,6 +412,191 @@ async def upload_file(
|
|||||||
"classification_confidence": material.get("classification_confidence", 0.0),
|
"classification_confidence": material.get("classification_confidence", 0.0),
|
||||||
"is_verified": False
|
"is_verified": False
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 저장된 material의 ID 가져오기
|
||||||
|
material_id = result.fetchone()[0]
|
||||||
|
|
||||||
|
# 카테고리별 상세 정보 저장
|
||||||
|
category = material.get("classified_category", "")
|
||||||
|
|
||||||
|
if category == "PIPE" and "pipe_details" in material:
|
||||||
|
pipe_details = material["pipe_details"]
|
||||||
|
pipe_insert_query = text("""
|
||||||
|
INSERT INTO pipe_details (
|
||||||
|
material_id, file_id, nominal_size, schedule,
|
||||||
|
material_standard, material_grade, material_type,
|
||||||
|
manufacturing_method, length_mm
|
||||||
|
) VALUES (
|
||||||
|
:material_id, :file_id, :nominal_size, :schedule,
|
||||||
|
:material_standard, :material_grade, :material_type,
|
||||||
|
:manufacturing_method, :length_mm
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute(pipe_insert_query, {
|
||||||
|
"material_id": material_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"nominal_size": material.get("size_spec", ""),
|
||||||
|
"schedule": pipe_details.get("schedule", material.get("schedule", "")),
|
||||||
|
"material_standard": pipe_details.get("material_spec", material.get("material_grade", "")),
|
||||||
|
"material_grade": material.get("material_grade", ""),
|
||||||
|
"material_type": material.get("material_grade", "").split("-")[0] if material.get("material_grade", "") else "",
|
||||||
|
"manufacturing_method": pipe_details.get("manufacturing_method", ""),
|
||||||
|
"length_mm": material.get("length", 0.0) if material.get("length", 0.0) else 0.0 # 이미 mm 단위임
|
||||||
|
})
|
||||||
|
|
||||||
|
elif category == "FITTING" and "fitting_details" in material:
|
||||||
|
fitting_details = material["fitting_details"]
|
||||||
|
fitting_insert_query = text("""
|
||||||
|
INSERT INTO fitting_details (
|
||||||
|
material_id, file_id, fitting_type, fitting_subtype,
|
||||||
|
connection_method, pressure_rating, material_standard,
|
||||||
|
material_grade, main_size, reduced_size
|
||||||
|
) VALUES (
|
||||||
|
:material_id, :file_id, :fitting_type, :fitting_subtype,
|
||||||
|
:connection_method, :pressure_rating, :material_standard,
|
||||||
|
:material_grade, :main_size, :reduced_size
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute(fitting_insert_query, {
|
||||||
|
"material_id": material_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"fitting_type": fitting_details.get("fitting_type", ""),
|
||||||
|
"fitting_subtype": fitting_details.get("fitting_subtype", ""),
|
||||||
|
"connection_method": fitting_details.get("connection_method", ""),
|
||||||
|
"pressure_rating": fitting_details.get("pressure_rating", ""),
|
||||||
|
"material_standard": fitting_details.get("material_standard", material.get("material_grade", "")),
|
||||||
|
"material_grade": fitting_details.get("material_grade", material.get("material_grade", "")),
|
||||||
|
"main_size": material.get("size_spec", ""),
|
||||||
|
"reduced_size": fitting_details.get("reduced_size", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
elif category == "VALVE" and "valve_details" in material:
|
||||||
|
valve_details = material["valve_details"]
|
||||||
|
valve_insert_query = text("""
|
||||||
|
INSERT INTO valve_details (
|
||||||
|
material_id, file_id, valve_type, valve_subtype,
|
||||||
|
actuator_type, connection_method, pressure_rating,
|
||||||
|
body_material, size_inches
|
||||||
|
) VALUES (
|
||||||
|
:material_id, :file_id, :valve_type, :valve_subtype,
|
||||||
|
:actuator_type, :connection_method, :pressure_rating,
|
||||||
|
:body_material, :size_inches
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute(valve_insert_query, {
|
||||||
|
"material_id": material_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"valve_type": valve_details.get("valve_type", ""),
|
||||||
|
"valve_subtype": valve_details.get("valve_subtype", ""),
|
||||||
|
"actuator_type": valve_details.get("actuator_type", "MANUAL"),
|
||||||
|
"connection_method": valve_details.get("connection_method", ""),
|
||||||
|
"pressure_rating": valve_details.get("pressure_rating", ""),
|
||||||
|
"body_material": material.get("material_grade", ""),
|
||||||
|
"size_inches": material.get("size_spec", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
elif category == "FLANGE" and "flange_details" in material:
|
||||||
|
flange_details = material["flange_details"]
|
||||||
|
flange_insert_query = text("""
|
||||||
|
INSERT INTO flange_details (
|
||||||
|
material_id, file_id, flange_type, flange_subtype,
|
||||||
|
facing_type, pressure_rating, material_standard,
|
||||||
|
material_grade, size_inches
|
||||||
|
) VALUES (
|
||||||
|
:material_id, :file_id, :flange_type, :flange_subtype,
|
||||||
|
:facing_type, :pressure_rating, :material_standard,
|
||||||
|
:material_grade, :size_inches
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute(flange_insert_query, {
|
||||||
|
"material_id": material_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"flange_type": flange_details.get("flange_type", ""),
|
||||||
|
"flange_subtype": flange_details.get("flange_subtype", ""),
|
||||||
|
"facing_type": flange_details.get("facing_type", ""),
|
||||||
|
"pressure_rating": flange_details.get("pressure_rating", ""),
|
||||||
|
"material_standard": material.get("material_grade", ""),
|
||||||
|
"material_grade": material.get("material_grade", ""),
|
||||||
|
"size_inches": material.get("size_spec", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
elif category == "BOLT" and "bolt_details" in material:
|
||||||
|
bolt_details = material["bolt_details"]
|
||||||
|
bolt_insert_query = text("""
|
||||||
|
INSERT INTO bolt_details (
|
||||||
|
material_id, file_id, bolt_type, bolt_subtype,
|
||||||
|
thread_standard, diameter, length, thread_pitch,
|
||||||
|
material_standard, material_grade, coating
|
||||||
|
) VALUES (
|
||||||
|
:material_id, :file_id, :bolt_type, :bolt_subtype,
|
||||||
|
:thread_standard, :diameter, :length, :thread_pitch,
|
||||||
|
:material_standard, :material_grade, :coating
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute(bolt_insert_query, {
|
||||||
|
"material_id": material_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"bolt_type": bolt_details.get("bolt_type", ""),
|
||||||
|
"bolt_subtype": bolt_details.get("bolt_subtype", ""),
|
||||||
|
"thread_standard": bolt_details.get("thread_standard", ""),
|
||||||
|
"diameter": material.get("size_spec", ""),
|
||||||
|
"length": bolt_details.get("length", ""),
|
||||||
|
"thread_pitch": bolt_details.get("thread_pitch", ""),
|
||||||
|
"material_standard": material.get("material_grade", ""),
|
||||||
|
"material_grade": material.get("material_grade", ""),
|
||||||
|
"coating": bolt_details.get("coating", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
elif category == "GASKET" and "gasket_details" in material:
|
||||||
|
gasket_details = material["gasket_details"]
|
||||||
|
gasket_insert_query = text("""
|
||||||
|
INSERT INTO gasket_details (
|
||||||
|
material_id, file_id, gasket_type, gasket_material,
|
||||||
|
flange_size, pressure_rating, temperature_range,
|
||||||
|
thickness, inner_diameter, outer_diameter
|
||||||
|
) VALUES (
|
||||||
|
:material_id, :file_id, :gasket_type, :gasket_material,
|
||||||
|
:flange_size, :pressure_rating, :temperature_range,
|
||||||
|
:thickness, :inner_diameter, :outer_diameter
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute(gasket_insert_query, {
|
||||||
|
"material_id": material_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"gasket_type": gasket_details.get("gasket_type", ""),
|
||||||
|
"gasket_material": gasket_details.get("gasket_material", ""),
|
||||||
|
"flange_size": material.get("size_spec", ""),
|
||||||
|
"pressure_rating": gasket_details.get("pressure_rating", ""),
|
||||||
|
"temperature_range": gasket_details.get("temperature_range", ""),
|
||||||
|
"thickness": gasket_details.get("thickness", ""),
|
||||||
|
"inner_diameter": gasket_details.get("inner_diameter", ""),
|
||||||
|
"outer_diameter": gasket_details.get("outer_diameter", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
elif category == "INSTRUMENT" and "instrument_details" in material:
|
||||||
|
instrument_details = material["instrument_details"]
|
||||||
|
instrument_insert_query = text("""
|
||||||
|
INSERT INTO instrument_details (
|
||||||
|
material_id, file_id, instrument_type, measurement_type,
|
||||||
|
measurement_range, output_signal, connection_size,
|
||||||
|
process_connection, accuracy_class
|
||||||
|
) VALUES (
|
||||||
|
:material_id, :file_id, :instrument_type, :measurement_type,
|
||||||
|
:measurement_range, :output_signal, :connection_size,
|
||||||
|
:process_connection, :accuracy_class
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute(instrument_insert_query, {
|
||||||
|
"material_id": material_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"instrument_type": instrument_details.get("instrument_type", ""),
|
||||||
|
"measurement_type": instrument_details.get("measurement_type", ""),
|
||||||
|
"measurement_range": instrument_details.get("measurement_range", ""),
|
||||||
|
"output_signal": instrument_details.get("output_signal", ""),
|
||||||
|
"connection_size": material.get("size_spec", ""),
|
||||||
|
"process_connection": instrument_details.get("process_connection", ""),
|
||||||
|
"accuracy_class": instrument_details.get("accuracy_class", "")
|
||||||
|
})
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -442,6 +648,7 @@ def parse_file(file_path: str) -> List[Dict]:
|
|||||||
'quantity': ['QTY', 'Quantity', 'quantity', 'QTY.', 'Qty', 'qty', 'AMOUNT', 'Amount', 'amount'],
|
'quantity': ['QTY', 'Quantity', 'quantity', 'QTY.', 'Qty', 'qty', 'AMOUNT', 'Amount', 'amount'],
|
||||||
'length': ['LENGTH', 'Length', 'length', 'LG', 'Lg', 'lg', 'LENGTH_MM', 'Length_mm', 'length_mm'],
|
'length': ['LENGTH', 'Length', 'length', 'LG', 'Lg', 'lg', 'LENGTH_MM', 'Length_mm', 'length_mm'],
|
||||||
'unit': ['UNIT', 'Unit', 'unit', 'UOM', 'Uom', 'uom'],
|
'unit': ['UNIT', 'Unit', 'unit', 'UOM', 'Uom', 'uom'],
|
||||||
|
'size': ['SIZE', 'Size', 'size', 'NOM_SIZE', 'Nom_Size', 'nom_size', 'MAIN_NOM', 'Main_Nom', 'main_nom'],
|
||||||
'drawing': ['DRAWING', 'Drawing', 'drawing', 'DWG', 'Dwg', 'dwg'],
|
'drawing': ['DRAWING', 'Drawing', 'drawing', 'DWG', 'Dwg', 'dwg'],
|
||||||
'area': ['AREA', 'Area', 'area', 'AREA_CODE', 'Area_Code', 'area_code'],
|
'area': ['AREA', 'Area', 'area', 'AREA_CODE', 'Area_Code', 'area_code'],
|
||||||
'line': ['LINE', 'Line', 'line', 'LINE_NO', 'Line_No', 'line_no', 'PIPELINE', 'Pipeline', 'pipeline']
|
'line': ['LINE', 'Line', 'line', 'LINE_NO', 'Line_No', 'line_no', 'PIPELINE', 'Pipeline', 'pipeline']
|
||||||
@@ -470,6 +677,7 @@ def parse_file(file_path: str) -> List[Dict]:
|
|||||||
length_raw = row.get(found_columns.get('length', 0), 0)
|
length_raw = row.get(found_columns.get('length', 0), 0)
|
||||||
length = float(length_raw) if length_raw is not None else 0.0
|
length = float(length_raw) if length_raw is not None else 0.0
|
||||||
unit = str(row.get(found_columns.get('unit', 'EA'), 'EA') or 'EA')
|
unit = str(row.get(found_columns.get('unit', 'EA'), 'EA') or 'EA')
|
||||||
|
size = str(row.get(found_columns.get('size', ''), '') or '')
|
||||||
drawing = str(row.get(found_columns.get('drawing', ''), '') or '')
|
drawing = str(row.get(found_columns.get('drawing', ''), '') or '')
|
||||||
area = str(row.get(found_columns.get('area', ''), '') or '')
|
area = str(row.get(found_columns.get('area', ''), '') or '')
|
||||||
line = str(row.get(found_columns.get('line', ''), '') or '')
|
line = str(row.get(found_columns.get('line', ''), '') or '')
|
||||||
@@ -480,6 +688,7 @@ def parse_file(file_path: str) -> List[Dict]:
|
|||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
"length": length,
|
"length": length,
|
||||||
"unit": unit,
|
"unit": unit,
|
||||||
|
"size_spec": size,
|
||||||
"drawing_name": drawing,
|
"drawing_name": drawing,
|
||||||
"area_code": area,
|
"area_code": area,
|
||||||
"line_no": line
|
"line_no": line
|
||||||
@@ -590,6 +799,92 @@ def classify_material_item(material: Dict) -> Dict:
|
|||||||
"length": length # 길이 정보 추가
|
"length": length # 길이 정보 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 카테고리별 상세 정보 추가
|
||||||
|
category = classification_result.get("category", "")
|
||||||
|
|
||||||
|
if category == "PIPE":
|
||||||
|
# PIPE 상세 정보 추출
|
||||||
|
final_result["pipe_details"] = {
|
||||||
|
"size_inches": size_spec,
|
||||||
|
"schedule": classification_result.get("schedule", {}).get("schedule", ""),
|
||||||
|
"material_spec": classification_result.get("material", {}).get("standard", ""),
|
||||||
|
"manufacturing_method": classification_result.get("manufacturing", {}).get("method", ""),
|
||||||
|
"length_mm": length * 1000 if length else 0, # meter to mm
|
||||||
|
"outer_diameter_mm": 0.0, # 추후 계산
|
||||||
|
"wall_thickness_mm": 0.0, # 추후 계산
|
||||||
|
"weight_per_meter_kg": 0.0 # 추후 계산
|
||||||
|
}
|
||||||
|
elif category == "FITTING":
|
||||||
|
# FITTING 상세 정보 추출
|
||||||
|
final_result["fitting_details"] = {
|
||||||
|
"fitting_type": classification_result.get("fitting_type", {}).get("type", ""),
|
||||||
|
"fitting_subtype": classification_result.get("fitting_type", {}).get("subtype", ""),
|
||||||
|
"connection_method": classification_result.get("connection_method", {}).get("method", ""),
|
||||||
|
"pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""),
|
||||||
|
"material_standard": classification_result.get("material", {}).get("standard", ""),
|
||||||
|
"material_grade": classification_result.get("material", {}).get("grade", ""),
|
||||||
|
"main_size": size_spec,
|
||||||
|
"reduced_size": ""
|
||||||
|
}
|
||||||
|
elif category == "VALVE":
|
||||||
|
# VALVE 상세 정보 추출
|
||||||
|
final_result["valve_details"] = {
|
||||||
|
"valve_type": classification_result.get("valve_type", {}).get("type", ""),
|
||||||
|
"valve_subtype": classification_result.get("valve_type", {}).get("subtype", ""),
|
||||||
|
"actuator_type": classification_result.get("actuation", {}).get("method", "MANUAL"),
|
||||||
|
"connection_method": classification_result.get("connection_method", {}).get("method", ""),
|
||||||
|
"pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""),
|
||||||
|
"body_material": classification_result.get("material", {}).get("grade", ""),
|
||||||
|
"size_inches": size_spec
|
||||||
|
}
|
||||||
|
elif category == "FLANGE":
|
||||||
|
# FLANGE 상세 정보 추출
|
||||||
|
final_result["flange_details"] = {
|
||||||
|
"flange_type": classification_result.get("flange_type", {}).get("type", ""),
|
||||||
|
"flange_subtype": classification_result.get("flange_type", {}).get("subtype", ""),
|
||||||
|
"facing_type": classification_result.get("face_finish", {}).get("finish", ""),
|
||||||
|
"pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""),
|
||||||
|
"material_standard": classification_result.get("material", {}).get("standard", ""),
|
||||||
|
"material_grade": classification_result.get("material", {}).get("grade", ""),
|
||||||
|
"size_inches": size_spec
|
||||||
|
}
|
||||||
|
elif category == "BOLT":
|
||||||
|
# BOLT 상세 정보 추출
|
||||||
|
final_result["bolt_details"] = {
|
||||||
|
"bolt_type": classification_result.get("fastener_type", {}).get("type", ""),
|
||||||
|
"bolt_subtype": classification_result.get("fastener_type", {}).get("subtype", ""),
|
||||||
|
"thread_standard": classification_result.get("thread_specification", {}).get("standard", ""),
|
||||||
|
"diameter": classification_result.get("dimensions", {}).get("diameter", size_spec),
|
||||||
|
"length": classification_result.get("dimensions", {}).get("length", ""),
|
||||||
|
"thread_pitch": classification_result.get("thread_specification", {}).get("pitch", ""),
|
||||||
|
"material_standard": classification_result.get("material", {}).get("standard", ""),
|
||||||
|
"material_grade": classification_result.get("material", {}).get("grade", ""),
|
||||||
|
"coating": ""
|
||||||
|
}
|
||||||
|
elif category == "GASKET":
|
||||||
|
# GASKET 상세 정보 추출
|
||||||
|
final_result["gasket_details"] = {
|
||||||
|
"gasket_type": classification_result.get("gasket_type", {}).get("type", ""),
|
||||||
|
"gasket_material": classification_result.get("gasket_material", {}).get("material", ""),
|
||||||
|
"flange_size": size_spec,
|
||||||
|
"pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""),
|
||||||
|
"temperature_range": classification_result.get("gasket_material", {}).get("temperature_range", ""),
|
||||||
|
"thickness": classification_result.get("size_info", {}).get("thickness", ""),
|
||||||
|
"inner_diameter": classification_result.get("size_info", {}).get("inner_diameter", ""),
|
||||||
|
"outer_diameter": classification_result.get("size_info", {}).get("outer_diameter", "")
|
||||||
|
}
|
||||||
|
elif category == "INSTRUMENT":
|
||||||
|
# INSTRUMENT 상세 정보 추출
|
||||||
|
final_result["instrument_details"] = {
|
||||||
|
"instrument_type": classification_result.get("instrument_type", {}).get("type", ""),
|
||||||
|
"measurement_type": "",
|
||||||
|
"measurement_range": classification_result.get("measurement_info", {}).get("range", ""),
|
||||||
|
"output_signal": classification_result.get("measurement_info", {}).get("signal_type", ""),
|
||||||
|
"connection_size": size_spec,
|
||||||
|
"process_connection": "",
|
||||||
|
"accuracy_class": ""
|
||||||
|
}
|
||||||
|
|
||||||
return final_result
|
return final_result
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ def generate_unique_filename(original_filename: str) -> str:
|
|||||||
|
|
||||||
def parse_dataframe(df):
|
def parse_dataframe(df):
|
||||||
df = df.dropna(how='all')
|
df = df.dropna(how='all')
|
||||||
|
# 원본 컬럼명 출력
|
||||||
|
print(f"원본 컬럼들: {list(df.columns)}")
|
||||||
df.columns = df.columns.str.strip().str.lower()
|
df.columns = df.columns.str.strip().str.lower()
|
||||||
|
print(f"소문자 변환 후: {list(df.columns)}")
|
||||||
|
|
||||||
column_mapping = {
|
column_mapping = {
|
||||||
'description': ['description', 'item', 'material', '품명', '자재명'],
|
'description': ['description', 'item', 'material', '품명', '자재명'],
|
||||||
@@ -87,6 +90,8 @@ def parse_dataframe(df):
|
|||||||
mapped_columns[standard_col] = possible_name
|
mapped_columns[standard_col] = possible_name
|
||||||
break
|
break
|
||||||
|
|
||||||
|
print(f"찾은 컬럼 매핑: {mapped_columns}")
|
||||||
|
|
||||||
materials = []
|
materials = []
|
||||||
for index, row in df.iterrows():
|
for index, row in df.iterrows():
|
||||||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||||||
@@ -269,6 +274,13 @@ async def upload_file(
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 첫 번째 자재에 대해서만 디버그 출력
|
||||||
|
if materials_inserted == 0:
|
||||||
|
print(f"첫 번째 자재 저장:")
|
||||||
|
print(f" size_spec: '{material_data['size_spec']}'")
|
||||||
|
print(f" original_description: {material_data['original_description']}")
|
||||||
|
print(f" category: {classification_result.get('category', 'UNKNOWN')}")
|
||||||
|
|
||||||
material_result = db.execute(material_insert_query, {
|
material_result = db.execute(material_insert_query, {
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"original_description": material_data["original_description"],
|
"original_description": material_data["original_description"],
|
||||||
@@ -291,20 +303,19 @@ async def upload_file(
|
|||||||
if classification_result.get("category") == "PIPE":
|
if classification_result.get("category") == "PIPE":
|
||||||
print("PIPE 상세 정보 저장 시작")
|
print("PIPE 상세 정보 저장 시작")
|
||||||
|
|
||||||
# 길이 정보 추출
|
# 길이 정보 추출 - material_data에서 직접 가져옴
|
||||||
length_mm = None
|
length_mm = material_data.get("length", 0.0) if material_data.get("length") else None
|
||||||
if "length_info" in classification_result:
|
|
||||||
length_mm = classification_result["length_info"].get("length_mm")
|
|
||||||
|
|
||||||
|
# material_id도 함께 저장하도록 수정
|
||||||
pipe_detail_insert_query = text("""
|
pipe_detail_insert_query = text("""
|
||||||
INSERT INTO pipe_details (
|
INSERT INTO pipe_details (
|
||||||
file_id, material_standard, material_grade, material_type,
|
material_id, file_id, material_standard, material_grade, material_type,
|
||||||
manufacturing_method, end_preparation, schedule, wall_thickness,
|
manufacturing_method, end_preparation, schedule, wall_thickness,
|
||||||
nominal_size, length_mm, material_confidence, manufacturing_confidence,
|
nominal_size, length_mm, material_confidence, manufacturing_confidence,
|
||||||
end_prep_confidence, schedule_confidence
|
end_prep_confidence, schedule_confidence
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
:file_id, :material_standard, :material_grade, :material_type,
|
:material_id, :file_id, :material_standard, :material_grade, :material_type,
|
||||||
:manufacturing_method, :end_preparation, :schedule, :wall_thickness,
|
:manufacturing_method, :end_preparation, :schedule, :wall_thickness,
|
||||||
:nominal_size, :length_mm, :material_confidence, :manufacturing_confidence,
|
:nominal_size, :length_mm, :material_confidence, :manufacturing_confidence,
|
||||||
:end_prep_confidence, :schedule_confidence
|
:end_prep_confidence, :schedule_confidence
|
||||||
@@ -319,6 +330,7 @@ async def upload_file(
|
|||||||
size_info = classification_result.get("size_info", {})
|
size_info = classification_result.get("size_info", {})
|
||||||
|
|
||||||
db.execute(pipe_detail_insert_query, {
|
db.execute(pipe_detail_insert_query, {
|
||||||
|
"material_id": material_id,
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"material_standard": material_info.get("standard"),
|
"material_standard": material_info.get("standard"),
|
||||||
"material_grade": material_info.get("grade"),
|
"material_grade": material_info.get("grade"),
|
||||||
@@ -327,7 +339,7 @@ async def upload_file(
|
|||||||
"end_preparation": end_prep_info.get("type"),
|
"end_preparation": end_prep_info.get("type"),
|
||||||
"schedule": schedule_info.get("schedule"),
|
"schedule": schedule_info.get("schedule"),
|
||||||
"wall_thickness": schedule_info.get("wall_thickness"),
|
"wall_thickness": schedule_info.get("wall_thickness"),
|
||||||
"nominal_size": size_info.get("nominal_size"),
|
"nominal_size": material_data.get("size_spec", ""), # material_data에서 직접 가져옴
|
||||||
"length_mm": length_mm,
|
"length_mm": length_mm,
|
||||||
"material_confidence": material_info.get("confidence", 0.0),
|
"material_confidence": material_info.get("confidence", 0.0),
|
||||||
"manufacturing_confidence": manufacturing_info.get("confidence", 0.0),
|
"manufacturing_confidence": manufacturing_info.get("confidence", 0.0),
|
||||||
@@ -447,6 +459,7 @@ async def get_materials(
|
|||||||
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
||||||
m.size_spec, m.material_grade, m.line_number, m.row_number,
|
m.size_spec, m.material_grade, m.line_number, m.row_number,
|
||||||
m.created_at, m.classified_category, m.classification_confidence,
|
m.created_at, m.classified_category, m.classification_confidence,
|
||||||
|
m.classification_details,
|
||||||
f.original_filename, f.project_id, f.job_no, f.revision,
|
f.original_filename, f.project_id, f.job_no, f.revision,
|
||||||
p.official_project_code, p.project_name
|
p.official_project_code, p.project_name
|
||||||
FROM materials m
|
FROM materials m
|
||||||
@@ -552,33 +565,85 @@ async def get_materials(
|
|||||||
count_result = db.execute(text(count_query), count_params)
|
count_result = db.execute(text(count_query), count_params)
|
||||||
total_count = count_result.fetchone()[0]
|
total_count = count_result.fetchone()[0]
|
||||||
|
|
||||||
|
# 각 자재의 상세 정보도 가져오기
|
||||||
|
material_list = []
|
||||||
|
for m in materials:
|
||||||
|
material_dict = {
|
||||||
|
"id": m.id,
|
||||||
|
"file_id": m.file_id,
|
||||||
|
"filename": m.original_filename,
|
||||||
|
"project_id": m.project_id,
|
||||||
|
"project_code": m.official_project_code,
|
||||||
|
"project_name": m.project_name,
|
||||||
|
"original_description": m.original_description,
|
||||||
|
"quantity": float(m.quantity) if m.quantity else 0,
|
||||||
|
"unit": m.unit,
|
||||||
|
"size_spec": m.size_spec,
|
||||||
|
"material_grade": m.material_grade,
|
||||||
|
"line_number": m.line_number,
|
||||||
|
"row_number": m.row_number,
|
||||||
|
"classified_category": m.classified_category,
|
||||||
|
"classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0,
|
||||||
|
"classification_details": m.classification_details,
|
||||||
|
"created_at": m.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
# 카테고리별 상세 정보 추가
|
||||||
|
if m.classified_category == 'PIPE':
|
||||||
|
pipe_query = text("SELECT * FROM pipe_details WHERE material_id = :material_id")
|
||||||
|
pipe_result = db.execute(pipe_query, {"material_id": m.id})
|
||||||
|
pipe_detail = pipe_result.fetchone()
|
||||||
|
if pipe_detail:
|
||||||
|
material_dict['pipe_details'] = {
|
||||||
|
"nominal_size": pipe_detail.nominal_size,
|
||||||
|
"schedule": pipe_detail.schedule,
|
||||||
|
"material_standard": pipe_detail.material_standard,
|
||||||
|
"material_grade": pipe_detail.material_grade,
|
||||||
|
"material_type": pipe_detail.material_type,
|
||||||
|
"manufacturing_method": pipe_detail.manufacturing_method,
|
||||||
|
"end_preparation": pipe_detail.end_preparation,
|
||||||
|
"wall_thickness": pipe_detail.wall_thickness,
|
||||||
|
"length_mm": float(pipe_detail.length_mm) if pipe_detail.length_mm else None
|
||||||
|
}
|
||||||
|
elif m.classified_category == 'FITTING':
|
||||||
|
fitting_query = text("SELECT * FROM fitting_details WHERE material_id = :material_id")
|
||||||
|
fitting_result = db.execute(fitting_query, {"material_id": m.id})
|
||||||
|
fitting_detail = fitting_result.fetchone()
|
||||||
|
if fitting_detail:
|
||||||
|
material_dict['fitting_details'] = {
|
||||||
|
"fitting_type": fitting_detail.fitting_type,
|
||||||
|
"fitting_subtype": fitting_detail.fitting_subtype,
|
||||||
|
"connection_method": fitting_detail.connection_method,
|
||||||
|
"pressure_rating": fitting_detail.pressure_rating,
|
||||||
|
"material_standard": fitting_detail.material_standard,
|
||||||
|
"material_grade": fitting_detail.material_grade,
|
||||||
|
"main_size": fitting_detail.main_size,
|
||||||
|
"reduced_size": fitting_detail.reduced_size
|
||||||
|
}
|
||||||
|
elif m.classified_category == 'VALVE':
|
||||||
|
valve_query = text("SELECT * FROM valve_details WHERE material_id = :material_id")
|
||||||
|
valve_result = db.execute(valve_query, {"material_id": m.id})
|
||||||
|
valve_detail = valve_result.fetchone()
|
||||||
|
if valve_detail:
|
||||||
|
material_dict['valve_details'] = {
|
||||||
|
"valve_type": valve_detail.valve_type,
|
||||||
|
"valve_subtype": valve_detail.valve_subtype,
|
||||||
|
"actuator_type": valve_detail.actuator_type,
|
||||||
|
"connection_method": valve_detail.connection_method,
|
||||||
|
"pressure_rating": valve_detail.pressure_rating,
|
||||||
|
"body_material": valve_detail.body_material,
|
||||||
|
"size_inches": valve_detail.size_inches
|
||||||
|
}
|
||||||
|
|
||||||
|
material_list.append(material_dict)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"total_count": total_count,
|
"total_count": total_count,
|
||||||
"returned_count": len(materials),
|
"returned_count": len(materials),
|
||||||
"skip": skip,
|
"skip": skip,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"materials": [
|
"materials": material_list
|
||||||
{
|
|
||||||
"id": m.id,
|
|
||||||
"file_id": m.file_id,
|
|
||||||
"filename": m.original_filename,
|
|
||||||
"project_id": m.project_id,
|
|
||||||
"project_code": m.official_project_code,
|
|
||||||
"project_name": m.project_name,
|
|
||||||
"original_description": m.original_description,
|
|
||||||
"quantity": float(m.quantity) if m.quantity else 0,
|
|
||||||
"unit": m.unit,
|
|
||||||
"size_spec": m.size_spec,
|
|
||||||
"material_grade": m.material_grade,
|
|
||||||
"line_number": m.line_number,
|
|
||||||
"row_number": m.row_number,
|
|
||||||
"classified_category": m.classified_category,
|
|
||||||
"classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0,
|
|
||||||
"created_at": m.created_at
|
|
||||||
}
|
|
||||||
for m in materials
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -863,6 +928,116 @@ async def get_pipe_details(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"PIPE 상세 정보 조회 실패: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"PIPE 상세 정보 조회 실패: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/fitting-details")
|
||||||
|
async def get_fitting_details(
|
||||||
|
file_id: Optional[int] = None,
|
||||||
|
job_no: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
FITTING 상세 정보 조회
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT fd.*, f.original_filename, f.job_no, f.revision,
|
||||||
|
m.original_description, m.quantity, m.unit
|
||||||
|
FROM fitting_details fd
|
||||||
|
LEFT JOIN files f ON fd.file_id = f.id
|
||||||
|
LEFT JOIN materials m ON fd.material_id = m.id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if file_id:
|
||||||
|
query += " AND fd.file_id = :file_id"
|
||||||
|
params["file_id"] = file_id
|
||||||
|
|
||||||
|
if job_no:
|
||||||
|
query += " AND f.job_no = :job_no"
|
||||||
|
params["job_no"] = job_no
|
||||||
|
|
||||||
|
query += " ORDER BY fd.created_at DESC"
|
||||||
|
|
||||||
|
result = db.execute(text(query), params)
|
||||||
|
fitting_details = result.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": fd.id,
|
||||||
|
"file_id": fd.file_id,
|
||||||
|
"fitting_type": fd.fitting_type,
|
||||||
|
"fitting_subtype": fd.fitting_subtype,
|
||||||
|
"connection_method": fd.connection_method,
|
||||||
|
"pressure_rating": fd.pressure_rating,
|
||||||
|
"material_standard": fd.material_standard,
|
||||||
|
"material_grade": fd.material_grade,
|
||||||
|
"main_size": fd.main_size,
|
||||||
|
"reduced_size": fd.reduced_size,
|
||||||
|
"classification_confidence": fd.classification_confidence,
|
||||||
|
"original_description": fd.original_description,
|
||||||
|
"quantity": fd.quantity
|
||||||
|
}
|
||||||
|
for fd in fitting_details
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"FITTING 상세 정보 조회 실패: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/valve-details")
|
||||||
|
async def get_valve_details(
|
||||||
|
file_id: Optional[int] = None,
|
||||||
|
job_no: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
VALVE 상세 정보 조회
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT vd.*, f.original_filename, f.job_no, f.revision,
|
||||||
|
m.original_description, m.quantity, m.unit
|
||||||
|
FROM valve_details vd
|
||||||
|
LEFT JOIN files f ON vd.file_id = f.id
|
||||||
|
LEFT JOIN materials m ON vd.material_id = m.id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if file_id:
|
||||||
|
query += " AND vd.file_id = :file_id"
|
||||||
|
params["file_id"] = file_id
|
||||||
|
|
||||||
|
if job_no:
|
||||||
|
query += " AND f.job_no = :job_no"
|
||||||
|
params["job_no"] = job_no
|
||||||
|
|
||||||
|
query += " ORDER BY vd.created_at DESC"
|
||||||
|
|
||||||
|
result = db.execute(text(query), params)
|
||||||
|
valve_details = result.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": vd.id,
|
||||||
|
"file_id": vd.file_id,
|
||||||
|
"valve_type": vd.valve_type,
|
||||||
|
"valve_subtype": vd.valve_subtype,
|
||||||
|
"actuator_type": vd.actuator_type,
|
||||||
|
"connection_method": vd.connection_method,
|
||||||
|
"pressure_rating": vd.pressure_rating,
|
||||||
|
"body_material": vd.body_material,
|
||||||
|
"size_inches": vd.size_inches,
|
||||||
|
"fire_safe": vd.fire_safe,
|
||||||
|
"classification_confidence": vd.classification_confidence,
|
||||||
|
"original_description": vd.original_description,
|
||||||
|
"quantity": vd.quantity
|
||||||
|
}
|
||||||
|
for vd in valve_details
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"VALVE 상세 정보 조회 실패: {str(e)}")
|
||||||
|
|
||||||
@router.get("/user-requirements")
|
@router.get("/user-requirements")
|
||||||
async def get_user_requirements(
|
async def get_user_requirements(
|
||||||
file_id: Optional[int] = None,
|
file_id: Optional[int] = None,
|
||||||
|
|||||||
@@ -132,11 +132,16 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('job_no', selectedProject.job_no);
|
formData.append('job_no', selectedProject.job_no);
|
||||||
formData.append('revision', 'Rev.0');
|
formData.append('revision', 'Rev.0');
|
||||||
|
formData.append('bom_name', file.name); // BOM 이름으로 파일명 사용
|
||||||
|
formData.append('bom_type', 'excel'); // 파일 타입
|
||||||
|
formData.append('description', ''); // 설명 (빈 문자열)
|
||||||
|
|
||||||
console.log('FormData 내용:', {
|
console.log('FormData 내용:', {
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
jobNo: selectedProject.job_no,
|
jobNo: selectedProject.job_no,
|
||||||
revision: 'Rev.0'
|
revision: 'Rev.0',
|
||||||
|
bomName: file.name,
|
||||||
|
bomType: 'excel'
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
99
frontend/src/components/FittingDetailsCard.jsx
Normal file
99
frontend/src/components/FittingDetailsCard.jsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material';
|
||||||
|
|
||||||
|
const FittingDetailsCard = ({ material }) => {
|
||||||
|
const fittingDetails = material.fitting_details || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 2, backgroundColor: '#f5f5f5' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||||
|
🔗 FITTING 상세 정보
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`신뢰도: ${Math.round((material.classification_confidence || 0) * 100)}%`}
|
||||||
|
color={material.classification_confidence >= 0.8 ? 'success' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">자재명</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
|
||||||
|
{material.original_description}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">피팅 타입</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{fittingDetails.fitting_type || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">세부 타입</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{fittingDetails.fitting_subtype || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">연결 방식</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{fittingDetails.connection_method || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">압력 등급</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{fittingDetails.pressure_rating || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">재질 규격</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{fittingDetails.material_standard || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">재질 등급</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{fittingDetails.material_grade || material.material_grade || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">주 사이즈</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{fittingDetails.main_size || material.size_spec || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">축소 사이즈</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{fittingDetails.reduced_size || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">수량</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||||
|
{material.quantity} {material.unit}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FittingDetailsCard;
|
||||||
@@ -1,28 +1,96 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material';
|
||||||
|
|
||||||
const PipeDetailsCard = ({ material, fileId }) => {
|
const PipeDetailsCard = ({ material }) => {
|
||||||
// 간단한 테스트 버전
|
const pipeDetails = material.pipe_details || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ mt: 2, backgroundColor: '#f5f5f5' }}>
|
<Card sx={{ mt: 2, backgroundColor: '#f5f5f5' }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
PIPE 상세 정보 (테스트)
|
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||||
</Typography>
|
🔧 PIPE 상세 정보
|
||||||
<Box>
|
|
||||||
<Typography variant="body2">
|
|
||||||
자재명: {material.original_description}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
분류: {material.classified_category}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
사이즈: {material.size_spec || '정보 없음'}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
수량: {material.quantity} {material.unit}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`신뢰도: ${Math.round((material.classification_confidence || 0) * 100)}%`}
|
||||||
|
color={material.classification_confidence >= 0.8 ? 'success' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">자재명</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
|
||||||
|
{material.original_description}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">크기</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{pipeDetails.size_inches || material.size_spec || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">스케줄</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{pipeDetails.schedule_type || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">재질</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{pipeDetails.material_spec || material.material_grade || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">제작방식</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{pipeDetails.manufacturing_method || '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">길이</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{pipeDetails.length_mm ? `${pipeDetails.length_mm}mm` : '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">외경</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{pipeDetails.outer_diameter_mm ? `${pipeDetails.outer_diameter_mm}mm` : '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">두께</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{pipeDetails.wall_thickness_mm ? `${pipeDetails.wall_thickness_mm}mm` : '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">중량</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{pipeDetails.weight_per_meter_kg ? `${pipeDetails.weight_per_meter_kg}kg/m` : '-'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">수량</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||||
|
{material.quantity} {material.unit}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert } from '@mui/material';
|
import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert, TextField, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { uploadFile as uploadFileApi } from '../api';
|
||||||
|
|
||||||
const BOMStatusPage = () => {
|
const BOMStatusPage = () => {
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [file, setFile] = useState(null);
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [bomName, setBomName] = useState('');
|
||||||
|
const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null });
|
||||||
|
const [revisionFile, setRevisionFile] = useState(null);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const jobNo = searchParams.get('job_no');
|
const jobNo = searchParams.get('job_no');
|
||||||
|
const jobName = searchParams.get('job_name');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 파일 목록 불러오기
|
// 파일 목록 불러오기
|
||||||
@@ -39,98 +44,325 @@ const BOMStatusPage = () => {
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [jobNo]);
|
}, [jobNo]);
|
||||||
|
|
||||||
|
// BOM 이름 중복 체크
|
||||||
|
const checkDuplicateBOM = () => {
|
||||||
|
return files.some(file =>
|
||||||
|
file.bom_name === bomName ||
|
||||||
|
file.original_filename === bomName ||
|
||||||
|
file.filename === bomName
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 파일 업로드 핸들러
|
// 파일 업로드 핸들러
|
||||||
const handleUpload = async (e) => {
|
const handleUpload = async () => {
|
||||||
e.preventDefault();
|
if (!selectedFile) {
|
||||||
if (!file) return;
|
setError('파일을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bomName.trim()) {
|
||||||
|
setError('BOM 이름을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const isDuplicate = checkDuplicateBOM();
|
||||||
formData.append('file', file);
|
if (isDuplicate && !confirm(`"${bomName}"은(는) 이미 존재하는 BOM입니다. 새로운 리비전으로 업로드하시겠습니까?`)) {
|
||||||
formData.append('project_id', 1); // 예시: 실제 프로젝트 ID로 대체 필요
|
setUploading(false);
|
||||||
const res = await fetch('http://localhost:8000/upload', {
|
return;
|
||||||
method: 'POST',
|
}
|
||||||
body: formData
|
|
||||||
});
|
const formData = new FormData();
|
||||||
if (!res.ok) throw new Error('업로드 실패');
|
formData.append('file', selectedFile);
|
||||||
setFile(null);
|
formData.append('job_no', jobNo);
|
||||||
fetchFiles();
|
formData.append('revision', 'Rev.0');
|
||||||
|
formData.append('bom_name', bomName);
|
||||||
|
formData.append('bom_type', 'excel');
|
||||||
|
formData.append('description', '');
|
||||||
|
|
||||||
|
const response = await uploadFileApi(formData);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setBomName('');
|
||||||
|
// 파일 input 초기화
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
|
||||||
|
fetchFiles();
|
||||||
|
alert(`업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision || 'Rev.0'}`);
|
||||||
|
} else {
|
||||||
|
setError(response.data.message || '업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('파일 업로드에 실패했습니다.');
|
console.error('업로드 에러:', e);
|
||||||
|
if (e.response?.data?.detail) {
|
||||||
|
setError(e.response.data.detail);
|
||||||
|
} else {
|
||||||
|
setError('파일 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 리비전 업로드 핸들러
|
||||||
|
const handleRevisionUpload = async () => {
|
||||||
|
if (!revisionFile) {
|
||||||
|
setError('파일을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', revisionFile);
|
||||||
|
formData.append('job_no', jobNo);
|
||||||
|
formData.append('revision', 'Rev.0'); // 백엔드에서 자동 증가
|
||||||
|
formData.append('bom_name', revisionDialog.bomName);
|
||||||
|
formData.append('bom_type', 'excel');
|
||||||
|
formData.append('description', '');
|
||||||
|
formData.append('parent_bom_id', revisionDialog.parentId);
|
||||||
|
|
||||||
|
const response = await uploadFileApi(formData);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||||
|
setRevisionFile(null);
|
||||||
|
fetchFiles();
|
||||||
|
alert(`리비전 업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision}`);
|
||||||
|
} else {
|
||||||
|
setError(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('리비전 업로드 에러:', e);
|
||||||
|
if (e.response?.data?.detail) {
|
||||||
|
setError(e.response.data.detail);
|
||||||
|
} else {
|
||||||
|
setError('리비전 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// BOM별로 그룹화
|
||||||
|
const groupFilesByBOM = () => {
|
||||||
|
const grouped = {};
|
||||||
|
files.forEach(file => {
|
||||||
|
const bomKey = file.bom_name || file.original_filename || file.filename;
|
||||||
|
if (!grouped[bomKey]) {
|
||||||
|
grouped[bomKey] = [];
|
||||||
|
}
|
||||||
|
grouped[bomKey].push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 각 그룹을 리비전 순으로 정렬
|
||||||
|
Object.keys(grouped).forEach(key => {
|
||||||
|
grouped[key].sort((a, b) => {
|
||||||
|
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||||
|
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||||
|
return revB - revA; // 최신 리비전이 먼저 오도록
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
|
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
|
||||||
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
||||||
← 뒤로가기
|
← 뒤로가기
|
||||||
</Button>
|
</Button>
|
||||||
<Typography variant="h4" gutterBottom>BOM 업로드 및 현황</Typography>
|
<Typography variant="h4" gutterBottom>BOM 업로드 및 현황</Typography>
|
||||||
<form onSubmit={handleUpload} style={{ marginBottom: 24 }}>
|
{jobNo && jobName && (
|
||||||
<input
|
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
|
||||||
type="file"
|
{jobNo} - {jobName}
|
||||||
accept=".csv,.xlsx,.xls"
|
</Typography>
|
||||||
onChange={e => setFile(e.target.files[0])}
|
)}
|
||||||
disabled={uploading}
|
|
||||||
|
{/* 파일 업로드 폼 */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2 }}>새 BOM 업로드</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="BOM 이름"
|
||||||
|
value={bomName}
|
||||||
|
onChange={(e) => setBomName(e.target.value)}
|
||||||
|
placeholder="예: PIPING_BOM_A구역"
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다"
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<input
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.xlsx,.xls"
|
||||||
|
onChange={(e) => setSelectedFile(e.target.files[0])}
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="contained" disabled={!file || uploading} sx={{ ml: 2 }}>
|
<Button
|
||||||
업로드
|
variant="contained"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!selectedFile || !bomName.trim() || uploading}
|
||||||
|
>
|
||||||
|
{uploading ? '업로드 중...' : '업로드'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</Box>
|
||||||
|
{selectedFile && (
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
선택된 파일: {selectedFile.name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
{loading && <CircularProgress sx={{ mt: 4 }} />}
|
|
||||||
<TableContainer component={Paper} sx={{ mt: 2 }}>
|
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>업로드된 BOM 목록</Typography>
|
||||||
<Table size="small">
|
{loading && <CircularProgress />}
|
||||||
<TableHead>
|
{!loading && files.length === 0 && (
|
||||||
<TableRow>
|
<Alert severity="info">업로드된 BOM이 없습니다.</Alert>
|
||||||
<TableCell>파일명</TableCell>
|
)}
|
||||||
<TableCell>리비전</TableCell>
|
{!loading && files.length > 0 && (
|
||||||
<TableCell>세부내역</TableCell>
|
<TableContainer component={Paper}>
|
||||||
<TableCell>리비전</TableCell>
|
<Table>
|
||||||
<TableCell>삭제</TableCell>
|
<TableHead>
|
||||||
</TableRow>
|
<TableRow>
|
||||||
</TableHead>
|
<TableCell>BOM 이름</TableCell>
|
||||||
<TableBody>
|
<TableCell>파일명</TableCell>
|
||||||
{files.map(file => (
|
<TableCell>리비전</TableCell>
|
||||||
<TableRow key={file.id}>
|
<TableCell>자재 수</TableCell>
|
||||||
<TableCell>{file.original_filename || file.filename}</TableCell>
|
<TableCell>업로드 일시</TableCell>
|
||||||
<TableCell>{file.revision}</TableCell>
|
<TableCell>작업</TableCell>
|
||||||
<TableCell>
|
</TableRow>
|
||||||
<Button size="small" variant="outlined" onClick={() => navigate(`/materials?fileId=${file.id}`)}>
|
</TableHead>
|
||||||
자재확인
|
<TableBody>
|
||||||
</Button>
|
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
||||||
</TableCell>
|
bomFiles.map((file, index) => (
|
||||||
<TableCell>
|
<TableRow key={file.id} sx={{
|
||||||
<Button size="small" variant="outlined" color="info" onClick={() => alert(`리비전 관리: ${file.original_filename}`)}>
|
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'inherit'
|
||||||
리비전
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button size="small" variant="outlined" color="error" onClick={async () => {
|
|
||||||
if (window.confirm(`정말로 ${file.original_filename}을 삭제하시겠습니까?`)) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`http://localhost:8000/files/${file.id}`, { method: 'DELETE' });
|
|
||||||
if (res.ok) {
|
|
||||||
fetchFiles();
|
|
||||||
} else {
|
|
||||||
alert('삭제에 실패했습니다.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('삭제 중 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}>
|
}}>
|
||||||
삭제
|
<TableCell>
|
||||||
</Button>
|
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
|
||||||
</TableCell>
|
{file.bom_name || bomKey}
|
||||||
</TableRow>
|
</Typography>
|
||||||
))}
|
{index === 0 && bomFiles.length > 1 && (
|
||||||
</TableBody>
|
<Typography variant="caption" color="textSecondary">
|
||||||
</Table>
|
(최신 리비전)
|
||||||
</TableContainer>
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{file.filename || file.original_filename}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color={index === 0 ? 'primary' : 'textSecondary'}
|
||||||
|
>
|
||||||
|
{file.revision || 'Rev.0'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{file.parsed_count || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={index === 0 ? "contained" : "outlined"}
|
||||||
|
onClick={() => navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.filename || file.original_filename)}`)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
자재확인
|
||||||
|
</Button>
|
||||||
|
{index === 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setRevisionDialog({
|
||||||
|
open: true,
|
||||||
|
bomName: file.bom_name || bomKey,
|
||||||
|
parentId: file.id
|
||||||
|
})}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
리비전
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={async () => {
|
||||||
|
if (confirm(`정말 "${file.revision || 'Rev.0'}"을 삭제하시겠습니까?`)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/files/${file.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
fetchFiles();
|
||||||
|
} else {
|
||||||
|
alert('삭제 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('삭제 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리비전 업로드 다이얼로그 */}
|
||||||
|
<Dialog open={revisionDialog.open} onClose={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}>
|
||||||
|
<DialogTitle>리비전 업로드</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
|
BOM 이름: <strong>{revisionDialog.bomName}</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||||
|
새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다.
|
||||||
|
</Typography>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.xlsx,.xls"
|
||||||
|
onChange={(e) => setRevisionFile(e.target.files[0])}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
{revisionFile && (
|
||||||
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||||
|
선택된 파일: {revisionFile.name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setRevisionDialog({ open: false, bomName: '', parentId: null });
|
||||||
|
setRevisionFile(null);
|
||||||
|
}}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleRevisionUpload}
|
||||||
|
disabled={!revisionFile || uploading}
|
||||||
|
>
|
||||||
|
{uploading ? '업로드 중...' : '업로드'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
@@ -21,7 +22,9 @@ import {
|
|||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Switch
|
Switch
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import PipeDetailsCard from '../components/PipeDetailsCard';
|
import PipeDetailsCard from '../components/PipeDetailsCard';
|
||||||
|
import FittingDetailsCard from '../components/FittingDetailsCard';
|
||||||
import { Pie, Bar } from 'react-chartjs-2';
|
import { Pie, Bar } from 'react-chartjs-2';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@@ -55,9 +58,14 @@ const MaterialsPage = () => {
|
|||||||
const [revisionComparison, setRevisionComparison] = useState(null);
|
const [revisionComparison, setRevisionComparison] = useState(null);
|
||||||
const [showOnlyPurchaseRequired, setShowOnlyPurchaseRequired] = useState(false);
|
const [showOnlyPurchaseRequired, setShowOnlyPurchaseRequired] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 확인
|
||||||
|
console.log('MaterialsPage 컴포넌트 마운트됨');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const id = urlParams.get('fileId');
|
const id = urlParams.get('file_id'); // fileId -> file_id로 변경
|
||||||
if (id) {
|
if (id) {
|
||||||
setFileId(id);
|
setFileId(id);
|
||||||
loadMaterials(id);
|
loadMaterials(id);
|
||||||
@@ -71,9 +79,20 @@ const MaterialsPage = () => {
|
|||||||
console.log('자재 로딩 시작, file_id:', id);
|
console.log('자재 로딩 시작, file_id:', id);
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await api.get('/files/materials', { params: { file_id: parseInt(id) } });
|
// limit을 충분히 크게 설정하여 모든 자재를 가져옴
|
||||||
|
const response = await api.get('/files/materials', { params: { file_id: parseInt(id), limit: 10000 } });
|
||||||
console.log('자재 데이터 로딩 성공:', response.data);
|
console.log('자재 데이터 로딩 성공:', response.data);
|
||||||
setMaterials(response.data);
|
|
||||||
|
// API 응답이 객체로 오는 경우 materials 배열 추출
|
||||||
|
if (response.data && response.data.materials) {
|
||||||
|
setMaterials(response.data.materials);
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
setMaterials(response.data);
|
||||||
|
} else {
|
||||||
|
console.error('예상치 못한 응답 형식:', response.data);
|
||||||
|
setMaterials([]);
|
||||||
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('자재 정보를 불러오는데 실패했습니다.');
|
setError('자재 정보를 불러오는데 실패했습니다.');
|
||||||
@@ -107,6 +126,12 @@ const MaterialsPage = () => {
|
|||||||
|
|
||||||
const calculateCategoryStats = () => {
|
const calculateCategoryStats = () => {
|
||||||
const stats = {};
|
const stats = {};
|
||||||
|
// materials가 배열인지 확인
|
||||||
|
if (!Array.isArray(materials)) {
|
||||||
|
console.error('materials is not an array:', materials);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
materials.forEach(material => {
|
materials.forEach(material => {
|
||||||
const category = material.classified_category || 'UNKNOWN';
|
const category = material.classified_category || 'UNKNOWN';
|
||||||
if (!stats[category]) {
|
if (!stats[category]) {
|
||||||
@@ -119,11 +144,24 @@ const MaterialsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAvailableCategories = () => {
|
const getAvailableCategories = () => {
|
||||||
|
if (!Array.isArray(materials)) return [];
|
||||||
const categories = [...new Set(materials.map(m => m.classified_category).filter(Boolean))];
|
const categories = [...new Set(materials.map(m => m.classified_category).filter(Boolean))];
|
||||||
return categories.sort();
|
return categories.sort();
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateClassificationStats = () => {
|
const calculateClassificationStats = () => {
|
||||||
|
if (!Array.isArray(materials)) {
|
||||||
|
return {
|
||||||
|
totalItems: 0,
|
||||||
|
classifiedItems: 0,
|
||||||
|
unclassifiedItems: 0,
|
||||||
|
highConfidence: 0,
|
||||||
|
mediumConfidence: 0,
|
||||||
|
lowConfidence: 0,
|
||||||
|
categoryBreakdown: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const totalItems = materials.length;
|
const totalItems = materials.length;
|
||||||
const classifiedItems = materials.filter(m => m.classified_category && m.classified_category !== 'UNKNOWN').length;
|
const classifiedItems = materials.filter(m => m.classified_category && m.classified_category !== 'UNKNOWN').length;
|
||||||
const unclassifiedItems = totalItems - classifiedItems;
|
const unclassifiedItems = totalItems - classifiedItems;
|
||||||
@@ -226,25 +264,103 @@ const MaterialsPage = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// PIPE 분석용 헬퍼 함수들
|
||||||
|
const groupPipesBySpecs = (pipeItems) => {
|
||||||
|
const groups = {};
|
||||||
|
|
||||||
|
pipeItems.forEach(item => {
|
||||||
|
const details = item.pipe_details || {};
|
||||||
|
|
||||||
|
// 재질-크기-스케줄-제작방식으로 키 생성
|
||||||
|
const material = details.material_standard || item.material_grade || 'Unknown';
|
||||||
|
let size = details.nominal_size || item.size_spec || 'Unknown';
|
||||||
|
|
||||||
|
// 크기 정리 (인치 표시)
|
||||||
|
if (size && size !== 'Unknown') {
|
||||||
|
size = size.replace(/["']/g, '').trim();
|
||||||
|
if (!size.includes('"') && !size.includes('inch')) {
|
||||||
|
size += '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = details.schedule || 'Unknown';
|
||||||
|
const manufacturing = details.manufacturing_method || 'Unknown';
|
||||||
|
|
||||||
|
const key = `${material}|${size}|${schedule}|${manufacturing}`;
|
||||||
|
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = {
|
||||||
|
material,
|
||||||
|
size,
|
||||||
|
schedule,
|
||||||
|
manufacturing,
|
||||||
|
items: [],
|
||||||
|
totalLength: 0,
|
||||||
|
count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[key].items.push(item);
|
||||||
|
groups[key].count += 1;
|
||||||
|
|
||||||
|
// 길이 합산
|
||||||
|
if (item.pipe_details?.length_mm) {
|
||||||
|
groups[key].totalLength += item.pipe_details.length_mm;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 배열로 변환하고 총 길이순으로 정렬
|
||||||
|
return Object.values(groups).sort((a, b) => b.totalLength - a.totalLength);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePipeChartData = (pipeItems, property) => {
|
||||||
|
const groups = groupPipesByProperty(pipeItems, property);
|
||||||
|
|
||||||
|
const chartData = Object.entries(groups).map(([key, items]) => {
|
||||||
|
const totalLength = items.reduce((sum, item) => {
|
||||||
|
let lengthMm = 0;
|
||||||
|
if (item.pipe_details?.length_mm) {
|
||||||
|
lengthMm = item.pipe_details.length_mm;
|
||||||
|
}
|
||||||
|
return sum + lengthMm;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: key,
|
||||||
|
value: totalLength,
|
||||||
|
count: items.length,
|
||||||
|
items: items
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: chartData.map(d => d.label),
|
||||||
|
datasets: [{
|
||||||
|
data: chartData.map(d => d.value),
|
||||||
|
backgroundColor: [
|
||||||
|
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
|
||||||
|
'#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384'
|
||||||
|
],
|
||||||
|
borderWidth: 1
|
||||||
|
}],
|
||||||
|
chartData: chartData
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const generateCategoryChartData = (category, items) => {
|
const generateCategoryChartData = (category, items) => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'PIPE':
|
case 'PIPE':
|
||||||
const totalLength = items.reduce((sum, item) => {
|
const totalLength = items.reduce((sum, item) => {
|
||||||
const details = item.classification_details || {};
|
let lengthMm = 0;
|
||||||
const cuttingDimensions = details?.cutting_dimensions || {};
|
if (item.pipe_details?.length_mm) {
|
||||||
let lengthMm = cuttingDimensions?.length_mm;
|
lengthMm = item.pipe_details.length_mm;
|
||||||
|
|
||||||
// 백엔드에서 전달된 length 필드도 확인
|
|
||||||
if (!lengthMm && item.length) {
|
|
||||||
lengthMm = item.length;
|
|
||||||
}
|
}
|
||||||
|
return sum + lengthMm;
|
||||||
return sum + (lengthMm || 0);
|
|
||||||
}, 0);
|
}, 0);
|
||||||
return {
|
return {
|
||||||
value: totalLength,
|
value: totalLength,
|
||||||
unit: 'mm',
|
unit: 'mm',
|
||||||
displayText: `${totalLength}mm`,
|
displayText: `${(totalLength / 1000).toFixed(1)}m`,
|
||||||
isLength: true
|
isLength: true
|
||||||
};
|
};
|
||||||
case 'BOLT':
|
case 'BOLT':
|
||||||
@@ -382,8 +498,22 @@ const MaterialsPage = () => {
|
|||||||
total_quantity: materials.reduce((sum, m) => sum + (m.quantity || 0), 0)
|
total_quantity: materials.reduce((sum, m) => sum + (m.quantity || 0), 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 에러 디버깅을 위한 로그
|
||||||
|
console.log('Rendering MaterialsPage, materials:', materials.length);
|
||||||
|
console.log('Loading:', loading, 'Error:', error);
|
||||||
|
console.log('FileId:', fileId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, p: 2 }}>
|
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, p: 2 }}>
|
||||||
|
{/* 뒤로가기 버튼 */}
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
뒤로가기
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
📋 자재 분류 결과
|
📋 자재 분류 결과
|
||||||
@@ -669,15 +799,79 @@ const MaterialsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 상세 목록 탭 */}
|
{/* 상세 목록 탭 */}
|
||||||
{!loading && materials.length > 0 && activeTab === 1 && (
|
{!loading && materials.length > 0 && activeTab === 1 && (() => {
|
||||||
<Box>
|
const pipeItems = materials.filter(m => m.classified_category === 'PIPE');
|
||||||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
|
||||||
📋 상세 자재 목록 (테스트)
|
return (
|
||||||
</Typography>
|
<Box>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||||
총 {materials.length}개 자재가 로드되었습니다.
|
📋 상세 자재 목록 (테스트)
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
총 {materials.length}개 자재가 로드되었습니다.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* PIPE 분석 섹션 */}
|
||||||
|
{pipeItems.length > 0 && (
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
🔧 PIPE 분석 ({pipeItems.length}개)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
동일한 재질-크기-스케줄-제작방식을 가진 파이프들을 그룹화하여 표시합니다.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TableContainer component={Paper} sx={{ mt: 2 }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell><strong>재질</strong></TableCell>
|
||||||
|
<TableCell><strong>외경</strong></TableCell>
|
||||||
|
<TableCell><strong>스케줄</strong></TableCell>
|
||||||
|
<TableCell><strong>제작방식</strong></TableCell>
|
||||||
|
<TableCell align="right"><strong>총 길이</strong></TableCell>
|
||||||
|
<TableCell align="center"><strong>개수</strong></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{groupPipesBySpecs(pipeItems).map((group, index) => (
|
||||||
|
<TableRow key={index} hover>
|
||||||
|
<TableCell>{group.material}</TableCell>
|
||||||
|
<TableCell>{group.size}</TableCell>
|
||||||
|
<TableCell>{group.schedule}</TableCell>
|
||||||
|
<TableCell>{group.manufacturing}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<strong>{(group.totalLength / 1000).toFixed(2)}m</strong>
|
||||||
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
|
({group.totalLength.toFixed(0)}mm)
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip
|
||||||
|
label={group.count}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{/* 총계 */}
|
||||||
|
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>총 파이프 길이: {(pipeItems.reduce((sum, item) => sum + (item.pipe_details?.length_mm || 0), 0) / 1000).toFixed(2)}m</strong>
|
||||||
|
{' '}({groupPipesBySpecs(pipeItems).length}가지 규격)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 필터 */}
|
{/* 필터 */}
|
||||||
<Box sx={{ mb: 2, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ mb: 2, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{getAvailableCategories().map(category => (
|
{getAvailableCategories().map(category => (
|
||||||
@@ -740,11 +934,45 @@ const MaterialsPage = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/* PIPE 상세 정보 */}
|
{/* 자재별 상세 정보 카드 */}
|
||||||
{material.classified_category === 'PIPE' && (
|
{material.classified_category === 'PIPE' && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} sx={{ p: 0 }}>
|
<TableCell colSpan={8} sx={{ p: 0 }}>
|
||||||
<PipeDetailsCard material={material} fileId={fileId} />
|
<PipeDetailsCard material={material} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{material.classified_category === 'FITTING' && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} sx={{ p: 0 }}>
|
||||||
|
<FittingDetailsCard material={material} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{material.classified_category === 'VALVE' && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} sx={{ p: 0 }}>
|
||||||
|
<Box sx={{ m: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>🚰 VALVE 상세 정보</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">밸브 타입</Typography>
|
||||||
|
<Typography>{material.valve_details?.valve_type || '-'}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">작동 방식</Typography>
|
||||||
|
<Typography>{material.valve_details?.actuator_type || '-'}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">압력 등급</Typography>
|
||||||
|
<Typography>{material.valve_details?.pressure_rating || '-'}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">크기</Typography>
|
||||||
|
<Typography>{material.valve_details?.size_inches || '-'}</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -755,7 +983,8 @@ const MaterialsPage = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 리비전 비교 탭 */}
|
{/* 리비전 비교 탭 */}
|
||||||
{!loading && materials.length > 0 && activeTab === 2 && (
|
{!loading && materials.length > 0 && activeTab === 2 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user