diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 5019b0c..934956b 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -7,7 +7,26 @@
"Bash(del \"detail_page.html\")",
"Bash(del \"simulation_page.html\")",
"Bash(rm:*)",
- "Bash(mv:*)"
+ "Bash(mv:*)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src\\types/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src\\components\\common/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
+ "Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)"
],
"deny": [],
"ask": []
diff --git a/backend/airflow/Dockerfile.airflow b/backend/airflow/Dockerfile.airflow
new file mode 100644
index 0000000..5cd0a24
--- /dev/null
+++ b/backend/airflow/Dockerfile.airflow
@@ -0,0 +1,55 @@
+# 1. 기반 이미지 설정: 공식 Airflow 이미지를 사용합니다.
+# docker-compose.yaml의 AIRFLOW_IMAGE_NAME 환경 변수와 버전을 일치시키는 것이 좋습니다.
+# 예: apache/airflow:2.9.3
+ARG AIRFLOW_VERSION=2.9.3
+FROM apache/airflow:${AIRFLOW_VERSION}
+
+# 2. 루트 사용자로 전환 (시스템 패키지 설치 위함)
+USER root
+
+# 3. 필요한 시스템 유틸리티 및 Chrome 브라우저 설치 (Debian/Ubuntu 기반)
+# 공식 Airflow 이미지는 Debian 기반이므로 apt-get 사용
+RUN apt-get update -yqq && \
+ apt-get install -yqq --no-install-recommends \
+ wget \
+ gnupg \
+ unzip \
+ # Chrome 실행에 필요한 라이브러리들
+ libglib2.0-0 libnss3 libgconf-2-4 libfontconfig1 libxi6 libgdk-pixbuf2.0-0 libgtk-3-0 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libxss1 libxtst6 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libpango-1.0-0 libcairo2 libasound2 && \
+ # Google Chrome 저장소 추가 및 설치
+ wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
+ echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list && \
+ apt-get update -yqq && \
+ apt-get install -yqq google-chrome-stable && \
+ # 설치 후 불필요한 파일 정리
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+# 4. ChromeDriver 설치
+# 중요: 설치된 Google Chrome 버전에 맞는 ChromeDriver 버전을 사용해야 합니다.
+# Chrome 버전 확인: google-chrome-stable --version (컨테이너 빌드 중에는 어려우므로, 로컬에서 확인 후 버전 지정)
+# ChromeDriver 다운로드 URL: https://googlechromelabs.github.io/chrome-for-testing/
+# 아래 ARG는 예시이며, 실제 Chrome 버전에 맞는 ChromeDriver 버전을 명시해야 합니다.
+ARG CHROME_DRIVER_VERSION="136.0.7103.92" # 예시: 2025년 5월 기준 최신 Stable과 유사한 버전 (실제 버전에 맞게 수정!)
+RUN wget -O /tmp/chromedriver.zip "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${CHROME_DRIVER_VERSION}/linux64/chromedriver-linux64.zip" && \
+ unzip /tmp/chromedriver.zip -d /usr/local/bin/ && \
+ # 압축 해제 시 chromedriver-linux64 디렉토리 안에 chromedriver가 있을 수 있음
+ (mv /usr/local/bin/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver || echo "chromedriver already in /usr/local/bin or mv failed") && \
+ rm -rf /tmp/chromedriver.zip /usr/local/bin/chromedriver-linux64 && \
+ chmod +x /usr/local/bin/chromedriver
+
+# (선택 사항) ChromeDriver 경로 환경 변수 설정 (코드에서 Airflow Variable을 사용하면 필수는 아님)
+# ENV CHROMEDRIVER_EXECUTABLE_PATH /usr/local/bin/chromedriver
+
+# 5. Airflow 사용자로 다시 전환
+USER airflow
+
+# 6. requirements.txt 파일을 이미지 안으로 복사
+# 이 Dockerfile과 같은 디렉토리에 requirements.txt 파일이 있다고 가정
+COPY requirements.txt /requirements.txt
+
+# 7. requirements.txt에 명시된 Python 패키지 설치
+RUN pip install --no-cache-dir -r /requirements.txt
+
+# 8. (선택 사항) 작업 디렉토리 설정 등
+# WORKDIR /opt/airflow
diff --git a/backend/airflow/config/airflow.cfg b/backend/airflow/config/airflow.cfg
new file mode 100644
index 0000000..2123116
--- /dev/null
+++ b/backend/airflow/config/airflow.cfg
@@ -0,0 +1,61 @@
+# Airflow Configuration File
+
+[core]
+# Airflow의 내부 스케줄링 및 시간 처리 기준 시간대
+default_timezone = Asia/Seoul
+
+# DAG가 처음 생성될 때 "paused" 상태로 할지 여부
+dags_are_paused_at_creation = True
+
+# 예제 DAG 로드 여부
+load_examples = False
+
+# 실행기 유형 (docker-compose.yaml의 환경 변수 설정과 일치시키는 것이 좋음)
+# executor = CeleryExecutor
+
+# Fernet 키 (docker-compose.yaml의 환경 변수에서 관리하는 것이 일반적)
+# fernet_key = YOUR_FERNET_KEY
+
+[webserver]
+# Airflow 웹 UI에 표시되는 시간의 기준 시간대
+default_ui_timezone = Asia/Seoul
+
+# API 인증 백엔드 (docker-compose.yaml의 환경 변수 설정과 일치시키는 것이 좋음)
+# auth_backends = airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session
+
+[logging]
+# 로그 메시지 형식
+# %(asctime)s는 일반적으로 컨테이너의 TZ 환경 변수와 아래 default_date_format, timezone_aware 설정의 영향을 받습니다.
+log_format = [%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s
+
+# 로그의 %(asctime)s에 적용될 날짜/시간 형식
+# %Z는 시간대 이름 (KST 등), %z는 UTC 오프셋 (+0900 등)을 표시합니다.
+default_date_format = %Y-%m-%d %H:%M:%S %Z%z
+
+# 시간대 인식 로깅 활성화 (기본값이 True이지만 명시적으로 설정)
+# 이 설정이 True이면, Airflow의 TimezoneAwareFormatter가 사용되어
+# default_timezone (Asia/Seoul) 기준으로 시간을 표시하려고 시도합니다.
+timezone_aware = True
+
+# 로그 파일 이름 템플릿 (기본값 사용 권장)
+# default_log_filename_template = {{ ti.dag_id }}/{{ ti.task_id }}/{{ ts }}/{{ try_number }}.log
+
+# 원격 로깅을 사용하지 않는 경우, 태스크 로그는 파일로 저장됩니다.
+# task_log_reader = file.task
+
+# 로그 레벨
+# logging_level = INFO
+
+
+# --- 기타 필요한 설정들 ---
+# 예: [database], [celery] 섹션의 설정들은
+# docker-compose.yaml의 환경 변수(AIRFLOW__DATABASE__SQL_ALCHEMY_CONN 등)를 통해
+# 이미 설정되어 있으므로, 여기서는 보통 명시적으로 다시 설정할 필요가 없습니다.
+# 만약 특정 설정을 airflow.cfg에서만 관리하고 싶다면 해당 섹션과 키를 추가할 수 있습니다.
+
+# [database]
+# sql_alchemy_conn = postgresql+psycopg2://airflow:airflow@postgres/airflow
+
+# [celery]
+# broker_url = redis://:@redis:6379/0
+# result_backend = db+postgresql://airflow:airflow@postgres/airflow
diff --git a/backend/airflow/dags/imei/dags/collect_news.py b/backend/airflow/dags/imei/dags/collect_news.py
new file mode 100644
index 0000000..afb71d2
--- /dev/null
+++ b/backend/airflow/dags/imei/dags/collect_news.py
@@ -0,0 +1,82 @@
+# 필요한 모듈
+import pendulum # 날짜/시간 처리를 위한 권장 라이브러리 (datetime 대체 가능)
+import logging
+
+from airflow.models.dag import DAG # DAG 객체 정의를 위한 클래스
+from airflow.operators.bash import BashOperator # Bash 명령어를 실행하는 오퍼레이터
+from airflow.operators.python import PythonOperator # Python 함수를 실행하는 오퍼레이터
+from airflow.operators.empty import EmptyOperator
+from datetime import datetime
+from airflow.models.variable import Variable
+from imei.tasks.task_collect_news import TaskCollectNews
+
+
+# 모든 Operator에 공통적으로 적용될 기본값들을 딕셔너리로 정의할 수 있습니다.
+# 스캐쥴링 설정
+scheduler = "0 0 * * *"
+# args 설정
+default_args = {
+ 'owner': 'airflow_user', # DAG의 소유자 (관리/알림 목적)
+ "start_date": datetime(2025, 9, 5, 15, 0, 0), # 첫 실행 날짜
+ "depends_on_past": False, # 이전 DAG Run 실행 여부에 따른 의존성 설정
+}
+
+# DAG 정의
+# 'with DAG(...) as dag:' 구문을 사용하면 이 블록 내에서 정의된 오퍼레이터들이 자동으로 'dag' 객체에 속하게 됩니다.
+with DAG(
+ dag_id='collect_news', # DAG의 고유 식별자. Airflow UI에 표시됨. (필수)
+ description='뉴스 수집 DAG', # DAG에 대한 설명 (UI에 표시됨)
+ schedule=scheduler, # DAG 실행 스케줄. None은 수동 실행(Manual Trigger)을 의미.
+ catchup=False, # True면 start_date부터 현재까지 놓친 스케줄을 모두 실행 (Backfill). 보통 False로 설정.
+ default_args=default_args, # 위에서 정의한 기본 인수 적용
+ max_active_runs=1,
+ tags=['뉴스'], # DAG를 분류하고 UI에서 필터링하기 위한 태그 목록
+) as dag: # 이 DAG 인스턴스를 'dag' 변수로 사용
+ # dag 설명
+ dag.doc_md = """
+뉴스 수집 DAG
+ """
+ # 임시 처리용 클래스
+ task_collect_news = TaskCollectNews()
+
+
+ # Task(작업) 정의
+ # 각 Task는 Operator 클래스의 인스턴스로 생성됩니다.
+ ####################################################################################################################
+ # Task: Start
+ ####################################################################################################################
+ task_start = EmptyOperator(
+ task_id="task_start"
+ )
+ task_start.doc_md = f"""- Empty Task"""
+
+ ####################################################################################################################
+ # Task1 : NewsAPI를 통해 뉴스 데이터를 수집합니다.
+ task_collect_news_newsapi = PythonOperator(
+ task_id='collect_news_newsapi',
+ python_callable=task_collect_news.collect_news_newsapi,
+ op_kwargs = {
+ "exec_dt_str": "{{ data_interval_end }}"
+ },
+ )
+ task_collect_news_newsapi.doc_md = f"""
+ - NewsAPI를 통해 뉴스 데이터를 수집합니다.
+ """
+
+ ####################################################################################################################
+
+
+ ####################################################################################################################
+ # Task: End
+ ####################################################################################################################
+ task_end = EmptyOperator(
+ task_id="task_end"
+ )
+ task_end.doc_md = f"""- Empty Task"""
+
+ # 6. Task 의존성(실행 순서) 설정
+ task_start >> \
+ task_collect_news_newsapi >> \
+ task_end
+
+
diff --git a/backend/airflow/dags/imei/tasks/task_collect_news.py b/backend/airflow/dags/imei/tasks/task_collect_news.py
new file mode 100644
index 0000000..d42accf
--- /dev/null
+++ b/backend/airflow/dags/imei/tasks/task_collect_news.py
@@ -0,0 +1,36 @@
+from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
+from airflow.models import Variable
+from airflow.providers.mysql.hooks.mysql import MySqlHook
+from plugins.utils.common_datetime import CommonDatetime
+import logging
+
+# Variable
+# NEWSAPI
+NEWSAPI_API_KEY = Variable.get("NEWSAPI_API_KEY", "")
+NEWSAPI_URL = Variable.get("NEWSAPI_URL", "https://newsapi.org/v2/everything")
+
+class TaskCollectNews():
+ # =======================================================================================================================================
+ # 임시 처리용 테스트
+ def collect_news_newsapi(self, exec_dt_str: str):
+ """
+ NewsAPI를 통해 뉴스 데이터를 수집합니다.
+ """
+ logging.info('============================== collect_news_newsapi start ==============================')
+ try:
+ # 기본 정보
+ exec_dt = CommonDatetime.make_datetime_kst_from_ts(exec_dt_str)
+ logging.info(f"exec_dt(): {exec_dt}")
+
+ # NewsAPI를 통해 뉴스 데이터를 수집합니다.
+ url = f"{NEWSAPI_URL}?q=keyword&apiKey={NEWSAPI_API_KEY}"
+ response = requests.get(url)
+ data = response.json()
+ logging.info(f"data: {data}")
+
+ except Exception as e:
+ logging.error(f"collect_news_newsapi() Exception: {str(e)}")
+ raise Exception(str(e))
+
+ finally :
+ logging.info('============================== collect_news_newsapi finish ==============================')
\ No newline at end of file
diff --git a/backend/airflow/dags/plugins/sql/hooks/mysql_hook.py b/backend/airflow/dags/plugins/sql/hooks/mysql_hook.py
new file mode 100644
index 0000000..96b6b02
--- /dev/null
+++ b/backend/airflow/dags/plugins/sql/hooks/mysql_hook.py
@@ -0,0 +1,616 @@
+import inspect
+import pymysql
+import logging
+import mysql.connector
+from sqlalchemy.engine import Engine
+from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
+from airflow.providers.mysql.hooks.mysql import MySqlHook
+from sqlalchemy.engine import Engine
+
+TIMEZONE = "Asia/Seoul"
+
+class CommonHookMySQL:
+
+ @staticmethod
+ def _set_timezone(cursor):
+ cursor.execute(f"SET time_zone = '{TIMEZONE}'")
+
+ @staticmethod
+ def select_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
+ message = ""
+ try:
+ cursor = conn.cursor()
+ cursor.execute(sql)
+ result = cursor.fetchall()
+
+ if result is None:
+ raise AirflowFailException(f"select result is None")
+
+ return result
+
+ except AirflowSkipException as e:
+ message = f"select_user_data() AirflowSkipException: {str(e)}"
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"select_user_data() AirflowFailException: {str(e)}"
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"select_user_data() AirflowException: {str(e)}"
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"select_user_data() Exception: {str(e)}"
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+
+
+ @staticmethod
+ def select_sql_to_dict(conn: Engine, sql: str): # 파라미터 바인딩 지원 추가
+ """
+ SQL 쿼리를 실행하고 결과를 딕셔너리의 리스트 형태로 반환합니다.
+ MySqlHook을 사용하며, DictCursor에 직접 의존하지 않습니다.
+
+ :param conn_id: Airflow MySQL Connection ID
+ :param sql: 실행할 SELECT SQL 쿼리 문자열
+ :param parameters: SQL 쿼리에 바인딩할 파라미터 (튜플 또는 리스트)
+ :return: 각 행이 딕셔너리인 리스트 (예: [{'col1': val1, 'col2': val2}, ...])
+ :rtype: list[dict]
+ """
+ message = ""
+ try:
+ cursor = conn.cursor()
+ cursor.execute(sql) # 파라미터 사용
+ column_names = [desc[0] for desc in cursor.description]
+ rows = cursor.fetchall() # 튜플의 리스트로 결과 가져오기
+
+ result_list = []
+ if rows:
+ for row_tuple in rows:
+ result_dict = dict(zip(column_names, row_tuple))
+ result_list.append(result_dict)
+ else:
+ logging.info("쿼리 결과가 없습니다.")
+
+ return result_list
+
+ except AirflowSkipException as e:
+ message = f"select_sql_to_dict() AirflowSkipException: {str(e)}"
+ logging.warning(message)
+ raise AirflowSkipException(message)
+ except AirflowFailException as e:
+ message = f"select_sql_to_dict() AirflowFailException: {str(e)}"
+ logging.error(message)
+ raise AirflowFailException(message)
+ except AirflowException as e: # 다른 Airflow 예외
+ message = f"select_sql_to_dict() AirflowException: {str(e)}"
+ logging.error(message)
+ raise AirflowException(message)
+ except Exception as e: # DB 연결 오류, SQL 문법 오류 등 포함
+ message = f"select_sql_to_dict() Exception: {type(e).__name__} - {str(e)}"
+ logging.error(message, exc_info=True) # Traceback 로깅
+ raise AirflowException(message) # AirflowException으로 래핑
+
+ finally:
+ if message:
+ logging.info(message)
+
+
+ @staticmethod
+ def insert_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+ cursor.execute(sql)
+ result = cursor.rowcount
+ conn.commit()
+ return result
+
+ except AirflowSkipException as e:
+ message = f"insert_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"insert_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"insert_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"insert_sql() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise e
+
+ finally:
+ if message:
+ logging.info(message)
+
+
+ @staticmethod
+ def insert_sql_many(conn: mysql.connector.connection.MySQLConnection, sql: str, data_list: list):
+ result = -1
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ # Execute bulk insert
+ cursor.executemany(sql, data_list)
+ conn.commit()
+ result = cursor.rowcount
+
+ except AirflowSkipException as e:
+ message = f"insert_sql_many() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"insert_sql_many() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"insert_sql_many() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"insert_sql_many() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+ return result
+
+
+ @staticmethod
+ def insert_sql_by_sql_list(conn: mysql.connector.connection.MySQLConnection, sql_list: list):
+ result = -1
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ for sql in sql_list:
+ try:
+ cursor.execute(sql)
+ except Exception as e:
+ logging.error(f"Error executing SQL: {sql}")
+ raise e
+ conn.commit()
+
+ except AirflowSkipException as e:
+ message = f"insert_sql_by_sql_list() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"insert_sql_by_sql_list() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"insert_sql_by_sql_list() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"insert_sql_by_sql_list() Exception: {str(e)}"
+ logging.error(message, exc_info=True)
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+ return result
+
+
+ @staticmethod
+ def update_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+ cursor.execute(sql)
+ result = cursor.rowcount
+ conn.commit()
+ return result
+
+ except AirflowSkipException as e:
+ message = f"update_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"update_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"update_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"update_sql() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise e
+
+ finally:
+ if message:
+ logging.info(message)
+
+
+ @staticmethod
+ def update_sql_many(conn: mysql.connector.connection.MySQLConnection, sql: str, data_list: list):
+ result = -1
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ # Execute bulk insert
+ cursor.executemany(sql, data_list)
+ conn.commit()
+ result = cursor.rowcount
+
+ except AirflowSkipException as e:
+ message = f"update_sql_many() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"update_sql_many() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"update_sql_many() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"update_sql_many() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+ return result
+
+
+ @staticmethod
+ def update_sql_by_sql_list(conn: mysql.connector.connection.MySQLConnection, sql_list: list):
+ result = -1
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ for sql in sql_list:
+ cursor.execute(sql)
+ conn.commit()
+
+ except AirflowSkipException as e:
+ message = f"update_sql_many_only_query() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"update_sql_many_only_query() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"update_sql_many_only_query() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"update_sql_many_only_query() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+ return result
+
+
+
+
+
+
+ @staticmethod
+ def transaction_sql(conn: mysql.connector.connection.MySQLConnection, sql_list):
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ for sql in sql_list:
+ cursor.execute(sql)
+ conn.commit()
+
+ except AirflowSkipException as e:
+ message = f"transaction_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"transaction_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"transaction_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"transaction_sql() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+
+ @staticmethod
+ def process_sql(conn: mysql.connector.connection.MySQLConnection, sql):
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+ cursor.execute(sql)
+ conn.commit()
+
+ except AirflowSkipException as e:
+ message = f"process_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"process_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"process_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"process_sql() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+
+
+ @staticmethod
+ def procedure_sql(conn: mysql.connector.connection.MySQLConnection, procedure_name: str, params: tuple = None):
+ """
+ 저장 프로시저를 실행하고 결과를 반환합니다.
+
+ Args:
+ conn_id (str): 데이터베이스 연결 ID
+ procedure_name (str): 실행할 저장 프로시저 이름
+ params (tuple, optional): 프로시저 파라미터. 기본값은 None
+
+ Returns:
+ dict: 프로시저 실행 결과. 컬럼명을 키로 하는 딕셔너리 형태로 반환됨
+ """
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ # 파라미터가 있는 경우 처리
+ if params:
+ param_values = []
+ for p in params:
+ if p is None:
+ param_values.append('NULL')
+ elif isinstance(p, (int, float)):
+ param_values.append(str(p))
+ else:
+ # 문자열 이스케이프 처리
+ escaped_p = str(p).replace("'", "''")
+ param_values.append(f"'{escaped_p}'")
+
+ # 프로시저 호출 SQL 생성
+ sql = f"CALL {procedure_name}({', '.join(param_values)})"
+ else:
+ # 파라미터가 없는 경우
+ sql = f"CALL {procedure_name}()"
+
+ logging.debug(f"Executing SQL: {sql}")
+
+ # 프로시저 실행
+ cursor.execute(sql)
+
+ # 결과 처리
+ result = None
+ if cursor.description:
+ columns = [col[0] for col in cursor.description]
+ row = cursor.fetchone()
+ if row:
+ result = dict(zip(columns, row))
+ logging.debug(f"Procedure result: {result}")
+ logging.info(f"Procedure executed. Affected rows (cursor.rowcount): {cursor.rowcount}")
+
+ while cursor.nextset():
+ pass
+
+ conn.commit()
+ return result
+
+ except AirflowSkipException as e:
+ message = f"procedure_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"procedure_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"procedure_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"procedure_sql() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+
+
+ @staticmethod
+ def procedure_sql_many(conn: mysql.connector.connection.MySQLConnection, procedure_name: str, data_set: list):
+ """
+ 저장 프로시저를 여러 번 실행하고 결과를 반환합니다.
+
+ Args:
+ conn_id (str): 데이터베이스 연결 ID
+ procedure_name (str): 실행할 저장 프로시저 이름
+ data_set (list): 프로시저 파라미터 리스트. 각 항목은 튜플이나 리스트 형태여야 함
+
+ Returns:
+ list: 프로시저 실행 결과 리스트. 각 결과는 딕셔너리 형태로 반환됨
+ """
+ message = ""
+ try:
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ results = []
+ for params in data_set:
+ try:
+ # 파라미터 타입에 따른 SQL 쿼리 생성
+ param_values = []
+ for p in params:
+ if p is None:
+ param_values.append('NULL')
+ elif isinstance(p, (int, float)):
+ param_values.append(str(p))
+ else:
+ # 문자열 이스케이프 처리
+ escaped_p = str(p).replace("'", "''")
+ param_values.append(f"'{escaped_p}'")
+
+ # 프로시저 호출 SQL 생성
+ sql = f"CALL {procedure_name}({', '.join(param_values)})"
+ logging.debug(f"Executing SQL: {sql}")
+
+ # 프로시저 실행
+ cursor.execute(sql)
+
+ # 결과 처리
+ if cursor.description:
+ columns = [col[0] for col in cursor.description]
+ row = cursor.fetchone()
+ if row:
+ result = dict(zip(columns, row))
+ results.append(result)
+ logging.debug(f"Procedure result: {result}")
+
+ # 커서 초기화
+ while cursor.nextset():
+ pass
+
+ except Exception as e:
+ logging.error(f"Error executing procedure with params {params}: {str(e)}")
+ raise
+
+ conn.commit()
+ logging.info(f"Successfully executed {len(data_set)} procedure calls")
+ return results
+
+ except AirflowSkipException as e:
+ message = f"procedure_sql_many() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"procedure_sql_many() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"procedure_sql_many() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"procedure_sql_many() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if message:
+ logging.info(message)
+
+
+
diff --git a/backend/airflow/dags/plugins/sql/hooks/mysql_hook1.py b/backend/airflow/dags/plugins/sql/hooks/mysql_hook1.py
new file mode 100644
index 0000000..7036346
--- /dev/null
+++ b/backend/airflow/dags/plugins/sql/hooks/mysql_hook1.py
@@ -0,0 +1,533 @@
+import inspect
+import pymysql
+import logging
+import mysql.connector
+from sqlalchemy.engine import Engine
+from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
+from airflow.providers.mysql.hooks.mysql import MySqlHook
+from sqlalchemy.engine import Engine
+
+TIMEZONE = "Asia/Seoul"
+
+class CommonHookMySQL:
+
+ @staticmethod
+ def _set_timezone(cursor):
+ cursor.execute(f"SET time_zone = '{TIMEZONE}'")
+
+ @staticmethod
+ def select_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
+ try:
+ cursor = conn.cursor()
+ cursor.execute(sql)
+ result = cursor.fetchall()
+
+ if result is None:
+ raise AirflowFailException(f"select result is None")
+
+ return result
+
+ except AirflowSkipException as e:
+ message = f"select_user_data() AirflowSkipException: {str(e)}"
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"select_user_data() AirflowFailException: {str(e)}"
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"select_user_data() AirflowException: {str(e)}"
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"select_user_data() Exception: {str(e)}"
+ raise AirflowException(message)
+
+
+ @staticmethod
+ def select_sql_to_dict(conn_id: str, sql: str): # 파라미터 바인딩 지원 추가
+ """
+ SQL 쿼리를 실행하고 결과를 딕셔너리의 리스트 형태로 반환합니다.
+ MySqlHook을 사용하며, DictCursor에 직접 의존하지 않습니다.
+
+ :param conn_id: Airflow MySQL Connection ID
+ :param sql: 실행할 SELECT SQL 쿼리 문자열
+ :param parameters: SQL 쿼리에 바인딩할 파라미터 (튜플 또는 리스트)
+ :return: 각 행이 딕셔너리인 리스트 (예: [{'col1': val1, 'col2': val2}, ...])
+ :rtype: list[dict]
+ """
+
+ conn = None
+ cursor = None
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ cursor.execute(sql) # 파라미터 사용
+ column_names = [desc[0] for desc in cursor.description]
+ rows = cursor.fetchall() # 튜플의 리스트로 결과 가져오기
+
+ result_list = []
+ if rows:
+ for row_tuple in rows:
+ result_dict = dict(zip(column_names, row_tuple))
+ result_list.append(result_dict)
+ else:
+ logging.info("쿼리 결과가 없습니다.")
+
+ return result_list
+
+ except AirflowSkipException as e:
+ message = f"select_sql_to_dict() AirflowSkipException: {str(e)}"
+ logging.warning(message)
+ raise AirflowSkipException(message)
+ except AirflowFailException as e:
+ message = f"select_sql_to_dict() AirflowFailException: {str(e)}"
+ logging.error(message)
+ raise AirflowFailException(message)
+ except AirflowException as e: # 다른 Airflow 예외
+ message = f"select_sql_to_dict() AirflowException: {str(e)}"
+ logging.error(message)
+ raise AirflowException(message)
+ except Exception as e: # DB 연결 오류, SQL 문법 오류 등 포함
+ message = f"select_sql_to_dict() Exception: {type(e).__name__} - {str(e)}"
+ logging.error(message, exc_info=True) # Traceback 로깅
+ raise AirflowException(message) # AirflowException으로 래핑
+
+
+ @staticmethod
+ def insert_sql(conn_id: str, sql: str):
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+ cursor.execute(sql)
+ result = cursor.rowcount
+ conn.commit()
+ return result
+
+ except AirflowSkipException as e:
+ message = f"insert_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"insert_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"insert_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ if conn:
+ conn.rollback()
+ raise e
+
+
+ @staticmethod
+ def insert_sql_many(conn_id: str, sql: str, data_list: list):
+ result = -1
+ conn = None
+ cursor = None
+
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ # Execute bulk insert
+ cursor.executemany(sql, data_list)
+ conn.commit()
+ result = cursor.rowcount
+
+ except AirflowSkipException as e:
+ message = f"insert_sql_many() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"insert_sql_many() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"insert_sql_many() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"insert_sql_many() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ return result
+
+
+ @staticmethod
+ def update_sql_many(conn_id: str, sql: str, data_list: list):
+ result = -1
+ conn = None
+ cursor = None
+
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ # Execute bulk insert
+ cursor.executemany(sql, data_list)
+ conn.commit()
+ result = cursor.rowcount
+
+ except AirflowSkipException as e:
+ message = f"update_sql_many() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"update_sql_many() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"update_sql_many() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"update_sql_many() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ return result
+
+
+ @staticmethod
+ def update_sql_by_sql_list(conn_id: str, sql_list: list):
+ result = -1
+ conn = None
+ cursor = None
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ for sql in sql_list:
+ cursor.execute(sql)
+ conn.commit()
+
+ except AirflowSkipException as e:
+ message = f"update_sql_many_only_query() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"update_sql_many_only_query() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"update_sql_many_only_query() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"update_sql_many_only_query() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ return result
+
+
+
+
+
+
+ @staticmethod
+ def transaction_sql(conn_id: str, sql_list):
+ conn = None
+ cursor = None
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ for sql in sql_list:
+ cursor.execute(sql)
+ conn.commit()
+
+ except AirflowSkipException as e:
+ message = f"transaction_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"transaction_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"transaction_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"transaction_sql() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ @staticmethod
+ def process_sql(conn_id: str, sql):
+ conn = None
+ cursor = None
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+ cursor.execute(sql)
+ conn.commit()
+
+ except AirflowSkipException as e:
+ message = f"process_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"process_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"process_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"process_sql() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+
+ @staticmethod
+ def procedure_sql(conn_id: str, procedure_name: str, params: tuple = None):
+ """
+ 저장 프로시저를 실행하고 결과를 반환합니다.
+
+ Args:
+ conn_id (str): 데이터베이스 연결 ID
+ procedure_name (str): 실행할 저장 프로시저 이름
+ params (tuple, optional): 프로시저 파라미터. 기본값은 None
+
+ Returns:
+ dict: 프로시저 실행 결과. 컬럼명을 키로 하는 딕셔너리 형태로 반환됨
+ """
+ conn = None
+ cursor = None
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ # 파라미터가 있는 경우 처리
+ if params:
+ param_values = []
+ for p in params:
+ if p is None:
+ param_values.append('NULL')
+ elif isinstance(p, (int, float)):
+ param_values.append(str(p))
+ else:
+ # 문자열 이스케이프 처리
+ escaped_p = str(p).replace("'", "''")
+ param_values.append(f"'{escaped_p}'")
+
+ # 프로시저 호출 SQL 생성
+ sql = f"CALL {procedure_name}({', '.join(param_values)})"
+ else:
+ # 파라미터가 없는 경우
+ sql = f"CALL {procedure_name}()"
+
+ logging.debug(f"Executing SQL: {sql}")
+
+ # 프로시저 실행
+ cursor.execute(sql)
+
+ # 결과 처리
+ result = None
+ if cursor.description:
+ columns = [col[0] for col in cursor.description]
+ row = cursor.fetchone()
+ if row:
+ result = dict(zip(columns, row))
+ logging.debug(f"Procedure result: {result}")
+ logging.info(f"Procedure executed. Affected rows (cursor.rowcount): {cursor.rowcount}")
+
+ while cursor.nextset():
+ pass
+
+ conn.commit()
+ return result
+
+ except AirflowSkipException as e:
+ message = f"procedure_sql() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"procedure_sql() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"procedure_sql() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"procedure_sql() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn.close()
+
+
+ @staticmethod
+ def procedure_sql_many(conn_id: str, procedure_name: str, data_set: list):
+ """
+ 저장 프로시저를 여러 번 실행하고 결과를 반환합니다.
+
+ Args:
+ conn_id (str): 데이터베이스 연결 ID
+ procedure_name (str): 실행할 저장 프로시저 이름
+ data_set (list): 프로시저 파라미터 리스트. 각 항목은 튜플이나 리스트 형태여야 함
+
+ Returns:
+ list: 프로시저 실행 결과 리스트. 각 결과는 딕셔너리 형태로 반환됨
+ """
+ conn = None
+ cursor = None
+ try:
+ db_hook = MySqlHook(mysql_conn_id=conn_id)
+ conn = db_hook.get_conn()
+ cursor = conn.cursor()
+ CommonHookMySQL._set_timezone(cursor)
+
+ results = []
+ for params in data_set:
+ try:
+ # 파라미터 타입에 따른 SQL 쿼리 생성
+ param_values = []
+ for p in params:
+ if p is None:
+ param_values.append('NULL')
+ elif isinstance(p, (int, float)):
+ param_values.append(str(p))
+ else:
+ # 문자열 이스케이프 처리
+ escaped_p = str(p).replace("'", "''")
+ param_values.append(f"'{escaped_p}'")
+
+ # 프로시저 호출 SQL 생성
+ sql = f"CALL {procedure_name}({', '.join(param_values)})"
+ logging.debug(f"Executing SQL: {sql}")
+
+ # 프로시저 실행
+ cursor.execute(sql)
+
+ # 결과 처리
+ if cursor.description:
+ columns = [col[0] for col in cursor.description]
+ row = cursor.fetchone()
+ if row:
+ result = dict(zip(columns, row))
+ results.append(result)
+ logging.debug(f"Procedure result: {result}")
+
+ # 커서 초기화
+ while cursor.nextset():
+ pass
+
+ except Exception as e:
+ logging.error(f"Error executing procedure with params {params}: {str(e)}")
+ raise
+
+ conn.commit()
+ logging.info(f"Successfully executed {len(data_set)} procedure calls")
+ return results
+
+ except AirflowSkipException as e:
+ message = f"procedure_sql_many() AirflowSkipException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowSkipException(message)
+
+ except AirflowFailException as e:
+ message = f"procedure_sql_many() AirflowFailException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowFailException(message)
+
+ except AirflowException as e:
+ message = f"procedure_sql_many() AirflowException: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ except Exception as e:
+ message = f"procedure_sql_many() Exception: {str(e)}"
+ if conn:
+ conn.rollback()
+ raise AirflowException(message)
+
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn.close()
+
+
+
diff --git a/backend/airflow/dags/plugins/utils/__pycache__/scraplib.cpython-313.pyc b/backend/airflow/dags/plugins/utils/__pycache__/scraplib.cpython-313.pyc
new file mode 100644
index 0000000..75ac741
Binary files /dev/null and b/backend/airflow/dags/plugins/utils/__pycache__/scraplib.cpython-313.pyc differ
diff --git a/backend/airflow/dags/plugins/utils/common_datetime.py b/backend/airflow/dags/plugins/utils/common_datetime.py
new file mode 100644
index 0000000..a603aec
--- /dev/null
+++ b/backend/airflow/dags/plugins/utils/common_datetime.py
@@ -0,0 +1,22 @@
+import pendulum
+
+from datetime import datetime, timezone, timedelta
+
+
+TIMEZONE_KST = timezone(timedelta(hours=9))
+
+
+class CommonDatetime:
+ @staticmethod
+ def make_kst_timezone():
+ return pendulum.timezone("Asia/Seoul")
+
+ @staticmethod
+ def make_datetime_kst_from_ts(ts: str) -> datetime:
+ return datetime.fromisoformat(ts).astimezone(TIMEZONE_KST)
+
+
+ # DATETIME으로 입력받고 한국 시간적용된 시간으로 리턴턴
+ @staticmethod
+ def make_datetime_kst_from_datetime(dt: datetime) -> datetime:
+ return dt.astimezone(TIMEZONE_KST)
diff --git a/backend/airflow/dags/plugins/utils/i2db.py b/backend/airflow/dags/plugins/utils/i2db.py
new file mode 100644
index 0000000..41110e1
--- /dev/null
+++ b/backend/airflow/dags/plugins/utils/i2db.py
@@ -0,0 +1,1238 @@
+import json
+import os
+import re
+import copy
+# from konfig import Config
+from datetime import datetime
+import sys
+import multiprocessing
+import threading
+import plugins.utils.scraplib as scraplib #공통 라이브러리
+import requests
+from concurrent.futures import ThreadPoolExecutor
+
+# pluins
+from plugins.sql.hooks.mysql_hook import CommonHookMySQL
+
+#환경설정 파일 로드
+# CONF = Config(os.path.dirname(__file__) + "\\conf.ini")
+# DB_CONF = CONF.get_map("DB")
+# DRIVER = CONF.get_map("DRIVER")
+# DRIVER["EXT_PATH"] = os.path.dirname(__file__) + DRIVER["EXT_PATH"]
+#===============================================================================================================================================
+#공고처리 db
+#===============================================================================================================================================
+# 공고문 자동화
+# self 로 불러오면 스레드때문에 여러개의 생성자를 계속생성함, db터짐
+
+
+
+# DB 연결 ID
+DB_CONN_ID = "COLLECT_SERVER_DB"
+
+def html_conv(bidid):
+ Util = scraplib.Util()
+ #auto_link = "http://222.234.3.20:9090/html_extract?bidid={bidid}".format(bidid=bidid)
+ auto_link = "http://222.234.3.21:9090/html_extract?bidid={bidid}".format(bidid=bidid)
+ print(auto_link)
+
+ try:
+ Util.get_URL(auto_link, {})
+ return "suceess"
+ except Exception as e:
+ print(e)
+ return e
+
+class I2:
+ #######################################################################
+ #######################공고 처리 부분####################################
+ #######################################################################
+ def __init__(self):
+ self.dbconn_BI = scraplib.dbconn()
+ self.Util = scraplib.Util()
+ self.Etl = scraplib.Etl()
+
+ self.worker_tp = ThreadPoolExecutor(10)
+
+
+
+ #create
+ #self.dbconn_BI.conn.cursor() # dbclose
+ def create_doc(self, syscollect, item_all_data, pattern):
+ print("테스트")
+
+ item_all = copy.deepcopy(item_all_data)
+ bid_key = item_all['bid_key']if "bid_key" in item_all else []
+ bid_value = item_all['bid_value'] if "bid_value" in item_all else []
+ bid_content = item_all['bid_content'] if "bid_content" in item_all else []
+ bid_local = item_all['bid_local'] if "bid_local" in item_all else []
+
+ # 2024.07.15 추가 -> 공고 원문에서 제공하는 텍스트 본연의 데이터를 저장하는 공간 생성
+ bid_org = item_all['bid_org'] if "bid_org" in item_all else []
+
+
+ bid_goods = item_all['bid_goods'] if "bid_goods" in item_all else []
+ bid_memo = item_all['bid_memo'] if "bid_memo" in item_all else []
+ bid_notice_memo = item_all['bid_notice_memo'] if "bid_notice_memo" in item_all else []
+
+ bid_res = item_all['bid_res'] if "bid_res" in item_all else []
+ bid_succom = item_all['bid_succom'] if "bid_succom" in item_all else []
+ bid_succom_mutual_flag = item_all['bid_succom_mutual_flag'] if "bid_succom_mutual_flag" in item_all else []
+ premiumList = item_all['premiumList'] if "premiumList" in item_all else []
+ const_cost_arr = item_all['bid_const_cost_list'] if "bid_const_cost_list" in item_all else []
+ #재입찰추가
+ const_cost_arr_rebid = item_all['const_cost_arr_rebid'] if "const_cost_arr_rebid" in item_all else []
+ bbs_common_issue = item_all['bbs_common_issue'] if "bbs_common_issue" in item_all else []
+ #재입찰추가끝
+
+ join_part_list = item_all['join_part_list'] if "join_part_list" in item_all else []
+ mutual_text = item_all['mutual_text'] if "mutual_text" in item_all else []
+ bid_error = item_all['bid_error'] if "bid_error" in item_all else []
+ nbid_jongsim_lnk = item_all['nbid_jongsim_lnk'] if "nbid_jongsim_lnk" in item_all else []
+
+ auto_service = item_all['auto_service'] if "auto_service" in item_all else []
+
+ uniform_price_list = item_all['uniform_price_list'] if "uniform_price_list" in item_all else []
+
+ crawl_text_log = item_all['crawl_text_log'] if "crawl_text_log" in item_all else []
+
+ print("****************************************")
+ #print(bid_local)
+ print("****************************************")
+
+ #item_etc = item_all['item_etc'] if "item_etc" in item_all else []
+
+
+ #bid_key = item_all['bid_key']
+ #bid_value = item_all['bid_value']
+ #bid_content = item_all['bid_content']
+ #item_etc = item_all['item_etc']
+ #bid_res = item_all['bid_res']
+ #bid_succom = item_all['bid_succom']
+ whereis = bid_key['whereis']
+ bidtype = bid_key['bidtype']
+
+ notinum = syscollect['dkey']
+ #notinum_ex예외처리
+ ext_whereis = ["53", "04","90","07","11","52","05","03"]# 철도시설공단, 가스공사, k아파트, 수자원공사, 석유공사, 철도공사,토지주택공사, 한전
+ if whereis in ext_whereis:
+ notinum_ex = ''
+ else:
+ notinum_ex = syscollect['dkey_ext']
+ proc = bid_key['bidproc'] #나중에 전체적용할때는 bid_key기준으로 넣는다. 그때는 item_etc['bidproc'] 뺄 예정 item_etc['bidproc']
+ rebid_no = syscollect['rebid_no']
+ if rebid_no == '':
+ rebid_no = None
+ bunryu_no = None #이게 뭔질 모르겟음 #복수공고에서 쓸것처럼 보임 #데이터확인결과 사실상 안쓰는듯
+ if bunryu_no == '':
+ bunryu_no = None
+
+
+ # 정정공고인 경우 임의로 수집되게 허용된 발주처
+ # 공고번호가 같아도 수집될수있게 허용한다. 후에 공고번호를 업데이트하여 정상처리
+ randomly_notinum_bidproc_m = ["93", "90", "91", "15"] #마사회, k아파트, 한국전자통신연구원, 국토정보공사
+ i2db_cnf={}
+ if whereis in randomly_notinum_bidproc_m:
+ if bid_key['bidproc'] == 'M':
+ i2db_cnf['notinum'] = notinum + "-1"
+ else:
+ i2db_cnf['notinum'] = notinum
+ else:
+ i2db_cnf['notinum'] = notinum
+
+ #rebid_no None처리( new_rebid() 안타게 하기위해) 발주처
+ # 재입찰있는데 재입찰 번호 필요없는 발주처 : 한국철도공사,전자통신연구원, 국토정보공사, KDN,K-apt
+ # 한국철도공사, 국방부, 석유공사, 비드포유, 학교장터, 한국수력원자력, 가스공사, 철도시설공단, 전자통신연구원, 토지주택공사, 국제협력단. 마사회, 국토정보공사,강원랜드, KDN, K-apt, SR, KBS
+ rebid_no_is_None = ["52", "10", "11", "17", "13", "07", "04", "53", "91", "05", "83", "93", "15", "16", "81", "90", "20", "19", "44", "21"]
+ if whereis in rebid_no_is_None:
+ i2db_cnf['rebid_no'] = None
+ else:
+ i2db_cnf['rebid_no'] = rebid_no
+
+ i2db_cnf['notinum_ex'] = notinum_ex
+ i2db_cnf['proc'] = proc
+
+ i2db_cnf['bunryu_no'] = bunryu_no
+ i2db_cnf['pattern'] = pattern
+ i2db_cnf['whereis'] = whereis
+
+ # 한전 공고수집시 이슈 조치 (2022.11.10)
+ # 용역으로 수집되는 조건이지만 입력자 입력시 공사로 변경한 공고의 경우 -> 전차수 공고로 매칭이 안되는 현상 발생
+ # 다른 발주처도 적용해도 되는지 확인이 안되어 한전만 우선 적용
+ if whereis == "03": bidtype = None
+
+ i2db_cnf['bidtype'] = bidtype
+
+ print("===============================")
+ print(pattern)
+ print(i2db_cnf['notinum'])
+ print("===============================")
+
+ pno = re.match(pattern, i2db_cnf['notinum'])
+ if pno:
+ print("공고처리 시작")
+ else:
+ print("공고번호 패턴이 맞지 않습니다.")
+ return False
+
+ #init 처리 : 필요없는경우 버릴듯
+ #공고번호 형식 유무
+
+
+
+ #recollect인지 확인
+ #입/낙찰 구분 함
+ if syscollect["recollect_stats"] == True or syscollect["proc"] == 'recollect':
+ # 개찰쪽
+ if i2db_cnf['proc'] == 'S' or i2db_cnf['proc'] == 'F':
+ self.load_last_bidid(i2db_cnf, 'nbid')
+ else: #입찰쪽쪽
+ self.load_last_bidid(i2db_cnf, 'bid')
+
+ #중복체크(함수로뺌)
+ b = self.isdup(i2db_cnf, 'duple')
+ if b == True:
+ print("이미 입력된 공고")
+ return False
+
+ #개찰
+ if proc == 'S' or proc == 'F':
+ bidid = self.load_last_bidid(i2db_cnf, 'bidid')
+ if bidid is None: #입찰 입력이 안되있으면 컷
+ print("입찰이없는공고")
+ return False
+ usp_query = "CALL usp_bid_proc('{bidid}', '{proc}', @bidid)".format(bidid=bidid, proc=proc)
+ usp_res = self.dbconn_BI.sql_exec(usp_query, "D")
+ new_bidid = bidid
+
+ #입찰
+ elif proc == 'R' and i2db_cnf['rebid_no'] is not None:
+ print("재입찰처리")
+ new_bidid = self.new_rebid(i2db_cnf)
+ elif proc == 'B' and i2db_cnf['bunryu_no'] is not None:
+ print("정상공고 이면서 분류번호 값이 있는경우 복수공고 등으로 보임. 사실상 사용안하는거같음")
+ new_bidid = self.new_bunryu(i2db_cnf)
+ else:
+ print("기타 조건 (정상, 정정, 취소, 재입찰 등) ")
+ new_bidid= self.new_normal(i2db_cnf)
+
+ print("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
+ print(new_bidid)
+ print("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
+
+ # 관내 처리
+ if len(bid_local) > 0:
+ bid_local = self.Etl.filter_bid_local(bid_local)
+ item_all['bid_local'] = bid_local # 밑에 gonggo_opt_stting에 item_all에 넣기 위함
+
+ #A값/순공사원가 처리
+ if new_bidid is not None and new_bidid != '': #공고처리 완료후
+ #bidproc 초기화
+ if proc != 'C':
+ bid_key['bidproc'] = None #상단에서 이미 bid_key의 bidproc을 세팅하여 스켈레톤 코드를 만들었으므로 더이상 업데이트 되지 않게 처리
+
+ # opt, constnm 처리
+ if len(bid_key) > 0 :
+ bid_key['opt'] = self.gonggo_opt_stting(proc, item_all, new_bidid)
+ if proc !='S' and proc != 'F':
+ bid_key['constnm'] = self.gonggo_constnm_stting(proc, item_all, new_bidid)
+ if proc !='R':
+ bid_key['concode'] = self.gonggo_concode_stting(item_all)
+
+ # UPSERT 처리 데이터 리스트
+ upsert_bid_key_list = []
+ upsert_bid_value_list = []
+ upsert_bid_content_list = []
+ upsert_bid_res_list = []
+ upsert_bid_memo_list = []
+ upsert_bid_notice_memo_list = []
+ upsert_premiumList_list = []
+ ##############################################
+ #################고정 업데이트 #################
+ ##############################################
+ #bid_key
+ if proc != 'S' and proc != 'F':
+ bid_key['writedt'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ _bid_key = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_key",
+ "type": "update",
+ "value": dict(bid_key),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_key)
+ #bid_value
+ _bid_value = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_value",
+ "type": "update",
+ "value": dict(bid_value),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_value)
+ #bid_content
+ _bid_content = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_content",
+ "type": "update",
+ "whereis": whereis,
+ "value": dict(bid_content),
+ "del_col": [],
+ "bidtype" : bidtype
+ }
+ self.dbconn_BI.Upsert_table(_bid_content)
+ #bid_res
+ _bid_res = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_res",
+ "type": "update",
+ "value": dict(bid_res),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_res)
+
+ ##############################################
+ #################유동 업데이트 #################
+ ##############################################
+ ###########
+ ####단일####
+ ###########
+ #bid_memo
+ if len(bid_memo) > 0:
+ bid_memo['bidid'] = new_bidid
+ _bid_memo = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_memo",
+ "type": "insertonly",
+ "value": dict(bid_memo),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_memo)
+ # bid_notice_memo
+ if len(bid_notice_memo) > 0:
+ bid_notice_memo['bidid'] = new_bidid
+ _bid_notice_memo = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_notice_memo",
+ "type": "insertonly",
+ "value": dict(bid_notice_memo),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_notice_memo)
+ # preminuList
+ if len(premiumList) > 0:
+ premiumList['bidid'] = new_bidid
+ premiumList['writedt'] = "NOW()"
+ if "cost1" in premiumList and premiumList['cost1'] is not None and premiumList['cost1'] != '' or\
+ "cost2" in premiumList and premiumList['cost2'] is not None and premiumList['cost2'] != '' or\
+ "cost3" in premiumList and premiumList['cost3'] is not None and premiumList['cost3'] != '' or\
+ "cost4" in premiumList and premiumList['cost4'] is not None and premiumList['cost4'] != '' or \
+ "cost5" in premiumList and premiumList['cost5'] is not None and premiumList['cost5'] != '' or \
+ "cost6" in premiumList and premiumList['cost6'] is not None and premiumList['cost6'] != '' or\
+ "cost7" in premiumList and premiumList['cost7'] is not None and premiumList['cost7'] != '' or\
+ "cost8" in premiumList and premiumList['cost8'] is not None and premiumList['cost8'] != '' or\
+ "cost_total" in premiumList and premiumList['cost_total'] is not None and premiumList['cost_total'] != '' or\
+ "direct_labor_cost" in premiumList and premiumList['direct_labor_cost'] is not None and premiumList['direct_labor_cost'] != '':
+ _premiumList = {
+ "key": {"bidid": new_bidid},
+ "table": "premiumList",
+ "type": "insertonly",
+ "value": dict(premiumList),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_premiumList)
+
+ #mutual_text
+ if len(mutual_text) > 0:
+ mutual_text['bidid'] = new_bidid
+ _mutual_text = {
+ "key": {"bidid": new_bidid},
+ "table": "mutual_text",
+ "type": "insertonly",
+ "value": dict(mutual_text),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_mutual_text)
+ #순공사원가
+ if len(const_cost_arr) > 0:
+ self.Etl.confirm_const_cost_list_new(bid_key, proc, const_cost_arr, new_bidid)
+ ##재입찰추가##
+ #순공사원가 재입찰 #재입찰은 기존차수데이터 인설트만 해준다
+ if len(const_cost_arr_rebid) > 0:
+ const_cost_arr_rebid['bidid'] = new_bidid
+ const_cost_arr_rebid['writedt'] = "NOW()"
+ _const_cost_arr_rebid = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_const_cost_list",
+ "type": "insertonly",
+ "value": dict(const_cost_arr_rebid),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_const_cost_arr_rebid)
+ #내역첨부여부
+ if len(bbs_common_issue) > 0:
+ bbs_common_issue['bidid'] = new_bidid
+ _bbs_common_issue = {
+ "key": {"bidid": new_bidid},
+ "table": "bbs_common_issue",
+ "type": "insertonly",
+ "value": dict(bbs_common_issue),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bbs_common_issue)
+ ##재입찰추가끝##
+ ###########
+ ####배열####
+ ###########
+ #bid_local
+ if len(bid_local) > 0:
+ for idx in bid_local:
+ if bid_local[idx] is not None:
+ if "loccode"in bid_local[idx]:
+ del bid_local[idx]['loccode']
+ bid_local[idx]['bidid'] = new_bidid
+ _bid_local = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_local",
+ "type": "insertonly",
+ "value": dict(bid_local[idx]),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_local)
+ #bid_goods
+ if len(bid_goods) > 0:
+ for idx in bid_goods:
+ bid_goods[idx]['bidid'] = new_bidid
+ _bid_goods = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_goods",
+ "type": "insertonly",
+ "value": dict(bid_goods[idx]),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_goods)
+ # bid_error
+ if len(bid_error) > 0:
+ for idx in bid_error:
+ _bid_err = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_error",
+ "type": "insertonly",
+ "value": {"err_code": bid_error[idx], "writedt": "NOW()", "bidid": new_bidid},
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_err)
+ # nbid_jongsim_lnk
+ if len(nbid_jongsim_lnk) > 0:
+ nbid_jongsim_lnk['bidid'] = new_bidid
+ _nbid_jongsim_lnk = {
+ "key": {"bidid": new_bidid},
+ "table": "nbid_jongsim_lnk",
+ "type": "insertonly",
+ "value": dict(nbid_jongsim_lnk),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_nbid_jongsim_lnk)
+ # bid_succom
+ if len(bid_succom) > 0:
+ for idx in bid_succom:
+ bid_succom[idx]['bidid'] = new_bidid
+ _bid_succom = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_succom",
+ "type": "insertonly",
+ "value": dict(bid_succom[idx]),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_succom)
+ # bid_succom_mutual_flag
+ if len(bid_succom_mutual_flag) > 0:
+ for idx in bid_succom_mutual_flag:
+ bid_succom_mutual_flag[idx]['bidid'] = new_bidid
+ _bid_succom_mutual_flag = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_succom_mutual_flag",
+ "type": "insertonly",
+ "value": dict(bid_succom_mutual_flag[idx]),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_succom_mutual_flag)
+ #join_part_list
+ if len(join_part_list) > 0:
+ for idx in join_part_list:
+ join_part_list[idx]['bidid'] = new_bidid
+ _join_part_list = {
+ "key": {"bidid": new_bidid},
+ "table": "join_part_list",
+ "type": "insertonly",
+ "value": dict(join_part_list[idx]),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_join_part_list)
+ #auto_service
+ if len(auto_service) > 0:
+ auto_service['bidid'] = new_bidid
+ auto_service['writedt'] = "NOW()"
+ _auto_service = {
+ "key": {"bidid": new_bidid},
+ "table": "auto_service_log",
+ "type": "insertonly",
+ "value": dict(auto_service),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_auto_service)
+
+ # uniform_price_list
+ if len(uniform_price_list) > 0:
+ uniform_price_list['bidid'] = new_bidid
+ uniform_price_list['writedt'] = "NOW()"
+ _uniform_price_list = {
+ "key": {"bidid": new_bidid},
+ "table": "uniform_price_list",
+ "type": "insertonly",
+ "value": dict(uniform_price_list),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_uniform_price_list)
+
+ # crawl_text_log
+ if len(crawl_text_log) > 0:
+ crawl_text_log['bidid'] = new_bidid
+ crawl_text_log['writedt'] = "NOW()"
+ _crawl_text_log = {
+ "key": {"bidid": new_bidid},
+ "table": "crawl_text_log",
+ "type": "insertonly",
+ "value": dict(crawl_text_log),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_crawl_text_log)
+
+ # bid_org
+ if len(bid_org) > 0:
+ bid_org['bidid'] = new_bidid
+ _bid_org = {
+ "key": {"bidid": new_bidid},
+ "table": "bid_org",
+ "type": "insertonly",
+ "value": dict(bid_org),
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_org)
+
+ # ===============================
+ # SMPP (입찰일때만)
+ # ===============================
+ # if (proc != 'S' or proc != 'F'):# and whereis == '44':
+ # print('SMPPPPP탐')
+ # _where = {}
+ # _where['bidid'] = new_bidid
+ #
+ # # check_bid_common_data --> bidid로 검색해서 있는지 확인
+ # check_bid_common_data = self.dbconn_BI.ck_Exist_cnt(_where, "bid_common_data")
+ # print('check_bid_common_data', check_bid_common_data)
+ #
+ # # 코드 값 return
+ # smpp_code = self.smpp(item_all_data, new_bidid)
+ # print('smpp_code : ', smpp_code)
+ #
+ # if check_bid_common_data > 0: # 데이터 있으면 update
+ # print('데이터 있음 --> UPDATE')
+ # _bid_common_data = {
+ # "key": {"bidid": new_bidid},
+ # "table": "bid_common_data",
+ # "type": "updateonly",
+ # "value": {"bidid": new_bidid, "notinum": notinum, "whereis" : whereis,
+ # "common_data_code" : smpp_code, "editdt" : "NOW()"},
+ # "del_col": []
+ # }
+ # self.dbconn_BI.Upsert_table(_bid_common_data)
+ #
+ # if check_bid_common_data == 0 and smpp_code != False: # 데이터 없고 SMPP 코드값 있으면 insert
+ # print('데이터 없고 SMPP 코드 있음 --> INSERT')
+ # _bid_common_data = {
+ # "key": {"bidid": new_bidid},
+ # "table": "bid_common_data",
+ # "type": "insertonly",
+ # "value": {"bidid": new_bidid, "notinum": notinum, "whereis" : whereis, "common_data_code" : smpp_code,
+ # "writedt" : "NOW()", "editdt" : "NOW()"},
+ # "del_col": []
+ # }
+ # self.dbconn_BI.Upsert_table(_bid_common_data)
+
+
+ #공고문자동화
+ #공고문 자동화 제외
+ ext_auto_gonggo = ["91",'17','21'] #전자통신연구원 , 이비즈포유, 농수산식품유통공사
+ if whereis in ext_auto_gonggo:
+ print("공고문 자동화 패스")
+ elif proc != 'S' and proc != 'F' and proc != 'R':
+ print("ㄱㄱㅁㅈㄷㅎ")
+ #worker_t = threading.Thread(target=html_conv, args=([new_bidid]))
+ #worker_t.setDaemon(True)
+ #worker_t.start()
+ #worker_t.join() #결과값 반환해야 나감 의미없음
+
+
+ # 공고문 자동수집 제외 조건
+ html_conv_process = True
+
+ # 전자시담공고로 자동점검 공고 기준의 경우 제외
+ if bid_key.get('auto_service_code') and 'e_sidam' in bid_key['auto_service_code']:
+ html_conv_process = False
+
+ if html_conv_process:
+ self.worker_tp.submit(html_conv, new_bidid)
+
+
+ print("ㄱㄱㅁㅈㄷㅎ")
+
+ #self.html_conv(new_bidid)
+
+
+ #lock풀기
+ self.unlock(new_bidid)
+ #self.dbconn_BI.conn.close() # dbclose
+
+ return new_bidid
+ else:
+ return False
+
+
+ print("라이브러리 끝")
+
+ #===============================
+ # SMPP 코드 체크
+ #===============================
+ # def smpp(self, item_all, bidid):
+ # print("SMPPPPPPPPPPPPPPPPPPPPPPPPPP")
+ # print('bidid\n', bidid)
+ # bid_key = item_all['bid_key'] if "bid_key" in item_all else []
+ # bid_value = item_all['bid_value'] if "bidㄴ_value" in item_all else []
+ # bid_content = item_all['bid_content'] if "bid_content" in item_all else []
+ #
+ # whereis = bid_key['whereis']
+ # bidtype = bid_key['bidtype']
+ # constnm = bid_key['constnm'].replace("\n", "").replace(" ", "")
+ #
+ # promise_org = ''
+ # bidcomment = ''
+ # bid_html = ''
+ #
+ # if 'promise_org' in bid_value and bid_value['promise_org'] is not None:
+ # promise_org = bid_value['promise_org'].replace("\n", "").replace(" ", "")
+ # print("[계약방법] : ", promise_org)
+ #
+ # if 'bidcomment' in bid_content and bid_content['bidcomment'] is not None:
+ # bidcomment = bid_content['bidcomment'].replace("\n", "").replace(" ", "")
+ # print("[자격조건] : ", bidcomment)
+ #
+ # if 'bid_html' in bid_content and bid_content['bid_html'] is not None:
+ # bid_html = bid_content['bid_html'].replace("\n", "").replace(" ", "")
+ # print("[bid_html] : ", bid_html)
+ #
+ # updateCheckCode = ''
+ # log = []
+ #
+ # auto_set_qry = "SELECT * FROM bid_common_data_auto_set WHERE state = '1'"
+ # auto_set_rst = self.dbconn_BI.sql_exec(auto_set_qry, "S")
+ #
+ # for row in auto_set_rst:
+ #
+ # # 판단 유무 기록
+ # auto_check = {}
+ #
+ # # DB정보 변수 세팅
+ # auto_set_checkCode = row[1]
+ # auto_set_searchWord = row[2]
+ # auto_set_whereis = row[3]
+ # auto_set_bidtype = row[4]
+ #
+ # # -----------------------------------
+ # # 발주처 조건 판단 -> 전체 or 발주처코드
+ # # -----------------------------------
+ # auto_check["whereis"] = 'Y' if auto_set_whereis == 'ALL' or whereis == auto_set_whereis else "N"
+ #
+ # # -----------------------------------
+ # # 공고분류 판단 -> 전체 or 시설,용역,물품
+ # # -----------------------------------
+ # auto_check["bidtype"] = 'Y' if auto_set_bidtype == 'ALL' or bidtype == auto_set_bidtype else "N"
+ #
+ # # -----------------------------------
+ # # 텍스트 비교 판단 -> 매칭, 제외 조건별 체크
+ # # -----------------------------------
+ # auto_check["searchWord"] = 'N'
+ #
+ # pattern = json.loads(auto_set_searchWord)
+ #
+ # # print(pattern)
+ # # 세팅된 공고명 <<< 매칭 >>> key 값이 있고 + 공고명에서 정규식 매칭이 될 경우
+ # if "Ture_constnm" in pattern and len(re.findall(pattern["Ture_constnm"], constnm)) > 0:
+ # # 세팅된 공고명 >>> 제외 <<< key 값이 없거나 | 공고명에서 정규식 매칭이 안될 경우
+ # if ("False_constnm" not in pattern or (
+ # "False_constnm" in pattern and len(re.findall(pattern["False_constnm"], constnm))) <= 0):
+ # auto_check["searchWord"] = 'Y'
+ # log.append(pattern["Ture_constnm"])
+ #
+ # # 세팅된 자격조건 <<< 매칭 >>> key 값이 있고 + 자격조건에서 정규식 매칭이 될 경우
+ # if "Ture_bidcomment" in pattern and len(re.findall(pattern["Ture_bidcomment"], bidcomment)) > 0:
+ # # 세팅된 자격조건 >>> 제외 <<< key 값이 없거나 | 자격조건에서 정규식 매칭이 안될 경우
+ # if ("False_bidcomment" not in pattern or (
+ # "False_bidcomment" in pattern and len(
+ # re.findall(pattern["False_bidcomment"], bidcomment))) <= 0):
+ # auto_check["searchWord"] = 'Y'
+ # log.append(pattern["Ture_bidcomment"])
+ #
+ # # 세팅된 공고문 <<< 매칭 >>> key 값이 있고 + 공고문에서 정규식 매칭이 될 경우
+ # if "Ture_bid_html" in pattern and len(re.findall(pattern["Ture_bid_html"], bid_html)) > 0:
+ # # 세팅된 공고문 >>> 제외 <<< key 값이 없거나 | 공고문에서 정규식 매칭이 안될 경우
+ # if ("False_bid_html" not in pattern or (
+ # "False_bid_html" in pattern and len(re.findall(pattern["False_bid_html"], bid_html))) <= 0):
+ # auto_check["searchWord"] = 'Y'
+ # log.append(pattern["Ture_bid_html"])
+ #
+ # # 세팅된 계약방법 <<< 매칭 >>> key 값이 있고 + 계약방법에서 정규식 매칭이 될 경우
+ # if "Ture_promise_org" in pattern and len(re.findall(pattern["Ture_promise_org"], promise_org)) > 0:
+ # # 세팅된 계약방법 >>> 제외 <<< key 값이 없거나 | 계약방법에서 정규식 매칭이 안될 경우
+ # if ("False_promise_org" not in pattern or (
+ # "False_promise_org" in pattern and len(
+ # re.findall(pattern["False_promise_org"], promise_org))) <= 0):
+ # auto_check["searchWord"] = 'Y'
+ # log.append(pattern["Ture_promise_org"])
+ #
+ # # N으로 판단된 조건이 없을 경우 코드 세팅
+ # if "N" not in auto_check.values():
+ # # SMPP저장된 코드에 없을 경우에만 코드 추가
+ # # if len(re.findall(auto_set_checkCode, common_data_code)) <= 0:
+ # updateCheckCode += auto_set_checkCode + '|'
+ #
+ # updateCheckCode = updateCheckCode.rstrip('|')
+ #
+ # print("==================================================")
+ # print("업데이트 될 코드 --> ", updateCheckCode)
+ # print("체크 된 텍스트", log)
+ #
+ # if updateCheckCode != '':
+ # return updateCheckCode
+ # else:
+ # return False
+
+
+ #중복체크 및 하위데이터 체크
+ def isdup(self, i2db_cnf, type):
+ print("여들어옴")
+ if type == 'child': #하위 데이터체크일경우
+ where_arr = self.build_where(i2db_cnf, 'arr', True)
+ elif type == 'duple': #중복체크
+ if i2db_cnf['proc'] == 'R' and i2db_cnf['rebid_no'] is not None and i2db_cnf['rebid_no'] != '':
+ where_arr = self.build_where(i2db_cnf, 'arr')
+ #duple_q = "SELECT COUNT(*) FROM view_bid_uid WHERE 1 AND {where_q}".format(where_q=where_q)
+ #duple_q = duple_q.replace("bidproc='R'", "bidproc='B'")
+ if where_arr['bidproc']['data'] == 'R': #재입찰일떄만 bidproc을 지워준다.
+ where_arr['bidproc']['data'] = 'B'
+ del where_arr['bidproc']
+ else:
+ where_arr = self.build_where(i2db_cnf, 'arr', None, False)
+ #duple_q = "SELECT COUNT(*) FROM view_bid_uid WHERE 1 AND {where_q}".format(where_q=where_q)
+
+ cnt_flag = self.dbconn_BI.ck_Exist_one_difftype(where_arr, "view_bid_uid") #0보다 크면 true
+ print("@#$@#$@#$")
+ print(cnt_flag)
+ print("@#$@#$@#$")
+ if cnt_flag == True:
+
+ #중복, 하위데이터잇음
+ #정정일 경우에는 자체정정을 한번 체크해준다.
+ if i2db_cnf['proc'] == 'M':
+ mod_arr = {}
+ mod_arr['whereis'] = {}
+ mod_arr['dkey'] = {}
+ mod_arr['whereis']['type'] = 'equal'
+ mod_arr['whereis']['data'] = i2db_cnf['whereis']
+ mod_arr['dkey']['type'] = 'equal'
+ mod_arr['dkey']['data'] = i2db_cnf['notinum']
+
+ if i2db_cnf['whereis'] == '11':
+ if i2db_cnf['rebid_no'] is None:
+ mod_arr['rebid_no'] = {}
+ mod_arr['rebid_no']['type'] = 'equal'
+ mod_arr['rebid_no']['data'] = '0'
+ if i2db_cnf['notinum_ex'] is None:
+ mod_arr['dkey_ext'] = {}
+ mod_arr['dkey_ext']['type'] = 'equal'
+ mod_arr['dkey_ext']['data'] = '0'
+
+ if i2db_cnf['rebid_no'] is not None and i2db_cnf['rebid_no'] != '':
+ mod_arr['rebid_no'] = {}
+ mod_arr['rebid_no']['type'] = 'equal'
+ mod_arr['rebid_no']['data'] = i2db_cnf['rebid_no']
+
+ if i2db_cnf['notinum_ex'] is not None and i2db_cnf['notinum_ex'] != '':
+ mod_arr['dkey_ext'] = {}
+ mod_arr['dkey_ext']['type'] = 'equal'
+ mod_arr['dkey_ext']['data'] = i2db_cnf['notinum_ex']
+
+ mod_flag = self.dbconn_BI.ck_Exist_one_difftype(mod_arr, "mod_data_table") # 0보다 크면 true
+
+ if mod_flag == True:
+ return False
+
+ return True
+ else:
+ return False
+
+
+ #where 만들어주는 함수
+ def build_where(self, i2db_cnf, return_type, isChild = False , state_d_include = True):
+ where_q =""
+ where_arr = {}
+
+ if isChild == True:
+ fno = re.match(i2db_cnf['pattern'], i2db_cnf['notinum'])
+ if fno:
+ where_q = where_q + "notinum LIKE '{fno}%'".format(fno=fno.group(1))
+ where_arr['notinum'] = {}
+ where_arr['notinum']['type'] = 'text'
+ where_arr['notinum']['data'] = "LIKE '{fno}%'".format(fno=fno.group(1))
+ else:
+ print("pattern 매치 결과가 없습니다")
+ return False
+ else:
+ where_q = where_q + "notinum = '{notinum}'".format(notinum=i2db_cnf['notinum'])
+ where_arr['notinum'] = {}
+ where_arr['notinum']['type'] = 'equal'
+ where_arr['notinum']['data'] = i2db_cnf['notinum']
+
+ if i2db_cnf['proc'] == 'B' or i2db_cnf['proc'] == 'M':
+ if i2db_cnf['whereis'] == '81' or i2db_cnf['whereis'] == '11':
+ where_q = where_q + "AND bidproc = '{bidproc}'".format(bidproc=i2db_cnf['proc'])
+ where_arr['bidproc'] = {}
+ where_arr['bidproc']['type'] = 'equal'
+ where_arr['bidproc']['data'] = i2db_cnf['proc']
+
+ if i2db_cnf['proc'] == 'L' or i2db_cnf['proc'] == 'R' or i2db_cnf['proc'] == 'S' or i2db_cnf['proc'] == 'F' or i2db_cnf['proc'] == 'C':
+ if i2db_cnf['proc'] == 'R' and i2db_cnf['rebid_no'] is not None and i2db_cnf['rebid_no'] != '':
+ where_q = where_q + "AND bidid_rebidno = '{rebid_no}'".format(rebid_no=i2db_cnf['rebid_no'])
+ where_arr['bidid_rebidno'] = {}
+ where_arr['bidid_rebidno']['type'] = 'equal'
+ where_arr['bidid_rebidno']['data'] = i2db_cnf['rebid_no']
+
+ where_q = where_q + "AND bidproc = '{proc}'".format(proc=i2db_cnf['proc'])
+ where_arr['bidproc'] = {}
+ where_arr['bidproc']['type'] = 'equal'
+ where_arr['bidproc']['data'] = i2db_cnf['proc']
+
+ where_q = where_q + "AND whereis = '{whereis}'".format(whereis=i2db_cnf['whereis'])
+ where_arr['whereis'] = {}
+ where_arr['whereis']['type'] = 'equal'
+ where_arr['whereis']['data'] = i2db_cnf['whereis']
+
+ if i2db_cnf['bunryu_no'] is not None and i2db_cnf['bunryu_no'] != '':
+ where_q = where_q + "AND bidid_bunryu = '{bunryu_no}'".format(whereis=i2db_cnf['bunryu_no'])
+ where_arr['bidid_bunryu'] = {}
+ where_arr['bidid_bunryu']['type'] = 'equal'
+ where_arr['bidid_bunryu']['data'] = i2db_cnf['bunryu_no']
+
+ if i2db_cnf['notinum_ex'] is not None and i2db_cnf['notinum_ex'] != '':
+ where_q = where_q + "AND notinum_ex = '{notinum_ex}'".format(notinum_ex=i2db_cnf['notinum_ex'])
+ where_arr['notinum_ex'] = {}
+ where_arr['notinum_ex']['type'] = 'equal'
+ where_arr['notinum_ex']['data'] = i2db_cnf['notinum_ex']
+
+ if i2db_cnf['bidtype'] is not None and i2db_cnf['bidtype'] != '':
+ where_q = where_q + "AND bidtype = '{bidtype}'".format(bidtype=i2db_cnf['bidtype'])
+ where_arr['bidtype'] = {}
+ where_arr['bidtype']['type'] = 'equal'
+ where_arr['bidtype']['data'] = i2db_cnf['bidtype']
+
+ if state_d_include == True: #없애도 될거같음. 일단 내비둠
+ where_q = where_q + "AND state NOT in ('D')"
+ where_arr['state'] = {}
+ where_arr['state']['type'] = 'text'
+ where_arr['state']['data'] = "NOT in ('D')"
+
+ if return_type == 'arr':
+ print("where_arr")
+ print(where_arr)
+ return where_arr
+ else:
+ return where_q
+
+ #마지막 공고 데이터
+ def load_last_bidid(self, i2db_cnf, flag):
+ #입찰 낙찰이 같은거같은데, 소스상에스 큰 차이를 보이지않음. 일단 통합
+
+ if flag == 'bidid':
+ where_q = self.build_where(i2db_cnf, 'text', True)
+ else:
+ where_q = self.build_where(i2db_cnf, 'text')
+ query = "SELECT bidid FROM view_bid_uid WHERE 1 AND {where_q} ORDER BY bidid DESC limit 1".format(where_q=where_q)
+ bidid = self.dbconn_BI.sql_exec(query, "S_one")
+
+ if bidid is not None:
+ if flag == 'nbid':
+ table_query = "CALL usp_bid_res_del('{bidid}')".format(bidid=bidid)
+ self.dbconn_BI.sql_exec(table_query, "D")
+ del_query = "DELETE FROM `i2`.`bid_succom` WHERE `bidid` = '{bidid}' ".format(bidid=bidid)
+ self.dbconn_BI.sql_exec(del_query, "D")
+ del_sub_query = "DELETE FROM `i2`.`bid_succom_mutual_flag` WHERE `bidid` = '{bidid}' ".format(bidid=bidid)
+ self.dbconn_BI.sql_exec(del_sub_query, "D")
+ #return None #bidid 셀렉트 할 경우에만 리턴값을 넣어줌. 추후 필요에 의해 변경할 여지 있음
+ elif flag == 'bid':
+ table_query = "CALL usp_bid_del('{bidid}')".format(bidid=bidid)
+ self.dbconn_BI.sql_exec(table_query, "D")
+ #return None # bidid 셀렉트 할 경우에만 리턴값을 넣어줌. 추후 필요에 의해 변경할 여지 있음
+ if flag == 'bidid':
+ return bidid
+
+ return None
+
+ def new_rebid(self, i2db_cnf):
+ last_bidid = self.load_last_bidid(i2db_cnf, 'bidid')
+ if last_bidid is None or last_bidid == '':
+ print('new_rebid create failed')
+ return ''
+ query = "CALL usp_bid_create_rebid2('{last_bidid}', '{rebid_no}', @bidid)".format(last_bidid=last_bidid, rebid_no=i2db_cnf['rebid_no'])
+ res = self.dbconn_BI.sql_exec(query, "D")
+ print("new_rebid")
+ if res == False:
+ return ''
+ bidid = self.dbconn_BI.sql_exec("SELECT @bidid", "S_one")
+ return bidid
+
+ def new_bunryu(self, i2db_cnf):
+ query = "SELECT bidid FROM view_bid_uid WHERE whereis='{whereis}' AND notinum='{notinum}'".format(whereis=i2db_cnf['whereis'], notinum=i2db_cnf['notinum'])
+ parent_bidid = self.dbconn_BI.sql_exec(query, "S_one")
+
+ if parent_bidid is None:
+ parent_bidid = ''
+ if i2db_cnf['bunryu_no'] is None or i2db_cnf['bunryu_no'] == '':
+ print("new_bunryu==> new_bunryu 파라미터 없음")
+ return ''
+ query = "CALL usp_bid_create_bunryu('{parent_bidid}', '{bunryu_no}', @bidid)".format(parent_bidid=parent_bidid, bunryu_no=i2db_cnf['bunryu_no'])
+ res = self.dbconn_BI.sql_exec(query, "D")
+
+ if res == False:
+ return ''
+ bidid = self.dbconn_BI.sql_exec("SELECT @bidid", "S_one")
+ return bidid
+
+ def new_normal(self, i2db_cnf):
+ ischild = self.isdup(i2db_cnf, 'child')
+ bidid = self.bulk(ischild, i2db_cnf)
+
+ #bulk에서 bidid 또는 ''을 반환
+ return bidid
+
+ def bulk(self,ismod, i2db_cnf):
+ proc = i2db_cnf['proc']
+
+ if ismod == False:
+ query = "CALL usp_bid_proc('', '{proc}', @bidid)".format(proc=proc)
+ res = self.dbconn_BI.sql_exec(query, "D")
+ bidid = self.dbconn_BI.sql_exec("SELECT @bidid", "S_one")
+ else:
+ print("@여기@")
+ last_bidid = self.load_last_bidid(i2db_cnf, 'bidid')
+ if proc == 'B':
+ proc = 'M'
+ query = "CALL usp_bid_proc('{last_bidid}', '{proc}', @bidid)".format(last_bidid=last_bidid, proc=proc)
+ res = self.dbconn_BI.sql_exec(query, "D")
+ bidid = self.dbconn_BI.sql_exec("SELECT @bidid", "S_one")
+
+ if bidid is None or bidid == '':
+ print("벌크데이터생성실패")
+ #return ''
+ bidid=''
+ return bidid
+
+ #######################################################################
+ ########################순공사 원가 ###########scraplib로 옯김############
+ #######################################################################
+
+
+
+
+ def gonggo_opt_stting(self, proc, data, bidid ):
+
+ #bid_key = data['bid_key'] if data['bid_key'] is not None else None
+ #bid_local = data['bid_local'] if data['bid_local'] is not None else None
+
+ bid_key = data['bid_key'] if "bid_key" in data else []
+ bid_local = data['bid_local'] if "bid_local" in data else []
+
+ chasu = ''
+ if bidid is not None:
+ chasu = re.match('[A-Z0-9]{15}-([0-9]{2})-[0-9]{2}-[0-9]{2}', bidid)
+ chasu = int(chasu.group(1))
+
+ opt_arr = self.Etl.conv_bin(bid_key['opt']) if bid_key['opt'] is not None else []
+ print("옵션셋")
+ #####발주처별세팅#######
+ if bid_key['whereis'] == '17':
+ if "explaindt" in bid_key and bid_key['explaindt'] is not None:
+ opt_arr.append(3)
+
+ if "convention" in bid_key and bid_key['convention'] is not None and bid_key['convention'] == '1':
+ opt_arr.append(8)
+
+ ######공통#########
+ #opt_arr.append(3)
+ if chasu != '':
+ if proc == 'M' or proc == 'B':
+ if int(chasu) > 0:
+ opt_arr.append(1)
+
+ if proc == 'S' or proc == 'F':
+ opt_arr.append(5)
+
+ if proc == 'C':
+ opt_arr.append(16)
+
+ if proc == 'R':
+ opt_arr.append(17)
+
+ if proc == 'L':
+ opt_arr.append(1) #정정추가해줌
+ opt_arr.append(18)
+
+ if len(bid_local)>0:
+ opt_arr.append(11)
+
+
+ opt = self.Etl.pow_sum(list(set(opt_arr)))
+ return opt
+
+ def gonggo_constnm_stting(self, proc, data, bidid):
+
+ #bid_key = data['bid_key'] if data['bid_key'] is not None else None
+ #bid_local = data['bid_local'] if data['bid_local'] is not None else None
+ bid_key = data['bid_key'] if "bid_key" in data else []
+ bid_local = data['bid_local'] if "bid_local" in data else []
+
+ chasu = ''
+ rebid_no = ''
+ if bidid is not None:
+ chasu = re.match('[A-Z0-9]{15}-([0-9]{2})-[0-9]{2}-[0-9]{2}', bidid)
+ rebid_no = re.match('[A-Z0-9]{15}-[0-9]{2}-([0-9]{2})-[0-9]{2}', bidid)
+ chasu = int(chasu.group(1))
+ rebid_no = int(rebid_no.group(1))
+
+
+ print("공고명")
+
+ constnm_ext_arr = []
+ if bid_key['whereis'] == '10':
+ constnm_ext_arr.append(bid_key['notinum_ex'])
+
+ if proc == 'C':
+ constnm_ext_arr.append('취소')
+
+ if proc == 'L':
+ constnm_ext_arr.append('연기공고')
+
+ if "bidcls" in bid_key and bid_key['bidcls'] is not None and bid_key['bidcls'] == '04':
+ constnm_ext_arr.append('방문입찰')
+
+ if "bidcls" in bid_key and bid_key['bidcls'] is not None and bid_key['bidcls'] == '00':
+ constnm_ext_arr.append('직찰')
+
+ if "conlevel" in bid_key and bid_key['conlevel'] is not None and bid_key['conlevel'] != '0' and bid_key['conlevel'] != '':
+ constnm_ext_arr.append('{conlevel}등급'.format(conlevel=bid_key['conlevel']))
+
+ if len(bid_local) > 0:
+ # {"0": {"code": "4722", "name": "경상북도 포항시"}}
+ for idx, value in enumerate(bid_local):
+ if bid_local[value] is not None:
+ bid_local_arr = bid_local[value]['name'].split(" ")
+ if len(bid_local_arr) > 1:
+ constnm_ext_arr.append(bid_local_arr[1])
+
+ if chasu != '' and proc != 'C':
+ if chasu > 2:
+ constnm_ext_arr.append('{chasu}차정정'.format(chasu=chasu))
+ if chasu == 2:
+ if bid_key['whereis'] == '01' or bid_key['whereis'] == '03':
+ constnm_ext_arr.append('재정정')
+
+ if rebid_no != '' and proc == 'R':
+ if rebid_no == 1:
+ constnm_ext_arr.append('재입찰')
+ if rebid_no > 1:
+ constnm_ext_arr.append('{rebid_no}차재입찰'.format(rebid_no=rebid_no))
+
+ if bid_key['constnm'].find("주계약자관리방식") != -1:
+ constnm_ext_arr.append('주계약자관리방식')
+
+ constnm_arr = bid_key['constnm'].split("//")
+
+ if len(constnm_ext_arr) > 0:
+ constnm = constnm_arr[0]+"//"
+ for idx, value in enumerate(constnm_ext_arr):
+ constnm = constnm+"("+constnm_ext_arr[idx]+")"
+ else:
+ constnm = bid_key['constnm']
+
+ return constnm
+
+ def gonggo_concode_stting(self, data):
+ print("gonggo_concode_stting")
+ bid_key = data['bid_key'] if "bid_key" in data else []
+
+ concode = ''
+ if bid_key['concode'] is not None:
+ concode = bid_key['concode']
+ if bid_key['concode'].find("C001") != -1 and bid_key['concode'].find("C003") == -1:
+ concode = bid_key['concode']+"|C003"
+ if bid_key['concode'].find("C002") != -1 and bid_key['concode'].find("C003") == -1:
+ concode = bid_key['concode']+"|C003"
+
+ return concode
+
+
+
+ #unlock 함수
+ def unlock(self, bidid):
+ table_query = "CALL usp_bid_unlock('{bidid}')".format(bidid=bidid)
+ self.dbconn_BI.sql_exec(table_query, "D")
+
+ def insert_bid_err(self, bidid, bid_err=[]):
+ # bid_error
+ if len(bid_err) > 0:
+ for value in bid_err:
+ _bid_err = {
+ "key": {"bidid": bidid},
+ "table": "bid_error",
+ "type": "insertonly",
+ "value": {"err_code": value, "writedt":"NOW()", "bidid":bidid},
+ "del_col": []
+ }
+ self.dbconn_BI.Upsert_table(_bid_err)
+
+def auto_service_set(item_bidkey, pct_org):
+ # print("[auto_service_set] 공고 미입력 자동 서비스 Start")
+
+ # ================================================== #
+ # 용역 특정 면허일경우 자동수집되도록 처리 진행
+ # 1. 지정된 면허가 단일로 나왔을 경우, 시스템상 지역정보가 없으면 전국을 찍어준다. (자동수집이여서 지역이 서비스 되야하기 때문)
+ # 2. 개찰일은 있으나 투찰마감일이 없을경우, 투찰마감일에 개찰일을 넣어준다.
+ # item_bidkey['location']
+ # item_bidkey['closedt'] = closedt
+ # item_bidkey['constdt'] = constdt
+ # S045 (소독), S048 (세탁물), S049 (장의/분묘), S055 (학술/연구), S057 (여행/휴양/관광), S064 (근로자파견), S065 (보험), S066 (회계), S074 (교육관련),
+ # 공고문 자동수집으로 면허가 추가될경우 단일공고가 아니게 되는 예외사항이 발생, log를 쌓고 공고문 자동수집에서 변경이 필요하면 데이터를 리셋해준다.
+ # ================================================== #
+
+ updateData = {}
+ logData = {}
+ update_code = ''
+ update_data = ''
+
+ Etl = scraplib.Etl()
+
+ # 용역 비주류 공고 자동화
+ sercode_auto = {"01"}
+
+ # k아파트 공고 자동화 (협정일 있는 공고)
+ kapt_auto = {"90"}
+
+ # ===============================
+ # k아파트 공고 자동화
+ # ===============================
+ if item_bidkey["whereis"] in kapt_auto:
+ print("[현장설명일시] --> ", item_bidkey["explaindt"])
+ if item_bidkey["explaindt"]:
+ logData['update_code'] = "explaindt"
+ logData['update_data'] = item_bidkey["explaindt"]
+ logData['updateProgram'] = 'crawl'
+ logData['whereis'] = item_bidkey["whereis"]
+
+
+ #===============================
+ # 용역 비주류 공고 자동화
+ #===============================
+ if item_bidkey["whereis"] in sercode_auto:
+
+ sercode_auto_process = False
+ if item_bidkey["sercode"] is not None:
+
+ # 1. 비주류 단일 면허이면 자동서비스 필요 True
+ sercode_auto_limit = ['S045', 'S048', 'S049', 'S055', 'S057', 'S064', 'S065', 'S066', 'S074']
+ if item_bidkey["sercode"] in sercode_auto_limit:
+ sercode_auto_process = True
+ # print("[용역 비주류 면허 포함 True]" + item_bidkey["sercode"])
+
+
+ # 2. 시설, 물품 면허가 있으면 단일 면허로 판단하지 않기로 함, 제외 False
+ if (item_bidkey["concode"] is not None and item_bidkey["concode"]) or (item_bidkey["purcode"] is not None and item_bidkey["purcode"]):
+ sercode_auto_process = False
+ # print("[시설, 물품 면허 있음 False]")
+
+ #=========== 테스트 ======================
+ # 테스트 공고 -> 20230508380-00
+ # 용역면허 단일 테스트 -> Y
+ # 시설,물품 면허 단일 테스트 -> Y
+ # 지역 세팅 테스트 -> N
+ # 날짜 세팅 테스트 -> Y
+ # 날짜 공백 체크 테스트 -> Y
+
+ #투찰마감일
+ #item_bidkey["closedt"] = None
+ #item_bidkey["closedt"] = "0000-00-00 00:00:00"
+ #item_bidkey["closedt"] = ""
+ #개찰일
+ #item_bidkey["constdt"] = None
+ #item_bidkey["constdt"] = "0000-00-00 00:00:00"
+ #item_bidkey["constdt"] = ""
+ #지역
+ #item_bidkey["location"] = None
+ #item_bidkey["location"] = ""
+ #item_bidkey["location"] = 1
+ #직찰공고
+ #item_bidkey["bidcls"] = "00"
+ # =========== 테스트 ======================
+
+ if sercode_auto_process == True:
+ # print("자동 서비스 조건 OOOOOOOO")
+
+ # 3. 직찰공고이면서, 투찰마감일이 없고 개찰일만 있을경우 투찰마감일에 개찰일 넣어줌
+ if item_bidkey["bidcls"] == "00" and not Etl.null_turn(item_bidkey["closedt"]) and Etl.null_turn(item_bidkey["constdt"]):
+ # print("개찰일 세팅")
+ updateData["closedt"] = item_bidkey["constdt"]
+ update_code = auto_service_err_set(update_code, 'C')
+ update_data = auto_service_err_set(update_data, 'closedt:'+item_bidkey["constdt"])
+
+ # 4. 지역정보가 없을 경우 전국으로 세팅
+ if not item_bidkey["location"]:
+ # print("전국 세팅")
+ updateData["location"] = 1
+ update_code = auto_service_err_set(update_code, 'L')
+ update_data = auto_service_err_set(update_data, 'location:1')
+
+ # 5. 투찰율 정보가 없을 경우 투찰율 세팅
+ if not item_bidkey["pct"] and pct_org:
+ updateData["pct"] = str(pct_org)
+ update_code = auto_service_err_set(update_code, 'P')
+ update_data = auto_service_err_set(update_data, 'pct:'+str(pct_org))
+
+
+ if update_code and update_data:
+ logData['update_code'] = update_code
+ logData['update_data'] = update_data
+ logData['updateProgram'] = 'crawl'
+
+ else:
+ # print("[자동 서비스 조건 XXXXXXX]")
+ pass
+
+ # print("[지역]" + str(item_bidkey["location"]))
+ # print("[개찰일]" + item_bidkey["constdt"])
+ # print("[투찰마감일]" + item_bidkey["closedt"])
+
+ # print("[auto_service_set] 공고 미입력 자동 서비스 End")
+ return updateData, logData
+
+def auto_service_err_set(data, plus_data):
+ if data: data = data + '|' + plus_data
+ else: data = plus_data
+ return data
\ No newline at end of file
diff --git a/backend/airflow/dags/plugins/utils/items/items.py b/backend/airflow/dags/plugins/utils/items/items.py
new file mode 100644
index 0000000..5108d7c
--- /dev/null
+++ b/backend/airflow/dags/plugins/utils/items/items.py
@@ -0,0 +1,204 @@
+syscollectItem = {
+ "dcode": "",
+ "whereis": "",
+ "dkey": "",
+ "dkey_ext": "",
+ "stats": "",
+ "rebid_no": "",
+ "link": "",
+ "link_post": "",
+ "ext_info1": "",
+ "ext_info2": "",
+ "ext_info3": "",
+ "rs_bidid": "",
+ "rs_msg": "",
+ "proc": "",
+ "set_time": "",
+ "seq": "",
+ "retrycnt": "",
+ "uptime": "",
+ "crawl_data_type": "",
+ "crawl_data_view": "",
+ "crawl_data_html": "",
+}
+
+
+bidkeyItem = {
+ "bidid": "",
+ "whereis": "",
+ "syscode": "",
+ "bidtype": "",
+ "bidview": "",
+ "notinum": "",
+ "notinum_ex": "",
+ "constnm": "",
+ "org": "",
+ "org_i": "",
+ "orgcode_i": "",
+ "org_y": "",
+ "orgcode_y": "",
+ "bidproc": "",
+ "contract": "",
+ "bidcls": "",
+ "succls": "",
+ "conlevel": "",
+ "concode": "",
+ "sercode": "",
+ "purcode": "",
+ "location": "",
+ "convention": "",
+ "presum": "",
+ "basic": "",
+ "pct": "",
+ "opt": "",
+ "noticedt": "",
+ "registdt": "",
+ "explaindt": "",
+ "agreedt": "",
+ "opendt": "",
+ "closedt": "",
+ "constdt": "",
+ "writedt": "",
+ "pqdt": "",
+ "docdt": "",
+ "resdt": "",
+ "editdt": "",
+ "inputer": "",
+ "inspecter": "",
+ "state": "",
+ "no": "",
+ "lock": "",
+ "isclosed": "",
+ "org_real": "",
+ "nbidcls": "",
+ "breakdown_bid_check": "",
+ "local_join_check": "",
+ "first_join_part": "",
+ "first_join_part_prevamt": "",
+ "first_join_part_presum": "",
+ "assessment_percent": "",
+ "plus_local": "",
+ "safebid": "",
+ "etc_option": "",
+ "state_a": "",
+ "blocal": "",
+ "big_part": "",
+ "auto_service_code": "",
+}
+
+
+
+bidvalueItem = {
+ "bidid": "",
+ "scrcls": "",
+ "scrid": "",
+ "constno": "",
+ "refno": "",
+ "realorg": "",
+ "yegatype": "",
+ "yegarng": "",
+ "prevamt": "",
+ "multispare": "",
+ "parbasic": "",
+ "lvcnt": "",
+ "contloc": "",
+ "contper": "",
+ "charger": "",
+ "exptax": "",
+ "contract_money": "",
+ "government_money": "",
+ "promise_org": "",
+ "c_exp_cost": "",
+ "pre_cost": "",
+ "industry_code": "",
+ "work_field": "",
+ "result_check_dt": "",
+ "explain_local": "",
+ "join_part_total_percent": "",
+ "ser_check": "",
+ "ser_join_agree_check": "",
+ "compute": "",
+ "jk_info_service": "",
+ "mutual_mayor": "",
+ "bid_statement_text": "",
+}
+
+
+bidcontentItem = {
+ "bidid": "",
+ "bidcomment_mod": "",
+ "bidcomment": "",
+ "nbidcomment": "",
+ "orign_lnk": "",
+ "s_orign_lnk": "",
+ "attchd_lnk": "",
+ "pur_lnk": "",
+ "bid_html": "",
+ "nbid_html": "",
+ "bid_file": "",
+ "nbid_file": "",
+ "pur_goods": "",
+ "partition_seq": "",
+}
+
+
+
+bidresItem = {
+ "bidid": "",
+ "yega": "",
+ "selms": "",
+ "multicnt": "",
+ "multispare": "",
+ "innum": "",
+ "officenm1": "",
+ "prenm1": "",
+ "officeno1": "",
+ "success1": "",
+ "reswdt": "",
+ "mult_yega_fg": "",
+}
+
+
+
+bidsuccomItem = {
+ "bidid": "",
+ "seq": "",
+ "officeno": "",
+ "officenm": "",
+ "prenm": "",
+ "success": "",
+ "pct": "",
+ "regdt": "",
+ "rank": "",
+ "selms": "",
+ "etc": "",
+ "partition_seq": "",
+}
+
+
+bidlocalItem = {
+ "bidid": "",
+ "code": "",
+ "name": "",
+}
+
+
+bidgoodsItem = {
+ "bidid": "",
+ "seq": "",
+ "gcode": "",
+ "gname": "",
+ "gorg": "",
+ "standard": "",
+ "cnt": "",
+ "unit": "",
+ "unitcost": "",
+ "period": "",
+ "place": "",
+ "condition": "",
+}
+
+
+
+
+
diff --git a/backend/airflow/dags/plugins/utils/scraplib.py b/backend/airflow/dags/plugins/utils/scraplib.py
new file mode 100644
index 0000000..186e977
--- /dev/null
+++ b/backend/airflow/dags/plugins/utils/scraplib.py
@@ -0,0 +1,3368 @@
+import inspect
+import pymysql
+import os
+from selenium import webdriver
+
+from selenium.webdriver.ie.options import Options
+from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
+from selenium.common.exceptions import TimeoutException
+from bs4 import BeautifulSoup
+import lxml.html
+# from lxml.cssselect import CSSSelector
+import requests
+import json
+import time
+import re
+# from konfig import Config
+import urllib
+from datetime import datetime, timedelta
+import sys
+import pandas as pd
+
+import html.entities # safe_encode
+
+import uuid
+import random
+import logging
+
+# pluins
+from plugins.sql.hooks.mysql_hook import CommonHookMySQL
+
+#환경설정 파일 로드
+# CONF = Config(os.path.dirname(__file__) + "\\conf.ini")
+# DB_CONF = CONF.get_map("DB")
+# DRIVER = CONF.get_map("DRIVER")
+# DRIVER["EXT_PATH"] = os.path.dirname(__file__) + DRIVER["EXT_PATH"]
+
+# DB 연결 ID
+DB_CONN_ID = "COLLECT_SERVER_DB"
+
+#===============================================================================================================================================
+#데이터베이스 커넥터
+#===============================================================================================================================================
+class dbconn(CommonHookMySQL):
+
+ def __init__(self):
+ pass
+
+ def change_DB(self, DB_CONF):
+ self.__init__(host=DB_CONF["host"], user=DB_CONF["user"], passwd=DB_CONF["passwd"], db=DB_CONF["db"], cs=DB_CONF["cs"])
+
+ #DB 접속
+ def sql_exec(self, qry, type):
+ #print(qry)
+
+ if type == "TEST":
+ return ''
+ else:
+ if (type == "DS" or type=="DS_one"):
+ cur = self.conn.cursor(pymysql.cursors.DictCursor)
+ else:
+ cur = self.conn.cursor()
+ cur.execute(qry)
+ if (type=="S" or type=="DS"):
+ rows = cur.fetchall()
+ if (type == "S_one" or type == "DS_one"):
+ rows = cur.fetchone()
+ #cur.close()
+ #conn.close()
+ if(type=="S" or type=="DS"):
+ return rows
+ if (type == "S_one" or type == "DS_one"):
+ if rows is not None:
+ return rows[0]
+ else:
+ return ''
+
+ #쿼리 문자열 클린징
+ def clean_param(self, _str):
+ _str = Util.clean_str(self, _str, [["'","\\'"],])
+ return _str
+
+
+
+ def Upsert_table(self, dataset, exec_mode="EXEC"):
+ # del_col은 비교시 제외할 컬럼 리스트
+ # 샘플 데이터셋
+ # dataset = {
+ # "key": {"n_num": last_nbbs_detail["n_num"], "biz_num": last_nbbs_detail["biz_num"]},
+ # "table": "last_nbbs_detail",
+ # "value": last_nbbs_detail
+ # "type": "update" ==> update : 없으면 입력 있으면 변경분 수정 , insert : 없으면 입력 있으면 아무것도 안함, updateonly : 없으면 입력 안함, insertonly : 입력만 함
+ # "del_col": ["code", "reg_date", "ipchal_date"]
+ # "orderby": ""
+ # }
+ orderby = dataset["orderby"] if "orderby" in dataset else ""
+ whereis = dataset["whereis"] if "whereis" in dataset else ""
+
+ #키가 있는지 확인
+ if dataset["table"] != 'bid_content':
+ row = self.get_one_row(dataset["key"], dataset["table"], orderby)
+ elif dataset["table"] == 'bid_content':
+
+ try:
+ attchd_lnk = dataset["value"]['attchd_lnk'] if "attchd_lnk" in dataset['value'] else ""
+ except:
+ attchd_lnk = ''
+
+ # bid_html로 오류가 날수잇으므로 발주처를 따로 관리, 나라장터 물품건 따로 HTML 넣어주기 Or 나라장터, 조달청 물품건 HTML 저장.
+ # 나라장터 경우 첨부파일 없을 경우 bid_html 넣어주며, 공사/용역/물품 전부 포함. -> 차세대 나라장터 오픈 후 제거
+ # if whereis in ["17","03","96","81","91","44","21"] or ( whereis in ['01',"84","85","86"] and not attchd_lnk):
+ if whereis in ["17","03","96","81","91","44","21"]:
+ row = self.get_one_row(dataset["key"], dataset["table"], orderby,"bidid,bidcomment_mod,bidcomment,nbidcomment,bid_html, orign_lnk,s_orign_lnk,attchd_lnk,pur_lnk,bid_file,nbid_file,pur_goods,partition_seq")
+ else: #bid_html로 오류가 날수잇으므로 오류가 나는 발주처는 빼준다
+ row = self.get_one_row(dataset["key"], dataset["table"], orderby,"bidid,bidcomment_mod,bidcomment,nbidcomment,orign_lnk,s_orign_lnk,attchd_lnk,pur_lnk,bid_file,nbid_file,pur_goods,partition_seq")
+ #print("+++")
+ #print(row)
+ #print("+++")
+ if row is None or dataset["type"] in ["insertonly", "insert"]:
+ print("Upsert_table 입력모드 ==> dataset:", dataset)
+ if dataset["type"] not in ["updateonly"]:
+ print("self.Insert_table 실행")
+ # ================================================================
+ # 인서트 처리부분
+ if exec_mode == "EXEC":
+ self.Insert_table(dataset["value"], dataset["table"])
+ # ================================================================
+ else:
+ print("updateonly 입력안함")
+
+ else:
+ if dataset["type"] in ["update", "updateonly"]:
+ Etl = scraplib.Etl()
+ #print("Upsert_table 수정모드 ==> row:", row)
+ # 비교제외 컬럼 삭제 후 비교
+ diff = Etl.diff_array(Etl.del_key(row, dataset["del_col"]), Etl.del_key(dataset["value"], dataset["del_col"]))
+ print("diff :", diff["MOD"])
+ if len(diff["MOD"]) > 0:
+ _data = {}
+ for _key, _value in diff["MOD"].items():
+ # if _value["NEW"] > _value["OLD"]: #새로운 데이터가 이전 데이터보다 클경우에만 수정
+ _data[_key] = _value["NEW"]
+ if len(_data) > 0:
+ print("self.Update_table 실행 [변경분 수정]=========>", _data)
+ # ================================================================
+ # 업데이트 처리부분
+ if exec_mode == "EXEC":
+ self.Update_table(_data, dataset["key"], dataset["table"])
+ # ================================================================
+ else:
+ print("[insert 모드 입력 안함] ==> row:", row)
+
+
+ def get_one_row(self, _where, tb_nm, orderby="", rtn_col="*"):
+ where = ''
+ for key, value in _where.items():
+ if value != None:
+ if type(value) == dict:
+ value_type = list(value.keys())[0]
+ value_content = list(value.values())[0]
+ if value_type == "KEY":
+ where = where + " AND {key} = {value}".format(key=key, value=value_content)
+ elif value_type == "LIKE":
+ where = where + " AND {key} LIKE '{value}'".format(key=key, value=value_content)
+ elif value_type == "IN":
+ where = where + " AND {key} IN {value}".format(key=key, value=value_content)
+ else:
+ where = where + " AND {key} = '{value}'".format(key=key, value=value)
+
+ query = "SELECT {rtn_col} FROM {tb_nm} WHERE 1 {where} {orderby} limit 1".format(tb_nm=tb_nm, where=where, orderby=orderby, rtn_col=rtn_col)
+ print(query)
+ row = self.select_sql_to_dict(query)
+ #print("@@@")
+ #print("get_one_row : ", row)
+ #print("get_one_row2 : ")
+ if( len(row) > 0 ):
+ for key, value in row[0].items():#반환데이터 전처리
+ if type(value) == datetime:#날짜 형식은 포맷팅해서 텍스트로 변환
+ row[0][key] = str(value.strftime('%Y-%m-%d %H:%M:%S'))
+ # print("=======================================================================>", key, row[0][key], type(row[0][key]))
+ return row[0]
+ else:
+ return None
+
+ def ck_Exist_one(self, _where, tb_nm):
+ where = ''
+ for key, value in _where.items():
+ if value != None:
+ where = where + " AND {key} = '{value}'".format(key=key, value=value)
+ query = "SELECT COUNT(*) FROM {tb_nm} WHERE 1 {where}".format(tb_nm=tb_nm, where=where)
+ cnt = self.select_sql_to_dict(query)[0]
+ if( cnt > 0 ):
+ return True
+ else:
+ return False
+
+ def ck_Exist_cnt(self, _where, tb_nm):
+ where = ''
+ for key, value in _where.items():
+ if value != None:
+ where = where + " AND {key} = '{value}'".format(key=key, value=value)
+ query = "SELECT COUNT(*) FROM {tb_nm} WHERE 1 {where}".format(tb_nm=tb_nm, where=where)
+ cnt = self.select_sql_to_dict(query)[0]
+ return cnt
+
+
+ def ck_Exist_one_difftype(self, _where, tb_nm):
+ where = ''
+ for key, value in _where.items():
+ if value is not None:
+ if value['type'] == 'equal':
+ where = where + " AND {key} = '{value}'".format(key=key, value=value['data'])
+ elif value['type'] == 'text':
+ where = where + " AND {key} {value}".format(key=key, value=value['data'])
+
+ query = "SELECT COUNT(*) FROM {tb_nm} WHERE 1 {where}".format(tb_nm=tb_nm, where=where)
+ print(query)
+ cnt = self.select_sql_to_dict(query)
+ if( cnt > 0 ):
+ return True
+ else:
+ return False
+
+ def Insert_table(self, _data, tb_nm, rtn_fg=False):
+ logging.info("[scraplib.Insert_table][쿼리실행] start")
+ data_str = ''
+ for key, value in _data.items():
+ if value != None:
+ if data_str != '':
+ data_str = data_str + " , "
+ if value == "NOW()":
+ data_str = data_str + "`{key}` = {value}".format(key=key, value=self.remove_quote(value))
+ else:
+ data_str = data_str + "`{key}` = '{value}'".format(key=key, value=self.remove_quote(value))
+ query = "INSERT INTO {tb_nm} SET {data_str}".format(tb_nm=tb_nm, data_str=data_str)
+ try:
+ self.insert_sql(query)
+ except:
+ logging.error('insert query error : ', query)
+ pass
+ # print(query)
+ if rtn_fg == True:
+ try:
+ row = self.select_sql_to_dict("SELECT LAST_INSERT_ID() AS seq")
+ logging.info("=====================>row:", row)
+ return row[0]["seq"]
+ except:
+ pass
+
+ logging.info("[scraplib.Insert_table][쿼리실행] end")
+
+ def remove_quote(self, _val):
+ if type(_val) == str:
+ return _val.replace("'", "\\'")
+ else:
+ return _val
+
+ def Update_table(self, _data, _where, tb_nm):
+ print("[scraplib.Update_table][쿼리실행] start")
+ data_str = ''
+ for key, value in _data.items():
+ if value != None:
+ if data_str != '':
+ data_str = data_str + " , "
+ if value == "NOW()":
+ data_str = data_str + "`{key}` = {value}".format(key=key, value=self.remove_quote(value))
+ else:
+ data_str = data_str + "`{key}` = '{value}'".format(key=key, value=self.remove_quote(value))
+ where = ''
+ for key, value in _where.items():
+ if where != '':
+ where = where + " AND "
+ where = where + "{key} = '{value}'".format(key=key, value=self.remove_quote(value))
+ if data_str != '':
+ query = "UPDATE {tb_nm} SET {data_str} WHERE {where}".format(tb_nm=tb_nm, data_str=data_str, where=where)
+ self.process_sql(query)
+ # print(query)
+ print("[scraplib.Update_table][쿼리실행] end")
+
+ def Delete_table(self, key, value, tb_nm):
+ print("[scraplib.Delete_table][쿼리실행] start")
+ if key and value and tb_nm:
+ query = "DELETE FROM {tb_nm} WHERE {key}='{value}'".format(tb_nm=tb_nm, key=key, value=value)
+ self.process_sql(query)
+ print("[scraplib.Delete_table][쿼리실행] end")
+
+
+
+
+#===============================================================================================================================================
+# 셀레늄 크롬드라이버 기반 크롤러
+#===============================================================================================================================================
+class chrome_selenium:
+
+ # chromedriver = os.path.dirname(__file__)+DRIVER["CHROMEDRIVER_PATH"]
+ # iedriver = os.path.dirname(__file__) + DRIVER["IEDRIVER_PATH"]
+ iedriver = ''
+ chromedriver = ''
+ # os.environ["webdriver.chrome.driver"] = chromedriver
+ driver = ""
+ exec_type = "exec"
+ crxs=[]
+
+ def __init__(self, _exec_type, _crxs=[]):
+
+ stack = inspect.stack()
+ caller_frame = stack[1]
+ caller_filename = caller_frame.filename
+ caller_function = caller_frame.function
+
+ if(_exec_type=="IE"):
+ ie_options = Options()
+ ie_options.ignore_protected_mode_settings = True
+ self.driver = webdriver.Ie(executable_path=self.iedriver, options=ie_options)
+ else:
+ chrome_options = webdriver.ChromeOptions()
+ if(_exec_type=="exec"):
+ chrome_options.add_argument("--headless")
+ chrome_options.add_argument("--no-sandbox")
+ chrome_options.add_argument("--disable-gpu")
+ chrome_options.set_capability('unhandledPromptBehavior', 'accept')
+
+ if "www_kapt_go_kr" in caller_filename and caller_function == 'bid_collect_process':
+ # kapt의 경우 첨부파일 경로를 가져오기위해 api 호출시 크로스도메인 오류를 뱉어내 재호출없이 네트워크 로그의 출력물을 판단하기 위해 아래 로직을 추가함
+ chrome_options.set_capability('goog:loggingPrefs', {"performance": "ALL"})
+
+ for crs in _crxs:
+ chrome_options.add_extension(crs)
+ #chrome_options.set_capability('unhandledPromptBehavior', 'accept')
+ self.exec_type = _exec_type
+ self.crxs = _crxs
+ self.driver = webdriver.Chrome(self.chromedriver, options=chrome_options)
+
+ # 해당 xpath 경로 프레임으로 포커스 이동
+ def switch_to_frame(self, XPATH):
+ self.driver.switch_to_default_content() # 프레임 초기화
+ self.driver.switch_to.frame(self.driver.find_element_by_xpath(XPATH)) # 프레임 이동
+
+ # 최상위 프레임으로 포커스 복귀
+ def switch_to_default_frame(self):
+ self.driver.switch_to_default_content() # 프레임 초기화
+
+ def rtn_elements_bs4(self, _selector):
+ try:
+ rtn = []
+ page_source = self.driver.page_source
+ soup = BeautifulSoup(page_source, 'html.parser')
+ listdata = soup.select(_selector)
+ for i in listdata:
+ rtn.append(Util.clean_str(self, i.text))
+ except Exception as e:
+ print("rtn_elements_bs4 : ", e)
+ rtn = []
+ return rtn
+
+ def rtn_elements_bs4_request(self, _selector, _attr="TEXT"):
+ try:
+ rtn = []
+ soup = BeautifulSoup(self.page_source_request, 'html.parser')
+ listdata = soup.select(_selector)
+ for i in listdata:
+ if _attr == "TEXT":
+ _tmp = i.text
+ else:
+ _tmp = i.attrs[_attr]
+ rtn.append(Util.clean_str(self, _tmp))
+ except Exception as e:
+ print("chrome_selenium rtn_elements_bs4_request : ", e)
+ rtn = []
+
+ return rtn
+
+
+ def rtn_elements_lxml_request(self, _selector, _attr="TEXT"):
+ try:
+ rtn = []
+ tree = lxml.html.fromstring(self.page_source_request)
+ # listdata = tree.findall(_selector)
+ listdata = list(tree.xpath(_selector))
+
+ # print(listdata)
+ for i in listdata:
+ if _attr == "TEXT":
+ _tmp = i.text_content().replace(" ", "")
+ else:
+ _tmp = i.attrib[_attr]
+ rtn.append(Util.clean_str(self, _tmp))
+ except Exception as e:
+ print("chrome_selenium rtn_elements_lxml_request : ", e)
+ rtn = []
+ return rtn
+
+ def rtn_elements_lxml_request_g2b(self, _selector, _type="XPATH", _attr="TEXT"):
+ try:
+ rtn = []
+ tree = lxml.html.fromstring(self.page_source_request)
+ # listdata = tree.findall(_selector)
+ listdata = list(tree.xpath(_selector))
+ if _attr == "TEXT_BR":
+ print("rtn_elements_lxml_request_g2b listdata", listdata, type(listdata))
+
+
+ for _idx, i in enumerate(listdata):
+ if _attr == "TEXT":
+ _tmp = "".join(i.text_content())
+ elif _attr == "TEXT_BR":
+ if _idx == 0:
+ tmp_sub = []
+ tmp_sub.append(Util.clean_str(self, i.text))
+ # print("===>", Util.clean_str(self, i.text))
+ for a in i:
+ tmp_sub.append(Util.clean_str(self, a.tail))
+ # print("===>", Util.clean_str(self, a.tail))
+ _tmp = "
".join(tmp_sub)
+ print(_tmp)
+ else:
+ _tmp = i.attrib[_attr]
+ rtn.append(Util.clean_str(self, _tmp))
+
+ if len(rtn) == 0:
+ rtn.append("")
+ except Exception as e:
+ print("chrome_selenium rtn_elements_lxml_request : ", e)
+ rtn = []
+ return rtn
+
+ def rtn_elements_regex_g2b(self, _selector):
+ try:
+ rtn = re.findall(_selector, self.page_source_request)
+ except Exception as e:
+ print("chrome_selenium rtn_elements_regex_g2b : ", e)
+ rtn = None
+ return rtn
+
+
+ # 페이지 소스에서 객체정보 추출 _type : CSS, XPATH
+ def rtn_element(self, _selector, _type="CSS", _attr=""):
+ try:
+ if(_type=="CSS_CLICK"):
+ rtn = self.driver.find_element_by_css_selector(_selector).click()
+ elif(_type=="XPATH_CLICK"):
+ rtn = self.driver.find_element_by_xpath(_selector).click()
+ elif (_type == "CSS"):
+ rtn = Util.clean_str(self, self.driver.find_element_by_css_selector(_selector).text)
+ elif (_type == "CSS_TEXT"):
+ self.driver.find_element_by_css_selector(_selector).clear()
+ self.driver.find_element_by_css_selector(_selector).send_keys(_attr)
+ rtn = ""
+ elif (_type == "XPATH_TEXT"):
+ self.driver.find_element_by_xpath(_selector).clear()
+ self.driver.find_element_by_xpath(_selector).send_keys(_attr)
+ rtn = ""
+ else:# XPATH
+ if (_attr == "getText"):
+ rtn = self.driver.find_element_by_xpath(_selector).getText()
+ elif (_attr != ""):
+ rtn = self.driver.find_element_by_xpath(_selector).get_attribute(_attr)
+ else:
+ rtn = Util.clean_str(self, self.driver.find_element_by_xpath(_selector).text)
+ except:
+ print("_selector not found : ", _selector)
+ rtn = ""
+ #특수문자 print 할경우 오류방생으로 주석
+ #print("rtn_element [", _selector, "] :", rtn)
+ return rtn
+
+
+ def rtn_elements(self, _selector, _type="CSS", _attr=""):
+ try:
+ if(_type=="CSS"):
+ tmp = self.driver.find_elements_by_css_selector(_selector)
+ else:
+ tmp = self.driver.find_elements_by_xpath(_selector)
+ rtn = []
+ for t in tmp:
+
+ if(_attr!="" and _type=="XPATH"):
+ rtn.append(t.get_attribute(_attr))
+ # 2021-08-23 추가, 상단 XPATH 제거해도 되는지 재택끝나면 확인
+ elif (_attr != ""):
+ rtn.append(t.get_attribute(_attr))
+ else:
+ rtn.append(Util.clean_str(self, t.text))
+ except:
+ print("_selector not found : ", _selector)
+ rtn = []
+ return rtn
+
+
+ def callback(self, callback=None,_lists=None, driver=None, _alert=False):
+ if (driver == None):
+ driver = chrome_selenium(self.exec_type, self.crxs)
+ for list in _lists:
+ print(list["url"])
+ try:
+ if _alert == True: #alert 처리
+ driver.get_URL(list["url"], 5, '', True)
+ else:
+ driver.get_URL(list["url"])
+ callback(list["list"], driver)
+ except Exception as e:
+ print('Exception : callback오류 ', e)
+ """
+ error_text = str(e)
+ if error_text.find("WinError") != -1:
+ os.exit(0)
+ """
+
+ def callback_for_s2b(self, callback=None,_lists=None, driver=None):
+ if (driver == None):
+ driver = chrome_selenium(self.exec_type, self.crxs)
+ for list in _lists:
+ print(list["url"])
+ try:
+ driver.get_URL(list["url"])
+ callback(list["list"], list["url"], driver)
+ except Exception as e:
+ print('Exception : callback오류 ', e)
+ """
+ error_text = str(e)
+ if error_text.find("WinError") != -1:
+ os.exit(0)
+ """
+
+ def callback_post(self, callback=None,_lists=None, driver=None):
+ for list in _lists:
+ print(list["url"],list["list"]["link_post"])
+ try:
+ try:
+ PARAMS = json.loads(list["list"]["link_post"])
+ except:
+ PARAMS = {}
+ driver.get_URL_request(list["url"], PARAMS, "POST", "TEXT")
+ callback(list["list"], driver)
+ except Exception as e:
+ print('Exception : ', e)
+
+ def callback_get(self, callback=None, _lists=None, driver=None):
+ for _list in _lists:
+ try:
+ driver.get_URL_request(_list["url"], "", "GET", "TEXT")
+ callback(_list["list"], driver)
+ except Exception as e:
+ print('Exception : ', e)
+
+
+
+ def callback_xml(self, callback=None, _lists=None, driver=None, _alert=False):
+ print("callback_xml")
+ if (driver == None):
+ driver = chrome_selenium(self.exec_type, self.crxs)
+ for list in _lists:
+ print(list)
+ try:
+ # driver.get_URL(list["url"])
+ headers = {'Content-Type': 'application/xml'}
+ response = requests.post(list["payload_url"], data=list["payload_xml"], headers=headers)
+ response_text = response.text
+
+ callback(list, response_text, driver)
+ except Exception as e:
+ print('Exception : callback오류 ', e)
+ """
+ error_text = str(e)
+ if error_text.find("WinError") != -1:
+ os.exit(0)
+ """
+
+
+ def get_URL_request(self, URL, PARAMS, METHOD="GET", RTN_TYPE="JSON", HEADERS=None, COOKIES=None, TIMEOUT=30):
+ self.page_source_request = Util.get_URL(self, URL, PARAMS, METHOD, RTN_TYPE, HEADERS, COOKIES, TIMEOUT)
+
+ # url 의 경우 단일일경우 문자열 "", 경유가 필요한경우 ["","" ...] 문자열리스트
+ def get_URL(self, url, sec=5, id="", fg_alert=False):
+
+ if (type(url) is str):#문자열일경우
+ try:
+ self.driver.get(url)
+ if(fg_alert==True):
+ try:
+ alert = self.driver.switch_to_alert()
+ alert.accept()
+ except Exception as e:
+ pass
+ # print('Exception : ', e)
+ time.sleep(1)
+
+ wait = WebDriverWait(self.driver, sec)
+ if id != '':
+ wait.until(EC.element_to_be_clickable((By.ID, id)))
+ except:
+ pass
+ else:#리스트일경우
+ try:
+ for _url in url:
+ self.driver.get(_url)
+ if(fg_alert==True):
+ try:
+ alert = self.driver.switch_to_alert()
+ alert.accept()
+ except Exception as e:
+ print('Exception : ', e)
+ time.sleep(1)
+ except Exception as e:
+ print('Exception : ', e)
+
+ #마지막 페이지만 기다렸다 수집
+ wait = WebDriverWait(self.driver, sec)
+ if id != '':
+ wait.until(EC.element_to_be_clickable((By.ID, id)))
+ pass
+ body = self.driver.page_source
+ return body
+
+ def check_until_alert_show(self, sec=7) -> None:
+ """
+ loop until every alert, confirm is dismissed in given second
+ :param sec: second that dev can set
+ :return: None
+ """
+ wait = WebDriverWait(self.driver, sec)
+ try:
+ wait.until(EC.alert_is_present())
+ confirm = self.driver.switch_to.alert
+ confirm.dismiss()
+ self.check_until_alert_show()
+ except Exception as TimeoutException:
+ pass
+
+
+#===============================================================================================================================================
+# 유틸리티 펑션
+#===============================================================================================================================================
+class Util:
+
+ # 네이트온 메세지 발송
+ def send_msg(self, msg, id="LOG"):
+ urls = {
+ "ERROR": "https://teamroom.nate.com/api/webhook/6585e4a8/BGnMXKey8HUTwQROkmIaDiDK",
+ "LOG": "https://teamroom.nate.com/api/webhook/6585e4a8/TapsrGOgqoSO8tfqCLwLpDqZ",
+ # 토지주택공사
+ "LBC": "https://teamroom.nate.com/api/webhook/6585e4a8/a4OWV4umnnNJImGiZWtkCU6Z",
+ "LBMC": "https://teamroom.nate.com/api/webhook/6585e4a8/a4OWV4umnnNJImGiZWtkCU6Z",
+ # 한국철도공사
+ "KRL_B": "https://teamroom.nate.com/api/webhook/6585e4a8/IfAZig74jUBJWyZxtGRG2TML",
+ "KRL_SB": "https://teamroom.nate.com/api/webhook/6585e4a8/IfAZig74jUBJWyZxtGRG2TML",
+ # 국가철도공단
+ "KRBC": "https://teamroom.nate.com/api/webhook/6585e4a8/cFH2bM8GleIbHESCKUGZrT1i",
+ # 국제협력단
+ "KOICA_B": "https://teamroom.nate.com/api/webhook/6585e4a8/M9ZTkpFS3TAtw9JqqOBIYvRk",
+ # 한국마사회
+ "KRABA": "https://teamroom.nate.com/api/webhook/6585e4a8/MtTy4Weo6km2B18A4BMehh3e",
+ # 전자통신연구원
+ "ETRI_B": "https://teamroom.nate.com/api/webhook/6585e4a8/lRNMkeMC8xSFjtzSr5Dq0UOy",
+ #도로공사
+ "EBC": "https://teamroom.nate.com/api/webhook/6585e4a8/eug9PTbaJJJvXkvr8mhe2Mxv",
+ "EBS": "https://teamroom.nate.com/api/webhook/6585e4a8/eug9PTbaJJJvXkvr8mhe2Mxv",
+ "EBI": "https://teamroom.nate.com/api/webhook/6585e4a8/eug9PTbaJJJvXkvr8mhe2Mxv",
+ #국토정보공사
+ "LX_B": "https://teamroom.nate.com/api/webhook/6585e4a8/KU8Np7Klx11XvNhnVZ0Sa52X",
+ #강원랜드
+ "K_LAND_B": "https://teamroom.nate.com/api/webhook/6585e4a8/QgqK5vPDid0bzdSuS289Z70D",
+ # 국방부
+ "D2B_B": "https://teamroom.nate.com/api/webhook/6585e4a8/s3FG0Tz2mk28nkp7jeNbAujZ",
+ # 수자원공사
+ "KWATER_B": "https://teamroom.nate.com/api/webhook/6585e4a8/NwL7xpqYiVlHcutUn9fErMX8",
+ # 나라장터 직접
+ "G2B_B": "https://teamroom.nate.com/api/webhook/6585e4a8/rSYzYnbAt3uCB5aambYDLDiZ",
+ }
+
+ try:
+ try:
+ url = urls[id]
+ except:
+ url = urls["LOG"]
+ msg = u"content=" + msg + " | " + str(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
+ txt = msg.encode('utf-8')
+ payload = txt
+ headers = {
+ 'content-type': "application/x-www-form-urlencoded",
+ 'cache-control': "no-cache",
+ 'postman-token': "6176cbc2-3050-4c1f-2d2a-4c25ae984e3f"
+ }
+ response = requests.request("POST", url, data=payload, headers=headers)
+ print(response.text)
+ except Exception as ex:
+ print("send_msg() ERROR :", ex)
+
+ #==============================================================================
+ ##텍스트 클린징 함수 _str 은 문자열("asdf") 또는 문자열리스트(["asdf","qwer","zxcv"])가 올수 있다
+ ##
+ def clean_str(self, _str, cus_pattern=[]):
+ patterns=[#여기에 리스트형태로 리플레이스 패턴 추가 ["원래패턴","변경패턴"],
+ ["①",""],
+ ["③",""],
+ ["․","."],
+ ["\xa0","-"],
+ ["\u2024","."],
+ ["\u2e31","."],
+ ["\u25ef", ""],
+ ["\u2027","."],
+ ["\uff62","["],
+ ["\uff63","]"],
+ ["\u2013","-"],
+ ["\u2610","□"],
+ ["\u2205","Ø"],
+ ["\r",""],
+ ["\t",""],
+ ["\n",""],
+ ["・","."],
+ ["\u25e6", ""],
+ ["\xa9", ""],
+ ["\ufeff", ""],
+ ["\u200b", ""],
+ ["\u2219", ""],
+ ["\u274d", ""],
+ ["\u2782", ""],
+ ["\u2981", ""],
+ ["\u3007", ""],
+ ["\u25fc", ""],
+ ["\ufffd", ""],
+ ["\u25ba", ""],
+ ["\u20de", ""],
+ ["\u302c", ""],
+ ["\u0223", ""],
+ ["\u22c5", ""],
+ ["\u1100", ""],
+ ["\u1161", ""],
+ ["\u11bc", ""],
+ ["\u1102", ""],
+ ["\u11b7", ""],
+ ["\u110b", ""],
+ ["\u116f", ""],
+ ["\u11ab", ""],
+ ["\u1112", ""],
+ ["\u116d", ""],
+ ["\u1109", ""],
+ ["\u1165", ""],
+ ["\u1175", ""],
+ ["\u2022", ""],
+ ["\u2613", ""],
+ ["\u27a1", ""],
+ ["\U00010a50",""],
+ ["\u25b9",""],
+ ["\u2023",""],
+ ["\u0228",""],
+ ["\xb5",""],
+ ["\u0387", ""],
+ ["\u3693", ""],
+ ["\u2715", ""],
+ ["\u2714", ""],
+ ["\u25a2", ""],
+ ["\u1110", ""],
+ ["\u1169", ""],
+ ["\u11a8", ""],
+ ["\u1107", ""],
+ ["\u1173", ""],
+ ["\u1111", ""],
+ ["\u1167", ""],
+ ["\u11af", ""],
+ ["\u110e", ""],
+ ["\u1103", ""],
+ ["\u1162", ""],
+ ["\u110c", ""],
+ ["\u116e", ""],
+ ["\uff65", ""],
+ ["\uf09e", ""],
+ ["\u24c7", ""],
+ ["\u2002", ""],
+ ["\u26aa", ""],
+ ["\U000f0854", ""],
+ ["\u2e33", ""],
+ ["\U000f02ea", ""],
+ ["\u2218",""],
+ ["\u0278",""],
+ ["\u2022", ""],
+ ["\u022f", ""],
+ ["\u24fd", ""],
+ ["\u302e", ""],
+ ["\u0368", ""],
+ ["\u301c", "~"],
+ ["\u02d1", "~"],
+ ["\u21e8", "->"],
+ ["\u25a2", ""],
+ ["\u231f", ""],
+ ["\u2780", ""],
+ ["\u119e", ""],
+ ["\u20a9", "원"],
+ ["\u25cc", ""],
+ ["\uf022", ""],
+ ["\u0301", ""],
+ ["\u1168", ""],
+ ["\u1163", ""],
+ ["\u1166", ""],
+ ["\u2215", ""],
+ ["\u231c", ""],
+ ["\U000f02ef", ""],
+ ["\uf0a0", ""],
+ ["\u2014", ""],
+ ["\u301a", ""],
+ ["\u301b", ""],
+ ["\uf028", ""],
+ ["\u30fb", ""],
+ ["\uf076", ""],
+ ["\u25aa", ""],
+ ["\u1104", ""],
+ ["\u2776", ""],
+ ["\u2777", ""],
+ ["\u2613", ""],
+ ["\u2000", ""],
+ ["\u25b8", ""],
+ ["\u2219", ""],
+ ["\u2012", ""],
+ ["\u233d", ""],
+ ["\u8f66", ""],
+ ["\u65f6", ""],
+ ["\u95f4", ""],
+ ["\u27f6", ""],
+ ["\uf0a6", ""],
+ ["\u21db", ""],
+ ["\u2783", ""],
+ ["\u2784", ""],
+ ["\u2785", ""],
+ ["\u2010", "-"],
+ ["\U0001d635", ""],
+ ["\u206d", ""],
+ ["\u279f", ""],
+ ["\u2d41", ""],
+ ["\u278a", ""],
+ ["\u278b", ""],
+ ["\u278c", ""],
+ ["\u27f9", ""],
+ ["\u2035", ""],
+ ["\u02dc", ""],
+ ["\u2053", ""],
+ ["\u301e", ""],
+
+ ]
+ # ["원래패턴", "변경패턴"],
+ if(len(cus_pattern)>0):#커스텀 패턴이 있는경우 커스텀패턴 적용
+ patterns = cus_pattern
+
+ if (type(_str) is str):#문자열일경우
+ try:
+ _str = _str.strip()
+ for pattern in patterns:
+ _str=_str.replace(pattern[0],pattern[1])
+ except:
+ pass
+ else:#리스트일경우
+ try:
+ _tmp = []
+ for _s in _str:
+ _s = _s.strip()
+ for pattern in patterns:
+ _s = _s.replace(pattern[0], pattern[1])
+ _tmp.append(_s)
+ _str = _tmp
+ except:
+ pass
+ return _str
+
+
+ #==============================================================================
+ ##None 을 공백으로
+ def ck_None(self, _str):
+ if(_str is None):
+ _str = ""
+ return _str
+
+
+ #==============================================================================
+ ## 문서에서 패턴을 매칭하여 반환
+ ## 샘플 : aa = spilt_rex_doc(text, "(var\sbidRateCode\s=\s')((\d|\.){2,6})(';)", 2)
+ ## idx 는 정규식 그룹중 추출할 인덱스
+ def spilt_rex_doc(self, _doc, _pattern, _idx):
+ for para in _doc.splitlines():
+ para_tmp = para.strip()
+ print("para_tmp:",para_tmp)
+ line = re.match(_pattern, para_tmp)
+
+ print("line:",line)
+ if line is not None:
+ rtn = re.sub(_pattern, r"\{idx}".format(idx=_idx), para_tmp)
+ return rtn
+ return None
+
+ #==============================================================================
+ ## URL 직접수집
+ ## URL : 수집주소, PARAMS : 파라미터, METHOD : 전송방법(POST,GET), RTN_TYPE : 리턴타입(JSON,TEXT,PLAIN) ==> 필수파라미터
+ ## HEADERS=None, COOKIES=None, TIMEOUT=3 ==> 선택파라미터
+ # response.content # 응답 데이터(binary형식 내용,이미지파일 등)
+ # response.text # 응답 데이터(텍스트형식 내용, 텍스트 파일에 씀)
+ # response.json # 응답 데이터 JSON형태
+ # response.url # 해당 url 반환
+ # response.status_code # 응답 상태코드 (200 이면 성공)
+ # response.headers # 응답 헤더의 {Key:Value} 형식의 딕셔너리 자료반환
+ # response.encoding = 'utf-8' # 응답 객체에 인코딩 지정
+ # HEADERS = {'User-Agent' : ('Mozilla/5.0 (Windows NT 10.0;Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36'),'Referer': 'http://www.igunsul.net/'}
+ def get_URL(self, URL, PARAMS, METHOD="GET", RTN_TYPE="JSON", HEADERS=None, COOKIES=None, TIMEOUT=60):
+
+ #print("URL:", re.findall("^https:", URL))
+ try:
+ if len(re.findall("^https:", URL)) > 0 or len(re.findall("^http://www.d2b.go.kr", URL)) > 0:#ssl 통신일경우 옵션 변경, 2024.10.18 국방부 SSL인증 임시조치
+ VAL_verify = False
+ else:
+ VAL_verify = True
+ except:
+ VAL_verify = True
+
+ if RTN_TYPE == "JSON":
+ NORMAL_PARAMS = ''
+ JSON_PARAMS = PARAMS
+ else:
+ NORMAL_PARAMS = PARAMS
+ JSON_PARAMS = ''
+
+ if(METHOD=="GET"):
+ if(HEADERS is not None and COOKIES is not None):
+ response = requests.get(URL, params=NORMAL_PARAMS, json=JSON_PARAMS, headers=HEADERS, cookies=COOKIES, timeout=TIMEOUT, verify=VAL_verify)
+ elif(HEADERS is not None and COOKIES is None):
+ response = requests.get(URL, params=NORMAL_PARAMS, json=JSON_PARAMS, headers=HEADERS, timeout=TIMEOUT, verify=VAL_verify)
+ elif(HEADERS is None and COOKIES is not None):
+ response = requests.get(URL, params=NORMAL_PARAMS, json=JSON_PARAMS, cookies=COOKIES, timeout=TIMEOUT, verify=VAL_verify)
+ # 나라장터 API 수집시 json 수집임에도 param을 json으로 넘겨주면 데이터를 못가져온다. json이 아닌 param으로 호출하도록 함
+ elif len(re.findall("apis.data.go.kr", URL)) > 0:
+ response = requests.get(URL, params=JSON_PARAMS, json='', timeout=TIMEOUT, verify=VAL_verify)
+ else:
+ response = requests.get(URL, params=NORMAL_PARAMS, json=JSON_PARAMS, timeout=TIMEOUT, verify=VAL_verify)
+ else: #POST
+ if(HEADERS is not None and COOKIES is not None):
+ response = requests.post(URL, data=NORMAL_PARAMS, json=JSON_PARAMS, headers=HEADERS, cookies=COOKIES, timeout=TIMEOUT, verify=VAL_verify)
+ elif(HEADERS is not None and COOKIES is None):
+ response = requests.post(URL, data=NORMAL_PARAMS, json=JSON_PARAMS, headers=HEADERS, timeout=TIMEOUT, verify=VAL_verify)
+ elif(HEADERS is None and COOKIES is not None):
+ response = requests.post(URL, data=NORMAL_PARAMS, json=JSON_PARAMS, cookies=COOKIES, timeout=TIMEOUT, verify=VAL_verify)
+ else:
+ response = requests.post(URL, data=NORMAL_PARAMS, json=JSON_PARAMS, timeout=TIMEOUT, verify=VAL_verify)
+ '''
+ if(METHOD=="GET"):
+ if(HEADERS is not None and COOKIES is not None):
+ response = requests.get(URL, params=PARAMS, headers=HEADERS, cookies=COOKIES, timeout=TIMEOUT, verify=VAL_verify)
+ elif(HEADERS is not None and COOKIES is None):
+ response = requests.get(URL, params=PARAMS, headers=HEADERS, timeout=TIMEOUT, verify=VAL_verify)
+ elif(HEADERS is None and COOKIES is not None):
+ response = requests.get(URL, params=PARAMS, cookies=COOKIES, timeout=TIMEOUT, verify=VAL_verify)
+ else:
+ response = requests.get(URL, params=PARAMS, timeout=TIMEOUT, verify=VAL_verify)
+ else: #POST
+ if(HEADERS is not None and COOKIES is not None):
+ response = requests.post(URL, data=PARAMS, headers=HEADERS, cookies=COOKIES, timeout=TIMEOUT, verify=VAL_verify)
+ elif(HEADERS is not None and COOKIES is None):
+ response = requests.post(URL, data=PARAMS, headers=HEADERS, timeout=TIMEOUT, verify=VAL_verify)
+ elif(HEADERS is None and COOKIES is not None):
+ response = requests.post(URL, data=PARAMS, cookies=COOKIES, timeout=TIMEOUT, verify=VAL_verify)
+ else:
+ response = requests.post(URL, data=PARAMS, timeout=TIMEOUT, verify=VAL_verify)
+ '''
+ #print("status_code:", response.status_code)
+ #print("response.text:", response.text)
+ #print("response.headers:", response.headers)
+
+ if(response.status_code == 200):
+ if(RTN_TYPE=="JSON"):
+ RTN = json.loads(response.text)
+ elif(RTN_TYPE=="TEXT"):
+ RTN = response.text
+ else:
+ RTN = response
+ else:
+ RTN = response.status_code
+
+ return RTN
+
+
+
+
+#===============================================================================================================================================
+# ETL 펑션
+#===============================================================================================================================================
+class Etl:
+ dbconn_BI = dbconn()
+ Util = Util()
+ array_diff_list = []
+ DIC_G2BPartCode = {}
+
+ def g2b_init(self):
+ self.DIC_G2BPartCode = self.G2BPartCode()
+
+ def del_key(self, _dic, _keys):# 딕셔너리 컬럼 삭제
+ for _col in _keys:
+ try:
+ del _dic[_col]
+ except:
+ pass
+ return _dic
+
+ def array_diff(self, a, b, path=None): # a : 기준값, b : 변경값 딕셔너리간 다른값 찾기
+ keys = a.keys()# a 를 기준으로 확인
+ for key in keys:
+ if self.getValue(a, key, {}) == self.getValue(b, key, {}):# 하위 값이 같으면 패스
+ pass
+ else: #하위값이 다른경우 내용을 풀어 확인한다
+ _path = key if path == None else path + "|" + key
+ if type(self.getValue(a, key, {})) == dict and type(self.getValue(b, key, {})) == dict:
+ self.array_diff(self.getValue(a, key, {}), self.getValue(b, key, {}), _path)
+ else:
+ self.array_diff_list.append([_path, self.getValue(a, key, {}), self.getValue(b, key, {})])
+ _path = path
+
+ def diff_array(self, a, b): # 변경데이터 교집합, 차집합 작성
+ try:
+ RTN = {}
+ a = a if type(a) == dict else json.loads(a, strict=False)
+ b = b if type(b) == dict else json.loads(b, strict=False)
+ self.array_diff_list = [] # 데이터 초기화
+ self.array_diff(a, b)
+ A = self.array_diff_list
+ self.array_diff_list = [] # 데이터 초기화
+ self.array_diff(b, a)
+ B = self.array_diff_list
+ dic_intersection = {}
+ dic_Acomplement = {} # A - B 차집합
+ dic_Bcomplement = {} # B - A 차집합
+
+ for Aval in A:
+ FG = False
+ for Bval in B:
+ if Aval[0]==Bval[0]:
+ FG = True
+ break
+ else:
+ FG = False
+ if FG == True:
+ dic_intersection[Aval[0]] = {"OLD": Aval[1], "NEW": Aval[2]}
+ else:
+ dic_Acomplement[Aval[0]] = {"OLD": Aval[1], "NEW": Aval[2]}
+
+ for Bval in B:
+ FG = False
+ for Aval in A:
+ if Bval[0]==Aval[0]:
+ FG = True
+ break
+ else:
+ FG = False
+ if FG == True:
+ pass
+ else:
+ dic_Bcomplement[Bval[0]] = {"NEW": Bval[1], "OLD": Bval[2]}
+
+ RTN["MOD"] = dic_intersection # 데이터가 수정된 경우
+ RTN["REMOVE"] = dic_Acomplement # 키가 사라진 경우
+ RTN["ADD"] = dic_Bcomplement # 키가 추가된 경우
+ return RTN
+ except Exception as e:
+ print(e)
+
+ def blank_none_zero_To_0(self, _param):
+ try:
+ rtn = float(_param)
+ except:
+ rtn = 0
+ return rtn
+
+ def change_detect_DB(self, _diff, syscollect, new): #데이터 변경시 테이블 자동반영
+ # ==================================================================================================================
+ # 기초금액, A값, 순공사원가 변경 허용할 발주처 세팅
+ # 기초금액 변경 확인 추가 -> ([53] 국가철도공산, 2021.7)
+ # A값 변경 확인 추가 -> ([53] 국가철도공산, 2021.7)
+ # 순공사원가 변경 확인 추가 -> ([05] 토지주택공사, 2021.7.21)
+ #
+ # ==================================================================================================================
+ print("===========================================================================")
+ print("=====================change_detect_DB===>start=============================")
+
+ allow_basic = ["52","53","91","08","05","04","03"]#기초금액 변경 확인 발주처 whereis
+ allow_basic_type2 = ["10"] # 기초금액 변경 확인시 투찰율+사정율+난이도계수 함께 업데이트 발주처 whereis
+ allow_premiumList = ["01","53","08","10","04"]#A값 변경 확인 발주처 whereis
+ allow_bid_const_cost_list = ["01","05","08","10","04"]#순공사원가 변경 확인 발주처 whereis
+ allow_bidcomment = ["91"] #전자통신연구원 참가자격 서류
+ allow_notice = ["10"] #공지사항 업데이트
+ # ==================================================================================================================
+ new = new if type(new) == dict else json.loads(new, strict=False)
+
+ bidid = syscollect["bidid"] # 파이프라인 detail_list_select 에서 데이터 추가
+
+ if bidid == "" or bidid is None:
+ print("===========================================================================")
+ print("=====================change_detect_DB======================================")
+ print("=====================bidid is None=========================================")
+ print("===========================================================================")
+ return None
+
+ if syscollect["a.whereis"] is not None:
+ whereis = syscollect["a.whereis"] # 파이프라인 detail_list_select 에서 데이터 추가
+ else:
+ whereis = None
+
+ # ==============================================================
+ # 변경감지에서 쌓인 로그를 임시로 남긴다. 정상적으로 쌓이는지 확인차
+ # 코드와 변경내용은 하단에서 종류별로 선언 후 insert
+ # ==============================================================
+ bid_notify = {}
+ bid_notify['case'] = 'Notify'
+ bid_notify['bidid'] = bidid
+ bid_notify['confirm'] = 'N'
+ bid_notify['uptime'] = "NOW()"
+
+
+ # =============================================================================================================
+ # A값 변경 확인 premiumList
+ if whereis in allow_premiumList:
+ ck_premiumList = ["cost1", "cost2", "cost3", "cost4", "cost5", "cost6", "cost7", "cost8", "cost_total"]# A값 확인 컬럼 리스트
+ mod_premiumList = list(filter(lambda x: x is not None, list(map(lambda x: x if "premiumList|" + x in _diff["MOD"] else None, ck_premiumList))))# 변경된 컬럼 리스트
+ if len(mod_premiumList) > 0:# 변경된 값에 A값이 있는지 확인
+ _premiumList = {}
+ for key in mod_premiumList:
+ _premiumList[key] = _diff["MOD"]["premiumList|" + key]["NEW"]# 변경된 값 딕셔너리 작성
+ query = "SELECT `cost1`,`cost2`,`cost3`,`cost4`,`cost5`,`cost6`,`cost7`,`cost8`,`cost_total` FROM `premiumList` WHERE bidid = '{bidid}' limit 1".format(bidid=bidid)# 기존 입력된 데이터 가져오기
+ row = self.dbconn_BI.sql_exec(query, "DS")
+
+ if len(row) > 0:# 이미 입력된 건이 있는경우 변경 비교
+ pass #추후 자동변경될시에 작업
+ # _premiumList_mod = {}
+ # for key in _premiumList:
+ # if _premiumList[key] != row[0][key]:# 저장데이터와 수집데이터가 다른것만 업데이트
+ # _premiumList_mod[key] = _premiumList[key]
+ #self.dbconn_BI.Update_table(_premiumList_mod, {'bidid': bidid}, 'premiumList')
+ #변경완료 후 알림이 필요하면 알림 작성
+
+ # JB A값 점검페이지 로그 남기기
+ new_cost_total = new["premiumList"].get("cost_total", 0) if "premiumList" in new else 0
+ _data_change_log_cost_a = {}
+ for key in _premiumList:
+ if _premiumList[key] != row[0][key]: # 저장데이터와 수집데이터가 다른 경우
+ _data_change_log_cost_a['data_type'] = 'cost_a_change'
+ _data_change_log_cost_a['bidid'] = bidid
+ _data_change_log_cost_a['notinum'] = syscollect["dkey"]
+ _data_change_log_cost_a['whereis'] = whereis
+ _data_change_log_cost_a['prev_data'] = row[0]['cost_total']
+ _data_change_log_cost_a['change_data'] = new_cost_total
+ _data_change_log_cost_a['writedt'] = 'NOW()'
+ self.dbconn_BI.Insert_table(_data_change_log_cost_a, 'data_change_log')
+
+ # A값 항목별로 쌓을 필요가 없어서 1개 항목만 달라도 쌓고 종료
+ break
+
+ elif whereis != '01': # 입력된 건이 없는 경우 입력 - 나라장터는 하단에서 별도 처리 함
+ _premiumList["bidid"] = bidid
+ _premiumList["writedt"] = "NOW()"
+ self.dbconn_BI.Insert_table(_premiumList, 'premiumList')
+
+ query = "SELECT `state_a` FROM bid_key WHERE bidid = '{bidid}'".format(bidid=bidid)
+ row = self.dbconn_BI.sql_exec(query, "DS")
+ if len(row) > 0:
+ if row[0]["state_a"] == "I":
+ update_query = "UPDATE bid_key SET state_a = 'Y' WHERE bidid = '{bidid}'".format(bidid=bidid)
+ self.dbconn_BI.sql_exec(update_query, "U")
+
+ bid_notify['code'] = '0102'
+ bid_notify['note'] = '[{dcode}] {dkey} - A값이 업데이트 되었습니다.'.format(dcode=syscollect['dcode'], dkey=syscollect['dkey'])
+ self.dbconn_BI.Insert_table(bid_notify, 'bid_notify')
+
+
+
+ # =============================================================================================================
+ # 순공사원가 변경확인 bid_const_cost_list
+ if whereis in allow_bid_const_cost_list:
+ ck_bid_const_cost_list = ["const_cost", "material", "labor", "expense", "tax", "const_cost_nbid", "updatedt_nbid"] # 순공사원가 확인 컬럼 리스트
+ mod_bid_const_cost_list = list(filter(lambda x: x is not None, list(map(lambda x: x if "bid_const_cost_list|" + x in _diff["MOD"] else None, ck_bid_const_cost_list)))) # 변경된 컬럼 리스트
+
+ if len(mod_bid_const_cost_list) > 0: # 변경된 값에 순공사원가 있는지 확인
+ _bid_const_cost_list = {}
+ for key in mod_bid_const_cost_list:
+ _bid_const_cost_list[key] = _diff["MOD"]["bid_const_cost_list|" + key]["NEW"] # 변경된 값 딕셔너리 작성
+
+ query = "SELECT `const_cost`,`material`,`labor`,`expense`,`tax`,`const_cost_nbid`,`updatedt_nbid` FROM `bid_const_cost_list` WHERE bidid = '{bidid}' limit 1".format(bidid=bidid) # 기존 입력된 데이터 가져오기
+ row = self.dbconn_BI.sql_exec(query, "DS")
+
+ if len(row) > 0: # 이미 입력된 건이 있는경우 변경 비교
+ pass # 추후 자동변경될시에 작업
+
+ # 순공사원가 점검페이지 로그 남기기
+ _data_change_log = {}
+ for key in _bid_const_cost_list:
+ if _bid_const_cost_list[key] != row[0][key]: # 저장데이터와 수집데이터가 다른 경우
+ _data_change_log['data_type'] = 'const_cost'
+ _data_change_log['bidid'] = bidid
+ _data_change_log['notinum'] = syscollect["dkey"]
+ _data_change_log['whereis'] = whereis
+ _data_change_log['prev_data'] = row[0]['const_cost']
+ _data_change_log['change_data'] = _bid_const_cost_list[key]
+ _data_change_log['writedt'] = 'NOW()'
+ self.dbconn_BI.Insert_table(_data_change_log, 'data_change_log')
+
+ # 입력하는 부분
+ # _bid_const_cost_list_mod = {}
+ # for key in _bid_const_cost_list:
+ # if _bid_const_cost_list[key] != row[0][key]: # 저장데이터와 수집데이터가 다른것만 업데이트
+ # _bid_const_cost_list_mod[key] = _bid_const_cost_list[key]
+ # _bid_const_cost_list_mod["updatedt"] = "NOW()"
+ # self.dbconn_BI.Update_table(_bid_const_cost_list_mod, {'bidid': bidid}, 'bid_const_cost_list')
+
+ else: # 입력된 건이 없는 경우 입력
+ _bid_const_cost_list["bidid"] = bidid
+ _bid_const_cost_list["writedt"] = "NOW()"
+ # self.dbconn_BI.Insert_table(_bid_const_cost_list, 'bid_const_cost_list')
+ pass
+
+ bid_notify['code'] = '0103'
+ bid_notify['note'] = '[{dcode}] {dkey} - 순공사원가가 업데이트 되었습니다.'.format(dcode=syscollect['dcode'], dkey=syscollect['dkey'])
+ # self.dbconn_BI.Insert_table(bid_notify, 'bid_notify')
+
+
+
+ #=============================================================================================================
+ # 기초금액 변경 확인
+ # print("=====================기초금액1=====================")
+ # print(whereis)
+ # print(syscollect)
+ if whereis in allow_basic:#whereis 값이 리스트에 허용된 발주처만 적용
+ print("=====================기초금액2=====================")
+ if "bid_key|basic" in _diff["MOD"]:# 변경된 값에 기초금액이 있는지 확인
+ basic = self.blank_none_zero_To_0(_diff["MOD"]["bid_key|basic"]["NEW"]) # 새로 수집된 기초금액
+ print("=====================기초금액3=====================")
+ if basic > 0:
+ print("=====================기초금액4=====================")
+ query = "SELECT `basic`, `bidtype`, `opt` FROM bid_key WHERE bidid = '{bidid}' AND constdt > NOW() limit 1".format(bidid=bidid)
+ row = self.dbconn_BI.sql_exec(query, "DS")
+ print("=====================기초금액5=====================")
+ if len(row) > 0:# 공고가 조건(constdt > NOW() AND state = 'Y' AND bidproc = 'B')에 부합할때만 처리
+ if self.blank_none_zero_To_0(row[0]["basic"]) == 0: #기존 입력된 기초금액이 공백이거나 0일때 수정
+ print("=====================기초금액6=====================")
+ _data = {}
+ _data["basic"] = basic
+ _data["opt"] = row[0]["opt"] + 512
+ self.dbconn_BI.Update_table(_data, {'bidid': bidid}, 'bid_key')
+ print("=====================기초금액7=====================")
+ bid_notify['code'] = '0101'
+ bid_notify['note'] = '[{dcode}] {dkey} - 기초금액이 업데이트 되었습니다.'.format(dcode=syscollect['dcode'], dkey=syscollect['dkey'])
+ self.dbconn_BI.Insert_table(bid_notify, 'bid_notify')
+
+
+ # =============================================================================================================
+ # 기초금액 변경 확인시 투찰율+사정율+난이도계수+A값+순공사원가 함께 업데이트 (국방부는 기초금액이 나올경우 해당항목들이 같이 나와서 같이 업데이트 해줘야 함)
+ if whereis in allow_basic_type2: # whereis 값이 리스트에 허용된 발주처만 적용
+ print("국방부 기초금액 업데이트 체크")
+ print(_diff)
+ _diff["MOD"].update(_diff["ADD"])
+ if "bid_key|basic" in _diff["MOD"]: # 변경된 값에 기초금액이 있는지 확인
+ print("1")
+ basic = self.blank_none_zero_To_0(_diff["MOD"]["bid_key|basic"]["NEW"]) # 새로 수집된 기초금액
+ new = new if type(new) == dict else json.loads(new, strict=False)
+ pct = new["bid_key"]["pct"] if "pct" in new["bid_key"] else '' # 투찰율
+ yegarng = new["bid_value"]["yegarng"] if "yegarng" in new["bid_value"] else '' # 예가범위
+ lvcnt = new["bid_value"]["lvcnt"] if "lvcnt" in new["bid_value"] else '' # 난이도계수
+ bidcomment = new["bid_content"]["bidcomment"] if "bidcomment" in new["bid_content"] else '' # 토목기초금액, 건설기초금액이 나오면 자격조건에 텍스트로만 넣어준다.
+
+
+ if basic > 0:
+ print("2")
+ query = "SELECT `basic`, `bidtype`, `opt` FROM bid_key WHERE bidid = '{bidid}' AND constdt > NOW() limit 1".format(bidid=bidid)
+ row = self.dbconn_BI.sql_exec(query, "DS")
+ if len(row) > 0: # 공고가 조건(constdt > NOW() AND state = 'Y' AND bidproc = 'B')에 부합할때만 처리
+ if self.blank_none_zero_To_0(row[0]["basic"]) == 0: # 기존 입력된 기초금액이 공백이거나 0일때 수정
+ _data = {}
+ _data["basic"] = basic
+ _data["opt"] = row[0]["opt"] + 512
+ if pct: _data["pct"] = pct
+ self.dbconn_BI.Update_table(_data, {'bidid': bidid}, 'bid_key')
+
+ # bid_value 업데이트
+ if yegarng or lvcnt:
+ _data = {}
+ if yegarng: _data["yegarng"] = yegarng
+ if lvcnt: _data["lvcnt"] = lvcnt
+ self.dbconn_BI.Update_table(_data, {'bidid': bidid}, 'bid_value')
+
+ # bid_content 업데이트
+ if bidcomment.find('기초금액') >= 0: # 1. 토목기초금액, 건설기초금액 내용이 자격조건에 세팅되었을때 -> 해당금액들은 따로 컬럼이 없고 자격조건에 텍스트로만 넣어주고 있음
+ bidcomment_query = "SELECT `bidcomment` FROM bid_content WHERE bidid = '{bidid}'".format(bidid=bidid)
+ bidcomment_row = self.dbconn_BI.sql_exec(bidcomment_query, "DS")
+ if bidcomment_row[0]["bidcomment"].find('기초금액') < 0: # 2. 기존 자격조건에 토목기초금액, 건설기초금액 내용이 없을때
+ if bidcomment_row[0]["bidcomment"]: bidcomment = bidcomment_row[0]["bidcomment"] + '\n' + bidcomment
+ _data = {}
+ _data["bidcomment"] = bidcomment
+ self.dbconn_BI.Update_table(_data, {'bidid': bidid}, 'bid_content')
+ # 기초금액 공개시 A값 같이 나와서 업데이트 필요
+ cost1 = new["premiumList"]["cost1"] if "cost1" in new["premiumList"] else ''
+ cost2 = new["premiumList"]["cost2"] if "cost2" in new["premiumList"] else ''
+ cost3 = new["premiumList"]["cost3"] if "cost3" in new["premiumList"] else ''
+ cost4 = new["premiumList"]["cost4"] if "cost4" in new["premiumList"] else ''
+ cost5 = new["premiumList"]["cost5"] if "cost5" in new["premiumList"] else ''
+ cost6 = new["premiumList"]["cost6"] if "cost6" in new["premiumList"] else ''
+ cost8 = new["premiumList"]["cost8"] if "cost8" in new["premiumList"] else ''
+ if cost1 or cost2 or cost3 or cost4 or cost5 or cost6 or cost8:
+ premium_query = "SELECT * FROM premiumList WHERE bidid ='{bidid}' LIMIT 1".format(bidid=bidid)
+ premium_check = self.dbconn_BI.sql_exec(premium_query, "S_one")
+
+ if premium_check is None or premium_check == '':
+ insert_query = "INSERT INTO premiumList SET cost1 = '{cost1}', cost2 = '{cost2}', cost3 = '{cost3}', cost4 = '{cost4}', cost5 = '{cost5}', cost6 = '{cost6}', cost8 = '{cost8}', bidid = '{bidid}'".format(cost1=cost1, cost2=cost2, cost3=cost3, cost4=cost4, cost5=cost5, cost6=cost6, cost8=cost8,bidid=bidid)
+ self.dbconn_BI.sql_exec(insert_query, "I")
+ if syscollect["ext_info2"] != "공개수의":
+ update_query = "UPDATE bid_key SET state_a = 'Y' WHERE bidid = '{bidid}'".format(bidid=bidid)
+ self.dbconn_BI.sql_exec(update_query, "U")
+ else:
+ update_query = "UPDATE premiumList SET cost1 = '{cost1}', cost2 = '{cost2}', cost3 = '{cost3}', cost4 = '{cost4}', cost5 = '{cost5}', cost6 = '{cost6}', cost8 = '{cost8}' WHERE bidid = '{bidid}'".format(cost1=cost1, cost2=cost2, cost3=cost3, cost4=cost4, cost5=cost5, cost6=cost6, cost8=cost8,bidid=bidid)
+ self.dbconn_BI.sql_exec(update_query, "U")
+ query = "SELECT `state_a` FROM bid_key WHERE bidid = '{bidid}'".format(bidid=bidid)
+ row = self.dbconn_BI.sql_exec(query, "DS")
+ if len(row) > 0:
+ if row[0]["state_a"] == "I":
+ update_query = "UPDATE bid_key SET state_a = 'Y' WHERE bidid = '{bidid}'".format(bidid=bidid)
+ self.dbconn_BI.sql_exec(update_query, "U")
+
+ # 기초금액 공개시 순공사원가 같이 나와서 업데이트 필요
+ const_cost = new["bid_const_cost_list"]["const_cost"] if "const_cost" in new["bid_const_cost_list"] else ''
+ expense = new["bid_const_cost_list"]["expense"] if "expense" in new["bid_const_cost_list"] else ''
+ labor = new["bid_const_cost_list"]["labor"] if "labor" in new["bid_const_cost_list"] else ''
+ material = new["bid_const_cost_list"]["material"] if "material" in new["bid_const_cost_list"] else ''
+ tax = new["bid_const_cost_list"]["tax"] if "tax" in new["bid_const_cost_list"] else ''
+ if const_cost or expense or labor or material or tax:
+ const_cost_query = "SELECT bidid FROM bid_const_cost_list WHERE bidid ='{bidid}' LIMIT 1".format(bidid=bidid)
+ const_cost_check = self.dbconn_BI.sql_exec(const_cost_query, "S_one")
+
+ if const_cost_check:
+ update_query = "UPDATE bid_const_cost_list SET material = '{material}', labor = '{labor}', expense = '{expense}', tax = '{tax}', const_cost = '{const_cost}' WHERE bidid = '{bidid}'".format(material=material, labor=labor, expense=expense, tax=tax, const_cost=const_cost,bidid=bidid)
+ self.dbconn_BI.sql_exec(update_query, "U")
+ else:
+ insert_query = "INSERT INTO bid_const_cost_list SET material = '{material}', labor = '{labor}', expense = '{expense}', tax = '{tax}', const_cost = '{const_cost}', bidid = '{bidid}'".format(material=material, labor=labor, expense=expense, tax=tax, const_cost=const_cost,bidid=bidid)
+ self.dbconn_BI.sql_exec(insert_query, "I")
+
+
+
+ # =============================================================================================================
+ # 공지사항 업데이트
+ if whereis in allow_notice:
+ if "bid_notice_memo|notice_memo" in _diff["MOD"]:
+ prev_notice_query = "SELECT notice_memo FROM bid_notice_memo WHERE bidid = '{bidid}' ORDER BY reg_date LIMIT 1".format(bidid=bidid)
+ prev_notice = self.dbconn_BI.sql_exec(prev_notice_query, "S_one")
+
+ updateCheck = False
+
+ # 이전 공지사항이 있으면 비교하여 다를경우, 공지사항 입력된게 없을경우
+ if prev_notice is not None and prev_notice != '':
+ # DB에 있는 공지사항내용과 수집된 공지사항 내용이 다르면
+ if prev_notice != _diff["MOD"]["bid_notice_memo|notice_memo"]["NEW"]:
+ updateCheck = True
+ else: updateCheck = True
+
+ if updateCheck == True:
+ _data = {}
+ _data["notice_memo"] = _diff["MOD"]["bid_notice_memo|notice_memo"]["NEW"]
+ _data["writer_name"] = _diff["MOD"]["bid_notice_memo|writer_name"]["NEW"]
+ _data["status"] = _diff["MOD"]["bid_notice_memo|status"]["NEW"]
+ _data["reg_date"] = 'NOW()'
+ _data["bidid"] = bidid
+ self.dbconn_BI.Insert_table(_data, 'bid_notice_memo')
+
+
+ # ======================================================
+ # 추후에 통합하여 트리거로 대체후 제거해야함.
+ # ======================================================
+ trigger_table = "log_bid_trigger_y"
+ if syscollect["ext_info"] == 'pur': trigger_table = "log_bid_trigger_pur"
+
+ self.trigger_insert(bidid, trigger_table)
+ # insert_query = "INSERT INTO {trigger_table} SET bidid='{bidid}', uptime= NOW()".format(trigger_table=trigger_table, bidid=bidid)
+ # self.dbconn_BI.sql_exec(insert_query, "I")
+ # ======================================================
+ # 추후에 통합하여 트리거로 대체후 제거해야함.
+ # ======================================================
+
+ # =============================================================================================================
+ # 조달청 기초금액, A값, 순공사원가, 공지사항 업데이트
+ if syscollect["dcode"] in ["G2B_B"]:
+ print("=================>조달청 입찰 업데이트 start")
+ print("_diff :", _diff)
+ if "bidtype" in syscollect:
+ trigger_flag = 'N'
+
+ ck_bid_value_MOD = self.extract_diff(_diff["MOD"], "bid_value", "NEW") # bid_value
+ _diff_bid_value_MOD = _diff["ADD"] if len(ck_bid_value_MOD) == 0 else _diff["MOD"] # 첫입력일경우 _diff["ADD"] 데이터을 입력
+ bid_value_dataset = self.extract_diff(_diff_bid_value_MOD, "bid_value", "NEW") # _diff 에서 해당 테이블 데이터만 추출
+ if len(bid_value_dataset) > 0: # yegarng 변경시
+ if 'yegarng' in bid_value_dataset and bid_value_dataset['yegarng'] is not None and bid_value_dataset['yegarng'] != '':
+ _bid_value_dataset = {
+ "key": {"bidid": bidid},
+ "table": "i2.bid_value",
+ "value": {"yegarng": bid_value_dataset['yegarng']},
+ "type": "updateonly",
+ "orderby": "",
+ "del_col": [],
+ }
+ print("i2.bid_value yegarng :", bid_value_dataset['yegarng'])
+ trigger_flag = 'Y'
+ self.dbconn_BI.Upsert_table(_bid_value_dataset, "EXEC")
+ if 'lvcnt' in bid_value_dataset and bid_value_dataset['lvcnt'] is not None and bid_value_dataset['lvcnt'] != '':
+ _bid_value_dataset = {
+ "key": {"bidid": bidid},
+ "table": "i2.bid_value",
+ "value": {"lvcnt": bid_value_dataset['lvcnt']},
+ "type": "updateonly",
+ "orderby": "",
+ "del_col": [],
+ }
+ print("i2.bid_value lvcnt :", bid_value_dataset['lvcnt'])
+ trigger_flag = 'Y'
+ self.dbconn_BI.Upsert_table(_bid_value_dataset, "EXEC")
+
+
+
+ ck_bid_key_MOD = self.extract_diff(_diff["MOD"], "bid_key", "NEW") #bid_key
+ _diff_bid_key_MOD = _diff["ADD"] if len(ck_bid_key_MOD) == 0 else _diff["MOD"] # 첫입력일경우 _diff["ADD"] 데이터을 입력
+ bid_key_dataset = self.extract_diff(_diff_bid_key_MOD, "bid_key", "NEW") # _diff 에서 해당 테이블 데이터만 추출
+ if len(bid_key_dataset) > 0: #기초금액 변경시
+ if "basic" in bid_key_dataset and bid_key_dataset['basic'] is not None:
+ bid_key_query = "SELECT * FROM bid_key WHERE bidid = '{bidid}' ORDER BY writedt LIMIT 1".format(bidid=bidid)
+ bid_key_row = self.dbconn_BI.sql_exec(bid_key_query, "DS")[0]
+ if self.blank_none_zero_To_0(bid_key_row["basic"]) == 0: #입력된 기초금액이 없을때
+ if bid_key_row["opt"] is not None:
+ prev_opt_arr = self.conv_bin(bid_key_row["opt"])
+ else:
+ prev_opt_arr = []
+ prev_opt_arr.append(9)
+ mod_opt = self.pow_sum(list(set(prev_opt_arr)))
+ _bid_key_dataset = {
+ "key": {"bidid": bidid},
+ "table": "i2.bid_key",
+ "value": {"basic": bid_key_dataset['basic'], "opt": mod_opt},
+ "type": "updateonly",
+ "orderby": "",
+ "del_col": [],
+ }
+ print("i2.bid_key basic :", bid_key_dataset['basic'])
+ self.dbconn_BI.Upsert_table(_bid_key_dataset, "EXEC")
+ else:
+ if bid_key_dataset['basic'] is not None and bid_key_dataset['basic'] != '' and int(bid_key_row["basic"]) != int(bid_key_dataset['basic']):
+ code_pattern = [
+ {"pattern": {"P1": "con", }, "value": "0031"},
+ {"pattern": {"P1": "ser", }, "value": "0032"},
+ {"pattern": {"P1": "pur", }, "value": "0033"},
+ ]
+ notify_code=self.mapping_pattern_value(code_pattern, syscollect['bidtype'], '')
+ #기초금액이 입력되있는데, 변경된경우
+ _notify_dataset = {
+ "key": {"bidid": bidid, "code": notify_code},
+ "table": "i2.bid_notify",
+ "value": {
+ "case": "Notify",
+ "code": notify_code,
+ "bidid": bidid,
+ "note": str(bid_key_row["basic"]) + "|" + str(bid_key_dataset['basic']),
+ "confirm": "N",
+ "uptime": "NOW()",
+ },
+ "type": "update",
+ "orderby": "",
+ "del_col": [],
+ }
+ print("i2.notify basic :", bid_key_dataset['basic'])
+ #잠ㅅㅣ빼놈
+ self.dbconn_BI.Upsert_table(_notify_dataset, "EXEC")
+
+
+
+ ck_premium_MOD = self.extract_diff(_diff["MOD"], "premiumList", "NEW") #premiumList
+ _diff_premium_MOD = _diff["ADD"] if len(ck_premium_MOD) == 0 else _diff["MOD"] # 첫입력일경우 _diff["ADD"] 데이터을 입력
+ premium_dataset = self.extract_diff(_diff_premium_MOD, "premiumList", "NEW") # _diff 에서 해당 테이블 데이터만 추출
+ if len(premium_dataset) > 0: #A값 변경시
+ print("여기")
+ print(premium_dataset)
+ if "cost1" in premium_dataset and premium_dataset['cost1'] is not None and premium_dataset['cost1'] != '' or \
+ "cost2" in premium_dataset and premium_dataset['cost2'] is not None and premium_dataset['cost2'] != '' or \
+ "cost3" in premium_dataset and premium_dataset['cost3'] is not None and premium_dataset['cost3'] != '' or \
+ "cost4" in premium_dataset and premium_dataset['cost4'] is not None and premium_dataset['cost4'] != '' or \
+ "cost5" in premium_dataset and premium_dataset['cost5'] is not None and premium_dataset['cost5'] != '' or \
+ "cost6" in premium_dataset and premium_dataset['cost6'] is not None and premium_dataset['cost6'] != '' or \
+ "cost7" in premium_dataset and premium_dataset['cost7'] is not None and premium_dataset['cost7'] != '' or \
+ "cost8" in premium_dataset and premium_dataset['cost8'] is not None and premium_dataset['cost8'] != '' or \
+ "cost_total" in premium_dataset and premium_dataset['cost_total'] is not None and premium_dataset['cost_total'] != '' or \
+ "direct_labor_cost" in premium_dataset and premium_dataset['direct_labor_cost'] is not None and premium_dataset['direct_labor_cost'] != '':
+ trigger_flag = 'Y'
+
+ premium_dataset['bidid'] = bidid
+ premium_dataset['writedt'] = "NOW()"
+
+ g2b_premium_total_cost_query = "SELECT `cost_total` FROM `premiumList` WHERE bidid = '{bidid}' limit 1".format(bidid=bidid) # 기존 입력된 데이터 가져오기
+ g2b_before_premium_total_cost = self.dbconn_BI.sql_exec(g2b_premium_total_cost_query, "S_one")
+
+ _diff_premium_dataset = {
+ "key": {"bidid": bidid},
+ "table": "i2.premiumList",
+ "value": premium_dataset,
+ "type": "update",
+ "orderby": "",
+ "del_col": ['writedt'],
+ }
+
+ print("i2.premiumList :", premium_dataset)
+ self.dbconn_BI.Upsert_table(_diff_premium_dataset, "EXEC")
+
+ #총값이 있을경우에만 Y로 업데이트
+ if syscollect["bidtype"] in ["con"] and premium_dataset['cost_total'] is not None and premium_dataset['cost_total'] != '':
+ _diff_state_a_dataset = {
+ "key": {"bidid": bidid},
+ "table": "i2.bid_key",
+ "value": {"state_a": "Y"},
+ "type": "updateonly",
+ "orderby": "",
+ "del_col": [],
+ }
+ print("i2.bid_key state_a : Y")
+ self.dbconn_BI.Upsert_table(_diff_state_a_dataset, "EXEC")
+
+ g2b_after_premium_total_cost = self.dbconn_BI.sql_exec(g2b_premium_total_cost_query, "S_one")
+
+ # 3. G2B A값(premiumList) 변경시 공지사항 등록
+ # 비포, 애프터값이 있고 서로 다르면 공지
+ Etl.change_cost_notice_memo(self, g2b_before_premium_total_cost, g2b_after_premium_total_cost, bidid, "cost_a", whereis)
+
+ ck_const_cost_MOD = self.extract_diff(_diff["MOD"], "bid_const_cost_list", "NEW") # bid_const_cost_list
+ _diff_const_cost_MOD = _diff["ADD"] if len(ck_const_cost_MOD) == 0 else _diff["MOD"] # 첫입력일경우 _diff["ADD"] 데이터을 입력
+ const_cost_dataset = self.extract_diff(_diff_const_cost_MOD, "bid_const_cost_list", "NEW") # _diff 에서 해당 테이블 데이터만 추출
+ if len(const_cost_dataset) > 0: # 순공사원가 변경시
+ trigger_flag = 'Y'
+ bid_key_query = "SELECT * FROM bid_key WHERE bidid = '{bidid}' ORDER BY writedt LIMIT 1".format(bidid=bidid)
+ bid_key_row = self.dbconn_BI.sql_exec(bid_key_query, "DS")[0]
+
+ g2b_const_cost_query = "SELECT const_cost FROM bid_const_cost_list WHERE bidid ='{bidid}' LIMIT 1".format(bidid=bidid)
+ g2b_before_const_cost = self.dbconn_BI.sql_exec(g2b_const_cost_query, "S_one")
+
+ self.confirm_const_cost_list_new(bid_key_row, syscollect['bidproc'], const_cost_dataset, bidid)
+
+ g2b_after_const_cost = self.dbconn_BI.sql_exec(g2b_const_cost_query, "S_one")
+
+ # 4. G2B 순공사원가(bid_const_cost_list) 변경시 공지사항 등록
+ # 비포, 애프터값이 있고 서로 다르면 공지
+ Etl.change_cost_notice_memo(self, g2b_before_const_cost, g2b_after_const_cost, bidid, "const_cost", whereis)
+
+ ck_bid_notice_memo_MOD = self.extract_diff(_diff["MOD"], "bid_notice_memo", "NEW") # bid_notice_memo
+ _diff_bid_notice_memo_MOD = _diff["ADD"] if len(ck_bid_notice_memo_MOD) == 0 else _diff["MOD"] # 첫입력일경우 _diff["ADD"] 데이터을 입력
+ bid_notice_memo_dataset = self.extract_diff(_diff_bid_notice_memo_MOD, "bid_notice_memo", "NEW") # _diff 에서 해당 테이블 데이터만 추출
+ if len(bid_notice_memo_dataset) > 0: # 공지사항이 수집된 경우에만 실행한다.
+ trigger_flag = 'Y'
+ bid_notice_memo_dataset['bidid']=bidid
+ dataset = {
+ "key": {"bidid": bidid},
+ "table": "i2.bid_notice_memo",
+ "value": bid_notice_memo_dataset,
+ "type": "update",
+ "orderby": "",
+ "del_col": ["writedt"],
+ }
+ print("i2.bid_notice_memo :", dataset)
+ self.dbconn_BI.Upsert_table(dataset, "EXEC")
+
+ # ======================================================
+ # 추후에 통합하여 트리거로 대체후 제거해야함.
+ # =====================================================
+ if trigger_flag == 'Y':
+ if syscollect["bidtype"] in ["pur"]:
+ self.trigger_insert(bidid, "log_bid_trigger_pur")
+ else:
+ self.trigger_insert(bidid, "log_bid_trigger_y")
+ # ======================================================
+ # 추후에 통합하여 트리거로 대체후 제거해야함.
+ # ======================================================
+
+
+
+ # =============================================================================================================
+ # 조달청 낙찰공지사항 업데이트
+ if syscollect["dcode"] in ["G2B_R"]:
+ print("=================>조달청 낙찰공지사항 업데이트 start")
+
+ print("_diff :", _diff)
+ if "bidtype" in syscollect:
+ ck_MOD = self.extract_diff(_diff["MOD"], "modify_nbbs", "NEW")# 이미 입력되어 있는 경우 수정사항이 있는지 확인. => 입력되지 않은 경우 {} 값을 리턴,
+ _diff_modify_nbbs = _diff["ADD"] if len(ck_MOD) == 0 else _diff["MOD"]# 첫입력일경우 _diff["ADD"] 데이터을 입력
+ modify_nbbs_dataset = self.extract_diff(_diff_modify_nbbs, "modify_nbbs", "NEW")# _diff 에서 해당 테이블 데이터만 추출
+ print("modify_nbbs_dataset :", modify_nbbs_dataset)
+
+
+ # if len(new["modify_nbbs"]) > 4:#공지사항이 수집된 경우에만 실행한다.
+ if len(modify_nbbs_dataset) > 0: # 공지사항이 수집된 경우에만 실행한다.
+ dataset = {
+ "key": {"bidid": bidid},
+ "table": "i2.modify_nbbs",
+ "value": modify_nbbs_dataset,
+ "type": "update",
+ "orderby": "",
+ "del_col": ["writedt"],
+ }
+ print("i2.modify_nbbs :", dataset)
+ self.dbconn_BI.Upsert_table(dataset, "TEST")
+ # self.dbconn_BI.Upsert_table(dataset, "EXEC")
+
+ # ======================================================
+ # 추후에 통합하여 트리거로 대체후 제거해야함.
+ # ======================================================
+ # if syscollect["bidtype"] in ["pur"]:
+ # self.trigger_insert(bidid, "log_bid_trigger_pur")
+ # else:
+ # self.trigger_insert(bidid, "log_bid_trigger_y")
+ # ======================================================
+ # 추후에 통합하여 트리거로 대체후 제거해야함.
+ # ======================================================
+
+
+
+
+ #=============================================================================================================
+ #참가자격서류 변경 확인 allow_bidcomment
+ if whereis in allow_bidcomment: #whereis 값이 리스트에 허용된 발주처만 적용
+ if "bid_content|bidcomment" in _diff["MOD"]:# 변경된 값에 기초금액이 있는지 확인
+ bidcomment = _diff["MOD"]["bid_content|bidcomment"]["NEW"].strip() # 새로 수집된 bidcomment
+
+ query = "SELECT `bidcomment`,`bidid` FROM bid_content WHERE bidid = '{bidid}' limit 1".format(bidid=bidid)
+ row = self.dbconn_BI.sql_exec(query, "DS")
+ if bidcomment != '':
+ if len(row) > 0:
+ if row[0]["bidcomment"] is None or row[0]["bidcomment"].strip() == '' or row[0]["bidcomment"].strip() == '* 공고 원문을 참조하시기 바랍니다. *' : #기존 입력된 bidcomment가 없을떄
+ _data = {}
+ _data["bidid"] = bidid
+ _data["whereis"] = whereis
+ _data["syscode"] = syscollect['dcode']
+ _data["notinum"] = syscollect['dkey']
+ if syscollect['dkey_ext']:
+ _data["notinum_ex"] = syscollect['dkey_ext']
+ _data["tableName"] = 'bid_content'
+ _data["fieldName"] = 'bidcomment'
+ _data["state"] = 'N'
+ _data["writedt"] = "NOW()"
+ #self.dbconn_BI.Insert_table(_data, 'modification_request_list')
+
+
+
+ print("===========================================================================")
+ print("=====================change_detect_DB===>end===============================")
+
+ #######################################################################
+ ########################순공사 원가 #####################################
+ #######################################################################
+ # 낙찰제외금액 분류함수
+ def confirm_const_cost_list_new(self, bid_key_arr, proc, const_cost_arr, bidid):
+ print("여안탐?")
+ iu_log_query = ""
+ if proc == 'S' or proc == 'F':
+ self.upsert_const_cost_nbid(const_cost_arr, bidid)
+ else:
+ print("입찰")
+ presum = 0
+ if 'presum' in bid_key_arr and 'basic' in bid_key_arr:
+ if bid_key_arr['presum'] is not None and bid_key_arr['presum'] != '':
+ presum = bid_key_arr['presum']
+ if (bid_key_arr['presum'] is None or bid_key_arr['presum'] == '') and (
+ bid_key_arr['basic'] is not None and bid_key_arr['basic'] != ''):
+
+ try:
+ int_basic = int(bid_key_arr['basic'])
+ except Exception as e:
+ int_basic = 0
+ presum = int_basic * 0.9
+
+ if int(presum) <= 10000000000:
+ self.upsert_const_cost_bid(bid_key_arr, const_cost_arr, bidid)
+
+ def upsert_const_cost_bid(self, bid_key_arr, const_cost_arr, bidid):
+ print("입찰 순공사 입력")
+
+ if "material" not in const_cost_arr:
+ const_cost_arr['material'] = None
+ if "labor" not in const_cost_arr:
+ const_cost_arr['labor'] = None
+ if "expense" not in const_cost_arr:
+ const_cost_arr['expense'] = None
+ if "tax" not in const_cost_arr:
+ const_cost_arr['tax'] = None
+ if "const_cost" not in const_cost_arr:
+ const_cost_arr['const_cost'] = None
+
+ # 국방부
+ # 소스단쪽에서 따로 처리됨
+ """
+ if bid_key_arr['whereis'] == '10':
+ material = 0
+ labor = 0
+ expense = 0
+ tax = 0
+
+ if const_cost_arr['material'] is not None:
+ material = const_cost_arr['material']
+ if const_cost_arr['labor'] is not None:
+ labor = const_cost_arr['labor']
+ if const_cost_arr['expense'] is not None:
+ expense = const_cost_arr['expense']
+ if const_cost_arr['tax'] is not None:
+ tax = const_cost_arr['tax']
+ self_const_cost = material + labor + expense + tax
+ if self_const_cost != 0:
+ const_cost_arr['const_cost'] = material + labor + expense + tax
+ """
+ if const_cost_arr['material'] is not None or const_cost_arr['labor'] is not None or \
+ const_cost_arr['expense'] is not None or const_cost_arr['tax'] is not None or \
+ const_cost_arr['const_cost'] is not None:
+ updsert_data = {
+ "key": {"bidid": bidid},
+ "table": "bid_const_cost_list",
+ "type": "update",
+ "value": {"bidid": bidid, "material": const_cost_arr['material'], "labor": const_cost_arr['labor'],
+ "expense": const_cost_arr['expense'], "tax": const_cost_arr['tax'],
+ "const_cost": const_cost_arr['const_cost'], "writedt": "NOW()", "updatedt": "NOW()"},
+ "del_col": ["updatedt", "writedt", "updatedt_nbid"]
+ }
+ self.dbconn_BI.Upsert_table(updsert_data)
+
+ def upsert_const_cost_nbid(self, const_cost_arr, bidid):
+ if "const_cost_nbid" in const_cost_arr:
+ if const_cost_arr['const_cost_nbid'] is not None and const_cost_arr['const_cost_nbid'] != '':
+ updsert_data = {
+ "key": {"bidid": bidid},
+ "table": "bid_const_cost_list",
+ "type": "update",
+ "value": {"bidid": bidid, "const_cost_nbid": const_cost_arr['const_cost_nbid'],
+ "updatedt_nbid": "NOW()"},
+ "del_col": ["updatedt", "writedt", "updatedt_nbid"]
+ }
+ self.dbconn_BI.Upsert_table(updsert_data)
+ print("@")
+
+ # diff 딕셔너리에서 특정 테이블 정보만 추출하여 테이블컬럼을 키로 가지는 딕셔너리 반환
+ # _type 는 NEW, OLD 를 구분
+ def extract_diff(self, _diff, _table, _type="NEW"):
+ RTN = {}
+ # print("extract_diff _diff :", _diff)
+ for key, val in _diff.items():
+ tmp = key.split("|")
+ if tmp[0] == _table:
+ RTN[tmp[1]] = val[_type]
+ return RTN
+
+ def trigger_insert(self, bidid, tablenm):
+ qry = "INSERT INTO {tablenm} SET `bidid`='{bidid}', `uptime`=NOW()".format(bidid=bidid, tablenm=tablenm)
+ self.dbconn_BI.sql_exec(qry, "I")
+
+
+ def change_detect(self, old, new, syscollect): # old : 이전수집데이터(json str), new : 새수집데이터(json str), syscollect(dic)
+ try:
+ # ==============================================
+ # 데이터 체크 및 업데이트
+ # ==============================================
+
+ # 52 -> 기존 제외조건, 07 -> dkey_ext 에 텍스트가 들어가고 있어서 제외시킴
+ pass_whereis= ["52", "07", "05", "53", "03"]
+
+ err_diff_flag = 'Y'
+ if syscollect["dcode"] in ["G2B_R"]:
+ logging.info("pass - 나라장터 낙찰건 - G2B_R")#나라장터 낙찰건은 일단빼논다. 나중에 일괄작업하여 입낙찰구분필요
+ elif syscollect["dcode"] in ["ERC"]:
+ logging.info("pass - 나라장터 낙찰건 - ERC")#나라장터 낙찰건은 일단빼논다. 나중에 일괄작업하여 입낙찰구분필요
+ elif syscollect["dcode"] in ["ERS"]:
+ logging.info("pass - 나라장터 낙찰건 - ERS")#나라장터 낙찰건은 일단빼논다. 나중에 일괄작업하여 입낙찰구분필요
+ elif syscollect["dcode"] in ["ERI"]:
+ logging.info("pass - 나라장터 낙찰건 - ERI")#나라장터 낙찰건은 일단빼논다. 나중에 일괄작업하여 입낙찰구분필요
+ else:
+ # 국방부데이터 데이터가 잘못 들어가는 현상이 있어 예외처리, 하면서 타 발주처도 같이 처리
+ # 오류있을때 로그에 쌓아준다.
+ new_data = new if type(new) == dict else json.loads(new, strict=False)
+ old_data = old if type(old) == dict else json.loads(old, strict=False)
+
+ if new_data['bid_key']['whereis'] in pass_whereis:
+ # print("pass52")
+ pass
+ else:
+ if new_data['bid_key']['notinum_ex'] is None:
+ new_data['bid_key']['notinum_ex'] = ''
+ if old_data['bid_key']['notinum'] != new_data['bid_key']['notinum']:
+ err_diff_flag = 'N'
+ # print("잘못된 데이터")
+ if syscollect["dkey_ext"] is not None and syscollect["dkey_ext"] != '':
+ if old_data['bid_key']['notinum_ex'] != new_data['bid_key']['notinum_ex']:
+ err_diff_flag = 'N'
+ # print("잘못된 데이터2")
+ if err_diff_flag == 'Y':
+ # 국방부는 공고번호에 조달청공고번호까지 넣어준다.
+ bcc_dkey = str(syscollect["dkey"]) + "|" + str(syscollect["dkey_ext"]) if syscollect["dcode"] == "D2B_B" else syscollect["dkey"]
+ #bcc_dkey = syscollect["dkey"]
+ #복수공고 공고번호 만들어주기
+ if syscollect["dcode"] == "G2B_B" and syscollect["dkey_ext"] is not None and syscollect["dkey_ext"] != "":
+ bcc_dkey = str(syscollect["dkey"]) + "|" + str(syscollect["dkey_ext"])
+
+ query = "SELECT * FROM bid_change_check WHERE notinum = '{notinum}' AND syscode = '{syscode}' limit 1".format(notinum=bcc_dkey, syscode=syscollect["dcode"])
+
+ row = self.dbconn_BI.sql_exec(query, "DS")
+ _diff = self.diff_array(old, new)
+ RTN = _diff
+
+
+ if len(row) > 0:
+ # 비교 제외 데이터 삭제
+ try:
+ #예외처리(불필요한 컬럼제외)함수 만들면 여기다 적용한다. 김영주
+ #국방부
+ if new_data['bid_key']['whereis'] == '10':
+ if "bid_key|constnm" in _diff['MOD']:
+ del _diff['MOD']['bid_key|constnm'] #공고명이 바뀜
+
+ del _diff['MOD']['bid_key|writedt']
+ except Exception as e:
+ print("change_detect : ", e)
+
+ # 변경분이 있는경우
+ if len(_diff['ADD'])+len(_diff['MOD'])+len(_diff['REMOVE']) > 0:
+ print("변경분이 있는경우")
+ print(row[0])
+ _diff_str = json.dumps(_diff, ensure_ascii=False, default=self.json_default, sort_keys=True) # dic to str
+ update_data = {}
+ update_data['prevdt'] = row[0]["updatedt"]
+ update_data['updatedt'] = 'NOW()'
+ update_data['state'] = 'Y'
+ update_data['diff'] = _diff_str
+ self.dbconn_BI.Update_table(update_data, {'seq': row[0]["seq"]}, 'bid_change_check')
+
+ bcs_cnt_select_query = "SELECT count(*) as cnt FROM bid_change_source WHERE notinum = '{notinum}' AND syscode = '{syscode}' limit 1".format(notinum=bcc_dkey, syscode=syscollect["dcode"])
+ bcs_cnt_select = self.dbconn_BI.sql_exec(bcs_cnt_select_query, "DS")
+ # 데이터가 오류가 있는듯, 계속 쌓이는 공고들로인해 조건 걸어둠, 같은공고번호로 3건이상은 안쌓이도록
+ if bcs_cnt_select[0]["cnt"] < 4:
+
+ bcs_data = {}
+ bcs_data['notinum'] = bcc_dkey
+ bcs_data['syscode'] = syscollect["dcode"]
+ bcs_data['content'] = new
+ bcs_data['writedt'] = 'NOW()'
+ self.dbconn_BI.Insert_table(bcs_data, 'bid_change_source')
+
+ #==================================================================================================================
+ # 변경처리 => A값, 순공사원가, 기초금액 비어 있는경우 자동입력
+ # ==================================================================================================================
+ self.change_detect_DB(_diff, syscollect, new)
+ # ==================================================================================================================
+ # 변경내용 자동수정 (설정한 데이터 변경시 자동으로 공고 수정)
+ # ==================================================================================================================
+ self.change_detect_DB_etc(_diff, syscollect)
+
+ msg ="[{dcode}] notinum : {notinum}, _diff : {_diff}".format(dcode=syscollect["dcode"], notinum=syscollect["dkey"], _diff=_diff_str.replace("&","_"))
+ #발주처별 팀룸 (2023.10.27 사용하지 않는것으로 판단되어 우선 주석)
+ #self.Util.send_msg(msg, syscollect["dcode"])
+ #전체 로그 팀룸 (2023.10.27 사용하지 않는것으로 판단되어 우선 주석)
+ #self.Util.send_msg(msg)
+ # ==================================================================================================================
+
+ else:
+ print("전과 동일")
+
+ #첫 수집일 경우
+ else:
+ print("첫 수집일 경우")
+ insert_data = {}
+ insert_data['notinum'] = bcc_dkey
+ insert_data['syscode'] = syscollect["dcode"]
+ insert_data['prevdt'] = 'NOW()'
+ insert_data['updatedt'] = 'NOW()'
+ insert_data['writedt'] = 'NOW()'
+ insert_data['state'] = 'N'
+ self.dbconn_BI.Insert_table(insert_data, 'bid_change_check')
+
+ bcs_data = {}
+ bcs_data['notinum'] = bcc_dkey
+ bcs_data['syscode'] = syscollect["dcode"]
+ bcs_data['content'] = new
+ bcs_data['writedt'] = 'NOW()'
+ self.dbconn_BI.Insert_table(bcs_data, 'bid_change_source')
+ else:
+ #공고번호 및 공고번호_ex 가 매칭이 안된경우
+ if syscollect['dkey_ext'] is None or syscollect['dkey_ext'] == '':
+ syscollect['dkey_ext'] = ''
+ _err_diff_data_dataset = {
+ "key": {"whereis": new_data['bid_key']['whereis'],
+ "dkey": syscollect['dkey'],
+ "dkey_ext": syscollect['dkey_ext'],
+ "notinum": new_data['bid_key']['notinum'],
+ "notinum_ex": new_data['bid_key']['notinum_ex'],
+ },
+ "table": "err_diff_data",
+ "value": {"whereis": new_data['bid_key']['whereis'],
+ "dkey": syscollect['dkey'],
+ "dkey_ext": syscollect['dkey_ext'],
+ "notinum": new_data['bid_key']['notinum'],
+ "notinum_ex":new_data['bid_key']['notinum_ex'],
+ #"crawl_data_view_old": syscollect['crawl_data_view'],
+ #"crawl_data_view_old": new_data,
+ "writedt": "NOW()",
+ },
+ "type": "insertonly",
+ "orderby": "",
+ "del_col": [],
+ }
+ self.dbconn_BI.Upsert_table(_err_diff_data_dataset, "EXEC")
+ RTN = {}
+
+ except Exception as ex:
+ RTN = {}
+ print("change_detect Exception : ", ex)
+
+ change_detect_error = {}
+ change_detect_error['notinum'] = syscollect['dkey']
+ change_detect_error['notinum_ex'] = syscollect['dkey_ext']
+ change_detect_error['whereis'] = syscollect['whereis']
+ change_detect_error['err_code'] = ex
+ change_detect_error['writedt'] = 'NOW()'
+ self.dbconn_BI.Insert_table(change_detect_error, 'change_detect_error')
+
+ return RTN
+
+ def change_detect_DB_etc(self, _diff, syscollect):
+ print("[change_detect_DB_etc] 들어옴")
+ diff_arr = {
+ # 91전자통신연구원 패턴
+ '91' : {
+ 'bid_content|bidcomment': {
+ 'tableName': 'bid_content',
+ 'fieldName': 'bidcomment',
+ 'action': "update", #update : 데이터 업데이트, modify : 정정데이터 쌓기
+ 'field_empty' : 'N', #저장데이터 빈값 허용 여부 Y: 빈값체크x N: 빈값체크
+ 'exlusion_pattern': [ #제외조건 데이터가 해당값일 경우엔 무시하고 저장
+ '^\* 공고 원문을 참조하시기 바랍니다. \*$',
+ '^$',
+ ],
+ },
+ 'bid_key|constnm': {
+ 'tableName': 'bid_key',
+ 'fieldName': 'constnm',
+ 'action': "modify", # update : 데이터 업데이트, modify : 정정데이터 쌓기
+ 'field_empty': 'N', # 저장데이터 빈값 허용 여부 Y: 빈값체크x N: 빈값체크
+ 'exlusion_pattern': [ # 제외조건
+ ],
+ },
+ },
+ #08 도로공사 패턴
+ '08': {
+ 'bid_key|constnm': {
+ 'tableName': 'bid_key',
+ 'fieldName': 'constnm',
+ 'action': "modify", # update : 데이터 업데이트, modify : 정정데이터 쌓기
+ 'field_empty': 'N', # 저장데이터 빈값 허용 여부 Y: 빈값체크x N: 빈값체크
+ 'exlusion_pattern': [ # 제외조건
+ ],
+ },
+ },
+ }
+
+ whereis = syscollect['whereis']
+ bidid = syscollect["bidid"]
+
+ if whereis in diff_arr:
+ for key, value in enumerate(diff_arr[whereis]):
+
+ if value in _diff["MOD"]: # 변경된 값에 키데이터가 있을때
+ updtate_data = _diff["MOD"][value]["NEW"].strip()
+
+
+ if diff_arr[whereis][value]['field_empty'] == 'Y' or updtate_data != '':
+ insert_yn = 'N' #저장은 기본적으로 하지 않는다.
+
+ #제외조건이 있을경우
+ if len(diff_arr[whereis][value]['exlusion_pattern']) > 0:
+ query = "SELECT {fieldName}, bidid FROM {tableName} WHERE bidid='{bidid}' limit 1 ".format(fieldName=diff_arr[whereis][value]['fieldName'], tableName=diff_arr[whereis][value]['tableName'], bidid=bidid)
+ row = self.dbconn_BI.sql_exec(query, "DS")
+ print(diff_arr[whereis][value]['exlusion_pattern'])
+
+ #db데이터 확인
+ if len(row) > 0: # 저장된 데이터값 없으면 패스
+ for value2 in diff_arr[whereis][value]['exlusion_pattern']:
+ field_tmp = row[0]["{fieldName}".format(fieldName=diff_arr[whereis][value]['fieldName'])]
+ print("테이블값 : "+field_tmp)
+ field_tmp = '' if field_tmp is None else field_tmp # table None 일때 공백처리
+ print("조건값 : "+value2)
+ if len(re.findall(value2, field_tmp)) > 0: #기존데이터가 exlusion_pattern에 걸리는 패턴일경우 저장하지 않는다.
+ print("저장")
+ #테이블 데이터와 저장데이터 조건이 맞는경우 저장한다.
+ insert_yn = 'Y'
+ else:
+ #제외조건이 없을경우 저장
+ insert_yn = 'Y'
+
+ if insert_yn == 'Y':
+ if diff_arr[whereis][value]['action'] == 'update':
+ self.insert_modification_request_list(syscollect, diff_arr[whereis][value]['tableName'], diff_arr[whereis][value]['fieldName'])
+ elif diff_arr[whereis][value]['action'] == 'modify':
+ self.self_mod_module(syscollect, updtate_data)
+ print("[change_detect_DB_etc] 나감")
+
+
+ def insert_modification_request_list(self, data, tableName, fieldName):
+ print("[insert_modification_request_list] 들어옴")
+ _data = {}
+ _data["bidid"] = data['bidid']
+ _data["whereis"] = data['whereis']
+ _data["syscode"] = data['dcode']
+ _data["notinum"] = data['dkey']
+ if data['dkey_ext']:
+ _data["notinum_ex"] = data['dkey_ext']
+ else:
+ _data["notinum_ex"] = ''
+ _data["tableName"] = tableName
+ _data["fieldName"] = fieldName
+ _data["state"] = 'N'
+ _data["writedt"] = "NOW()"
+ self.dbconn_BI.Insert_table(_data, 'modification_request_list')
+
+
+
+ def self_mod_module(self, syscollect,updtate_data):
+ self.mod_table_insert(syscollect, updtate_data)
+ self.mod_syscollect_insert(syscollect, updtate_data)
+
+
+ def mod_table_insert(self, syscollect,updtate_data): # 김영주
+ whereis = syscollect['whereis']
+ dcode = syscollect["dcode"]
+ notinum = syscollect["dkey"]
+ notinum_ex = syscollect["dkey_ext"] if syscollect["dkey_ext"] is not None else None
+
+ notinum_ex_q=''
+ if notinum_ex is not None:
+ notinum_ex_q="and dkey_ext='{dkey_ext}'".format(dkey_ext=notinum_ex)
+
+ # mod_data_table 쌓기
+ tmp_sql = "SELECT * FROM mod_data_table WHERE dcode='{dcode}' and dkey='{dkey}' {notinum_ex_q} and mod_data='{mod_data}' and whereis='{whereis}' order by uptime desc limit 1" \
+ .format(dcode=dcode, dkey=notinum, mod_data=updtate_data, whereis=whereis, notinum_ex_q=notinum_ex_q)
+ tmp_row = self.dbconn_BI.sql_exec(tmp_sql, "DS")
+ if len(tmp_row) > 0:
+ print("pass")
+ else:
+ # sys_collect 정보가져오기
+ sys_sql = "SELECT stats,rebid_no,seq FROM sys_collect WHERE dcode='{dcode}' and dkey='{dkey}' order by uptime desc limit 1".format(
+ dcode=dcode, dkey=notinum)
+ sys_row = self.dbconn_BI.sql_exec(sys_sql, "DS")
+
+ # 기본 정정차수는 1
+ mod_chasu = 1
+ if len(tmp_row) > 0:
+ # 이전 정정차수가 mod_data_table에 있을경우 정정차수를 업데이트 해준다
+ mod_chasu = int(tmp_row[0]['mod_chasu']) + 1
+
+ insert_data_arr = {}
+ insert_data_arr['dkey'] = notinum
+ insert_data_arr['dkey_ext'] = notinum_ex
+ insert_data_arr['dcode'] = dcode
+
+ insert_data_arr['stats'] = sys_row[0]['stats']
+ insert_data_arr['rebid_no'] = sys_row[0]['rebid_no']
+
+ insert_data_arr['mod_chasu'] = mod_chasu
+ insert_data_arr['mod_text'] = updtate_data
+ #insert_data_arr['prevcode'] = row['prevcode'] --사용x
+ #insert_data_arr['updatecode'] = row['updatecode'] --사용x
+ insert_data_arr['whereis'] = whereis
+ self.dbconn_BI.Insert_table(insert_data_arr, "mod_data_table")
+
+ update_data_arr = {}
+ update_data_arr['state'] = 'D'
+ update_data_arr['updatedt'] = "NOW()"
+ self.dbconn_BI.Update_table(update_data_arr, {'seq': tmp_row['seq']}, 'bid_change_check')
+
+ def mod_syscollect_insert(self, syscollect, updtate_data):
+ whereis = syscollect['whereis']
+ dcode = syscollect["dcode"]
+ notinum = syscollect["dkey"]
+ notinum_ex = syscollect["dkey_ext"] if syscollect["dkey_ext"] is not None else None
+ notinum_ex_q = ''
+ if notinum_ex is not None:
+ notinum_ex_q = 'and dkey_ext={dkey_ext}'.format(dkey_ext=notinum_ex)
+
+ mod_sql = "SELECT * FROM mod_data_table WHERE dcode='{dcode}' and dkey='{dkey}' {notinum_ex_q} and mod_data='{mod_data}' whereis='{whereis}' and state ='N' "\
+ .format(dcode=dcode, dkey=notinum, mod_data=updtate_data, whereis=whereis, notinum_ex_q=notinum_ex_q)
+ mod_row = self.dbconn_BI.sql_exec(mod_sql, "DS")
+
+ if len(mod_row) > 0:
+ sys_sql = "SELECT * FROM sys_collect WHERE dcode='{dcode}' and dkey='{dkey}' and stats='{stats}' and rebid_no ='{rebid_no}' order by uptime desc limit 1" \
+ .format(dcode=mod_row['dcode'], dkey=mod_row['dkey'], stats=mod_row['stats'], rebid_no=mod_row['rebid_no'])
+ sys_res = self.dbconn_BI.sql_exec(sys_sql, "DS")
+
+ insert_arr = {}
+ insert_arr['dkey'] = mod_row['dkey']
+ insert_arr['dkey_ext'] = mod_row['dkey_ext']
+ insert_arr['dcode'] = mod_row['dcode']
+ insert_arr['stats'] = "자체정정" + mod_row['mod_chasu'] + "차"
+ insert_arr['rebid_no'] = mod_row['rebid_no']
+
+ insert_arr['link'] = sys_res[0]['link']
+ insert_arr['ext_info1'] = sys_res[0]['ext_info1']
+ insert_arr['ext_info2'] = sys_res[0]['ext_info2']
+ insert_arr['proc'] = 'new'
+ if dcode == 'KNOCBA':
+ insert_arr['link_post'] = sys_res[0]['link_post']
+ self.dbconn_BI.Insert_table(insert_arr, "sys_collect")
+
+ update_data_arr = {}
+ update_data_arr['state'] = 'I'
+ update_data_arr['updatedt'] = "NOW()"
+ self.dbconn_BI.Update_table(update_data_arr, {'seq': mod_row['seq']}, 'bid_change_check')
+
+ # ============================================================
+ # "CREATE TABLE `modification_request_list` (
+ # `seq` int(6) NOT NULL AUTO_INCREMENT,
+ # `bidid` char(24) NOT NULL COMMENT '입찰번호(일련번호-차수-재입찰번호-분류번호)',
+ # `whereis` char(2) DEFAULT NULL COMMENT '공고게시기관코드',
+ # `syscode` char(12) DEFAULT NULL COMMENT '수집시스템코드',
+ # `notinum` char(32) NOT NULL COMMENT '공고번호',
+ # `notinum_ex` char(32) NOT NULL,
+ # `tableName` varchar(30) NOT NULL COMMENT '테이블이름',
+ # `fieldName` varchar(30) NOT NULL COMMENT '컬럼이름',
+ # `writedt` datetime DEFAULT NULL COMMENT '요청등록일',
+ # `updatedt` datetime DEFAULT NULL COMMENT '처리시간',
+ # `state` char(1) DEFAULT NULL COMMENT '처리유무',
+ # PRIMARY KEY (`seq`)
+ # ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=euckr COMMENT='특정 데이터 업데이트 요청 리스트'" \
+ # 입력페이지에서 해당 테이블에 업데이트할 정보를 쌓는다.
+ # 테이블이름과, 컬럼이름을 매칭해서 데이터를 업데이트 한다.
+ # ============================================================
+ def modification_request_list(self, new, syscollect):
+ print("[modification_request_list] start")
+ tableName = syscollect["tableName"]
+ fieldName = syscollect["fieldName"]
+ seq = syscollect["modification_seq"]
+ bidid = syscollect["bidid"]
+ new_parser = new if type(new) == dict else json.loads(new, strict=False)
+ self.dbconn_BI.Update_table({fieldName: new_parser[tableName][fieldName]}, {'bidid': bidid}, tableName)
+ self.dbconn_BI.Update_table({'state': 'Y', 'updatedt': 'NOW()'}, {'seq': seq}, 'modification_request_list')
+ print("[modification_request_list] end")
+
+
+
+ def blank_None(self, _str):
+ if _str == "":
+ return None
+ else:
+ return _str
+
+
+ def clean_page_source(self, _html):
+ pattern = [
+ ["\r",""],
+ ["\t",""],
+ ["\n",""],
+ ["\"", ""],
+ ["\u25e6", ""],
+ ["\xa9", ""],
+ ["\xa0", ""],
+ ["\uf0e8", ""],
+ ["\uff62", ""],
+ ["\uff63", ""],
+ ["\ufeff", ""],
+ ["\u200b", ""],
+ ["\u2013", ""],
+ ["\u2024", ""],
+ ["\u2027",""],
+ ["\u2219", ""],
+ ["\u25ef", ""],
+ ["\u274d", ""],
+ ["\u2782", ""],
+ ["\u2981", ""],
+ ["\u3007", ""],
+ ["\uff63", ""],
+ ["\u25fc", ""],
+ ["\u2003", ""],
+ ["\u231c", ""],
+ ["\u0223", ""],
+ ["\u2023", ""],
+ ["\u0228", ""],
+ ["\u2011", ""],
+ ["\u0387", ""],
+ ["\u2781", ""],
+ ["\U000f02ea", ""],
+ ["\u0278", ""],
+ ["\u2022", ""],
+ ["\u22c5",""],
+ ["\u022f", ""],
+ ["\u24fd", ""],
+ ["\u302e", ""],
+ ["\u0368", ""],
+ ["\u301c", "~"],
+ ["\u02d1", "~"],
+ ["\u21e8", "->"],
+ ["\u25a2", ""],
+ ["\u231f", ""],
+ ["\xb5", ""],
+ ["\u2780", ""],
+ ["\u119e", ""],
+ ["\u20a9", ""],
+ ["\u25cc", ""],
+ ["\uf022", ""],
+ ["\uf09e", ""],
+ ["\u0301", ""],
+ ["\uff65", ""],
+ ["\u1168", ""],
+ ["\u1163", ""],
+ ["\u1166", ""],
+ ["\u2215", ""],
+ ["\u231c", ""],
+ ["\U000f02ef", ""],
+ ["\uf0a0", ""],
+ ["\u2014", ""],
+ ["\u2205", "Ø"],
+ ["\u301a", ""],
+ ["\u301b", ""],
+ ["\uf028", ""],
+ ["\u30fb", ""],
+ ["\uf076", ""],
+ ["\u25aa", ""],
+ ["\u1104", ""],
+ ["\u2776", ""],
+ ["\u2777", ""],
+ ["\u2613", ""],
+ ["\u2000", ""],
+ ["\u25b8", ""],
+ ["\u2219", ""],
+ ["\u2012", ""],
+ ["\u233d", ""],
+ ["\u8f66", ""],
+ ["\u65f6", ""],
+ ["\u95f4", ""],
+ ["\u27f6", ""],
+ ["\uf0a6", ""],
+ ["\u21db", ""],
+ ["\u2783", ""],
+ ["\u2784", ""],
+ ["\u2785", ""],
+ ["\u2010", "-"],
+ ["\U0001d635", ""],
+ ["\u206d", ""],
+ ["\u279f", ""],
+ ["\u2d41", ""],
+ ["\ufffd", ""],
+ ["\u278a", ""],
+ ["\u278b", ""],
+ ["\u278c", ""],
+ ["\u27f9", ""],
+ ["\u2035", ""],
+ ["\u02dc", ""],
+ ["\u2053", ""],
+ ["\u301e", ""],
+
+ ]
+ return self.Util.clean_str(_html, pattern)
+
+ def enc_UTF8(self, _str, encoding='utf-8'):
+ return urllib.parse.unquote(_str, encoding=encoding)
+
+ # 기관 코드 가져오기
+ def getOrg(self, tblNm, orgNm, data_code_org_i, data_code_org_y, data_order_name):
+
+ # order_code 테이블에서 발주처명 변환이 필요한지 확인한다.
+ orgTmp = orgNm
+ if orgTmp:
+ # order_code_q = "SELECT * FROM order_name WHERE REPLACE(`before`, ' ', '') = '{orgNm}'".format(orgNm=orgNm.replace(" ", ""))
+ order_code_row = data_order_name[data_order_name['before'].str.replace(' ', '', regex=False) == orgNm.replace(" ", "")]
+ order_code_row = order_code_row.to_dict(orient='records')
+ # order_code_row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, order_code_q)
+ if order_code_row and len(order_code_row) > 0 and order_code_row[0].get('after'):
+ orgTmp = order_code_row[0]['after']
+ # print("order_name 추출 O")
+ # else:
+ # print("order_name 추출 X")
+ # print("orgTmp >>> ", orgTmp)
+
+ if tblNm == "code_org_y":
+ # query ="select order_code, order_name from code_org_y where replace(order_name, ' ', '') = replace('{orgNm}', ' ', '')".format(orgNm=orgTmp)
+ query_result = data_code_org_y[data_code_org_y['order_name'].str.replace(' ', '', regex=False) == orgTmp.replace(" ", "")]
+ query_result = query_result[['order_code', 'order_name']]
+ elif tblNm == "code_org_i":
+ # query = "select org_Scode as order_code, result_name as order_name from code_org_i where org_name = '{orgNm}'".format(orgNm=orgTmp)
+ query_result = data_code_org_i[data_code_org_i['org_name'].str.replace(' ', '', regex=False) == orgTmp.replace(" ", "")]
+ query_result = query_result[['org_Scode', 'result_name']]
+ query_result = query_result.rename(columns={'org_Scode': 'order_code', 'result_name': 'order_name'})
+ try:
+ # row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ row = query_result.to_dict(orient='records')
+ if len(row) > 0:
+ return row[0]
+ else:
+ return {"order_code": None, "order_name": orgNm}
+ except:
+ return {"order_code": None, "order_name": None}
+
+ #code_etc 가져오기
+ def getCodeEtc(self, kind, data_code_etc):
+ if data_code_etc.empty:
+ return None
+ # query = "select * from code_etc where kind='{kind}'".format(kind=kind)
+ query_result = data_code_etc[data_code_etc['kind'] == kind]
+ if kind == "succls":
+ #제한적 최저가가 먼저 체크되도록
+ # query = "select * from code_etc where kind='{kind}' ORDER BY CASE WHEN `code` = '03' THEN 0 ELSE 1 END, `code`;".format(kind=kind)
+ # 1. 'kind'로 먼저 필터링
+ filtered_df = data_code_etc[data_code_etc['kind'] == kind].copy() # 원본 수정을 피하기 위해 .copy() 사용
+ # 2. 정렬 우선순위를 위한 임시 컬럼 생성
+ # 'code'가 '03'이면 0, 아니면 1을 할당
+ filtered_df['sort_priority'] = filtered_df['code'].apply(lambda x: 0 if x == '03' else 1)
+ # 3. 'sort_priority'로 먼저 정렬하고, 그 다음 'code'로 정렬
+ # 두 컬럼 모두 오름차순으로 정렬 (ascending=True가 기본값)
+ query_result = filtered_df.sort_values(by=['sort_priority', 'code'])
+ # 4. 임시로 사용한 'sort_priority' 컬럼 제거
+ query_result = query_result.drop(columns=['sort_priority'])
+ # row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ row = query_result.to_dict(orient='records')
+ if len(row) > 0:
+ return row
+ else:
+ return None
+
+ # 종목코드 가져오기
+ def G2BPartCode(self):
+ query = "SELECT g2b_code, i2_code FROM code_item_match WHERE g2b_code NOT IN('1459','1460') AND i2_code IS NOT NULL"
+ row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ if len(row) > 0:
+ rtn = {}
+ for v in row:
+ rtn[v["g2b_code"]] = v["i2_code"]
+ return rtn
+ else:
+ return None
+
+ # 종목코드 가져오기
+ def KEPCOPartCode(self):
+ query = "SELECT bi_code, k_code FROM code_kepco WHERE state ='Y' AND bi_code IS NOT NULL"
+ row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ if len(row) > 0:
+ rtn = {}
+ for v in row:
+ rtn[v["k_code"]] = v["bi_code"]
+ return rtn
+ else:
+ return None
+
+ # 한전 종목코드 체크 후 없는 데이터일 경우 데이터삽입
+ def insertKEPCOPartCode(self,k_code,k_name):
+ query = "SELECT bi_code, k_code FROM code_kepco WHERE k_code='{k_code}' ".format(k_code=k_code)
+ row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ result = None
+ if len(row) <= 0 :
+ insert_query = "INSERT INTO code_kepco SET k_code = '{k_code}', k_name = '{k_name}', state = 'N', writedt = NOW() ".format(k_code=k_code, k_name=k_name)
+ result = self.dbconn_BI.process_sql(DB_CONN_ID, insert_query)
+ return result
+
+
+ # 물품분류번호로 면허찍어지는 코드 가져오기
+ def G2BpurCodeChange(self, data_g2b_pur_code_change):
+ # query = "SELECT *,LENGTH(g2b_code) AS codelength FROM g2b_pur_code_change ORDER BY codelength ASC"
+ if data_g2b_pur_code_change.empty:
+ return None
+ data_g2b_pur_code_change_copy = data_g2b_pur_code_change.copy()
+ data_g2b_pur_code_change_copy['codelength'] = data_g2b_pur_code_change_copy['g2b_code'].astype(str).str.len()
+ query_result = data_g2b_pur_code_change_copy.sort_values(by='codelength', ascending=True)
+ row = query_result.to_dict(orient='records')
+ # row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ if len(row) > 0:
+ rtn = {}
+ for v in row:
+ rtn[v["g2b_code"]] = v["change_code"]
+ return rtn
+ else:
+ return None
+
+ # 물품분류번호로 면허찍어지는 코드 가져오기
+ def G2BpurCodeLikeChange(self, gcode, data_g2b_pur_code_change):
+ tmpcode = gcode[0:2]
+
+ # 아래 항목일땐 2자리로 검색하지 않는다.
+ likecode = gcode
+ if tmpcode == '39' and gcode != '3911260201' and gcode != '3911260301' and gcode != '3911260302' and gcode != '3911260303' and gcode != '3911260401' and gcode != '3913170609' and gcode != '3913170610':
+ rtn = {}
+ rtn["39"] = 'C007'
+ return rtn
+ else:
+ # query = "SELECT *,LENGTH(g2b_code) AS codelength FROM g2b_pur_code_change WHERE g2b_code Like '{likecode}%' ORDER BY codelength ASC".format(likecode=likecode)
+ data_g2b_pur_code_change_copy = data_g2b_pur_code_change.copy()
+ data_g2b_pur_code_change = data_g2b_pur_code_change_copy[data_g2b_pur_code_change_copy['g2b_code'].str.contains(likecode)]
+ if data_g2b_pur_code_change.empty:
+ return None
+ data_g2b_pur_code_change_copy['codelength'] = data_g2b_pur_code_change_copy['g2b_code'].astype(str).str.len()
+ query_result = data_g2b_pur_code_change_copy.sort_values(by='codelength', ascending=True)
+ row = query_result.to_dict(orient='records')
+ # row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ # print(row)
+ if len(row) > 0:
+ rtn = {}
+ for v in row:
+ rtn[v["g2b_code"]] = v["change_code"]
+ return rtn
+ else:
+ return None
+
+
+ def pluscode_set(self, concode, sercode, pluscode):
+
+ rt = {}
+
+ g2b_code = []
+ prev_code = [concode, sercode]
+
+ for k, v in pluscode.items():
+
+ if len(re.findall("C", v)) > 0:
+ if concode is None: concode = v
+ elif len(re.findall(v, concode)) < 0: concode = "{code}|{pluscode}".format(code=concode, pluscode=v)
+ if len(re.findall("S", v)) > 0:
+ if sercode is None: sercode = v
+ elif len(re.findall(v, sercode)) < 0: sercode = "{code}|{pluscode}".format(code=sercode, pluscode=v)
+ g2b_code.append(k)
+
+ change_code = [concode, sercode]
+
+ g2b_pur_code_change_log = {}
+ g2b_pur_code_change_log['g2b_code'] = "|".join(list(filter(lambda x: x is not None, g2b_code)))
+ g2b_pur_code_change_log['prev_code'] = "|".join(list(filter(lambda x: x is not None, prev_code)))
+ g2b_pur_code_change_log['change_code'] = "|".join(list(filter(lambda x: x is not None, change_code)))
+
+ rt['concode'] = concode
+ rt['sercode'] = sercode
+ rt['g2b_pur_code_change_log'] = g2b_pur_code_change_log
+
+ return rt
+
+
+
+ # def G2BNmToYERAMCode(self, nm, data_code_item_match):
+ # # query = "SELECT g2b_code, i2_code, g2b_code_nm FROM code_item_match WHERE REPLACE(REPLACE(g2b_code_nm,'.',''),'·','') = REPLACE(REPLACE('{nm}','.',''),'·','') ".format(nm=nm)
+ # query = "SELECT g2b_code, i2_code, g2b_code_nm FROM code_item_match WHERE REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(g2b_code_nm,' ',''),'.',''),'·',''),',',''),'ㆍ', '') = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE('{nm}',' ',''),'.',''),'·',''),',',''),'ㆍ', ''),'기계설비가스공사업','기계가스설비공사업')".format(nm=nm)
+ # row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ # rtn = row[0] if len(row) > 0 else {"g2b_code": "", "i2_code": "", "g2b_code_nm": "", }
+ # return rtn
+ def G2BNmToYERAMCode(self, nm: str, data_code_item_match: pd.DataFrame):
+ """
+ 입력된 이름(nm)과 DataFrame(data_code_item_match)의 'g2b_code_nm'을
+ 동일한 방식으로 전처리한 후, 정확히 일치하는 행을 찾아 관련 정보를 반환합니다.
+
+ :param nm: 비교할 이름 문자열
+ :param data_code_item_match: 'g2b_code_nm', 'g2b_code', 'i2_code' 컬럼을 포함하는 DataFrame
+ :return: 일치하는 행의 정보를 담은 딕셔너리 또는 기본값 딕셔너리
+ """
+ if not isinstance(nm, str):
+ # logging.warning(f"입력값 nm이 문자열이 아닙니다: {type(nm)}. 빈 문자열로 처리합니다.")
+ nm = ""
+
+ if not isinstance(data_code_item_match, pd.DataFrame) or data_code_item_match.empty:
+ # logging.warning("입력 DataFrame data_code_item_match가 비어있거나 DataFrame이 아닙니다.")
+ return {"g2b_code": "", "i2_code": "", "g2b_code_nm": ""}
+
+ # 원본 DataFrame 수정을 피하기 위해 복사본 사용
+ data_code_item_match_copy = data_code_item_match.copy()
+
+ # 전처리할 문자들
+ chars_to_remove = [' ', '.', '·', ',', 'ㆍ']
+ # 특정 문자열 치환 (SQL의 마지막 REPLACE에 해당)
+ specific_replacements = {
+ '기계설비가스공사업': '기계가스설비공사업'
+ # 필요시 다른 치환 규칙 추가
+ }
+
+ # 1. DataFrame의 'g2b_code_nm' 컬럼 전처리
+ if 'g2b_code_nm' not in data_code_item_match_copy.columns:
+ logging.error("'g2b_code_nm' 컬럼이 DataFrame에 존재하지 않습니다.")
+ return {"g2b_code": "", "i2_code": "", "g2b_code_nm": ""}
+
+ # NaN 값을 빈 문자열로 처리 후 문자열로 변환
+ processed_g2b_code_nm_col = data_code_item_match_copy['g2b_code_nm'].fillna('').astype(str)
+ for char in chars_to_remove:
+ processed_g2b_code_nm_col = processed_g2b_code_nm_col.str.replace(char, '', regex=False)
+ for old, new in specific_replacements.items(): # 특정 문자열 치환 적용
+ processed_g2b_code_nm_col = processed_g2b_code_nm_col.str.replace(old, new, regex=False)
+
+ # 전처리된 컬럼을 DataFrame에 임시로 저장하거나, 바로 비교에 사용
+ # data_code_item_match_copy['processed_g2b_code_nm'] = processed_g2b_code_nm_col
+
+ # 2. 입력 문자열 'nm' 전처리 (DataFrame 컬럼과 동일한 방식으로)
+ processed_nm = str(nm) # nm이 숫자로 들어올 경우를 대비해 문자열로 변환
+ for char in chars_to_remove:
+ processed_nm = processed_nm.replace(char, '')
+ for old, new in specific_replacements.items(): # 특정 문자열 치환 적용
+ processed_nm = processed_nm.replace(old, new)
+
+ # logging.debug(f"전처리된 g2b_code_nm 컬럼 (일부): \n{processed_g2b_code_nm_col.head()}")
+ # logging.debug(f"전처리된 입력 nm: '{processed_nm}'")
+
+ # 3. 전처리된 값들로 정확히 일치하는 행 필터링 (str.contains 대신 == 사용)
+ # data_code_item_match_copy = data_code_item_match_copy[processed_g2b_code_nm_col == processed_nm]
+ # 필터링된 결과를 새로운 변수에 할당하여 원본 data_code_item_match_copy의 참조 문제를 피함
+ filtered_df = data_code_item_match_copy[processed_g2b_code_nm_col == processed_nm]
+
+ # 결과가 여러 개일 경우 첫 번째 행을 사용할지, 아니면 다른 로직이 필요한지 결정해야 합니다.
+ # 현재 코드는 to_dict 후 첫 번째 결과를 사용합니다.
+
+ # 4. 결과를 딕셔너리 리스트로 변환
+ rows_list = filtered_df.to_dict(orient='records')
+
+ # 5. 최종 결과 반환
+ if rows_list: # 일치하는 행이 있는 경우
+ # SQL 쿼리는 g2b_code, i2_code, g2b_code_nm 컬럼만 선택했으므로, 여기서도 해당 컬럼만 추출
+ first_match = rows_list[0]
+ rtn = {
+ "g2b_code": first_match.get("g2b_code", ""),
+ "i2_code": first_match.get("i2_code", ""),
+ "g2b_code_nm": first_match.get("g2b_code_nm", "") # 원본 g2b_code_nm 반환
+ }
+ # logging.info(f"일치하는 데이터 찾음: {rtn}")
+ else:
+ rtn = {"g2b_code": "", "i2_code": "", "g2b_code_nm": ""}
+ # logging.info(f"일치하는 데이터를 찾지 못함. 입력 nm: '{nm}', 전처리된 nm: '{processed_nm}'")
+
+ return rtn
+
+ # g2b 종목코드 예람코드로 변환
+ # code 는 list 형태의 g2b 코드 ['1232','1145',...],
+ # 리턴값은 문자열 dic {'purcode': None, 'concode': 'C011|C013', 'sercode': None}
+ def G2BCodeToYERAMCode(self, code, data_DIC_G2BPartCode):
+ PartCode = data_DIC_G2BPartCode
+ YeramCode = list(filter(lambda x: x is not None, list(map(lambda x: PartCode[x] if x in PartCode else None, code))))
+ YeramCode = [x for x in YeramCode if x != '']
+
+ #코드별 예람 종목추가 적용[ key => g2b code, value => 추가할 예람코드(리스트)로 세팅]
+ add_code = {
+ "0001": ["C003"],
+ "0002": ["C003"],
+ "0032": ["C030"],
+ "0033": ["C030"],
+ "1121": ["S001", "S054"],
+ "1139": ["S031", "S032"],
+ "1177": ["S080", "C053"],
+ "1189": ["S072"],
+ "1401": ["C035", "C078"],
+ "3562": ["S999"],
+ "3585": ["S019"],
+ "3589": ["S037"],
+ "4817": ["S001"],
+ "4949": ["C030"],
+ "4950": ["C030"],
+ "5220": ["S057"],
+ "6117": ["C011", "S999"],
+ "6311": ["S031", "S032"],
+ "6312": ["S031", "S032"],
+ "6313": ["S031", "S032"],
+ "6815": ["C020", "S999"],
+ "4989": ["C021", "C034", "C032"], #B001 #지반조성.포장공사업 [토공|포장|보링.그라우팅]
+ "4990": ["C020"], # B002 #실내건축공사업 [실내건축]
+ "4991": ["C027", "C028"], # B003 #금속창호.지붕건축물조립공사업 [금속창호|지풍판금,건축물]
+ "4992": ["C024", "C022", "C023"], # B004 #도장.습식.방수.석공사업 [도장|습식.방수|석공]
+ "4993": ["C036", "C037"], # B005 # 조경식재.시설물공사업 [조경식재|조경시설물]
+ "4994": ["C029"], # B006 # 철근.콘크리트공사업 [철콘]
+ "4995": ["C025"], # B007 # 구조물해체.비계공사업 [비계구조]
+ "4996": ["C031"], # B008 # 상.하수도설비공사업 [상,하수도]
+ "4997": ["C033"], # B009 # 철도.궤도공사업 [철도,궤도]
+ "4998": ["C039", "C038"], # B010 # 철강구조물공사업 [철강재|강구조물]
+ "4999": ["C035", "C041"], # B011 # 수중.준설공사업 [수중|준설]
+ "6201": ["C042", "C040"], # B012 # 승강기,삭도공사업 [승강기|삭도]
+ "6202": ["C030", "C045"], # B013 # 기계가스설비공사업 [기계설비|가스1종]
+ "6203": ["C046", "C047", "C049", "C050", "C051"], # B013 # 가스난방공사업 [가스2종|가스3종|난방1종|난방2종|난방3종]
+ '토공사': ['C021','B001' ], # ========================= 여기서부터 주력공사 붙는 경우 해당 문구에 맞는 면허 반환될 수 있게 처리.
+ '포장공사': ['C034','B001' ],
+ '보링·그라우팅·파일공사': ['C032', 'B001'], #
+ '실내건축공사': ['C020', 'B002'], #
+ '금속구조물·창호·온실공사': ['C027','B003' ], #
+ '지붕판금·건축물조립공사': ['C028', 'B003'], #
+ '도장공사': ['C024','B004' ], #
+ '습식·방수공사': ['C022','B004' ], #
+ '석공사': ['C023','B004' ], #
+ '조경식재공사': ['C036','B005'],
+ '조경시설물설치공사': ['C037', 'B005'],
+ '철근·콘크리트공사': ['C029', 'B006'], #
+ '구조물해체·비계공사': ['C025', 'B007'], #
+ '상하수도설비공사': ['C031', 'B008'], #
+ '철도·궤도공사': ['C033', 'B009'],
+ '철강구조물공사' : ['C039', 'C038', 'B010'],
+ '수중공사': ['C035', 'B011'],
+ '준설공사': ['C041', 'B011'],
+ '승강기설치공사': ['C042', 'B012'],
+ '삭도설치공사': ['C040', 'B012'],
+ '기계설비공사': ['C030', 'B013'], #
+ '가스시설공사(제1종)': ['C045', 'B013'],
+ '가스시설공사(제2종)': ['C046', 'B014'],
+ '가스시설공사(제3종)': ['C047', 'B014'],
+ '난방공사(제1종)': ['C049', 'B014'],
+ '난방공사(제2종)': ['C050', 'B014'],
+ '난방공사(제3종)': ['C051', 'B014'],
+ '토공사 와 포장공사': ['C021', 'C034', 'B001'],
+ '토공사 와 보링·그라우팅·파일공사': ['C021', 'C032', 'B001'],
+ '포장공사 와 토공사': ['C021', 'C034', 'B001'],
+ '포장공사 와 보링·그라우팅·파일공사': ['C034', 'C032', 'B001'],
+ '보링·그라우팅·파일공사 와 포장공사': ['C034', 'C032', 'B001'],
+ '보링·그라우팅·파일공사 와 토공사': ['C021', 'C032', 'B001'],
+ }
+
+ AddYeramCode = list(filter(lambda x: x is not None, list(map(lambda x: add_code[x] if x in add_code else None, code))))
+ if len(AddYeramCode) > 0:
+ AddYeramCodeList = []
+ list(map(lambda x: list(map(lambda y: AddYeramCodeList.append(y), x)), AddYeramCode))
+ if len(AddYeramCodeList) > 0:
+ YeramCode = YeramCode + AddYeramCodeList #추가 코드 병합
+
+ YeramCode = list(set(YeramCode)) #중복제거
+ YeramCode.sort() #정렬
+ dic_partcode = {}
+ dic_partcode['concode'] = "|".join(list(filter(lambda x: x[0] == "C", YeramCode))) if len(list(filter(lambda x: x[0] == "C", YeramCode))) > 0 else None # 공사
+ dic_partcode['sercode'] = "|".join(list(filter(lambda x: x[0] == "S", YeramCode))) if len(list(filter(lambda x: x[0] == "S", YeramCode))) > 0 else None # 용역
+ dic_partcode['purcode'] = "|".join(list(filter(lambda x: x[0] == "P", YeramCode))) if len(list(filter(lambda x: x[0] == "P", YeramCode))) > 0 else None # 물품
+ dic_partcode['big_part'] = "|".join(list(filter(lambda x: x[0] == "B", YeramCode))) if len(list(filter(lambda x: x[0] == "B", YeramCode))) > 0 else None # 물품
+ return dic_partcode
+
+
+ def dicCodeEtc(self, kind, data_code_etc):
+ rtn = []
+ rows = self.getCodeEtc(kind, data_code_etc)
+ if rows is not None:
+ for row in rows:
+ rtn.append({"pattern": {"P1": row["val"], }, "value": row["code"]})
+
+ # 입찰방식 추가조건, 상단엔 DB데이터를 기반으로 세팅하기때문에 따로 넣어준다
+ if kind == 'bidcls':
+ rtn.append({"pattern": {"P1": '수기', }, "value": '00'})
+ return rtn
+
+
+ # xpath 로 구분한 테이블 시리얼 데이터를 줄단위 key value 데이터로 변환 리턴값 [{key:value,...},{key:value,...},{key:value,...}]
+ def change_serial_to_dic(self, _data, _keys):
+ _len = len(_data)
+ key_len = len(_keys)
+ _tmp = {}
+ RTN = []
+ for _idx, key in enumerate(_keys):
+ _tmp[key] = _data[_idx:_len:key_len]
+
+ for _idx, row in enumerate(_tmp[_keys[0]]):
+ list = {}
+ for idx, key in enumerate(_keys):
+ list[key] = _tmp[key][_idx]
+ RTN.append(list)
+
+ return RTN
+
+ # [{key:value,...},{key:value,...},{key:value,...}] 형식의 데이터중 컬럼하나만 함수적용하여 데이터 변경시 사용
+ def column_func(self, _list, _key, func, err_init=None):
+ for _idx, row in enumerate(_list):
+ try:
+ _list[_idx][_key] = func(row[_key])
+ except:
+ _list[_idx][_key] = err_init
+ return _list
+
+ def getPartCode(self, partNm):
+ try:
+ query = "select * from code_item where i2_name = '{partNm}' and state = 'y'".format(partNm=partNm)
+ row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ if len(row) > 0:
+ return row[0]
+ else:
+ return None
+ except:
+ return None
+
+ def getCodeLocal(self, locationNm, data_code_local):
+ try:
+ location_pattern = [
+ {"pattern": {"P1": "당진", }, "value": "충청남도 당진시"},
+ {"pattern": {"P1": "포항", }, "value": "경상북도 포항시"},
+ ]
+ tmp = self.mapping_pattern_value(location_pattern, locationNm, None)
+ if tmp is not None:
+ locationNm = tmp
+
+ # query = "select * from code_local where `name` like '%{locationNm}%'".format(locationNm=locationNm)
+ query_result = data_code_local[data_code_local['name'].str.contains(locationNm)]
+ # row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ row = query_result.to_dict(orient='records')
+ if len(row) > 0:
+ return row[0]
+ else:
+ return None
+ except:
+ return None
+
+
+ # bid_local의 코드 앞자리 2개로 해당 지역 관내 개수 카운트
+ def count_code_local(self, code_value):
+ try:
+ query = "select count(*) as cnt from code_local where code like '{code_value}%'".format(code_value=code_value)
+ row = self.dbconn_BI.select_sql_to_dict(DB_CONN_ID, query)
+ return row[0]['cnt']
+
+ except:
+ return False
+
+
+ # 도내에 해당하는 관내가 모두 찍힌 경우 bid_local에서 제외하는 함수
+ def filter_bid_local(self, item_bid_local):
+ divide_bid_local_code = {}
+ filtered_bid_local = {}
+ new_key = 0
+
+ # bid_local의 코드 앞자리 2개를 key / 코드 뒷자리 2개를 value로 가지는 dict 세팅
+ for key, value in item_bid_local.items():
+ # 25/01/24 nil 처리 안되어 있어 추가
+ if value and value.get('code') and value['code'] and len(value['code']) == 4:
+ #if value['code'] and len(value['code']) == 4:
+ code = value['code']
+ local_code_key = code[:2]
+ local_code_value = code[2:]
+
+ if local_code_key in divide_bid_local_code:
+ divide_bid_local_code[local_code_key].append(local_code_value) # 키값 있으면 value append
+ else:
+ divide_bid_local_code[local_code_key] = [local_code_value] # 키값 없으면 키 세팅, value는 리스트형태로
+ # {'46': ['20', '14', '09', '17'], '20': ['16'], '49': ['03']} << 결과 예시
+
+ else:
+ logging.info("bid_local 코드 값 비정상@@@")
+
+ # 키 값 개수만큼 반복하여 bid_local_item+1과 DB의 지역 개수가 같은지 체크 (같으면 관내체크 안함)
+ # +1은 DB의 도내 값과 맞춰주기 위함(ex 1100, 서울특별시)
+ for key, value in divide_bid_local_code.items():
+ # 관내가 여러 지역이더라도 하나라도 관내면 관내이다.
+ if self.count_code_local(key) == len(value)+1:
+ continue
+ else:
+ for local_key, local_value in item_bid_local.items():
+ # 25/01/24 nil 처리 안되어 있어 추가
+ if local_value and local_value.get('code'):
+ if local_value['code'][:2] == key:
+ filtered_bid_local[new_key] = local_value
+ new_key = new_key + 1
+
+ return filtered_bid_local
+
+ # json => 문자열 변환시 value를 문자열로 변환하는 함수
+ def json_default(self, value):
+ return str(value)
+
+ # 딕셔너리에서 키 참조시 키가 존재하지 안는경우 에러가 나기 때문에 예외처리 및 키없는경우 초기값 지정
+ def getValue(self, arr, key, _init=""):
+ try:
+ return arr[key.strip()]
+ except:
+ return _init
+
+ # 숫자 스트링 클린징
+ def amt(self, _str):
+ _str = self.Util.clean_str(_str, [[",",""],[" ",""],["원",""],["\t",""],["₩",""],])
+ _str = _str if _str and self.Not_None_Zero(_str) else ""
+ return _str
+
+ # 코드(정수) 리스트 => 리스트 값을 승수로 한 2의 승수 구하여 sum 값 반환
+ def pow_sum(self, arr):
+ rtn = 0
+ for i in arr:
+ rtn = rtn + pow(2, i)
+ return rtn
+
+ def conv_bin(self, num):
+ tmp = reversed(str(bin(num))[2:])
+ rtn = []
+ for _idx, row in enumerate(tmp):
+ if row == "1":
+ rtn.append(_idx)
+ return rtn
+
+ # _str 에서 정규식 패턴 리스트에 맞는 값 매칭
+ def mapping_rex_to_value(self, pattern_list, _str, _init=""):
+ rtn = _init
+ for pattern in pattern_list:
+ if len(re.findall(pattern[0],_str))>0 :
+ rtn=pattern[1]
+ return rtn
+
+ # 지역 리스트 => 지역코드 리스트 변환
+ def loction_to_code(self, arr):
+ local_pattern_arr = [
+ #["rex pattern", "value"]
+ ["전국", 0],
+ ["서울", 1],
+ ["부산", 2],
+ ["광주", 3],
+ ["대전", 4],
+ ["인천", 5],
+ ["대구", 6],
+ ["울산", 7],
+ ["경기", 8],
+ ["강원", 9],
+ ["충북|충청북도", 10],
+ ["충남|충청남도", 11],
+ ["경북|경상북도", 12],
+ ["경남|경상남도", 13],
+ ["전북|전라북도", 14],
+ ["전남|전라남도", 15],
+ ["제주", 16],
+ ["세종", 17],
+ ]
+ rtn = []
+ for val in arr:
+ tmp = self.mapping_rex_to_value(local_pattern_arr, val, None)
+ if tmp is not None:
+ rtn.append(tmp)
+
+ rtn = list(set(rtn))
+ if len(rtn)>0:
+ return rtn
+ else:
+ return []
+ #raise Exception("loction_to_code : 지역값이 없습니다.")
+
+ #==============================================================================
+ ## 문서에서 패턴을 매칭하여 반환
+ ## 샘플 : aa = spilt_rex_doc(text, "(var\sbidRateCode\s=\s')((\d|\.){2,6})(';)", 2)
+ ## idx 는 정규식 그룹중 추출할 인덱스
+ def spilt_rex_doc(self, _doc, _pattern, _idx):
+ try:
+ _idx_str = ""
+ if type(_idx) == list:
+ _idx_str = '|#==#|\\'.join(_idx)
+ _idx_str = "\{idx}".format(idx=_idx_str)
+ #print(_idx_str)
+ else:
+ _idx_str = "\{idx}".format(idx=_idx)
+
+ for para in _doc.splitlines():
+ para_tmp = para.strip()
+ #print("para_tmp:", para_tmp)
+ line = re.match(_pattern, para_tmp)
+ #print("line:", line)
+ if line is not None:
+ rtn = re.sub(_pattern, _idx_str, para_tmp)
+ if type(_idx)==list:
+ rtn = rtn.split("|#==#|")
+ return rtn
+ except:
+ pass
+ return None
+
+ def spilt_rex_doc_list(self, _list, _pattern, _idx):
+ #print("_list, _pattern, _idx",_list, _pattern, _idx)
+ rtn=[]
+ for list in _list:
+ rtn.append(self.spilt_rex_doc(list, _pattern, _idx))
+ return rtn
+
+
+ #==============================================================================
+ ## 샘플 {"pattern": {"P1": "공동도급.", "P2": ".혼합허용", "N1": "ZZZ",}, "value": "1"}
+ ## P(n) => 포함 조건 , P1, P2 ... and, or 조건은 정규식으로, = 조건은 ^word$
+ ## N(n) => 제외 조건 , N1, N2 ... and, or 조건은 정규식으로, = 조건은 ^word$
+ def mapping_pattern_value(self, _patterns, _str, _init):
+ for _row in _patterns:
+ fg = True
+ for key in _row["pattern"]:
+ tmp = self.check_pattern(key, _row["pattern"][key], _str)
+ #print(key, _row["pattern"][key], tmp)
+ if (tmp != True): # 조건중 하나라도 참이 아니면 초기값 리턴
+ fg = False
+ if fg == True:
+ return _row["value"]
+ return _init
+
+ # K-APT 면허 매칭
+ def mapping_pattern_list(self, _patterns, _str): #매칭되는 모든 값을 리스트로 반환
+ rtn = []
+ for _row in _patterns:
+ fg = True
+ for key in _row["pattern"]:
+ tmp = self.check_pattern(key, _row["pattern"][key].rstrip('|'), _str)
+ # print(key, _row["pattern"][key], tmp)
+ if (tmp != True): # 조건중 하나라도 참이 아니면 초기값 리턴
+ fg = False
+ if fg == True:
+ rtn.append(_row["value"])
+ return rtn
+ def check_pattern(self, key, pattern, txt):
+ try:
+ if(key[0]=="P"):
+
+
+ if(len(re.findall(pattern, txt))>0):
+ rtn = True
+ else:
+ rtn = False
+ else:
+ if(len(re.findall(pattern, txt))<=0):
+ rtn = True
+ else:
+ rtn = False
+ except Exception as ex:
+ # print("check_pattern :", ex)
+ rtn = None
+ return rtn
+
+ def Not_None_Zero(self, _str):
+ try:
+ if float(_str.replace(",",""))>0:
+ return True
+ else:
+ return False
+ except:
+ return False
+
+ #JSON 변환전 딕셔너리 구조 정리 함수
+ def dict_reorganization(self, item):
+ returnData = {}
+ try:
+ for item_key, item_value in item.items():
+ if type(item_value) == int or type(item_value) == str or type(item_value) == float or item_value is None:
+ returnData[item_key] = item_value
+ else:
+ returnData[item_key] = self.dict_reorganization(item_value)
+ return returnData
+ except Exception as ex:
+ print("dict_reorganization :", ex)
+
+ def listTOdict(self, _lists):
+ rtn = {}
+ for _idx, _list in enumerate(_lists):
+ rtn[_idx] = _list
+ return rtn
+
+ # sys_collect 다음차수 공고 있는지 체크 (리스트 수집시 같은공고정보가 다수일경우 전차수가 뒤에 수집되기때문에 전차수가 나중에 수집되면 pass하기위해)
+ def prev_notinum_check(self, syscollect):
+
+ rtn = 'new'
+ dkey = syscollect['dkey']
+
+ # 현재 whereis 안들어가는 발주처가 많아서 dcode로 함
+ if syscollect['dcode'] == "D2B_B":
+ dkey = syscollect['dkey'].split("-")[0] + "-" + str(int(syscollect['dkey'].split("-")[1]) + 1)
+
+ where = {}
+ where['dcode'] = syscollect['dcode']
+ where['dkey'] = dkey
+ where['dkey_ext'] = syscollect['dkey_ext']
+
+ chk = self.dbconn_BI.ck_Exist_one(where, "sys_collect")
+
+ if chk == True:
+ print("다음차수 공고 있음")
+ rtn = 'closed'
+ else:
+ pass
+
+ return rtn
+
+ def null_turn(self, data):
+ if data is None: data = ''
+ if len(re.findall('0000-00-00', str(data))) > 0: data = ''
+ return data
+
+ def filter_num(self, num):
+ rt = 0
+ if num:
+ numbers = re.findall(r'\d+', str(num))
+ if numbers: rt = numbers[0]
+ return rt
+
+
+ def crawl_error_list(self, site, spiders, error_type, error_log):
+ logging.info("[crawl_error_list] start")
+
+ _insert = {}
+ _insert['site'] = site
+ _insert['spiders'] = spiders
+ _insert['error_type'] = error_type
+ _insert['error_log'] = str(error_log)
+ _insert['writedt'] = 'NOW()'
+ self.dbconn_BI.Insert_table(_insert, 'crawl_error_list')
+
+ logging.info("[crawl_error_list] end")
+
+
+ def crawl_moniter(self, type, site, spiders, spiderType):
+ print("[crawl_moniter] start")
+
+ # 특정 데이터를 업데이트 하는 경우 수집프로그램 구동으로 보지 않는다.
+ if spiderType != "modification":
+
+ columnName = ""
+ if type == "start": columnName = "startTime"
+ elif type == "end": columnName = "endTime"
+
+ _where = {}
+ _where['site'] = site
+ _where['spiders'] = spiders
+ _where['spiderType'] = spiderType
+
+ row = self.dbconn_BI.get_one_row(_where, "crawl_moniter")
+
+ if row == None:
+ _insert = {}
+ _insert['site'] = site
+ _insert['spiders'] = spiders
+ _insert['spiderType'] = spiderType
+ if columnName:
+ _insert[columnName] = "NOW()"
+ self.dbconn_BI.Insert_table(_insert, 'crawl_moniter')
+ else:
+ _update = {}
+ if columnName:
+ _update[columnName] = "NOW()"
+ # 종료시간 저장시 이전 종료시간을 남겨두기 위해 기록한다.
+ if type == "end": _update['prevTime'] = row['endTime']
+ self.dbconn_BI.Update_table(_update, _where, 'crawl_moniter')
+
+ print("[crawl_moniter] end")
+ return ""
+
+ def safe_encode(self, input_str, encoding='euc_kr'):
+ if input_str is None:
+ return ''
+
+ result = []
+ for char in input_str:
+ try:
+ char.encode(encoding)
+ result.append(char)
+ except UnicodeEncodeError:
+ # HTML 엔티티로 대체
+ if char in html.entities.codepoint2name:
+ # 공식 HTML 엔티티가 있으면 사용
+ name = html.entities.codepoint2name[ord(char)]
+ result.append('&{0};'.format(name))
+ else:
+ # HTML 엔티티가 없는 경우 숫자 참조를 사용
+ result.append('{0};'.format(ord(char)))
+ return ''.join(result)
+
+ def change_cost_notice_memo(self, before, after, bidid, type, whereis):
+ try:
+ # 국방부는 기초금액이 있을 때에만 수집되어서 변경에 대한 로그가 없을것으로 판단 제외함
+ print("change_cost_notice_memo")
+ if whereis == '01':
+ content1 = "g2b"
+ elif whereis == '10':
+ content1 = "d2b"
+
+ if type == 'const_cost':
+ type_kor = "순공사원가"
+ elif type == 'cost_a':
+ type_kor = "A값"
+
+ #수집시각
+ crawl_date_time = str(datetime.now().strftime('%Y/%m/%d %H:%M'))
+
+ if (before is not None and before != "" and int(before) > 0
+ and after is not None and after != "" and int(after) > 0
+ and before != after):
+ notice_memo_premium = "[수집시각: {}] 본 공고의 {} 금액이 정정이나 공지없이 변경되었습니다. 입찰에 참고하시기 바랍니다.(변경 전: {:,}원 / 변경 후: {:,}원)".format(crawl_date_time, type_kor, int(before), int(after))
+ crawl_monitor_data = {"category": "bid_change_notice_ck", "error_detail": notice_memo_premium, "content1": content1, "content2": type, "bidid": bidid}
+ Etl.crawl_monitor_error(self, crawl_monitor_data)
+
+ notice_data = {}
+ notice_data["notice_memo"] = notice_memo_premium
+ notice_data["writer_name"] = "자동수집"
+ notice_data["status"] = "done"
+ notice_data["category_type"] = ""
+ notice_data["reg_date"] = 'NOW()'
+ notice_data["bidid"] = bidid
+ self.dbconn_BI.Insert_table(notice_data, 'bid_notice_memo')
+
+ except Exception as e:
+ crawl_monitor_data = {"category": "bid_change_notice_ck", "error_detail": "TRY-CATCH - bidid({}), whereis({}) : {}".format(bidid, whereis, e), "content1": content1, "content2": type, "bidid": bidid}
+ Etl.crawl_monitor_error(self, crawl_monitor_data)
+
+ def crawl_monitor_error(self, data):
+ # 'category' 컬럼이 존재하는지 확인
+ category_column = data.get('category', None)
+ if category_column is not None:
+ # 'category' 컬럼에 공백, None이 아닌 값이 있는지 확인
+ if all(item is not None and str(item).strip() != '' for item in category_column):
+ # 에러 로그라 이후 구문에 문제 없도록 try catch
+ try:
+ data["reg_date"] = "NOW()"
+ self.dbconn_BI.Insert_table(data, 'crawl_monitor_error')
+
+ except Exception as e:
+ # 오류가 발생하더라도 로그로만 남기고 계속 진행
+ error = {
+ 'category': data['category'],
+ 'error_detail': e,
+ "reg_date": "NOW()"
+ }
+ self.dbconn_BI.Insert_table(error, 'crawl_monitor_error')
+
+ print("crawl_monitor_error 오류: " + e)
+ else:
+ print("crawl_monitor_error: category 공백")
+
+
+ # 자동 검수 코드 세팅
+ def auto_service_code_set(self, auto_service_code, item_bidkey, item_bidvalue, item_bidcontent):
+ # print("[auto_service_code_set] 공고 미입력 자동 서비스 start")
+
+ update_bid_key = {}
+ update_bid_value = {}
+ update_bid_content = {}
+
+ ##########################
+ # 추후 추가할 항목들
+ ##########################
+ # [K아파트] 자동점검
+ # [물품] 자동점검
+ # [나라장터] 특정용역 자동점검
+ # [나라장터] 재입찰 자동점검
+ # [나라장터] 취소 자동점검
+
+
+
+ ################################################################################################
+ # [나라장터] 전자시담 자동점검 (start)
+ ################################################################################################
+ e_sidam_whereis = {"01"}
+ e_sidam_bidcls = {"06"}
+
+ if item_bidkey["whereis"] in e_sidam_whereis and item_bidkey["bidcls"] in e_sidam_bidcls:
+
+ ################################
+ # 1. state 세팅
+ ################################
+ update_bid_key['state'] = 'A'
+ update_bid_key['inputer'] = '19'
+
+ ################################
+ # 2. location 세팅 (시스템에서 수집한 면허가 없을 경우)
+ ################################
+ if not item_bidkey["location"] or item_bidkey["location"] == '0':
+ update_bid_key["location"] = 1
+
+ ################################
+ # 3. auto_service_code 세팅
+ ################################
+ # bid_key에 코드가 세팅되어 있을 경우
+ if 'auto_service_code' in item_bidkey and item_bidkey['auto_service_code']:
+ # 세팅된 코드에 추가하려는 코드가 이미 있을 경우
+ if auto_service_code in item_bidkey['auto_service_code']: pass
+ # 세팅된 코드에 추가하려는 코드가 없을 경우
+ else: update_bid_key['auto_service_code'] = item_bidkey['auto_service_code'] + '|' + auto_service_code
+ # bid_key에 코드가 세팅되지 않은 경우
+ else: update_bid_key['auto_service_code'] = auto_service_code
+
+ # 시스템에서 수집한 면허가 없을 경우 -> 시설 : 기타공사, 용역 : 기타용역, 물품 : 기타물품
+ if not item_bidkey["concode"] and not item_bidkey["sercode"] and not item_bidkey["purcode"]:
+ if item_bidkey["bidtype"] == "con": update_bid_key["concode"] = "C999"
+ elif item_bidkey["bidtype"] == "ser": update_bid_key["sercode"] = "S999"
+ elif item_bidkey["bidtype"] == "pur": update_bid_key["purcode"] = "P501"
+
+ ################################
+ # 공고명으로 텍스트 판단해서 면허 세팅 (시스템에서 수집한 면허가 없을 경우)
+ # 해당조건 제거 -> 나중에 살릴수 있으니 코드 남겨 놓음
+ ################################
+ if False:
+ update_part_tmp = {"concode": item_bidkey["concode"], "sercode": item_bidkey["sercode"], "purcode": item_bidkey["purcode"]}
+
+ # 시스템에서 수집한 면허가 없을 경우
+ if not update_part_tmp["concode"] and not update_part_tmp["sercode"] and not update_part_tmp["purcode"]:
+
+ auto_part_check_query = "SELECT * FROM auto_partcode_check WHERE whereis = 'e_sidam' AND state IN ('T', 'TT')"
+ auto_part_check_data = self.dbconn_BI.sql_exec(auto_part_check_query, "DS")
+
+ for item in auto_part_check_data:
+
+ # 현재공고와 자동세팅된 정보의 기준이 동일할때 (시설, 용역, 물품)
+ if item['bidtype'] == item_bidkey['bidtype']:
+
+ # 매칭 조건만 있을 때
+ if item['state'] == 'T':
+ pattern_value_dict = {"pattern": {"P1": item['searchWord']}, "value": item['checkCode']}
+ # 매칭 + 제외 조건 있을 때
+ elif item['state'] == 'TT':
+ split_search_word = item['searchWord'].split('","')
+ pattern_value_dict = {"pattern": {"P1": split_search_word[0].replace('{"P":"', ''),
+ "N1": split_search_word[1].replace('N":"', '').replace('"}', '')},
+ "value": item['checkCode']}
+
+ # 공고명과 매칭해서 추가할 면허를 추출
+ mapping_part = self.mapping_pattern_list([pattern_value_dict], item_bidkey["constnm"].replace(" ", ""))
+
+ # 추출된 면허가 있으면
+ if mapping_part:
+ print("매핑", pattern_value_dict)
+ # 기존에 면허가 없으면 면허만 세팅
+ if not update_part_tmp[item['codetype']]:
+ update_part_tmp[item['codetype']] = mapping_part[0]
+ update_bid_key[item['codetype']] = mapping_part[0]
+ # 기존 면허정보에 추가할 면허가 없을 경우
+ elif mapping_part[0] not in update_part_tmp[item['codetype']]:
+ # 기존에 면허가 있으면 면허 뒤에 | 붙여서 세팅
+ update_part_tmp[item['codetype']] = update_part_tmp[item['codetype']] + "|" + mapping_part[0]
+ update_bid_key[item['codetype']] = update_bid_key[item['codetype']] + "|" + mapping_part[0]
+
+ else: print("시스템 수집 면허 있음")
+
+ ################################
+ # 5. 자격조건, 공고문 세팅
+ ################################
+ if not item_bidcontent['bidcomment']: update_bid_content['bidcomment'] = '[* 공고 원문을 참조하시기 바랍니다. *]'
+ elif '원문' not in item_bidcontent['bidcomment']: update_bid_content['bidcomment'] = item_bidcontent['bidcomment'] + '[* 공고 원문을 참조하시기 바랍니다. *]'
+ #else: update_bid_content['bidcomment'] = item_bidcontent['bidcomment'] + '[* 공고 원문을 참조하시기 바랍니다. *]'
+ #if not item_bidcontent['bid_html']: update_bid_content['bid_html'] = '[* 공고 원문을 참조하시기 바랍니다. *]'
+ #update_bid_content['bidcomment'] = '[* 공고 원문을 참조하시기 바랍니다. *]'
+ update_bid_content['bid_html'] = '[* 공고 원문을 참조하시기 바랍니다. *]'
+
+ ################################################################################################
+ # [나라장터] 전자시담 자동점검 (end)
+ ################################################################################################
+
+ # print("[auto_service_code_set] 공고 미입력 자동 서비스 end")
+ return update_bid_key, update_bid_value, update_bid_content
+
+
+
+
+
+class g2b_attchd_lnk:
+
+ # make_guid 함수 정의
+ def make_guid(self, a=None):
+ if hasattr(uuid, 'uuid4'):
+ c = str(uuid.uuid4()).replace("-", "")
+ else:
+ c = ''.join([hex(random.randint(0, 65535))[2:] for _ in range(8)])
+ if a is not None:
+ c = "{}-{}".format(a, c)
+ return c
+
+ # 문자열 삽입 함수
+ def insertAt(self, a, c, b):
+ return a[:c] + b + a[c:]
+
+ # UTF-8 인코딩 함수
+ def utf8_encode(self, b):
+ b = b.replace('\r\n', '\n')
+ c = ""
+ for a in range(len(b)):
+ d = ord(b[a])
+ if d < 128:
+ c += chr(d)
+ else:
+ if 127 < d < 2048:
+ c += chr((d >> 6) | 192)
+ else:
+ c += chr((d >> 12) | 224)
+ c += chr(((d >> 6) & 63) | 128)
+ c += chr((d & 63) | 128)
+ return c
+
+ # Base64 인코딩 함수
+ def base64_encode(self, a):
+ keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
+ c = ""
+ l = 0
+ a = self.utf8_encode(a)
+ while l < len(a):
+ b = ord(a[l])
+ l += 1
+ if l < len(a):
+ e = ord(a[l])
+ l += 1
+ else:
+ e = None
+ if l < len(a):
+ f = ord(a[l])
+ l += 1
+ else:
+ f = None
+
+ d = b >> 2
+ if e is not None:
+ b_new = ((b & 3) << 4) | (e >> 4)
+ if f is not None:
+ h = ((e & 15) << 2) | (f >> 6)
+ k = f & 63
+ else:
+ h = ((e & 15) << 2)
+ k = 64 # Padding index for '='
+ else:
+ b_new = ((b & 3) << 4)
+ h = k = 64 # Padding indices for '='
+ c += keyStr[d]
+ c += keyStr[b_new]
+ c += keyStr[h]
+ c += keyStr[k]
+ return c
+
+ # 암호화 파라미터 생성 함수
+ def makeEncryptParam(self, a):
+ #a = self.base64_encode(self.utf8_encode(a))
+ # 중복 사용이어서 수정
+ a = self.base64_encode(a)
+ if len(a) >= 10:
+ a = self.insertAt(a, 8, "r")
+ a = self.insertAt(a, 6, "a")
+ a = self.insertAt(a, 9, "o")
+ a = self.insertAt(a, 7, "n")
+ a = self.insertAt(a, 8, "w")
+ a = self.insertAt(a, 6, "i")
+ a = self.insertAt(a, 9, "z")
+ else:
+ a = self.insertAt(a, len(a) - 1, "$")
+ a = self.insertAt(a, 0, "$")
+ a = a.replace('+', '%2B')
+ return a
+
+ # 최종 암호화 파라미터 함수
+ def makeEncryptParamFinal(self, a, c):
+ b = {
+ 'name': "",
+ 'value': ""
+ }
+ e = "1"
+ f = "1"
+ d = "2018.1548512.1555.33"
+ if "1" >= e:
+ if f == "0":
+ b['name'] = "k10"
+ b['value'] = self.makeEncryptParam(a) if c and c == 1 else d + self.makeEncryptParam(a)
+ else:
+ b['name'] = "k00"
+ b['value'] = self.makeEncryptParam(a)
+ return b
+
+ def attchd_lnk_set(self, attchd_lnk_data):
+
+ # 데이터 없을 경우 데이터 반환하지 않도록 처리 필요
+ # 첨부파일 여러개이면 여러개 데이터 세팅하도록 처리 필요
+
+ print("attchd_lnk_data > ", attchd_lnk_data)
+
+ returnData = ""
+
+ orgnlAtchFileNm = html.unescape(urllib.parse.unquote(attchd_lnk_data['orgnlAtchFileNm']))
+ atchFilePathNm = html.unescape(urllib.parse.unquote(attchd_lnk_data['atchFilePathNm']))
+
+ print("orgnlAtchFileNm > ", orgnlAtchFileNm)
+ print("atchFilePathNm > ", atchFilePathNm)
+
+ file_set_data = "kc\fc11\u000b"
+ file_set_data += "k01\f1\u000b"
+ file_set_data += "k12\f{}\u000b".format(self.make_guid())
+ file_set_data += "k26\f{}\u000b".format(atchFilePathNm)
+ file_set_data += "k31\f{}\u000b".format(orgnlAtchFileNm)
+ file_set_data += "k21\f{},{}\u000b".format(attchd_lnk_data['untyAtchFileNo'], attchd_lnk_data['atchFileSqno'])
+
+ print("file_set_data > ", file_set_data)
+
+ # 마지막 수직 탭 제거 (원하지 않으면 생략 가능)
+ # file_set_data = file_set_data.rstrip("\u000b")
+ # print("file_set_data 수직 탭 제거> ", file_set_data)
+
+ # Now, process a using makeEncryptParamFinal
+ file_set_data_result = self.makeEncryptParamFinal(file_set_data, c=1)
+ file_set_data_name = file_set_data_result.get('name')
+ file_set_data_value = file_set_data_result.get('value')
+
+ returnData = "{orgnlAtchFileNm}#=====#https://nwww.g2b.go.kr/fs/fsc/fsca/fileUpload.do?{file_set_data_name}={file_set_data_value}".format(orgnlAtchFileNm=orgnlAtchFileNm, file_set_data_name=file_set_data_name, file_set_data_value=file_set_data_value)
+
+ print("file_set_data_result > ", file_set_data_result)
+ print("https://nwww.g2b.go.kr/fs/fsc/fsca/fileUpload.do?" + file_set_data_name + "=")
+ print("file_set_data_value > ", file_set_data_value)
+
+ return returnData
\ No newline at end of file
diff --git a/backend/airflow/dags/plugins/utils/setup_utils.py b/backend/airflow/dags/plugins/utils/setup_utils.py
new file mode 100644
index 0000000..4ab1a9c
--- /dev/null
+++ b/backend/airflow/dags/plugins/utils/setup_utils.py
@@ -0,0 +1,154 @@
+from selenium import webdriver
+from selenium.webdriver.chrome.options import Options
+from selenium.webdriver.chrome.service import Service
+import logging
+import os
+import time
+
+from airflow.exceptions import AirflowException
+from airflow.models import Variable
+from airflow.providers.mysql.hooks.mysql import MySqlHook
+
+from plugins.sql.hooks.mysql_hook import CommonHookMySQL
+
+class SetupUtils:
+
+ # DB 연결 ID
+ DB_CONN_ID = "COLLECT_SERVER_DB"
+
+ # 나라장터 메인 URL
+ G2B_MAIN_URL = Variable.get("G2B_MAIN_URL", '')
+
+ # 크롬 드라이버 경로
+ CHROMEDRIVER_EXECUTABLE_PATH = Variable.get("CHROMEDRIVER_EXECUTABLE_PATH", '/usr/local/bin/chromedriver')
+
+ def __init__(self):
+ self.driver = None
+ self._all_cookies = {}
+ self.CommonHookMySQL = CommonHookMySQL()
+ logging.info(f"=============== MySQL 연결 시작 ================")
+ self.db_hook = MySqlHook(mysql_conn_id=self.DB_CONN_ID)
+ self.conn = self.db_hook.get_conn()
+ logging.info(f"===============================================")
+
+
+ # 소멸자
+ def __del__(self):
+ logging.info(f"=============== MySQL 연결 종료 ================")
+ self.conn.close()
+ logging.info(f"===============================================")
+
+
+ # Selenium 드라이버 초기화
+ def _setup_selenium(self):
+ logging.info("Selenium Chrome 드라이버 초기화 중...")
+ chrome_options = Options()
+ chrome_options.add_argument('--headless')
+ chrome_options.add_argument('--no-sandbox')
+ chrome_options.add_argument('--disable-dev-shm-usage')
+ chrome_options.add_argument('--disable-gpu')
+ chrome_options.add_argument('--window-size=1920,1080')
+ chrome_options.add_argument('user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36')
+
+ logging.info(f"사용할 ChromeDriver 경로: {self.CHROMEDRIVER_EXECUTABLE_PATH}")
+
+ if not os.path.exists(self.CHROMEDRIVER_EXECUTABLE_PATH):
+ error_msg = f"ChromeDriver 실행 파일이 지정된 경로에 없습니다: {self.CHROMEDRIVER_EXECUTABLE_PATH}. " \
+ "Airflow Variable 'CHROMEDRIVER_EXECUTABLE_PATH' 설정을 확인하거나, " \
+ "Airflow 워커 이미지에 해당 경로로 ChromeDriver가 설치되었는지 확인해주세요."
+ logging.error(error_msg)
+ raise FileNotFoundError(error_msg)
+
+ if not os.access(self.CHROMEDRIVER_EXECUTABLE_PATH, os.X_OK):
+ error_msg = f"ChromeDriver 실행 파일에 실행 권한이 없습니다: {self.CHROMEDRIVER_EXECUTABLE_PATH}. " \
+ "파일에 실행 권한(chmod +x)을 부여해주세요."
+ logging.error(error_msg)
+ raise PermissionError(error_msg)
+
+ try:
+ service = Service(executable_path=self.CHROMEDRIVER_EXECUTABLE_PATH)
+ self.driver = webdriver.Chrome(
+ service=service,
+ options=chrome_options
+ )
+ logging.info("Selenium Chrome 드라이버가 성공적으로 초기화되었습니다.")
+ except Exception as e:
+ logging.error(f"Selenium Chrome 드라이버 초기화 중 심각한 오류 발생: {e}")
+ logging.error(f"ChromeDriver 경로: {self.CHROMEDRIVER_EXECUTABLE_PATH}, 사용된 옵션: {chrome_options.arguments}")
+ raise AirflowException(f"Selenium 드라이버 초기화 실패: {e}")
+
+
+ # =======================================================================================================================================
+ # 크롬 드라이버 닫기
+ def _close_driver(self):
+ try:
+ if self.driver:
+ self.driver.quit()
+ logging.info("Selenium 드라이버가 안전하게 종료되었습니다.")
+ self.driver = None
+ else:
+ logging.warning("Selenium 드라이버가 이미 종료되었거나 초기화되지 않았습니다.")
+ except Exception as e:
+ logging.error(f"Selenium 드라이버 종료 중 오류 발생: {e}")
+ raise
+
+
+ # =======================================================================================================================================
+ # 쿠키 가져오기
+ def _get_cookies(self):
+ main_page_url = self.G2B_MAIN_URL
+ try:
+ self._setup_selenium()
+
+ logging.info(f"{main_page_url} 에 접속하여 쿠키를 가져오고 있습니다...")
+ self.driver.get(main_page_url)
+ time.sleep(5) # 페이지 로딩 및 JS 실행 시간 고려
+
+ cookies = self.driver.get_cookies()
+ # 모든 쿠키를 딕셔너리에 저장
+ self._all_cookies = {cookie['name']: cookie['value'] for cookie in cookies}
+
+ logging.info(f"Selenium을 사용하여 모든 쿠키를 가져왔습니다: {self._all_cookies}")
+
+ # 최소한 필요한 쿠키(JSESSIONID, XTVID 등)가 있는지 확인 (선택 사항이지만 권장)
+ if not self._all_cookies.get('JSESSIONID') or not self._all_cookies.get('XTVID'):
+ logging.warning("필요한 쿠키(JSESSIONID or XTVID)가 없을 수 있습니다.")
+
+ self._close_driver()
+
+ except Exception as e:
+ logging.error(f"Selenium을 사용하여 쿠키를 가져오는 중 오류가 발생했습니다: {e}")
+ raise
+
+
+ # =======================================================================================================================================
+ # 코드 데이터 DB UPSERT
+ def common_code_db_upsert(self, code_data):
+ try:
+ query = f"""
+ INSERT INTO common_code (group_id, code_key, code_value, update_dt)
+ VALUES ('{code_data['group_id']}', '{code_data['code_key']}', '{code_data['code_value']}', NOW())
+ ON DUPLICATE KEY UPDATE
+ group_id = '{code_data['group_id']}', code_key = '{code_data['code_key']}', code_value = '{code_data['code_value']}', update_dt = NOW();
+ """
+ self.CommonHookMySQL.process_sql(self.conn, query)
+ except Exception as e:
+ logging.error(f"쿼리: {query}")
+ logging.error(f"코드 데이터 DB UPSERT 중 오류가 발생했습니다: {e}")
+ raise
+
+
+ # =======================================================================================================================================
+ # 코드 데이터 select
+ def common_code_db_select(self, group_id, code_key):
+ try:
+ query = f"""
+ SELECT code_value FROM common_code WHERE group_id = '{group_id}' AND code_key = '{code_key}'
+ """
+ result = self.CommonHookMySQL.select_sql(self.conn, query)
+ return result[0][0]
+ except Exception as e:
+ logging.error(f"쿼리: {query}")
+ logging.error(f"코드 데이터 DB select 중 오류가 발생했습니다: {e}")
+ raise
+
diff --git a/backend/airflow/dags/plugins/utils/status_enum.py b/backend/airflow/dags/plugins/utils/status_enum.py
new file mode 100644
index 0000000..df66797
--- /dev/null
+++ b/backend/airflow/dags/plugins/utils/status_enum.py
@@ -0,0 +1,15 @@
+
+class StatusEnum:
+
+ @staticmethod
+ def status_enum(status: str):
+ if status == 'WAIT':
+ return 1
+ elif status == 'RUNNING':
+ return 2
+ elif status == 'FAIL':
+ return 3
+ elif status == 'SUCCESS':
+ return 4
+ else:
+ return 0
diff --git a/backend/airflow/dags/plugins/utils/transformers/__pycache__/bid_key_transformer.cpython-313.pyc b/backend/airflow/dags/plugins/utils/transformers/__pycache__/bid_key_transformer.cpython-313.pyc
new file mode 100644
index 0000000..059f1ca
Binary files /dev/null and b/backend/airflow/dags/plugins/utils/transformers/__pycache__/bid_key_transformer.cpython-313.pyc differ
diff --git a/backend/airflow/dags/plugins/utils/transformers/__pycache__/transformer_helper.cpython-313.pyc b/backend/airflow/dags/plugins/utils/transformers/__pycache__/transformer_helper.cpython-313.pyc
new file mode 100644
index 0000000..df222b8
Binary files /dev/null and b/backend/airflow/dags/plugins/utils/transformers/__pycache__/transformer_helper.cpython-313.pyc differ
diff --git a/backend/airflow/dags/plugins/utils/transformers/attchd_lnk.py b/backend/airflow/dags/plugins/utils/transformers/attchd_lnk.py
new file mode 100644
index 0000000..20bfdcf
--- /dev/null
+++ b/backend/airflow/dags/plugins/utils/transformers/attchd_lnk.py
@@ -0,0 +1,153 @@
+import uuid
+import random
+import urllib
+import html.entities
+
+class AttchdLnk:
+
+ # make_guid 함수 정의
+ def make_guid(self, a=None):
+ if hasattr(uuid, 'uuid4'):
+ c = str(uuid.uuid4()).replace("-", "")
+ else:
+ c = ''.join([hex(random.randint(0, 65535))[2:] for _ in range(8)])
+ if a is not None:
+ c = "{}-{}".format(a, c)
+ return c
+
+ # 문자열 삽입 함수
+ def insertAt(self, a, c, b):
+ return a[:c] + b + a[c:]
+
+ # UTF-8 인코딩 함수
+ def utf8_encode(self, b):
+ b = b.replace('\r\n', '\n')
+ c = ""
+ for a in range(len(b)):
+ d = ord(b[a])
+ if d < 128:
+ c += chr(d)
+ else:
+ if 127 < d < 2048:
+ c += chr((d >> 6) | 192)
+ else:
+ c += chr((d >> 12) | 224)
+ c += chr(((d >> 6) & 63) | 128)
+ c += chr((d & 63) | 128)
+ return c
+
+ # Base64 인코딩 함수
+ def base64_encode(self, a):
+ keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
+ c = ""
+ l = 0
+ a = self.utf8_encode(a)
+ while l < len(a):
+ b = ord(a[l])
+ l += 1
+ if l < len(a):
+ e = ord(a[l])
+ l += 1
+ else:
+ e = None
+ if l < len(a):
+ f = ord(a[l])
+ l += 1
+ else:
+ f = None
+
+ d = b >> 2
+ if e is not None:
+ b_new = ((b & 3) << 4) | (e >> 4)
+ if f is not None:
+ h = ((e & 15) << 2) | (f >> 6)
+ k = f & 63
+ else:
+ h = ((e & 15) << 2)
+ k = 64 # Padding index for '='
+ else:
+ b_new = ((b & 3) << 4)
+ h = k = 64 # Padding indices for '='
+ c += keyStr[d]
+ c += keyStr[b_new]
+ c += keyStr[h]
+ c += keyStr[k]
+ return c
+
+ # 암호화 파라미터 생성 함수
+ def makeEncryptParam(self, a):
+ #a = self.base64_encode(self.utf8_encode(a))
+ # 중복 사용이어서 수정
+ a = self.base64_encode(a)
+ if len(a) >= 10:
+ a = self.insertAt(a, 8, "r")
+ a = self.insertAt(a, 6, "a")
+ a = self.insertAt(a, 9, "o")
+ a = self.insertAt(a, 7, "n")
+ a = self.insertAt(a, 8, "w")
+ a = self.insertAt(a, 6, "i")
+ a = self.insertAt(a, 9, "z")
+ else:
+ a = self.insertAt(a, len(a) - 1, "$")
+ a = self.insertAt(a, 0, "$")
+ a = a.replace('+', '%2B')
+ return a
+
+ # 최종 암호화 파라미터 함수
+ def makeEncryptParamFinal(self, a, c):
+ b = {
+ 'name': "",
+ 'value': ""
+ }
+ e = "1"
+ f = "1"
+ d = "2018.1548512.1555.33"
+ if "1" >= e:
+ if f == "0":
+ b['name'] = "k10"
+ b['value'] = self.makeEncryptParam(a) if c and c == 1 else d + self.makeEncryptParam(a)
+ else:
+ b['name'] = "k00"
+ b['value'] = self.makeEncryptParam(a)
+ return b
+
+ def attchd_lnk_set(self, attchd_lnk_data):
+
+ # 데이터 없을 경우 데이터 반환하지 않도록 처리 필요
+ # 첨부파일 여러개이면 여러개 데이터 세팅하도록 처리 필요
+
+ #print("attchd_lnk_data > ", attchd_lnk_data)
+
+ returnData = ""
+
+ orgnlAtchFileNm = html.unescape(urllib.parse.unquote(attchd_lnk_data['orgnlAtchFileNm']))
+ atchFilePathNm = html.unescape(urllib.parse.unquote(attchd_lnk_data['atchFilePathNm']))
+
+ #print("orgnlAtchFileNm > ", orgnlAtchFileNm)
+ #print("atchFilePathNm > ", atchFilePathNm)
+
+ file_set_data = "kc\fc11\u000b"
+ file_set_data += "k01\f1\u000b"
+ file_set_data += "k12\f{}\u000b".format(self.make_guid())
+ file_set_data += "k26\f{}\u000b".format(atchFilePathNm)
+ file_set_data += "k31\f{}\u000b".format(orgnlAtchFileNm)
+ file_set_data += "k21\f{},{}\u000b".format(attchd_lnk_data['untyAtchFileNo'], attchd_lnk_data['atchFileSqno'])
+
+ #print("file_set_data > ", file_set_data)
+
+ # 마지막 수직 탭 제거 (원하지 않으면 생략 가능)
+ # file_set_data = file_set_data.rstrip("\u000b")
+ # print("file_set_data 수직 탭 제거> ", file_set_data)
+
+ # Now, process a using makeEncryptParamFinal
+ file_set_data_result = self.makeEncryptParamFinal(file_set_data, c=1)
+ file_set_data_name = file_set_data_result.get('name')
+ file_set_data_value = file_set_data_result.get('value')
+
+ returnData = "{orgnlAtchFileNm}#=====#https://www.g2b.go.kr/fs/fsc/fsca/fileUpload.do?{file_set_data_name}={file_set_data_value}".format(orgnlAtchFileNm=orgnlAtchFileNm, file_set_data_name=file_set_data_name, file_set_data_value=file_set_data_value)
+
+ #print("file_set_data_result > ", file_set_data_result)
+ #print("https://www.g2b.go.kr/fs/fsc/fsca/fileUpload.do?" + file_set_data_name + "=")
+ #print("file_set_data_value > ", file_set_data_value)
+
+ return returnData
\ No newline at end of file
diff --git a/backend/airflow/dags/plugins/utils/transformers/bid_content_transformer.py b/backend/airflow/dags/plugins/utils/transformers/bid_content_transformer.py
new file mode 100644
index 0000000..afdbc5b
--- /dev/null
+++ b/backend/airflow/dags/plugins/utils/transformers/bid_content_transformer.py
@@ -0,0 +1,240 @@
+import plugins.utils.scraplib as scraplib #공통 라이브러리
+from plugins.utils.transformers.transformer_helper import TransformerHelper
+from plugins.utils.transformers.attchd_lnk import AttchdLnk
+from bs4 import BeautifulSoup
+
+class BidContentTransformer(TransformerHelper):
+
+ # todo 진행중
+ # 자격조건
+ def bidcomment(self, api_data, param):
+ bidcomment = ""
+ if param["bidtype"] == "견적":
+ bidcomment = "공고원문을 참고하시기 바랍니다.\n\n"
+ else:
+ if param["pqdt"] is not None and param["pqdt"] != "":
+ bidcomment += "PQ심사신청서 신청기한 : " + param["pqdt"] + " \n\n "
+
+ if param["jkdt"] is not None and param["jkdt"] != "":
+ bidcomment += "적격성심사신청서 신청기한 : " + param["jkdt"] + " \n\n "
+
+ # 입찰보증서접수 마감일시
+ guarantee = self.extract_value(api_data, "dmItemMap", "bidDepoPayTermDt")
+
+ if guarantee is not None: # 보증서접수마감일시 있는경우 추가
+ bidcomment += "보증서접수마감일시 : {guarantee}\n보증서 접수마감일시를 입력하지 않은 경우에는, 입찰서 접수마감일 전일 18시까지 제출이 가능합니다.\n" \
+ "(단, 입찰보증금지급각서로 대체하는 경우 보증금이 면제됩니다.)\n\n ".format(guarantee=guarantee.replace("/", "-"))
+ if "notice_memo" in param["item_bidnoticememo"]: # 공지사항이 있는경우 추가
+ bidcomment += param["notice_memo"] # 첨부파일까지 넣으려면 item_bidnoticememo["notice_memo"] 를 넣으면 됨
+
+ # 첨부파일 없을 때, 해당 문구 뜰 수 있게 적용
+ if not param['attchd_lnk']:
+ bidcomment += " \n [* 공고 원문을 참조하시기 바랍니다. *]"
+
+ return bidcomment
+
+ # todo 진행중
+ # 공고변경사유
+ def bidcomment_mod(self, noticeClCd, bidcomment_mod_notice_memo):
+ bidcomment_mod = None
+ if noticeClCd != "" and bidcomment_mod_notice_memo is not None: # 공지사항이 있고, 취소 또는 정정일 경우
+ bidcomment_mod = self.Etl.clean_page_source(bidcomment_mod_notice_memo)
+ return bidcomment_mod
+
+ # todo 진행중
+ # 공고문첨부파일
+ def attchd_lnk(self, json_res_file):
+
+ file_params = self.extract_value(json_res_file, "dlUntyAtchFileL")
+ if file_params is None:
+ # print("첨부파일 없음")
+ return None
+
+ make_attchd_lnk = AttchdLnk()
+
+ file_list = []
+ for item in file_params:
+ attchd_lnk_data = {
+ "untyAtchFileNo": self.extract_value(item, "untyAtchFileNo", default=""),
+ "atchFileSqno": self.extract_value(item, "atchFileSqno", default=""),
+ "orgnlAtchFileNm": self.extract_value(item, "orgnlAtchFileNm", default=""),
+ "atchFilePathNm": self.extract_value(item, "atchFilePathNm", default="")
+ }
+
+ file_list.append(make_attchd_lnk.attchd_lnk_set(attchd_lnk_data))
+ return "|~~~~~|".join(file_list)
+
+ """
+ print("bidtype :", bidtype)
+ if bidtype == "견적":
+ attchd_lnk_nm = driver.rtn_elements("//caption[.='첨부문서']/parent::table/tbody/tr/td[@class='tl'][2]", "XPATH", "")
+ #print("attchd_lnk_nm :", attchd_lnk_nm)
+ attchd_lnk_href = list(map(lambda x: x.replace("javascript:toFile('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='첨부문서']/parent::table/tbody/tr/td[@class='tl'][2]/a", "XPATH", "href")))
+ attchd_lnk_href = list(map(lambda x: x[0] if len(x) < 2 else "https://www.g2b.go.kr:8402/gtob/all/pr/estimate/fileDownloadG2B.do?estmtReqNo={estmtReqNo},{_idx}".format(estmtReqNo=x[0], _idx=x[1]), attchd_lnk_href))
+ #print("attchd_lnk_href :", attchd_lnk_href)
+ elif bidtype == "기타":
+ attchd_lnk_nm = driver.rtn_elements("//caption[.='공고서 테이블']/parent::table/tbody/tr/td/a", "XPATH", "")
+ attchd_lnk_href = list(map(lambda x: x.replace("javascript:otherBidDtl_fileDownload('", "").replace("');", "").replace(" ", ""), driver.rtn_elements("//caption[.='공고서 테이블']/parent::table/tbody/tr/td/a", "XPATH", "href")))
+ attchd_lnk_href = list(map(lambda x: "https://www.g2b.go.kr:8101/ep/tbid/downloadOtherBidAttafile.do?path=&fileName=&bidattaFileNo={bidattaFileNo}&tbidno={tbidno}&bidseq={bidseq}".format(bidattaFileNo=x, tbidno=PARAMS["tbidno"][0], bidseq=PARAMS["bidseq"][0]), attchd_lnk_href))
+ elif len(driver.rtn_elements("//iframe[@title='혁신장터 첨부파일']", "XPATH", ""))>0 :#혁신장터
+ driver.switch_to_frame("//iframe[@title='혁신장터 첨부파일']")
+ attchd_lnk_nm = driver.rtn_elements("//tbody/tr/td[@class='left retTD']//em", "XPATH", "")
+ attchd_lnk_href = list(map(lambda x: x.replace("fileModule.download('", "").replace("')", "").replace(" ", "").split("','"), driver.rtn_elements("//tbody/tr/td[@class='left retTD']//a[contains(@onclick,'fileModule')]", "XPATH", "onclick")))
+ attchd_lnk_href = list(map(lambda x: "https://ppi.g2b.go.kr:8914/cmmn/atachFile/downloadPlainFile.do?untyAtchmnflNo={untyAtchmnflNo}&atchmnflSno={atchmnflSno}".format(untyAtchmnflNo=x[0], atchmnflSno=x[1]), attchd_lnk_href))
+ driver.switch_to_default_frame()
+ else:
+ attchd_lnk_nm = driver.rtn_elements("//caption[.='업로드 된 파일 정보']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'dtl_fileDownload')]", "XPATH", "")
+ attchd_lnk_href = list(map(lambda x: x.replace("javascript:dtl_fileDownload('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='업로드 된 파일 정보']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'dtl_fileDownload')]", "XPATH", "href")))
+ attchd_lnk_href = list(map(lambda x: "https://www.g2b.go.kr:8081/ep/co/fileDownload.do?fileTask=NOTIFY&fileSeq={fileSeq}".format(fileSeq=x[0]), attchd_lnk_href))
+
+ attchd_lnk_nm2 = driver.rtn_elements("//caption[.='E발주 제안요청서 첨부파일']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'eeOrderAttachFileDownload')]","XPATH", "")
+ attchd_lnk_href2 = list(map(lambda x: x.replace("javascript:eeOrderAttachFileDownload('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='E발주 제안요청서 첨부파일']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'eeOrderAttachFileDownload')]","XPATH", "href")))
+ attchd_lnk_href2 = list(map(lambda x: "https://rfp.g2b.go.kr:8426/cmm/FileDownload.do?atchFileId={fileSeq1}&fileSn={fileSeq2}".format(fileSeq1=x[1], fileSeq2=x[2]), attchd_lnk_href2))
+
+ attchd_lnk_nm = attchd_lnk_nm + attchd_lnk_nm2
+ attchd_lnk_href = attchd_lnk_href + attchd_lnk_href2
+
+ #print("attchd_lnk_nm :", attchd_lnk_nm)
+ #print("attchd_lnk_href :", attchd_lnk_href)
+ item_bidcontent['attchd_lnk'] = "|~~~~~|".join(list(map(lambda x, y: x + "#=====#" + y, attchd_lnk_nm, attchd_lnk_href)))
+ #print("attchd_lnk_str :", item_bidcontent['attchd_lnk'])
+ return ""
+ """
+
+ # todo 진행중
+ # 입찰상세내용(공고문)
+ def bid_html(self, bidtype, bidproc, bidcls, whereis):
+ # todo None이면 암것도 안하고 ''이면 공백으로 덮어 쓰는듯??
+ if bidproc == 'M':
+ bid_html = '' # 정정공고일경우 전차수 bid_html 초기화
+ else:
+ bid_html = None
+
+ # todo 민간 견적공고 공고문 만들기
+
+ # 공고 시스템 수집
+ # 공사/용역 이면서 전자시담
+ # 민간공고 이면서 취소건
+ # 견적공고
+ #if (bidcls == '06' and whereis == '01') or (item_bidkey['bidproc'] == 'C' and whereis == '84') or whereis == '85':
+ if (item_bidkey['bidproc'] == 'C' and whereis == '84') or whereis == '85':
+ bid_source = page_source.split('\n')
+ html_tmp = ""
+ html_check = False
+ for i in bid_source:
+ #print(i)
+ i = self.Etl.clean_page_source(i)
+ if whereis == '01' or whereis == '84':
+ if i.find('
가나다라마바사
' > /tmp/test_korean.html + +COPY ./app /app + +# document_files 디렉토리 생성 및 sn3hcv 파일 변환기 복사 +RUN mkdir -p /home/user1/yjh/fastapi/document_files +COPY ./document_files /home/user1/yjh/fastapi/document_files + +# pip 업그레이드 & Selenium 별도 설치 (버전 고정, no-cache-dir로 캐시 피함, retry 추가) +RUN pip install --upgrade pip --no-cache-dir \ + && pip install selenium==4.10.0 webdriver-manager --no-cache-dir --retries 10 --timeout 60 + +# PDF 변환 도구들 설치 (WeasyPrint 및 pdfkit 지원) +RUN apt-get update && \ + (apt-get install -y libpango-1.0-0 || true) && \ + (apt-get install -y libpangocairo-1.0-0 || true) && \ + (apt-get install -y libgdk-pixbuf2.0-0 || true) && \ + (apt-get install -y wkhtmltopdf || true) && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Python 패키지 설치 +RUN pip install -r requirements.txt --no-cache-dir --retries 10 --timeout 60 + +# ============================================================================= +# 포트 설정 +# - 컨테이너 내부에서는 8097 포트 사용 (표준화) +# - 외부 포트는 docker-compose에서 매핑 +# ============================================================================= +EXPOSE 8097 + +# ============================================================================= +# 서버 시작 명령어 +# - uvicorn: ASGI 서버 (FastAPI 권장) +# - 워커 수: CPU 코어 수의 2-4배가 적절 (예: 4코어 = 8-16 워커) +# - 비동기 처리로 적은 워커로도 많은 동시 요청 처리 가능 +# ============================================================================= +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8097", "--workers", "4"] \ No newline at end of file diff --git a/backend/fastapi/docker-compose.yaml b/backend/fastapi/docker-compose.yaml new file mode 100644 index 0000000..f29a71c --- /dev/null +++ b/backend/fastapi/docker-compose.yaml @@ -0,0 +1,51 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile-fastapi + image: fastapi:v1 + container_name: fastapi-dev-test + ports: + - "8096:8097" # 개발 테스트용 포트 (외부:8096 → 내부:8097) + volumes: + # 실시간 코드 반영을 위한 볼륨 마운트 + - /home/user1/yjh/fastapi/app:/app + # 문서 파일 공유 + - /home/user1/yjh/fastapi/document_files:/home/user1/yjh/fastapi/document_files + - /home/user1/yjh/rebbitMQ/logs:/rebbitMQ/logs + # rebbitMQ 문서 파일 마운트 (데이터 공유) + - /home/user1/yjh/rebbitMQ/document_files:/home/user1/yjh/rebbitMQ/document_files + - /home/user1/yjh/rebbitMQ/document_files_test:/home/user1/yjh/rebbitMQ/document_files_test + environment: + - SERVER_NAME=dev-test # 서버 식별용 + - PORT=8097 + - ENV=development # 개발 환경 표시 + # 운영서버 접속정보 + - MYSQL_HOST=222.234.3.20 + - MYSQL_PORT=3306 + - MYSQL_USER=i2 + - MYSQL_PW=whdgus&cndaks + - MYSQL_DB=i2 + # AI API 키 + - GEMINI_API_KEY=AIzaSyDafw8OSsYEFBPvjtrB5kkwIDdJgaKjKyk + networks: + - db_network # 데이터베이스 네트워크 연결 + restart: unless-stopped + # 개발 중에는 자동 재시작으로 편의성 제공 + healthcheck: + # 개발 서버 상태 확인 + test: ["CMD", "curl", "-f", "http://localhost:8097/docs"] + interval: 60s # 개발 환경이므로 느슨한 체크 + timeout: 10s + retries: 3 + start_period: 30s + +# =========================================================================== +# 네트워크 설정 +# - 외부 데이터베이스 네트워크에 연결 +# - 다른 서비스들과 네트워크 분리 +# =========================================================================== +networks: + db_network: + external: true # 외부에서 생성된 네트워크 사용 + diff --git a/backend/fastapi/test.py b/backend/fastapi/test.py deleted file mode 100644 index 3cc762b..0000000 --- a/backend/fastapi/test.py +++ /dev/null @@ -1 +0,0 @@ -"" \ No newline at end of file diff --git a/imai-invest-manager-AI/.env b/imai-invest-manager-AI/.env new file mode 100644 index 0000000..f5f6f5a --- /dev/null +++ b/imai-invest-manager-AI/.env @@ -0,0 +1,3 @@ +[AWS KEY INFO] +AWS_YOUR_ACCESS_KEY=AKIA1234567890EXAMPLE +AWS_YOUR_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \ No newline at end of file diff --git a/imai-invest-manager-AI/package-lock.json b/imai-invest-manager-AI/package-lock.json index e14d844..526fa0f 100644 --- a/imai-invest-manager-AI/package-lock.json +++ b/imai-invest-manager-AI/package-lock.json @@ -8,23 +8,51 @@ "name": "imai-invest-manager-ai", "version": "0.0.0", "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "@types/chart.js": "^2.9.41", + "chart.js": "^4.5.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.542.0", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-chartjs-2": "^5.3.0", + "react-dom": "^19.1.1", + "socket.io-client": "^4.8.1", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.33.0", + "@tailwindcss/postcss": "^4.1.13", + "@types/node": "^24.3.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react-swc": "^4.0.0", + "autoprefixer": "^10.4.21", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.12", "typescript": "~5.8.3", "typescript-eslint": "^8.39.1", "vite": "^7.1.2" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -687,6 +715,75 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -725,6 +822,39 @@ "node": ">= 8" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.32", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", @@ -1012,6 +1142,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.4.tgz", @@ -1238,6 +1374,291 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tailwindcss/node": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", + "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.18", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", + "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-x64": "4.1.13", + "@tailwindcss/oxide-freebsd-x64": "4.1.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-x64-musl": "4.1.13", + "@tailwindcss/oxide-wasm32-wasi": "4.1.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", + "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", + "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", + "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", + "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", + "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", + "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", + "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", + "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", + "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", + "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", + "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.13", + "@tailwindcss/oxide": "4.1.13", + "postcss": "^8.4.41", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@types/chart.js": { + "version": "2.9.41", + "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz", + "integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.10.2" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1252,11 +1673,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, "node_modules/@types/react": { "version": "19.1.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1610,6 +2041,44 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1641,6 +2110,39 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1651,6 +2153,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001739", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz", + "integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1668,6 +2191,49 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1714,7 +2280,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1742,6 +2308,76 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.214", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz", + "integrity": "sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -1784,6 +2420,16 @@ "@esbuild/win32-x64": "0.25.9" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2100,6 +2746,20 @@ "dev": true, "license": "ISC" }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2141,6 +2801,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2235,6 +2902,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2293,6 +2970,245 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2316,6 +3232,25 @@ "dev": true, "license": "MIT" }, + "node_modules/lucide-react": { + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.18", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", + "integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2353,11 +3288,58 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2386,6 +3368,23 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2518,6 +3517,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2568,6 +3574,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", @@ -2707,6 +3723,68 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2743,6 +3821,55 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -2868,6 +3995,44 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3010,6 +4175,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3022,6 +4226,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/imai-invest-manager-AI/package.json b/imai-invest-manager-AI/package.json index bea7904..f57732b 100644 --- a/imai-invest-manager-AI/package.json +++ b/imai-invest-manager-AI/package.json @@ -10,18 +10,33 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "@types/chart.js": "^2.9.41", + "chart.js": "^4.5.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.542.0", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-chartjs-2": "^5.3.0", + "react-dom": "^19.1.1", + "socket.io-client": "^4.8.1", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.33.0", + "@tailwindcss/postcss": "^4.1.13", + "@types/node": "^24.3.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react-swc": "^4.0.0", + "autoprefixer": "^10.4.21", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.12", "typescript": "~5.8.3", "typescript-eslint": "^8.39.1", "vite": "^7.1.2" diff --git a/imai-invest-manager-AI/postcss.config.js b/imai-invest-manager-AI/postcss.config.js new file mode 100644 index 0000000..af9d8dc --- /dev/null +++ b/imai-invest-manager-AI/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/imai-invest-manager-AI/src/App.tsx b/imai-invest-manager-AI/src/App.tsx index 3d7ded3..5f9bf52 100644 --- a/imai-invest-manager-AI/src/App.tsx +++ b/imai-invest-manager-AI/src/App.tsx @@ -1,35 +1,98 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import React, { useEffect } from 'react'; +import { useAppStore } from './stores/useAppStore'; +import Header from './components/common/Header'; +import Dashboard from './components/dashboard/Dashboard'; +import ProductDetail from './components/detail/ProductDetail'; +import Simulation from './components/simulation/Simulation'; +import Settings from './components/settings/Settings'; +import Loading from './components/common/Loading'; +import AdBanner from './components/common/AdBanner'; function App() { - const [count, setCount] = useState(0) + const { currentView, isLoading, theme } = useAppStore(); + + // 테마 적용 (body 클래스 변경) + useEffect(() => { + document.body.className = theme === 'dark' ? 'dark' : 'light'; + }, [theme]); + + // 현재 뷰에 따른 컴포넌트 렌더링 + const renderCurrentView = () => { + switch (currentView) { + case 'dashboard': + return
- Edit src/App.tsx
and save to test HMR
-
- Click on the Vite and React logos to learn more -
- > - ) + +{text}
+ )} +총 상품
+{marketSummary.totalProducts}
+상승
+{marketSummary.gainers}
+하락
+{marketSummary.losers}
+총 거래량
++ {(marketSummary.totalVolume / 1000000).toFixed(1)}M +
+검색 조건에 맞는 상품이 없습니다.
+{product.code}
++ 거래량: {formatPrice(product.volume)} +
+{selectedProduct.code}
+현재가
++ ${formatPrice(selectedProduct.currentPrice)} +
+등락률
++ {formatPercent(selectedProduct.changeRate)} +
+거래량
++ {formatPrice(selectedProduct.volume)} +
+AI 점수
++ {currentAnalysis ? currentAnalysis.investScore : 'N/A'} +
++ TradingView 차트가 여기에 표시됩니다 +
++ 실제 구현 시 TradingView 위젯 연동 +
+{currentAnalysis.entryPriceReason}
+{currentAnalysis.targetPriceReason}
+{currentAnalysis.stopLossReason}
++ 뉴스 요약 내용이 여기에 표시됩니다... +
++ 언어 변경 후 페이지가 새로고침됩니다 +
+v1.0.0
+현재 버전
+10
+지원 투자 상품
+24/7
+실시간 모니터링
++ © 2024 I'm AI 투자매니저. 투자 정보 제공 목적으로, 투자 권유가 아닙니다. +
++ {selectedProduct.name} ({selectedProduct.code}) +
+AI 분석 점수
++ {selectedAnalysis.investScore}/100 +
++ {getRiskText(simulationResult.riskLevel)} +
+= 0 ? 'text-success' : 'text-danger'}`}> + {formatPercent(simulationResult.expectedReturn)} +
++ {selectedAnalysis.investScore}/100 +
+[TECHNICAL ANALYSIS] RSI indicator at 65 level shows bullish signals. Moving average breakout strengthens upward momentum.
-[NEWS ANALYSIS] Major institutional Bitcoin ETF approvals by BlackRock and others drive significant capital inflow.
-[STRATEGY] Recommend entry at $42,800, hold until target $46,500. Strict adherence to stop-loss at $40,200 required.
-