From b66129b5d0b33fae1845380fa84ac7045ec828ec Mon Sep 17 00:00:00 2001 From: thug0bin Date: Fri, 27 Feb 2026 22:10:38 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=BE=20=EC=B4=88=EA=B8=B0=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B:=20=EB=B0=98=EB=A0=A4=EB=8F=99=EB=AC=BC=20=EC=95=BD?= =?UTF-8?q?=ED=92=88=20=EC=B6=94=EC=B2=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pet_recommend_app.py: Flask 기반 약품 추천 API (포트 7001) - db_setup.py: PostgreSQL ORM 모델 (apdb_master) - requirements.txt: 패키지 의존성 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 7 + db_setup.py | 967 +++++++++++++ pet_recommend_app.py | 3119 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 6 + 4 files changed, 4099 insertions(+) create mode 100644 .gitignore create mode 100644 db_setup.py create mode 100644 pet_recommend_app.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82d9caa --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +.env +venv/ +.venv/ +*.log diff --git a/db_setup.py b/db_setup.py new file mode 100644 index 0000000..839d743 --- /dev/null +++ b/db_setup.py @@ -0,0 +1,967 @@ +# /Flask-backend/db_setup.py +from sqlalchemy import create_engine, Column, Integer, String, DECIMAL, Boolean, Text, ForeignKey, DateTime, Index, func, TIMESTAMP, Float, Date, Enum +from sqlalchemy.orm import sessionmaker, declarative_base, relationship, scoped_session +from sqlalchemy.dialects.postgresql import JSONB +from datetime import datetime + +# 1. 데이터베이스 설정 +# PostgreSQL 데이터베이스 연결 설정 +DATABASE_URI = 'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master' +engine = create_engine( + DATABASE_URI, + pool_size=20, # 기본 연결 수를 20으로 설정 (사용 패턴에 따라 조정) + max_overflow=20, # 추가 연결 수를 20개까지 허용 + pool_timeout=30, # 연결 획득 대기 시간 30초 + pool_recycle=3600 # 1시간마다 오래된 연결 재생성 (연결 유실 방지) +) +SessionLocal = sessionmaker(bind=engine) + +# 전역 세션 생성 (scoped_session) +session = scoped_session(SessionLocal) + +# 2. ORM 기반 모델 정의 +Base = declarative_base() + +# 1. APDB 테이블 (제품 정보) +# 주의: 이 모델은 실제 테이블명이 'apc'로 되어 있습니다. +class APDB(Base): + __tablename__ = 'apc' + idx = Column(Integer, primary_key=True, comment='고유 IDX') + item_seq = Column(String(100), nullable=True, comment='제품 시퀀스') + product_name = Column(String(200), nullable=True, comment='제품명') + product_english_name = Column(String(200), nullable=True, comment='제품 영문명') + company_name = Column(String(200), nullable=True, comment='회사명') + item_code = Column(String(100), nullable=True, comment='아이템 코드') + approval_number = Column(String(100), nullable=True, comment='승인번호') + ac = Column(String(100), nullable=True, comment='AC') + pc = Column(String(100), nullable=True, comment='PC') + dosage_code = Column(String(100), nullable=True, comment='용량 코드') + packaging_code = Column(String(100), nullable=True, comment='포장 코드') + dosage = Column(String(100), nullable=True, comment='용량') + packaging = Column(String(100), nullable=True, comment='포장') + apc = Column(String(100), nullable=True, unique=True, comment='APC 코드') + approval_date = Column(String(100), nullable=True, comment='승인일') + product_type = Column(String(100), nullable=True, comment='제품 유형') + main_ingredient = Column(String(100), nullable=True, comment='주성분') + finished_material = Column(String(200), nullable=True, comment='완제품 정보') + approval_report = Column(String(100), nullable=True, comment='승인 보고서') + manufacture_import = Column(String(100), nullable=True, comment='제조/수입 구분') + country_of_manufacture = Column(String(100), nullable=True, comment='제조국') + basic_info = Column(Text, nullable=True, comment='기본 정보') + raw_material = Column(Text, nullable=True, comment='원료 정보') + efficacy_effect = Column(Text, nullable=True, comment='효능 효과') + dosage_instructions = Column(Text, nullable=True, comment='복용 지침') + precautions = Column(Text, nullable=True, comment='주의사항') + appearance_manufacturing = Column(Text, nullable=True, comment='외관 및 제조정보') + collection_status = Column(String(100), nullable=True, comment='수집 상태') + component_code = Column(String(100), nullable=True, comment='성분 코드') + component_name_ko = Column(String(200), nullable=True, comment='성분 한글명') + component_name_en = Column(String(200), nullable=True, comment='성분 영문명') + dosage_factor = Column(String(100), nullable=True, comment='용량 인자') + llm_pharm = Column(JSONB, nullable=True, comment='LLM 약국 JSON 데이터') + llm_user = Column(String(100), nullable=True, comment='LLM 사용자') + image_url1 = Column(String(500), nullable=True, comment='이미지 URL 1') + image_url2 = Column(String(500), nullable=True, comment='이미지 URL 2') + image_url3 = Column(String(500), nullable=True, comment='이미지 URL 3') + + # 신규 추가: 고도몰 CDN URL을 저장 (각각 앞, 뒤, 상세(내부) 이미지) + godoimage_url_f = Column(String(500), nullable=True, comment='고도몰 CDN URL - 앞 이미지') + godoimage_url_b = Column(String(500), nullable=True, comment='고도몰 CDN URL - 뒤 이미지') + godoimage_url_d = Column(String(500), nullable=True, comment='고도몰 CDN URL - 상세(내부) 이미지') + + # 네이밍 신규 추가 필드 + pill_color = Column(String(100), nullable=True, comment='약 색상') + weight_min_kg = Column(Float, nullable=True, comment='적용 체중 하한 (kg)') + weight_max_kg = Column(Float, nullable=True, comment='적용 체중 상한 (kg)') + pet_size_label = Column(String(100), nullable=True, comment='반려동물 크기 분류명 (예: 초소형, 중형)') + pet_size_code = Column(String(10), nullable=True, comment='반려동물 크기 코드 (예: XS, S, M, L, XL)') + + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='업데이트 시간') + parent_item_id = Column(Integer, ForeignKey('apc.idx'), nullable=True, comment='부모 항목 (기본 항목의 idx)') + + # 애완동물용 분류 + for_pets = Column(Boolean, default=False, nullable=True, comment='반려동물용 여부') + # 처방대상 동물의약품 여부 + prescription_target = Column(Boolean, default=False, nullable=True, comment='처방대상 동물의약품 여부') + # 의약품 아님 여부 (AZIT에 없는, 혼합사료 등으로 허가된 제품) + is_not_medicine = Column(Boolean, default=False, nullable=True, comment='의약품 아님 (AZIT에 없는 혼합사료 등으로 허가된 제품)') + + # 제품별 상세 사용 안내 (희석비율, 도포방법 등) - llm_pharm과 분리 + usage_guide = Column(JSONB, nullable=True, comment='제품별 상세 사용 안내 (희석비율, 도포방법 등)') + + # 관계 설정 + variants = relationship("APDB", backref="parent", remote_side=[idx]) + inventories = relationship("Inventory", back_populates="apdb") + +# 2. ComponentCode 테이블 (성분 코드 테이블) +class ComponentCode(Base): + __tablename__ = 'component_code' + __table_args__ = {'comment': '각 성분 코드와 관련된 정보를 저장하는 테이블'} + + idx = Column(Integer, primary_key=True, autoincrement=True, comment='고유 ID') + code = Column(String(500), nullable=False, unique=True, comment='성분 코드 (중복되지 않는 고유 값, 외래키로 사용 가능)') + description = Column(String(500), nullable=True, comment='성분 설명') + efficacy = Column(Text, nullable=True, comment='효능효과 (성분의 주요 효능 및 효과)') + target_animals = Column(JSONB, nullable=True, comment='사용대상 동물 (개, 고양이, 조류 등)') + precautions = Column(Text, nullable=True, comment='주의사항 (기본적인 주의사항)') + additional_precautions = Column(Text, nullable=True, comment='추가 주의사항') + prohibited_breeds = Column(String(500), nullable=True, comment='금지 품종 (예: 불독, 시츄 등)') + offlabel = Column(Text, nullable=True, comment='오프레이블 효능 (비허가 용도)') + created_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), comment='생성 날짜 및 시간') + updated_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now(), comment='업데이트 날짜 및 시간') + + # 추가 칼럼: 성분 한글명 및 성분 영문명 (APDB 테이블에서 추출된 데이터를 반영) + component_name_ko = Column(String(200), nullable=True, comment='성분 한글명') + component_name_en = Column(String(200), nullable=True, comment='성분 영문명') + + # 관계 설정 (증상-성분코드 매핑 테이블과 연결) + symptom_matches = relationship("SymptomComponentMapping", back_populates="component") + +# 3. Symptoms 테이블 (증상 정보 테이블) +class Symptoms(Base): + __tablename__ = 'symptoms' + __table_args__ = {'comment': '각 증상별 질병코드를 부여하는 테이블'} + + idx = Column(Integer, primary_key=True, autoincrement=True, comment='고유 ID') + prefix = Column(String(1), nullable=False, comment='질병 코드 접두어 (예: A, B 등)') + prefix_description = Column(String(50), comment='접두어 설명') + symptom_code = Column(String(10), unique=True, nullable=False, comment='고유 질병 코드') + symptom_description = Column(String(255), nullable=False, comment='증상 설명') + disease_description = Column(String(255), nullable=False, comment='질병 설명') + created_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), comment='생성 날짜 및 시간') + updated_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now(), comment='업데이트 날짜 및 시간') + + # 관계 설정: Symptoms 테이블과 SymptomComponentMapping 테이블 간의 관계 + component_matches = relationship("SymptomComponentMapping", back_populates="symptoms") + +# 4. SymptomComponentMapping 테이블 (증상-성분코드 매핑 테이블) +class SymptomComponentMapping(Base): + __tablename__ = 'symptom_component_mapping' + __table_args__ = ( + Index('uq_symptom_component_mapping', 'symptom_code', 'component_code', unique=True), + {'comment': '각 성분 코드와 증상 코드 간의 매핑을 저장하는 테이블'} + ) + + idx = Column(Integer, primary_key=True, autoincrement=True, comment='고유 ID') + symptom_code = Column(String(10), ForeignKey('symptoms.symptom_code'), nullable=False, comment='증상 코드') + component_code = Column(String(500), ForeignKey('component_code.code'), nullable=False, comment='성분 코드') + created_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), comment='생성 날짜 및 시간') + updated_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now(), comment='업데이트 날짜 및 시간') + + # 관계 설정: Symptoms 및 ComponentCode와 연결 + symptoms = relationship("Symptoms", back_populates="component_matches") + component = relationship("ComponentCode", back_populates="symptom_matches") + +# 5. Location 테이블 (물리적 위치 관리) +class Location(Base): + __tablename__ = 'location' + __table_args__ = ( + Index('idx_location_code', 'location_code'), + {'comment': '재고의 물리적 위치(창고, 선반 등)를 관리하는 테이블'} + ) + + idx = Column(Integer, primary_key=True, comment='위치 고유 IDX') + location_code = Column(String(50), nullable=False, unique=True, comment='위치 코드 (예: WH1-A1)') + warehouse_name = Column(String(100), nullable=True, comment='창고 이름') + zone = Column(String(50), nullable=True, comment='구역 (예: Zone A)') + rack = Column(String(50), nullable=True, comment='랙 번호 (예: Rack 1)') + shelf = Column(String(50), nullable=True, comment='선반 번호 (예: Shelf 1)') + description = Column(Text, nullable=True, comment='위치 설명') + capacity = Column(Integer, nullable=True, comment='최대 수용 수량') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 수정 시간') + + inventories = relationship("Inventory", back_populates="location") + +# 6. Inventory 테이블 (입출고 및 예약 관리) +class Inventory(Base): + __tablename__ = 'inventory' + __table_args__ = ( + Index('idx_inventory_order_no', 'order_no'), + Index('idx_inventory_transaction_type', 'transaction_type'), + Index('idx_inventory_reserved_at', 'reserved_at'), + Index('idx_inventory_reservation_id', 'reservation_id'), + Index('idx_inventory_expiration_date', 'expiration_date'), + Index('idx_inventory_location_id', 'location_id'), + Index('idx_inventory_receipt_seq', 'receipt_id', 'receipt_seq'), # 신규 인덱스 + {'comment': '입고/출고 및 예약 내역을 관리하는 테이블, es_goods와 동기화'} + ) + + id = Column(Integer, primary_key=True, comment='입고/출고/예약/반품 이력 고유 ID') + apdb_id = Column(Integer, ForeignKey('apc.idx'), nullable=False, comment='APDB 테이블 FK') + parent_id = Column(Integer, ForeignKey('inventory.id'), nullable=True, comment='원본 거래 ID (반품 시 참조)') + supplier_cost = Column(DECIMAL(12,2), nullable=False, comment='입고가 (도매상 사입가,도도매가)') + wholesaler_price = Column(DECIMAL(12,2), nullable=False, comment='도매가 (도매상->약국 판매가)') + retail_price = Column(DECIMAL(12,2), nullable=False, comment='소매가 (약국 소비자 판매가)') + quantity = Column(Integer, nullable=False, comment='입고/출고/반품 수량') + transaction_date = Column(DateTime, default=datetime.utcnow, nullable=False, comment='거래 일자') + remarks = Column(Text, nullable=True, comment='비고') + entity_id = Column(String(50), nullable=True, comment='공급처 ID (vendor_code 또는 pharmacy_id)') + entity_type = Column(String(20), nullable=True, comment='공급처 유형 (VENDOR, PHARMACY)') + receipt_id = Column(Integer, ForeignKey('receipt.idx', ondelete='SET NULL'), nullable=True, comment='입고장 ID') + return_document_id = Column(Integer, ForeignKey('return_document.idx', ondelete='SET NULL'), nullable=True, comment='반품장 ID') + location_id = Column(Integer, ForeignKey('location.idx', ondelete='SET NULL'), nullable=True, comment='물리적 위치 ID') + serial_number = Column(String(100), nullable=True, comment='일련번호') + expiration_date = Column(Date, nullable=True, comment='유효기간 (es_goods.expirationDate와 호환, date, YYYY-MM-DD)') + transaction_type = Column(String(20), nullable=False, comment='거래 유형 (INBOUND, OUTBOUND, RETURN_INBOUND, RETURN_OUTBOUND, DISPOSAL, RESERVED, RESERVED_RETURN)') + order_no = Column(String(100), nullable=True, comment='고도몰 주문번호 (예약 및 출고/반품 연계)') + order_date = Column(Date, nullable=True, comment='고도몰 주문 일자 (YYYY-MM-DD)') + reservation_id = Column(String(36), nullable=True, comment='예약 ID (UUID)') + reserved_at = Column(DateTime, nullable=True, comment='예약 시간 (RESERVED 또는 RESERVED_RETURN 시 사용)') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 수정 시간') + actual_sale_price = Column(DECIMAL(12,2), nullable=True, comment='고도몰 실제 판매가 (주문 시 약국 판매가)') + goods_no = Column(Integer, nullable=True, comment='고도몰 상품 번호 (es_goods.goodsNo)') + receipt_seq = Column(Integer, nullable=False, default=0, comment='입고장 내 항목 정렬 순서') # 신규 컬럼 + + apdb = relationship("APDB", back_populates="inventories") + receipt = relationship("Receipt", back_populates="inventories") + return_document = relationship("ReturnDocument", back_populates="inventories") + location = relationship("Location", back_populates="inventories") + parent = relationship("Inventory", remote_side=[id], back_populates="children") + children = relationship("Inventory", back_populates="parent") + sales_transactions = relationship("SalesTransaction", back_populates="inventory") + returns = relationship("Return", back_populates="inventory") + +# 7. Receipt 테이블 (입고장 정보) +class Receipt(Base): + __tablename__ = 'receipt' + __table_args__ = { + 'comment': '입고장 정보를 관리하는 테이블, 공급자 입고장 번호와 실제 입고일 포함' + } + + idx = Column(Integer, primary_key=True, comment='입고장 고유 IDX') + receipt_number = Column(String(100), nullable=False, unique=True, comment='입고장 번호') + receipt_date = Column(DateTime, default=datetime.utcnow, nullable=False, comment='입고일') + entity_id = Column(String(50), nullable=False, comment='공급처 ID (vendor_code 또는 pharmacy_id)') + entity_type = Column(String(20), nullable=False, comment='공급처 유형 (VENDOR, PHARMACY)') + total_quantity = Column(Integer, nullable=False, comment='총 입고 수량') + product_amount = Column(DECIMAL(10,2), nullable=False, comment='제품 금액') + shipping_cost = Column(DECIMAL(10,2), nullable=False, default=0.0, comment='배송비') + total_amount = Column(DECIMAL(10,2), nullable=False, comment='총 금액 (제품 금액 + 배송비)') + remarks = Column(Text, nullable=True, comment='비고') + vendor_receipt_number = Column(String(100), nullable=True, unique=True, comment='공급자 측 입고장 번호 (예: VEND-12345), 중복 불가') + actual_receipt_date = Column(Date, nullable=True, comment='실제 입고일 (YYYY-MM-DD), 물리적 입고 또는 공급자 지정 일자') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 수정 시간') + + inventories = relationship("Inventory", back_populates="receipt") + +# 8. Return 테이블 (반품 관리) +class Return(Base): + __tablename__ = 'return' + __table_args__ = ( + Index('idx_return_inventory_id', 'inventory_id'), + Index('idx_return_status', 'status'), + {'comment': '반품 요청 및 상태를 관리하는 테이블'} + ) + + idx = Column(Integer, primary_key=True, comment='반품 요청 고유 IDX') + inventory_id = Column(Integer, ForeignKey('inventory.id', ondelete='RESTRICT'), nullable=False, comment='반품 대상 Inventory ID') + return_document_id = Column(Integer, ForeignKey('return_document.idx', ondelete='SET NULL'), nullable=True, comment='반품장 ID') + return_type = Column(String(20), nullable=False, comment='반품 유형 (SUPPLIER, CUSTOMER)') + quantity = Column(Integer, nullable=False, comment='반품 수량') + reason = Column(Text, nullable=False, comment='반품 사유 (예: 품질 이슈, 잘못된 배송)') + status = Column(String(20), nullable=False, default='PENDING', comment='반품 상태 (PENDING, APPROVED, REJECTED, COMPLETED)') + requested_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment='반품 요청 일자') + approved_at = Column(DateTime, nullable=True, comment='반품 승인 일자') + completed_at = Column(DateTime, nullable=True, comment='반품 완료 일자') + remarks = Column(Text, nullable=True, comment='비고') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 수정 시간') + + inventory = relationship("Inventory", back_populates="returns") + return_document = relationship("ReturnDocument", back_populates="returns") + +# 9. ReturnDocument 테이블 (반품장 정보) +class ReturnDocument(Base): + __tablename__ = 'return_document' + __table_args__ = ( + Index('idx_return_document_number', 'return_document_number'), + {'comment': '반품장 정보를 관리하는 테이블'} + ) + + idx = Column(Integer, primary_key=True, comment='반품장 고유 IDX') + return_document_number = Column(String(100), nullable=False, unique=True, comment='반품장 번호') + receipt_id = Column(Integer, ForeignKey('receipt.idx', ondelete='SET NULL'), nullable=True, comment='원본 입고장 IDX') + entity_id = Column(String(50), nullable=False, comment='반품 대상 ID (vendor_code 또는 pharmacy_id)') + entity_type = Column(String(20), nullable=False, comment='반품 대상 유형 (VENDOR, PHARMACY)') + total_quantity = Column(Integer, nullable=False, comment='총 반품 수량') + return_date = Column(DateTime, default=datetime.utcnow, nullable=False, comment='반품일') + remarks = Column(Text, nullable=True, comment='비고') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 수정 시간') + + receipt = relationship("Receipt") + returns = relationship("Return", back_populates="return_document") + inventories = relationship("Inventory", back_populates="return_document") + +# 10. Pharmacy 테이블 (약국 정보) +class Pharmacy(Base): + __tablename__ = 'pharmacy' + __table_args__ = ( + Index('uq_pharmacy_id', 'pharmacy_id', unique=True), + {'comment': '약국 정보'} + ) + + idx = Column(Integer, primary_key=True, autoincrement=True, comment='고유 IDX (기본 키)') + pharmacy_id = Column(String(50), nullable=False, comment='약국 고유값 (PXXXX)') + name = Column(String(200), nullable=False, comment='약국명') + address = Column(String(300), nullable=True, comment='주소') + latitude = Column(DECIMAL(9,6), nullable=True, comment='위도') + longitude = Column(DECIMAL(9,6), nullable=True, comment='경도') + contact_info = Column(String(100), nullable=True, comment='연락처') + region_id = Column(Integer, ForeignKey('region.idx'), nullable=True, comment='상위 지역 IDX') + subregion_id = Column(Integer, ForeignKey('subregion.idx'), nullable=True, comment='하위 지역 IDX') + + region = relationship("Region", back_populates="pharmacies") + subregion = relationship("Subregion", back_populates="pharmacies") + + + pharmacy_inventories = relationship("PharmacyInventory", back_populates="pharmacy") + sales_transactions = relationship("SalesTransaction", back_populates="pharmacy") + pharmacy_members = relationship("PharmacyMember", back_populates="pharmacy") + + + + + +# 11. PharmacyMember 테이블 (약국 회원 정보) +class PharmacyMember(Base): + __tablename__ = 'p_member' + __table_args__ = ( + {'comment': '약국 회원 정보, 약국당 여러 약사가 허용됨 (pharmacy.pharmacy_id 참조, 1:N 관계)'} + ) + + idx = Column(Integer, primary_key=True, autoincrement=True, nullable=False, comment='고유 IDX') + memNo = Column("memno", Integer, nullable=True, comment='회원 번호 (es_member.memNo와 매핑)') + pharmacy_id = Column(String(50), ForeignKey('pharmacy.pharmacy_id', on_delete='RESTRICT'), nullable=True, comment='약국 ID (pharmacy.pharmacy_id 참조)') + mem_nm = Column(String(100), nullable=True, comment='약사 이름 (es_member.memNm)') # 추가 + licenseNo = Column("licenseno", String(50), nullable=True, comment='면허번호') + institutionCode = Column("institutioncode", String(50), nullable=True, comment='요양기관부호') + pharmacyName = Column("pharmacyname", String(100), nullable=True, comment='약국 이름') + businessRegNo = Column("businessregno", String(20), nullable=True, comment='사업자등록번호') + kioskUsage = Column("kioskusage", Boolean, nullable=False, default=False, comment='키오스크 사용 여부') + kioskIdCreated = Column("kioskidcreated", Boolean, nullable=False, default=False, comment='키오스크 아이디 생성 여부') + kioskId = Column("kioskid", String(50), nullable=True, comment='키오스크 아이디') + kioskPw = Column("kioskpw", String(255), nullable=True, default='1234', comment="키오스크 비밀번호 (초기값 '1234')") + isKioskSetupComplete = Column("iskiosksetupcomplete", Boolean, nullable=False, default=False, comment='키오스크 설정 완료 여부') + lastLoginDt = Column("lastlogindt", DateTime, nullable=True, comment='최근 키오스크 로그인 시간') + loginMethod = Column("loginmethod", String(20), nullable=True, comment="로그인 방식 (예: 'kakao', 'licenseNo', 'kioskId')") + memo = Column("memo", Text, nullable=True, comment='메모') + sync_status = Column(String(50), nullable=True, comment='동기화 상태 (pending, synced, failed)') + + pharmacy = relationship("Pharmacy", back_populates="pharmacy_members") + + +# 12. PharmacyInventory 테이블 (약국별 재고 관리) +class PharmacyInventory(Base): + __tablename__ = 'p_inventory' + __table_args__ = ( + Index('idx_apc', 'apc'), + Index('idx_pharmacy_id', 'pharmacy_id'), + {'comment': '약국별 재고 관리'} + ) + + idx = Column(Integer, primary_key=True, autoincrement=True, comment='고유 ID') + pharmacy_id = Column(String(50), ForeignKey('pharmacy.pharmacy_id', on_delete='NO ACTION'), nullable=False, comment='약국 DB 고유 ID') + apc = Column(String(500), ForeignKey('apc.apc', on_delete='NO ACTION'), nullable=False, comment='APC 고유 코드') + expiration_date = Column(String(10), nullable=True, comment='유통기한') + lot_number = Column(String(50), nullable=True, comment='로트 번호') + quantity_in_stock = Column(Integer, nullable=True, comment='재고 수량') + minimum_quantity = Column(Integer, nullable=True, comment='최소 재고 수량') + purchase_price = Column(DECIMAL(10,2), nullable=True, comment='사입가') + list_price = Column(DECIMAL(10,2), nullable=True, comment='권장 판매가') + sale_price = Column(DECIMAL(10,2), nullable=True, comment='약국 판매 가격') + + pharmacy = relationship("Pharmacy", back_populates="pharmacy_inventories") + apdb = relationship("APDB") # 단방향 관계로 수정, back_populates 제거 + +# 13. SalesTransaction 테이블 (약국 키오스크 판매 거래 내역) +class SalesTransaction(Base): + __tablename__ = 'salestransaction' + __table_args__ = {'comment': '약국 키오스크 판매 거래 내역'} + + id = Column(Integer, primary_key=True, autoincrement=True, comment='판매 거래 고유 ID') + pharmacy_id = Column(String(50), ForeignKey('pharmacy.pharmacy_id', on_delete='RESTRICT'), nullable=False, comment='약국 ID (pharmacy.pharmacy_id 참조)') + inventory_id = Column(Integer, ForeignKey('inventory.id', on_delete='RESTRICT'), nullable=False, comment='Inventory 테이블 FK') + purchase_cost = Column(DECIMAL(10,2), nullable=True, comment='약국 입고가 (도매상 판매가)') + sale_price = Column(DECIMAL(10,2), nullable=True, comment='실제 소비자 판매가') + quantity = Column(Integer, nullable=True, comment='판매 수량') + transaction_date = Column(DateTime, nullable=True, default=datetime.utcnow, comment='거래 일자') + + pharmacy = relationship("Pharmacy", back_populates="sales_transactions") + inventory = relationship("Inventory", back_populates="sales_transactions") + +# 14. Vendor 테이블 (거래처 정보) +class Vendor(Base): + __tablename__ = 'vendor' + idx = Column(Integer, primary_key=True, autoincrement=True, comment='거래처 고유 IDX') + vendor_code = Column(String(50), nullable=True, comment='거래처 고유 코드') + vendor_type = Column(String(50), nullable=True, comment='거래처구분') + old_vendor_code = Column(String(50), nullable=True, comment='기존거래처코드') + name = Column(String(200), nullable=True, comment='거래처명') + ceo_name = Column(String(100), nullable=True, comment='대표자명') + business_type = Column(String(100), nullable=True, comment='업태') + item = Column(String(100), nullable=True, comment='종목') + business_reg_no = Column(String(50), nullable=True, comment='사업자등록번호') + postal_code = Column(String(20), nullable=True, comment='우편번호') + address_detail = Column(String(300), nullable=True, comment='상세주소') + phone = Column(String(50), nullable=True, comment='전화번호') + fax = Column(String(50), nullable=True, comment='팩스번호') + salesperson_name = Column(String(100), nullable=True, comment='영업사원명') + vendor_group_name = Column(String(100), nullable=True, comment='거래처그룹명') + vendor_type_name = Column(String(100), nullable=True, comment='거래처종류명') + facility_code = Column(String(50), nullable=True, comment='요양기관기호') + ceo_ssn = Column(String(50), nullable=True, comment='대표자주민번호') + contact_person = Column(String(100), nullable=True, comment='거래처업무담당자') + is_active = Column(Boolean, nullable=True, comment='거래여부') + abbreviation = Column(String(50), nullable=True, comment='약어명') + display_name = Column(String(100), nullable=True, comment='상호출력명') + remarks = Column(Text, nullable=True, comment='비고') + address = Column(String(300), nullable=True, comment='주소') + email = Column(String(100), nullable=True, comment='이메일') + mobile = Column(String(50), nullable=True, comment='핸드폰') + facility_name = Column(String(100), nullable=True, comment='요양기관명') + tax_invoice_issue = Column(String(50), nullable=True, comment='세금계산서발행구분') + store_code = Column(String(50), nullable=True, comment='점포코드') + store_name = Column(String(100), nullable=True, comment='점포명') + delivery_center = Column(String(100), nullable=True, comment='배송센터') + etc3 = Column(String(100), nullable=True, comment='기타3') + price_applicable_code = Column(String(50), nullable=True, comment='단가적용처코드') + stock_applicable_code = Column(String(50), nullable=True, comment='재고적용처코드') + supply_type = Column(String(50), nullable=True, comment='공급형태') + contract_type = Column(String(50), nullable=True, comment='계약구분') + road_postal_code = Column(String(20), nullable=True, comment='도로명우편번호') + road_address_detail = Column(String(300), nullable=True, comment='도로명상세주소') + +# 15. User 테이블 (사용자) +class User(Base): + __tablename__ = 'user' + id = Column(String(100), primary_key=True, comment="이메일 주소 (고유 식별자)") + name = Column(String(100), nullable=False, comment="사용자 이름") + password_hash = Column(String(255), nullable=False, comment="비밀번호 해시") + role = Column(String(50), nullable=False, default="staff", comment="사용자 권한 (예: admin, staff)") + position = Column(String(100), nullable=True, comment="직급") + created_at = Column(DateTime, default=datetime.utcnow, comment="계정 생성일") + +# 16. SyncStatus 테이블 (동기화 상태 기록) +class SyncStatus(Base): + """ + 동기화 상태를 기록하는 테이블. + APDB의 inventory 테이블에서 es_goods 테이블로 retail_price와 expiration_date를 동기화한 결과를 저장. + es_goods의 retailPrice (decimal(12,2))와 expirationDate (date)와 호환. + """ + __tablename__ = 'sync_status' + __table_args__ = ( + Index('idx_sync_status_apc', 'apc'), + Index('idx_sync_status_created_at', 'created_at'), + {'comment': 'APDB와 es_goods 동기화 상태 기록 (retail_price: decimal(12,2), expiration_date: date)'} + ) + + id = Column(Integer, primary_key=True, comment='고유 ID') + last_sync = Column(DateTime, nullable=False, comment='마지막 동기화 시간 (UTC)') + success_count = Column(Integer, nullable=False, default=0, comment='성공한 동기화 건수') + failed_count = Column(Integer, nullable=False, default=0, comment='실패한 동기화 건수') + apc = Column(String(30), nullable=True, comment='APC 코드 (es_goods.APC: varchar(30), inventory.apc: varchar(100))') + retail_price = Column(DECIMAL(12,2), nullable=True, comment='소비자가 (es_goods.retailPrice: decimal(12,2), inventory.retail_price: decimal(12,2))') + expiration_date = Column(Date, nullable=True, comment='유효기간 (es_goods.expirationDate: date, inventory.expiration_date: date, YYYY-MM-DD)') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), comment='레코드 생성 시간 (UTC)') + + def __repr__(self): + return f"" + +# 17. CSMemo 테이블 (약국 회원별 CS 메모 기록) +class CSMemo(Base): + __tablename__ = 'cs_memo' + __table_args__ = ( + Index('idx_cs_memo_memno', 'memno'), + Index('idx_cs_memo_orderno', 'orderno'), + Index('idx_cs_memo_created_at', 'created_at'), + {'comment': '약국 회원별 CS 메모 기록, memNo와 orderNo로 매핑'} + ) + + id = Column(Integer, primary_key=True, comment='CS 메모 고유 ID') + memno = Column(Integer, nullable=False, comment='약국 회원 번호 (es_member.memNo)') + orderno = Column(String(100), nullable=True, comment='연관 주문 번호 (es_order.orderNo, 선택)') + memo = Column(Text, nullable=False, comment='CS 메모 내용') + created_by = Column(String(100), nullable=False, comment='작성자 (User.id)') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='메모 등록 일시') + + +class MemberGroupChangeLog(Base): + __tablename__ = "member_group_change_logs" + + idx = Column(Integer, primary_key=True, index=True, comment="Primary key") + mem_no = Column(Integer, nullable=False, index=True, comment="고도몰 회원 번호 (es_member.memNo)") + mem_nm = Column(String(100), nullable=True, comment="회원 이름 (es_member.memNm)") + old_group_sno = Column(String(50), nullable=True, comment="변경 전 그룹 번호") + new_group_sno = Column(String(50), nullable=True, comment="변경 후 그룹 번호") + changed_by = Column(String(100), nullable=False, comment="변경자 (예: 관리자 이메일 또는 ID)") + changed_at = Column(DateTime, default=datetime.utcnow, comment="변경 일시") + change_reason = Column(Text, nullable=True, comment="변경 사유 (옵션)") + + + + + +class Region(Base): + __tablename__ = 'region' + __table_args__ = ( + Index('idx_region_code', 'region_code'), + {'comment': '상위 지역(시/도) 정보를 저장하는 테이블'} + ) + + idx = Column(Integer, primary_key=True, comment='고유 IDX') + region_code = Column(String(50), unique=True, nullable=False, comment='지역 코드 (예: SEOUL, GANGWON)') + region_name = Column(String(100), nullable=False, comment='지역 이름 (예: 서울, 강원)') + latitude = Column(DECIMAL(9,6), nullable=True, comment='지역 중심 위도') + longitude = Column(DECIMAL(9,6), nullable=True, comment='지역 중심 경도') + zoom_level = Column(Integer, default=6, nullable=True, comment='기본 줌 레벨') + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + subregions = relationship("Subregion", back_populates="region") + pharmacies = relationship("Pharmacy", back_populates="region") + + +class Subregion(Base): + __tablename__ = 'subregion' + __table_args__ = ( + Index('idx_subregion_code', 'subregion_code'), + Index('idx_region_id', 'region_id'), + {'comment': '하위 지역(시/군/구) 정보를 저장하는 테이블'} + ) + + idx = Column(Integer, primary_key=True, comment='고유 IDX') + subregion_code = Column(String(50), unique=True, nullable=False, comment='하위 지역 코드 (예: SEONGBUK, GANGBUK)') + subregion_name = Column(String(100), nullable=False, comment='하위 지역 이름 (예: 성북, 강북)') + region_id = Column(Integer, ForeignKey('region.idx'), nullable=False, comment='상위 지역 IDX') + latitude = Column(DECIMAL(9,6), nullable=True, comment='하위 지역 중심 위도') + longitude = Column(DECIMAL(9,6), nullable=True, comment='하위 지역 중심 경도') + zoom_level = Column(Integer, default=8, nullable=True, comment='기본 줌 레벨') + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + region = relationship("Region", back_populates="subregions") + pharmacies = relationship("Pharmacy", back_populates="subregion") + + +# ExcludedPharmacies 테이블 (예외 처리 약국 정보) +class ExcludedPharmacies(Base): + __tablename__ = 'excluded_pharmacies' + __table_args__ = ( + Index('idx_excluded_pharmacies_memno', 'memno'), + {'comment': '예외 처리 약국 정보를 저장하는 테이블, es_member.memNo와 매핑'} + ) + + idx = Column(Integer, primary_key=True, autoincrement=True, comment='고유 IDX') + memno = Column(Integer, nullable=False, unique=True, comment='약국 회원 번호 (es_member.memNo)') + mem_nm = Column(String(100), nullable=False, comment='회원 이름 (es_member.memNm)') + pharmacy_name = Column(String(200), nullable=True, comment='약국명 (es_member.company, NULL 허용)') + reason = Column(Text, nullable=True, comment='예외 처리 사유 (예: 테스트 약국, 비활성)') + created_by = Column(String(100), nullable=False, comment='등록자 (예: 관리자 이메일 또는 ID)') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='레코드 수정 시간') + + +# SystemLog 테이블 (시스템 로그) +class SystemLog(Base): + __tablename__ = 'system_log' + __table_args__ = ( + Index('idx_system_log_action', 'action'), + Index('idx_system_log_entity', 'entity_type', 'entity_id'), + Index('idx_system_log_created', 'created_at'), + {'comment': '시스템 작업 로그를 저장하는 테이블'} + ) + + idx = Column(Integer, primary_key=True, autoincrement=True, comment='고유 IDX') + action = Column(String(50), nullable=False, comment='작업 유형 (예: DELETE_RECEIPT, UPDATE_INVENTORY)') + entity_type = Column(String(50), nullable=False, comment='대상 엔티티 유형 (예: receipt, inventory, product)') + entity_id = Column(String(100), nullable=False, comment='대상 엔티티 ID') + details = Column(JSONB, nullable=True, comment='상세 로그 정보 (JSON 형태)') + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment='로그 생성 시간') + created_by = Column(String(100), nullable=False, comment='작업 수행자') + ip_address = Column(String(45), nullable=True, comment='요청 IP 주소') + user_agent = Column(String(500), nullable=True, comment='사용자 에이전트') + result = Column(String(20), default='SUCCESS', comment='작업 결과 (SUCCESS/FAILURE)') + error_message = Column(Text, nullable=True, comment='에러 메시지 (실패 시)') + +# 18. OptimalStock 테이블 (적정 재고 관리) +class OptimalStock(Base): + __tablename__ = 'optimal_stock' + __table_args__ = ( + Index('idx_optimal_stock_apc', 'apc'), + Index('idx_optimal_stock_goods_no', 'goods_no'), + Index('idx_optimal_stock_created_at', 'created_at'), + # APC 또는 goods_no 중 하나는 반드시 존재해야 함 + # APC가 있으면 APC로 unique, 없으면 goods_no로 unique + {'comment': '제품별 적정 재고 관리 테이블 - APC 우선, goods_no 보조 식별'} + ) + + idx = Column(Integer, primary_key=True, autoincrement=True, comment='적정 재고 설정 고유 IDX') + + # 제품 식별자 (APC 우선, goods_no 보조) + apc = Column(String(100), nullable=True, comment='APC 코드 (APDB.apc FK, 우선 식별자)') + goods_no = Column(Integer, nullable=True, comment='고도몰 상품 번호 (보조 식별자, APC 없을 때)') + + # 적정 재고 값들 + minimum_stock = Column(Integer, nullable=False, default=0, comment='최소 재고 (안전 재고)') + optimal_stock = Column(Integer, nullable=False, default=0, comment='적정 재고 (권장 보유량)') + maximum_stock = Column(Integer, nullable=True, comment='최대 재고 (과재고 방지, NULL 허용)') + + # 재주문 관리 + reorder_point = Column(Integer, nullable=True, comment='재주문 시점 (이 수량 이하 시 주문 필요)') + reorder_quantity = Column(Integer, nullable=True, comment='재주문 수량 (한 번에 주문할 수량)') + + # 메타데이터 + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='설정 생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='설정 수정 시간') + created_by = Column(String(100), nullable=False, comment='설정자 (User.id 또는 관리자)') + notes = Column(Text, nullable=True, comment='비고 (설정 사유, 특이사항 등)') + + # 활성화 상태 + is_active = Column(Boolean, default=True, nullable=False, comment='활성화 상태 (비활성화 시 알림 제외)') + + # 관계 설정 + apdb = relationship("APDB", foreign_keys=[apc], primaryjoin="OptimalStock.apc == APDB.apc") + +# 19. 재고 정정 이력 테이블 (InventoryAdjustment) +class InventoryAdjustment(Base): + """ + 재고 정정 이력 테이블 + + 모든 재고 정정 작업의 이력을 추적합니다. + - 재고 추가/차감/이동/취소 + - 정정 사유 및 승인자 기록 + - 변경 전후 수량 추적 + """ + __tablename__ = 'inventory_adjustment' + __table_args__ = ( + {'comment': '재고 정정 이력 - 모든 재고 수량 변경 작업 추적'} + ) + + # 기본 키 + id = Column(Integer, primary_key=True, autoincrement=True, comment='정정 이력 ID') + + # 관련 재고 정보 + inventory_id = Column(Integer, ForeignKey('inventory.id'), nullable=False, comment='원본 재고 ID') + apdb_id = Column(Integer, ForeignKey('apc.idx'), nullable=False, comment='제품 ID (APDB.idx FK)') + + # 정정 유형 및 수량 + adjustment_type = Column(Enum('ADD', 'REMOVE', 'TRANSFER', 'CANCEL', 'CORRECTION', name='adjustment_type_enum'), + nullable=False, comment='정정 유형 (ADD:추가, REMOVE:차감, TRANSFER:이동, CANCEL:취소, CORRECTION:수정)') + + before_quantity = Column(Integer, nullable=False, comment='정정 전 수량') + after_quantity = Column(Integer, nullable=False, comment='정정 후 수량') + adjustment_quantity = Column(Integer, nullable=False, comment='정정 수량 (양수/음수)') + + # 시리얼번호 및 유효기간 (정정 대상 식별용) + serial_number = Column(String(100), nullable=True, comment='시리얼번호') + expiration_date = Column(Date, nullable=True, comment='유효기간') + + # 이동 관련 (TRANSFER 시 사용) + source_location_id = Column(Integer, ForeignKey('location.idx'), nullable=True, comment='원본 위치 ID') + target_location_id = Column(Integer, ForeignKey('location.idx'), nullable=True, comment='대상 위치 ID') + + # 정정 사유 및 승인 + reason = Column(Text, nullable=False, comment='정정 사유') + reason_category = Column(Enum('STOCK_ERROR', 'DAMAGE', 'EXPIRY', 'THEFT', 'TRANSFER', 'SYSTEM_ERROR', 'OTHER', + name='reason_category_enum'), + nullable=True, comment='사유 분류') + + # 작업자 및 승인자 + created_by = Column(String(100), nullable=False, comment='정정 요청자') + approved_by = Column(String(100), nullable=True, comment='승인자 (필요시)') + approval_status = Column(Enum('PENDING', 'APPROVED', 'REJECTED', name='approval_status_enum'), + default='APPROVED', nullable=False, comment='승인 상태') + + # 시스템 로그 연결 + system_log_id = Column(Integer, ForeignKey('system_log.idx'), nullable=True, comment='연관 시스템 로그 ID') + + # 메타데이터 + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='정정 실행 시간') + approved_at = Column(DateTime, nullable=True, comment='승인 시간') + + # 추가 정보 (JSON) + adjustment_metadata = Column(JSONB, nullable=True, comment='추가 메타데이터 (JSON 형태)') + + # 관계 설정 + inventory = relationship("Inventory", foreign_keys=[inventory_id]) + apdb = relationship("APDB", foreign_keys=[apdb_id]) + source_location = relationship("Location", foreign_keys=[source_location_id]) + target_location = relationship("Location", foreign_keys=[target_location_id]) + system_log = relationship("SystemLog", foreign_keys=[system_log_id]) + +# 20. 동물약국 테이블 (Animal Pharmacy) +class AnimalPharmacy(Base): + __tablename__ = 'animal_pharmacies' + + # 기본 정보 + id = Column(Integer, primary_key=True, comment='고유 ID') + management_number = Column(String(50), unique=True, nullable=False, comment='관리번호 (고유)') + name = Column(String(200), nullable=False, comment='사업장명') + phone = Column(String(20), nullable=True, comment='전화번호') + + # 주소 정보 + address_old = Column(String(500), nullable=True, comment='구주소 (지번주소)') + address_new = Column(String(500), nullable=True, comment='신주소 (도로명주소)') + postal_code = Column(String(10), nullable=True, comment='우편번호') + + # 좌표 정보 + latitude = Column(DECIMAL(10,8), nullable=True, comment='위도 (WGS84)') + longitude = Column(DECIMAL(11,8), nullable=True, comment='경도 (WGS84)') + x_coordinate = Column(DECIMAL(12,3), nullable=True, comment='X좌표 (EPSG5174)') + y_coordinate = Column(DECIMAL(12,3), nullable=True, comment='Y좌표 (EPSG5174)') + + # 분류 정보 + business_status = Column(String(10), nullable=True, comment='영업상태') + license_date = Column(Date, nullable=True, comment='인허가일자') + region_code = Column(String(50), nullable=True, comment='지역코드') + province = Column(String(50), nullable=True, comment='시/도 (지역 필터용)') + city = Column(String(100), nullable=True, comment='시/군/구 (세부 지역용)') + + # 타임스탬프 + created_at = Column(TIMESTAMP, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='생성일시') + updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='수정일시') + +# 21. DosageInfo 테이블 (체중 기반 용량 계산 정보) +class DosageInfo(Base): + """ + 체중 기반 용량 계산을 위한 구조화된 용량 정보 테이블 + + 정제형 (예: 아시엔로정): dose_per_kg + unit_dose로 정제 수 계산 + 피펫형 (예: 프론트라인): weight_min/max_kg + unit_dose로 적합 제품 매칭 + """ + __tablename__ = 'dosage_info' + __table_args__ = ( + Index('idx_dosage_info_apdb_idx', 'apdb_idx'), + Index('idx_dosage_info_animal_type', 'animal_type'), + Index('idx_dosage_info_weight_range', 'weight_min_kg', 'weight_max_kg'), + {'comment': '체중 기반 용량 계산을 위한 구조화된 용량 정보'} + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment='고유 ID') + apdb_idx = Column(Integer, ForeignKey('apc.idx'), nullable=False, comment='APDB 테이블 FK') + component_code = Column(String(100), nullable=True, comment='성분 코드 (특정 성분용 용량)') + + # 핵심 용량 계산 필드 + dose_per_kg = Column(Float, nullable=True, comment='kg당 용량 (예: 5.0 → 5mg/kg)') + dose_unit = Column(String(20), nullable=True, comment='용량 단위 (mg, ml, mcg)') + + # 1회분 함량 (정제/캡슐/피펫 등) + unit_dose = Column(Float, nullable=True, comment='1정/1ml/1피펫 당 함량') + unit_type = Column(String(20), nullable=True, comment='제형 (정, 캡슐, ml, 피펫)') + + # 투여 정보 + frequency = Column(String(50), nullable=True, comment='투여 주기 (1일 1회, 월 1회 등)') + route = Column(String(30), nullable=True, comment='투여 경로 (경구, 도포, 점안)') + + # 체중 범위 (피펫형 제품 등 체중별 제품 구분용) + weight_min_kg = Column(Float, nullable=True, comment='적용 최소 체중 (kg)') + weight_max_kg = Column(Float, nullable=True, comment='적용 최대 체중 (kg)') + + # 동물 타입 + animal_type = Column(String(10), nullable=True, comment='대상 동물 (dog, cat, all)') + + # 용량 범위 (예: 5~10mg/kg) + dose_per_kg_min = Column(Float, nullable=True, comment='kg당 최소 용량') + dose_per_kg_max = Column(Float, nullable=True, comment='kg당 최대 용량') + + # 메타데이터 + source = Column(String(20), default='manual', comment='데이터 소스 (manual, ai_parsed)') + verified = Column(Boolean, default=False, comment='사람 검증 여부') + raw_text = Column(Text, nullable=True, comment='원본 용법용량 텍스트 (AI 파싱 시 참조용)') + + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='수정 시간') + + # 관계 설정 + product = relationship("APDB", backref="dosage_infos") + + +# ============================================================ +# 22. SupplementaryProduct 테이블 (보조제품 마스터) +# 동물용 건강기능식품, 보조기구, 케어용품 등 +# ============================================================ +from sqlalchemy import UniqueConstraint + +class SupplementaryProduct(Base): + """동물용 건기식, 보조기구, 케어용품 마스터 테이블""" + __tablename__ = 'supplementary_product' + __table_args__ = ( + UniqueConstraint('product_name', 'brand', name='uq_supplementary_name'), + Index('ix_supplementary_category', 'category'), + Index('ix_supplementary_sub_category', 'sub_category'), + Index('ix_supplementary_target_animal', 'target_animal'), + {'comment': '동물용 건기식, 보조기구, 케어용품 마스터'} + ) + + id = Column(Integer, primary_key=True, comment='고유 ID') + + # 기본 정보 + product_name = Column(String(200), nullable=False, comment='제품명') + product_name_en = Column(String(200), nullable=True, comment='제품 영문명') + brand = Column(String(100), nullable=True, comment='브랜드') + manufacturer = Column(String(100), nullable=True, comment='제조사') + + # 코드 정보 (nullable - 바코드/제품코드 없어도 등록 가능) + barcode = Column(String(50), nullable=True, comment='바코드 (GTIN/EAN)') + product_code = Column(String(50), nullable=True, comment='제품코드 (자체 관리용)') + sku = Column(String(50), nullable=True, comment='SKU (재고관리용)') + + # 분류 + category = Column(String(50), nullable=False, comment='대분류: supplement, equipment, care') + sub_category = Column(String(50), nullable=True, comment='소분류: vitamin, probiotics, collar, shampoo 등') + + # 상세 정보 + description = Column(Text, nullable=True, comment='제품 설명') + usage_instructions = Column(Text, nullable=True, comment='사용 방법') + + # 성분 정보 (건기식용) + ingredients = Column(Text, nullable=True, comment='성분 목록 (텍스트)') + main_ingredient = Column(String(200), nullable=True, comment='주요 성분') + ingredient_details = Column(JSONB, nullable=True, comment='성분 상세 정보 (JSON)') + # ingredient_details 예시: [{"name": "글루코사민", "amount": "500mg", "effect": "관절 건강"}] + + # 효능/효과 정보 + efficacy = Column(Text, nullable=True, comment='효능/효과 설명') + efficacy_tags = Column(JSONB, nullable=True, comment='효능 태그 (JSON 배열)') + # efficacy_tags 예시: ["관절건강", "연골보호", "노령견"] + + # 대상 동물 + target_animal = Column(String(50), nullable=True, comment='대상 동물: dog, cat, all, rabbit 등') + target_age = Column(String(50), nullable=True, comment='대상 연령: puppy, adult, senior, all') + target_size = Column(String(50), nullable=True, comment='대상 크기: small, medium, large, all') + + # 주의사항 + precautions = Column(Text, nullable=True, comment='주의사항') + warnings = Column(JSONB, nullable=True, comment='경고 사항 (JSON 배열)') + + # 이미지 + image_url = Column(String(500), nullable=True, comment='대표 이미지 URL') + image_url_2 = Column(String(500), nullable=True, comment='추가 이미지 URL') + + # 가격/규격 + price = Column(DECIMAL(10, 2), nullable=True, comment='판매가') + unit = Column(String(50), nullable=True, comment='단위: 개, 봉, ml, g, 정 등') + package_size = Column(String(50), nullable=True, comment='포장 규격: 30정, 500ml 등') + + # AI 분석 정보 (APDB의 llm_pharm과 유사) + llm_info = Column(JSONB, nullable=True, comment='AI 분석 정보 (JSON)') + + # 외부 링크 (판매처 등) + external_url = Column(String(500), nullable=True, comment='외부 판매 링크') + + # 메타 정보 + is_active = Column(Boolean, default=True, comment='활성화 여부') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='생성 시간') + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now(), nullable=False, comment='수정 시간') + + # 관계 설정 + inventories = relationship("InventorySupplementary", back_populates="supplementary") + + +# ============================================================ +# 23. UnifiedProductRecommendation 테이블 (통합 제품 추천 매핑) +# APDB ↔ APDB, APDB ↔ Supplementary, Supplementary ↔ Supplementary 매핑 +# ============================================================ +class UnifiedProductRecommendation(Base): + """통합 제품 추천 매핑 테이블""" + __tablename__ = 'unified_product_recommendation' + __table_args__ = ( + UniqueConstraint('source_type', 'source_id', 'target_type', 'target_id', + name='uq_unified_recommendation'), + Index('ix_unified_source', 'source_type', 'source_id'), + Index('ix_unified_target', 'target_type', 'target_id'), + Index('ix_unified_relation', 'relation_type'), + {'comment': '통합 제품 추천 매핑 (APDB ↔ Supplementary)'} + ) + + id = Column(Integer, primary_key=True, comment='고유 ID') + + # 소스 (추천 기준 제품) + source_type = Column(String(20), nullable=False, comment='소스 타입: apdb, supplementary') + source_id = Column(Integer, nullable=False, comment='소스 제품 ID (apc.idx 또는 supplementary_product.id)') + + # 타겟 (추천 대상 제품) + target_type = Column(String(20), nullable=False, comment='타겟 타입: apdb, supplementary') + target_id = Column(Integer, nullable=False, comment='타겟 제품 ID') + + # 추천 정보 + relation_type = Column(String(50), nullable=False, comment='관계 유형: pre_use, post_use, combo, support, alternative, recovery, prevention') + recommendation_reason = Column(Text, nullable=True, comment='추천 이유') + priority = Column(Integer, default=0, comment='우선순위 (높을수록 먼저 표시)') + + # 메타 정보 + is_active = Column(Boolean, default=True, comment='활성화 여부') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='생성 시간') + created_by = Column(String(100), nullable=True, comment='등록자') + + +# ============================================================ +# 24. SymptomSupplementaryRecommendation 테이블 (증상 → 보조제품 매핑) +# 증상 선택 시 보조제품 직접 추천 +# ============================================================ +class SymptomSupplementaryRecommendation(Base): + """증상 → 보조제품 추천 매핑 테이블""" + __tablename__ = 'symptom_supplementary_recommendation' + __table_args__ = ( + UniqueConstraint('symptom_code', 'supplementary_id', name='uq_symptom_supplementary'), + Index('ix_symptom_supp_code', 'symptom_code'), + Index('ix_symptom_supp_id', 'supplementary_id'), + {'comment': '증상 → 보조제품 추천 매핑'} + ) + + id = Column(Integer, primary_key=True, comment='고유 ID') + + symptom_code = Column(String(20), ForeignKey('symptoms.symptom_code'), nullable=False, comment='증상 코드') + supplementary_id = Column(Integer, ForeignKey('supplementary_product.id'), nullable=False, comment='보조제품 ID') + + # 추천 정보 + relation_type = Column(String(50), nullable=False, comment='관계 유형: support, prevention, recovery') + recommendation_reason = Column(Text, nullable=True, comment='추천 이유') + priority = Column(Integer, default=0, comment='우선순위') + + # 메타 정보 + is_active = Column(Boolean, default=True, comment='활성화 여부') + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='생성 시간') + + # 관계 설정 + symptom = relationship('Symptoms') + supplementary = relationship('SupplementaryProduct') + + +# ============================================================ +# 25. InventorySupplementary 테이블 (보조제품 재고) +# ============================================================ +class InventorySupplementary(Base): + """보조제품 재고 테이블""" + __tablename__ = 'inventory_supplementary' + __table_args__ = ( + Index('ix_inv_supp_id', 'supplementary_id'), + {'comment': '보조제품 재고 관리'} + ) + + id = Column(Integer, primary_key=True, comment='고유 ID') + supplementary_id = Column(Integer, ForeignKey('supplementary_product.id'), nullable=False, comment='보조제품 ID') + + quantity = Column(Integer, default=0, comment='재고 수량') + location = Column(String(100), nullable=True, comment='보관 위치') + + # 가격 정보 + purchase_price = Column(DECIMAL(10, 2), nullable=True, comment='입고가') + retail_price = Column(DECIMAL(10, 2), nullable=True, comment='판매가') + + # 유통기한 + expiration_date = Column(Date, nullable=True, comment='유통기한') + + # 메타 정보 + last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='최종 수정일') + + # 관계 설정 + supplementary = relationship('SupplementaryProduct', back_populates='inventories') + + +# 26. 테이블 생성 +Base.metadata.create_all(engine) \ No newline at end of file diff --git a/pet_recommend_app.py b/pet_recommend_app.py new file mode 100644 index 0000000..b158ec9 --- /dev/null +++ b/pet_recommend_app.py @@ -0,0 +1,3119 @@ +""" +동물약 추천 MVP 웹앱 +- 반려동물 정보 + 증상 입력 → 약품 추천 +- 실행: python pet_recommend_app.py +- 접속: http://localhost:5001 +""" + +from flask import Flask, render_template_string, request, jsonify +from db_setup import ( + session, APDB, Inventory, ComponentCode, Symptoms, SymptomComponentMapping, DosageInfo, + SupplementaryProduct, UnifiedProductRecommendation, SymptomSupplementaryRecommendation, InventorySupplementary +) +from sqlalchemy import distinct, and_, or_ +from concurrent.futures import ThreadPoolExecutor +import openai +import re + +app = Flask(__name__) + +# ============================================================ +# OpenAI API 설정 +# ============================================================ +OPENAI_API_KEY = "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" +openai.api_key = OPENAI_API_KEY + +# ============================================================ +# MDR-1 유전자 변이 관련 설정 +# ============================================================ +# MDR-1 민감 성분 코드 (Avermectin 계열 등) +MDR1_SENSITIVE_COMPONENTS = { + 'IC2010131', # 셀라멕틴 (Selamectin) + 'IC2030110', # 이버멕틴+피란텔 (Ivermectin + Pyrantel) + 'IC2030132', # 이미다클로프리드+목시덱틴 (Imidacloprid + Moxidectin) +} + +# MDR-1 민감 견종 정보 +MDR1_BREEDS = { + # 고위험군 (40%+) + 'collie': {'name': '콜리', 'risk': 'high', 'frequency': '70%'}, + 'aussie': {'name': '오스트레일리안 셰퍼드', 'risk': 'high', 'frequency': '50%'}, + 'mini_aussie': {'name': '미니어처 오스트레일리안 셰퍼드', 'risk': 'high', 'frequency': '50%'}, + 'longhair_whippet': {'name': '롱헤어 휘핏', 'risk': 'high', 'frequency': '65%'}, + 'silken_windhound': {'name': '실켄 윈드하운드', 'risk': 'high', 'frequency': '30-50%'}, + # 중위험군 (10-40%) + 'sheltie': {'name': '셰틀랜드 쉽독', 'risk': 'medium', 'frequency': '15%'}, + 'english_shepherd': {'name': '잉글리쉬 셰퍼드', 'risk': 'medium', 'frequency': '15%'}, + 'whippet': {'name': '휘핏', 'risk': 'medium', 'frequency': '10-20%'}, + 'mcnab': {'name': '맥냅', 'risk': 'medium', 'frequency': '17%'}, + 'german_shepherd': {'name': '저먼 셰퍼드', 'risk': 'medium', 'frequency': '10%'}, + 'white_shepherd': {'name': '화이트 셰퍼드', 'risk': 'medium', 'frequency': '14%'}, + # 저위험군 (10% 미만) + 'old_english': {'name': '올드 잉글리쉬 쉽독', 'risk': 'low', 'frequency': '5%'}, + 'border_collie': {'name': '보더 콜리', 'risk': 'low', 'frequency': '2-5%'}, + 'chinook': {'name': '치눅', 'risk': 'low', 'frequency': '미확인'}, + # 기타 + 'mix_herding': {'name': '믹스견 (목양견 계통)', 'risk': 'unknown', 'frequency': '변동'}, + 'mix_hound': {'name': '믹스견 (하운드 계통)', 'risk': 'unknown', 'frequency': '변동'}, + 'other': {'name': '기타 견종', 'risk': 'none', 'frequency': '-'}, + 'unknown': {'name': '모름', 'risk': 'unknown', 'frequency': '-'}, +} + +# ============================================================ +# 제품군(카테고리) 정의 +# ============================================================ +PRODUCT_CATEGORIES = { + 'heartworm': { + 'name': '심장사상충제', + 'icon': '💗', + 'keywords': ['심장사상충', '사상충', 'dirofilaria', '하트웜'], + 'description': '심장사상충 예방약' + }, + 'parasite': { + 'name': '구충제', + 'icon': '🐛', + 'keywords': ['구충', '기생충', '내부구충', '촌충', '회충', '십이지장충', '선충', '벼룩', '진드기'], + 'description': '내/외부 기생충 치료' + }, + 'skin': { + 'name': '피부약', + 'icon': '🧴', + 'keywords': ['피부', '진균', '피부염', '알레르기', '가려움', '습진', '곰팡이', '아토피'], + 'description': '피부질환 치료' + }, + 'ear': { + 'name': '귀약', + 'icon': '👂', + 'keywords': ['귀', '외이도염', '귓병', '이염', '이진드기'], + 'description': '귀질환 치료' + }, + 'eye': { + 'name': '안약', + 'icon': '👁️', + 'keywords': ['안약', '눈', '결막염', '각막염', '안구'], + 'description': '눈질환 치료' + }, + 'antibiotic': { + 'name': '항생제', + 'icon': '💊', + 'keywords': ['항생', '감염', '세균', '항균'], + 'description': '세균감염 치료' + }, + 'digestive': { + 'name': '정장제', + 'icon': '🍀', + 'keywords': ['정장', '장내세균', '설사', '유산균', '프로바이오틱', '소화불량', '소화장애'], + 'description': '소화기 건강' + }, + 'painkiller': { + 'name': '소염진통제', + 'icon': '💪', + 'keywords': ['소염', '진통', '관절', '염증', '통증'], + 'description': '통증/염증 완화' + }, + 'vomit': { + 'name': '구토억제제', + 'icon': '🤢', + 'keywords': ['구토', '메스꺼움', '오심'], + 'description': '구토 억제' + }, +} + + +# ============================================================ +# 체중 기반 용량 계산 함수 +# ============================================================ +def format_tablets(tablets): + """소수점 정제 수를 사람이 읽기 쉬운 형태로 변환""" + if tablets is None: + return None + if tablets < 0.3: + return "1/4정" + elif tablets < 0.6: + return "1/2정" + elif tablets < 0.9: + return "3/4정" + elif tablets < 1.2: + return "1정" + elif tablets < 1.6: + return "1.5정" + elif tablets < 2.2: + return "2정" + elif tablets < 2.6: + return "2.5정" + elif tablets < 3.2: + return "3정" + else: + return f"{round(tablets)}정" + + +def get_tablets_numeric(tablets): + """ + format_tablets의 역방향 함수 - 정제 수를 숫자로 반환 + 1/4정 → 0.25, 1/2정 → 0.5, 3/4정 → 0.75, 1정 → 1.0, 1.5정 → 1.5 등 + """ + if tablets < 0.3: + return 0.25 # 1/4정 + elif tablets < 0.6: + return 0.5 # 1/2정 + elif tablets < 0.9: + return 0.75 # 3/4정 + elif tablets < 1.2: + return 1.0 # 1정 + elif tablets < 1.6: + return 1.5 # 1.5정 + elif tablets < 2.2: + return 2.0 # 2정 + elif tablets < 2.6: + return 2.5 # 2.5정 + elif tablets < 3.2: + return 3.0 # 3정 + else: + return round(tablets) + + +def calculate_recommended_dosage(product_idx, weight_kg, animal_type): + """ + 체중 기반 적정 용량 계산 + + Args: + product_idx: APDB.idx (제품 고유 ID) + weight_kg: 반려동물 체중 (kg) + animal_type: 'dog' 또는 'cat' + + Returns: + dict: 계산된 용량 정보 또는 None + """ + if not weight_kg or weight_kg <= 0: + return None + + try: + # 먼저 이 제품의 모든 DosageInfo 조회 (피펫형 여부 판단용) + all_dosage_infos = session.query(DosageInfo).filter( + DosageInfo.apdb_idx == product_idx, + or_( + DosageInfo.animal_type == animal_type, + DosageInfo.animal_type == 'all' + ) + ).all() + + # 피펫형 제품 여부 판단 (DosageInfo가 하나라도 피펫이면 피펫형) + is_pipette = any(d.unit_type == '피펫' for d in all_dosage_infos) + + # 체중 범위에 맞는 DosageInfo 찾기 + dosage_info = None + for d in all_dosage_infos: + # 체중 범위가 지정된 경우 + if d.weight_min_kg is not None and d.weight_max_kg is not None: + if d.weight_min_kg <= weight_kg <= d.weight_max_kg: + dosage_info = d + break + # 체중 범위가 없는 경우 (전체 적용) + elif d.weight_min_kg is None and d.weight_max_kg is None: + dosage_info = d + break + + # 피펫형 제품인데 체중 범위에 맞지 않으면 필터링을 위해 특별 응답 반환 + if is_pipette and not dosage_info: + return { + 'calculated': False, + 'is_pipette': True, # 피펫형이므로 필터링 대상 + 'message': '', + 'details': {}, + 'warning': None + } + + if not dosage_info: + return None + + result = { + 'calculated': False, + 'is_pipette': is_pipette, # 피펫형 제품 여부 (체중 필터링에 사용) + 'message': '', + 'details': {}, + 'warning': None + } + + # 케이스 1: kg당 용량이 있는 경우 (정제형 등) + if dosage_info.dose_per_kg: + total_dose = weight_kg * dosage_info.dose_per_kg + result['calculated'] = True + result['details'] = { + 'total_dose': round(total_dose, 2), + 'dose_unit': dosage_info.dose_unit, + 'frequency': dosage_info.frequency, + 'route': dosage_info.route + } + + # 정제 수 계산 (정제/캡슐형인 경우) + if dosage_info.unit_dose and dosage_info.unit_type in ['정', '캡슐']: + tablets = total_dose / dosage_info.unit_dose + tablets_formatted = format_tablets(tablets) + # 실제 복용량 계산 (정제 수 × 1정당 함량) + tablets_numeric = get_tablets_numeric(tablets) + actual_dose = tablets_numeric * dosage_info.unit_dose + + result['details']['tablets'] = round(tablets, 2) + result['details']['tablets_formatted'] = tablets_formatted + result['details']['unit_type'] = dosage_info.unit_type + result['details']['actual_dose'] = actual_dose # 실제 복용량 + + result['message'] = f"체중 {weight_kg}kg 기준: 1회 {tablets_formatted} ({round(actual_dose, 1)}{dosage_info.dose_unit})" + else: + result['message'] = f"체중 {weight_kg}kg 기준: 1회 {round(total_dose, 1)}{dosage_info.dose_unit}" + + if dosage_info.frequency: + result['message'] += f", {dosage_info.frequency}" + + # 케이스 2: 체중 범위별 고정 용량 (피펫형 등) + elif dosage_info.weight_min_kg and dosage_info.weight_max_kg: + result['calculated'] = True + result['details'] = { + 'weight_min': dosage_info.weight_min_kg, + 'weight_max': dosage_info.weight_max_kg, + 'unit_dose': dosage_info.unit_dose, + 'dose_unit': dosage_info.dose_unit, + 'frequency': dosage_info.frequency, + 'route': dosage_info.route + } + + result['message'] = f"체중 {dosage_info.weight_min_kg}~{dosage_info.weight_max_kg}kg용" + + if dosage_info.unit_dose and dosage_info.dose_unit: + result['message'] += f": {dosage_info.unit_dose}{dosage_info.dose_unit}" + + if dosage_info.frequency: + result['message'] += f", {dosage_info.frequency}" + + # 케이스 3: 기본 정보만 있는 경우 + elif dosage_info.frequency or dosage_info.route: + result['calculated'] = True + parts = [] + if dosage_info.frequency: + parts.append(dosage_info.frequency) + if dosage_info.route: + parts.append(f"{dosage_info.route} 투여") + result['message'] = ', '.join(parts) + + return result + + except Exception as e: + print(f"[용량 계산 오류] product_idx={product_idx}, error={e}") + return None + + +def generate_recommendation_reason(animal_type, symptom_descriptions, product_name, component_name, llm_pharm, efficacy_clean, component_code=None, symptom_codes=None): + """GPT-4o-mini로 추천 이유 생성""" + try: + # 한글 동물 타입 + animal_ko = '강아지' if animal_type == 'dog' else '고양이' + + # 특수 케이스: 아시엔로정 + 소화기 증상 → 중증 세균성 장염 설명 추가 + special_note = "" + digestive_symptoms = {'g01', 'g02', 'g03', 'g04', 'g05'} # 소화기 관련 증상 코드 + if component_code == 'IA5010124' and symptom_codes: # 아시엔로정 (엔로플록사신) + if any(s in digestive_symptoms for s in symptom_codes): + special_note = "\n⚠️ 특별 참고: 이 제품은 일반 설사가 아닌 중증 세균성 장염에 사용하는 항생제입니다. 증상이 심하거나 점액/혈변이 있는 경우에 적합합니다." + + # 복합 성분 여부 확인 (+ 기호로 연결된 경우) + is_complex_formula = '+' in component_name + + # llm_pharm에서 컨텍스트 추출 + if llm_pharm: + indication = llm_pharm.get('어떤질병에사용하나요?', '') + description = llm_pharm.get('자연어설명', '') or llm_pharm.get('LLM설명', '') + usage = llm_pharm.get('기간/용법', '') + caution = llm_pharm.get('check', '') + else: + indication = '' + description = efficacy_clean[:200] if efficacy_clean else '' + usage = '' + caution = '' + + context = f""" +제품명: {product_name} +성분: {component_name} +적응증: {indication} +설명: {description} +용법: {usage} +""" + + # 특별 지시 사항 + special_instruction = "" + + # 복합 성분 제품일 경우 각 성분 역할 설명 요청 + if is_complex_formula: + special_instruction += "\n중요: 이 제품은 여러 성분이 함께 들어간 '복합제'입니다. 각 성분의 역할을 간단히 설명해주세요 (예: 'A성분은 세균을 억제하고, B성분은 염증을 가라앉힙니다'). 단, '성분이 결합되어 있다'는 표현은 화학적 오해를 줄 수 있으니 사용하지 마세요." + + # 아시엔로정 소화기 증상일 경우 프롬프트에 특별 지시 추가 + if component_code == 'IA5010124' and symptom_codes and any(s in digestive_symptoms for s in symptom_codes): + special_instruction += "\n주의: 이 제품은 항생제로, 중증 세균성 장염(심한 염증성 장질환)에 사용됩니다. 일반적인 설사가 아닌 세균 감염에 의한 장염에 효과적이라는 점을 반드시 언급해주세요." + + # 복합 성분 제품은 더 긴 설명 허용 + sentence_limit = "3-4문장" if is_complex_formula else "2문장 이내" + + prompt = f"""반려인이 {animal_ko}의 증상으로 "{', '.join(symptom_descriptions)}"를 선택했습니다. + +아래 제품 정보를 바탕으로, 왜 이 제품이 해당 증상에 적합한지 {sentence_limit}로 친절하고 따뜻하게 설명해주세요. +예상 질환명을 언급하고, 제품의 효능을 간단히 설명해주세요. +전문용어는 쉽게 풀어서 설명해주세요. + +주의사항: +- "귀하", "고객님" 같은 딱딱한 표현 대신 "반려인님" 또는 생략하세요. +- 복합 성분 제품은 "A와 B가 결합되어" 대신 "A와 B가 함께 들어있어" 또는 "A 성분과 B 성분이 각각" 표현을 사용하세요.{special_instruction} + +{context} +""" + + # 복합 성분 제품은 더 긴 응답 허용 + max_tok = 280 if is_complex_formula else 120 + + client = openai.OpenAI(api_key=OPENAI_API_KEY) + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + max_tokens=max_tok, + temperature=0.7 + ) + + gpt_response = response.choices[0].message.content.strip() + + # 특별 참고사항 추가 (아시엔로정 + 소화기 증상) + if special_note: + return gpt_response + special_note + return gpt_response + + except Exception as e: + print(f"OpenAI API Error: {e}") + # fallback: DB의 자연어설명 사용 + if llm_pharm and llm_pharm.get('자연어설명'): + fallback_msg = llm_pharm.get('자연어설명') + if special_note: + return fallback_msg + special_note + return fallback_msg + return "해당 증상에 효과적인 성분이 포함된 제품입니다." + +# ============================================================ +# HTML 템플릿 (단일 파일로 포함) +# ============================================================ + +HTML_TEMPLATE = ''' + + + + + + 동물약 추천 - 애니팜 + + + +
+ +
+
+
+ +

동물약 추천

+

증상 또는 제품군을 선택하면 적합한 약을 추천해드려요

+
+ + +
+ + +
+ +
+ +
+ +
+
+
강아지
+
강아지
+
+
+
고양이
+
고양이
+
+
+ +
+ + + + + +
+ + +
+ + +
+ +
+ + + +
+
+ + +
+ +
+ {% for category, symptoms in symptoms_by_category.items() %} +
+
{{ category }}
+
+ {% for symptom in symptoms %} +
+ {{ symptom.description }} +
+ {% endfor %} +
+
+ {% endfor %} +
+
+ + + + + +
+
+
+ + +
+
+
+ +
+ +
+
+ + +
+ + + + +''' + +# ============================================================ +# 라우트 +# ============================================================ + +def get_product_image(product): + """제품 이미지 URL 반환 (없으면 동일 성분 제품에서 fallback)""" + # 1순위: 고도몰 CDN 이미지 + if product.godoimage_url_f: + return product.godoimage_url_f + + # 2순위: ani.0bin.in 이미지 + if product.image_url1: + return product.image_url1 + + # 3순위: 동일 성분의 다른 제품에서 이미지 가져오기 + if product.component_code: + fallback = session.query(APDB).filter( + APDB.component_code == product.component_code, + APDB.idx != product.idx, + (APDB.godoimage_url_f != None) | (APDB.image_url1 != None) + ).first() + + if fallback: + return fallback.godoimage_url_f or fallback.image_url1 + + return None + + +@app.route('/') +def index(): + """메인 페이지""" + # 증상 목록 조회 (카테고리별) + symptoms = session.query(Symptoms).order_by(Symptoms.prefix, Symptoms.symptom_code).all() + + symptoms_by_category = {} + category_names = { + 'a': '👁️ 눈', + 'b': '🦷 구강/치아', + 'c': '👂 귀', + 'd': '🐾 피부/털', + 'e': '🦵 다리/발', + 'f': '🦴 뼈/관절', + 'g': '🍽️ 소화기/배변' + } + + for symptom in symptoms: + category = category_names.get(symptom.prefix, symptom.prefix_description or symptom.prefix) + if category not in symptoms_by_category: + symptoms_by_category[category] = [] + symptoms_by_category[category].append({ + 'code': symptom.symptom_code, + 'description': symptom.symptom_description + }) + + return render_template_string(HTML_TEMPLATE, symptoms_by_category=symptoms_by_category) + + +@app.route('/api/categories', methods=['GET']) +def get_categories(): + """카테고리별 제품 수 조회 API""" + try: + animal_type = request.args.get('animal_type', 'dog') + animal_ko = '개' if animal_type == 'dog' else '고양이' + + # 재고가 있는 제품 조회 + inventory_products = session.query(APDB).join( + Inventory, Inventory.apdb_id == APDB.idx + ).filter( + Inventory.transaction_type == 'INBOUND' + ).distinct().all() + + # 카테고리별 제품 수 계산 + categories = [] + for cat_code, cat_info in PRODUCT_CATEGORIES.items(): + count = 0 + keywords = cat_info.get('keywords', []) + + for product in inventory_products: + # 동물 타입 체크 (ComponentCode에서) + component = session.query(ComponentCode).filter( + ComponentCode.code == product.component_code + ).first() + + if component: + target_animals = component.target_animals or [] + # JSONB 리스트 형태 처리 + if isinstance(target_animals, list): + target_str = ', '.join(target_animals).lower() + else: + target_str = str(target_animals).lower() + if animal_ko not in target_str and '개, 고양이' not in target_str and '고양이, 개' not in target_str: + continue + + # 효능효과에서 키워드 매칭 + efficacy = (product.efficacy_effect or '').lower() + # HTML 태그 제거 + import re + efficacy_clean = re.sub(r'<[^>]+>', '', efficacy) + + if any(kw in efficacy_clean for kw in keywords): + count += 1 + + if count > 0: + categories.append({ + 'code': cat_code, + 'name': cat_info['name'], + 'icon': cat_info['icon'], + 'count': count, + 'description': cat_info['description'] + }) + + # 제품 수 기준 정렬 + categories.sort(key=lambda x: x['count'], reverse=True) + + return jsonify({ + 'success': True, + 'categories': categories + }) + + except Exception as e: + print(f"카테고리 조회 오류: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'message': str(e), + 'categories': [] + }) + + +@app.route('/api/recommend', methods=['POST']) +def recommend(): + """추천 API - 증상-성분 매핑 테이블 활용""" + try: + data = request.get_json() + animal_type = data.get('animal_type') # 'dog' or 'cat' + breed = data.get('breed') # 견종 (MDR-1 체크용) + weight = data.get('weight') + pregnancy = data.get('pregnancy', 'none') # 임신/수유 여부 + symptom_codes = data.get('symptoms', []) + + if not animal_type or not symptom_codes: + return jsonify({ + 'success': False, + 'message': '동물 종류와 증상을 선택해주세요.' + }) + + # 한글 변환 + animal_ko = '개' if animal_type == 'dog' else '고양이' + + # 1. 선택된 증상 정보 조회 + matched_symptoms = session.query(Symptoms).filter( + Symptoms.symptom_code.in_(symptom_codes) + ).all() + symptom_descriptions = [s.symptom_description for s in matched_symptoms] + + # 2. 증상-성분 매핑에서 관련 성분 코드 조회 + mapped_components = session.query(SymptomComponentMapping.component_code).filter( + SymptomComponentMapping.symptom_code.in_(symptom_codes) + ).distinct().all() + mapped_component_codes = [m[0] for m in mapped_components] + + # 매핑된 성분이 없으면 추천 불가 + if not mapped_component_codes: + return jsonify({ + 'success': True, + 'animal_type': animal_type, + 'matched_symptoms': symptom_descriptions, + 'recommendations': [], + 'total_count': 0, + 'message': '선택하신 증상에 맞는 약품 매핑이 아직 없습니다. 수의사와 상담을 권장합니다.' + }) + + # 3. 재고 제품 조회 (INBOUND) + 매핑된 성분 필터 + inventory_products = session.query(APDB).join( + Inventory, Inventory.apdb_id == APDB.idx + ).filter( + Inventory.transaction_type == 'INBOUND', + APDB.component_code.in_(mapped_component_codes) + ).distinct().all() + + # 4. 동물 종류에 맞는 제품 필터링 + 점수 계산 + recommendations = [] + + for product in inventory_products: + # ComponentCode에서 target_animals 확인 + comp = session.query(ComponentCode).filter( + ComponentCode.code == product.component_code + ).first() + + if not comp: + continue + + target_animals = comp.target_animals or [] + + # 대상 동물 체크 (target_animals가 없으면 전체 사용 가능으로 간주) + if target_animals and animal_ko not in target_animals: + continue # 해당 동물에게 사용 불가 + + # 이 성분이 몇 개의 선택 증상과 매핑되는지 점수 계산 + matching_symptom_count = session.query(SymptomComponentMapping).filter( + SymptomComponentMapping.component_code == product.component_code, + SymptomComponentMapping.symptom_code.in_(symptom_codes) + ).count() + + # 효능효과에서 키워드 추출 + efficacy = product.efficacy_effect or '' + efficacy_clean = re.sub(r'<[^>]+>', '', efficacy).lower() + + # 용도 간단 설명 추출 + efficacy_short = '' + if '외이도염' in efficacy_clean or '귓병' in efficacy_clean or '귀' in efficacy_clean: + efficacy_short = '귀 치료제' + elif '구토' in efficacy_clean: + efficacy_short = '구토 억제제' + elif '피부' in efficacy_clean or '진균' in efficacy_clean or '피부염' in efficacy_clean: + efficacy_short = '피부 치료제' + elif '관절' in efficacy_clean or '소염' in efficacy_clean or '진통' in efficacy_clean: + efficacy_short = '소염진통제' + elif '구충' in efficacy_clean or '기생충' in efficacy_clean or '진드기' in efficacy_clean: + efficacy_short = '구충제' + elif '항생' in efficacy_clean or '감염' in efficacy_clean: + efficacy_short = '항생제' + elif '장' in efficacy_clean or '설사' in efficacy_clean or '정장' in efficacy_clean: + efficacy_short = '정장제' + + recommendations.append({ + 'idx': product.idx, # 추천 제품 API 호출용 + 'name': product.product_name, + 'product_idx': product.idx, # 용량 계산용 + 'component_code': product.component_code, # MDR-1 체크용 + 'component_name': comp.component_name_ko or product.component_name_ko or '', + 'target_animals': ', '.join(target_animals) if target_animals else '전체', + 'efficacy': efficacy_short, + 'score': matching_symptom_count, # 매핑 점수 + 'image_url': get_product_image(product), # 고도몰 CDN 이미지 (fallback 포함) + 'llm_pharm': product.llm_pharm, # AI 추천 이유 생성용 + 'efficacy_clean': efficacy_clean, # AI 추천 이유 생성용 fallback + 'usage_guide': product.usage_guide # 상세 사용 안내 (희석 비율 등) + }) + + # 점수순 정렬 (높은 점수 = 더 많은 증상과 매핑) + recommendations.sort(key=lambda x: x['score'], reverse=True) + + # 중복 제거 (동일 제품명) + seen_names = set() + unique_recommendations = [] + for rec in recommendations: + if rec['name'] not in seen_names: + seen_names.add(rec['name']) + unique_recommendations.append(rec) + + # 상위 5개만 + unique_recommendations = unique_recommendations[:5] + + # AI 추천 이유 생성 (병렬 처리) + def generate_reason_for_product(rec): + reason = generate_recommendation_reason( + animal_type=animal_type, + symptom_descriptions=symptom_descriptions, + product_name=rec['name'], + component_name=rec['component_name'], + llm_pharm=rec.get('llm_pharm'), + efficacy_clean=rec.get('efficacy_clean', ''), + component_code=rec.get('component_code'), + symptom_codes=symptom_codes + ) + return {**rec, 'reason': reason} + + with ThreadPoolExecutor(max_workers=5) as executor: + unique_recommendations = list(executor.map(generate_reason_for_product, unique_recommendations)) + + # MDR-1 체크 (강아지 + 견종 선택된 경우) + if animal_type == 'dog' and breed and breed != 'other': + breed_info = MDR1_BREEDS.get(breed, {}) + breed_risk = breed_info.get('risk', 'none') + breed_name = breed_info.get('name', '') + + for rec in unique_recommendations: + component_code = rec.get('component_code', '') + if component_code in MDR1_SENSITIVE_COMPONENTS: + if breed_risk == 'high': + rec['mdr1_warning'] = 'danger' + rec['mdr1_message'] = f"⚠️ {breed_name}은(는) MDR-1 유전자 변이 고위험 견종입니다. 이 약물(이버멕틴/셀라멕틴 계열)은 심각한 신경독성 부작용을 유발할 수 있습니다. 반드시 수의사 상담 후 사용하세요." + elif breed_risk == 'medium': + rec['mdr1_warning'] = 'caution' + rec['mdr1_message'] = f"⚠️ {breed_name}은(는) MDR-1 유전자 변이 중위험 견종입니다. 이 약물 사용 시 저용량부터 시작하고, 부작용 발생 시 즉시 수의사에게 연락하세요." + elif breed_risk == 'low': + rec['mdr1_warning'] = 'info' + rec['mdr1_message'] = f"ℹ️ {breed_name}은(는) MDR-1 저위험 견종이나, 개체별 차이가 있을 수 있습니다. 처음 사용 시 주의깊게 관찰하세요." + elif breed_risk == 'unknown': + rec['mdr1_warning'] = 'info' + rec['mdr1_message'] = "ℹ️ 견종에 따라 이 약물(Avermectin 계열)에 민감할 수 있습니다. 수의사 상담을 권장합니다." + + # 체중 기반 용량 계산 + 피펫형 제품 필터링 + weight_kg = None + try: + if weight: + weight_kg = float(weight) + except (ValueError, TypeError): + weight_kg = None + + # 피펫형 제품 체중 필터링 적용 + weight_filtered_recommendations = [] + for rec in unique_recommendations: + if weight_kg and weight_kg > 0: + dosage_info = calculate_recommended_dosage( + product_idx=rec.get('product_idx'), + weight_kg=weight_kg, + animal_type=animal_type + ) + + # 피펫형 제품: 체중 범위에 맞지 않으면 제외 + if dosage_info and dosage_info.get('is_pipette'): + if dosage_info.get('calculated'): + rec['dosage_info'] = dosage_info + weight_filtered_recommendations.append(rec) + # else: 체중 범위 밖이므로 제외 (추가하지 않음) + else: + # 피펫형이 아닌 제품은 그대로 유지 + if dosage_info and dosage_info.get('calculated'): + rec['dosage_info'] = dosage_info + weight_filtered_recommendations.append(rec) + else: + # 체중 미입력 시 필터링 없이 모두 포함 + weight_filtered_recommendations.append(rec) + + unique_recommendations = weight_filtered_recommendations + + # 응답에서 불필요한 필드 제거 + for rec in unique_recommendations: + rec.pop('llm_pharm', None) + rec.pop('efficacy_clean', None) + rec.pop('component_code', None) + rec.pop('product_idx', None) # 용량 계산 후 제거 + + return jsonify({ + 'success': True, + 'animal_type': animal_type, + 'matched_symptoms': symptom_descriptions, + 'recommendations': unique_recommendations, + 'total_count': len(unique_recommendations), + 'weight_kg': weight_kg, # 입력된 체중 반환 + 'pregnancy_status': pregnancy # 임신/수유 상태 반환 + }) + + except Exception as e: + print(f"Error: {e}") + return jsonify({ + 'success': False, + 'message': f'오류가 발생했습니다: {str(e)}' + }) + + +@app.route('/api/recommend_by_category', methods=['POST']) +def recommend_by_category(): + """제품군 기반 추천 API""" + try: + data = request.get_json() + animal_type = data.get('animal_type') # 'dog' or 'cat' + breed = data.get('breed') # 견종 (MDR-1 체크용) + category = data.get('category') # 제품군 코드 + pregnancy = data.get('pregnancy', 'none') # 임신/수유 여부 + + if not animal_type or not category: + return jsonify({ + 'success': False, + 'message': '동물 종류와 제품군을 선택해주세요.' + }) + + animal_ko = '개' if animal_type == 'dog' else '고양이' + + # 카테고리 정보 + category_info = PRODUCT_CATEGORIES.get(category, {}) + keywords = category_info.get('keywords', []) + category_name = category_info.get('name', category) + + if not keywords: + return jsonify({ + 'success': False, + 'message': '잘못된 제품군입니다.' + }) + + # 재고가 있는 제품 조회 + inventory_products = session.query(APDB).join( + Inventory, Inventory.apdb_id == APDB.idx + ).filter( + Inventory.transaction_type == 'INBOUND' + ).distinct().all() + + recommendations = [] + import re + + for product in inventory_products: + # 동물 타입 체크 (ComponentCode에서) + component = session.query(ComponentCode).filter( + ComponentCode.code == product.component_code + ).first() + + if component: + target_animals = component.target_animals or [] + # JSONB 리스트 형태 처리 + if isinstance(target_animals, list): + target_str = ', '.join(target_animals).lower() + else: + target_str = str(target_animals).lower() + if animal_ko not in target_str and '개, 고양이' not in target_str and '고양이, 개' not in target_str: + continue + + # 효능효과에서 키워드 매칭 + efficacy = (product.efficacy_effect or '').lower() + efficacy_clean = re.sub(r'<[^>]+>', '', efficacy) + + if any(kw in efficacy_clean for kw in keywords): + # 효능효과 짧게 추출 + efficacy_short = efficacy_clean[:100] + '...' if len(efficacy_clean) > 100 else efficacy_clean + + # target_animals 문자열로 변환 + target_animals_str = ', '.join(component.target_animals) if component and isinstance(component.target_animals, list) else (component.target_animals if component else '') + + rec = { + 'idx': product.idx, # 추천 제품 API 호출용 + 'name': product.product_name, + 'product_idx': product.idx, # 용량 계산용 + 'component_name': component.component_name_ko if component else product.component_code, + 'component_code': product.component_code, + 'target_animals': target_animals_str, + 'efficacy': efficacy_short, + 'image_url': get_product_image(product), + 'usage_guide': product.usage_guide # 상세 사용 안내 (희석 비율 등) + } + + # MDR-1 체크 (강아지이고 견종이 선택된 경우) + if animal_type == 'dog' and breed and breed != 'other': + if product.component_code in MDR1_SENSITIVE_COMPONENTS: + breed_info = MDR1_BREEDS.get(breed, {}) + risk = breed_info.get('risk', 'none') + + if risk == 'high': + rec['mdr1_warning'] = 'danger' + rec['mdr1_message'] = f"⚠️ {breed_info.get('name', breed)}은(는) 이 약물에 심각한 신경독성 부작용이 발생할 수 있습니다. 반드시 수의사 상담 후 사용하세요." + elif risk == 'medium': + rec['mdr1_warning'] = 'caution' + rec['mdr1_message'] = f"⚠️ {breed_info.get('name', breed)}은(는) 이 약물에 민감할 수 있습니다. 저용량부터 시작하고 모니터링하세요." + elif risk == 'unknown': + rec['mdr1_warning'] = 'info' + rec['mdr1_message'] = "ℹ️ 견종에 따라 이 약물에 민감할 수 있습니다. 수의사 상담을 권장합니다." + + recommendations.append(rec) + + # 중복 제거 (제품명 기준) + seen_names = set() + unique_recommendations = [] + for rec in recommendations: + if rec['name'] not in seen_names: + seen_names.add(rec['name']) + unique_recommendations.append(rec) + + # 체중 기반 용량 계산 + 피펫형 제품 필터링 + weight = data.get('weight') + weight_kg = None + try: + if weight: + weight_kg = float(weight) + except (ValueError, TypeError): + weight_kg = None + + # 피펫형 제품 체중 필터링 적용 + weight_filtered_recommendations = [] + for rec in unique_recommendations: + if weight_kg and weight_kg > 0: + dosage_info = calculate_recommended_dosage( + product_idx=rec.get('product_idx'), + weight_kg=weight_kg, + animal_type=animal_type + ) + + # 피펫형 제품: 체중 범위에 맞지 않으면 제외 + if dosage_info and dosage_info.get('is_pipette'): + if dosage_info.get('calculated'): + rec['dosage_info'] = dosage_info + weight_filtered_recommendations.append(rec) + # else: 체중 범위 밖이므로 제외 (추가하지 않음) + else: + # 피펫형이 아닌 제품은 그대로 유지 + if dosage_info and dosage_info.get('calculated'): + rec['dosage_info'] = dosage_info + weight_filtered_recommendations.append(rec) + else: + # 체중 미입력 시 필터링 없이 모두 포함 + weight_filtered_recommendations.append(rec) + + unique_recommendations = weight_filtered_recommendations + + # component_code, product_idx 제거 + for rec in unique_recommendations: + rec.pop('component_code', None) + rec.pop('product_idx', None) + + return jsonify({ + 'success': True, + 'animal_type': animal_type, + 'category_name': category_name, + 'recommendations': unique_recommendations, + 'total_count': len(unique_recommendations), + 'weight_kg': weight_kg, + 'pregnancy_status': pregnancy # 임신/수유 상태 반환 + }) + + except Exception as e: + print(f"제품군 추천 오류: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'message': f'오류가 발생했습니다: {str(e)}' + }) + + +# ============================================================ +# 통합 추천 API (v2) - APDB + 보조제품 +# ============================================================ + +def get_supplementary_image(supp): + """보조제품 이미지 URL 반환""" + return supp.image_url or '/static/img/no-image.png' + + +def has_supplementary_stock(supplementary_id): + """보조제품 재고 확인""" + inv = session.query(InventorySupplementary).filter( + InventorySupplementary.supplementary_id == supplementary_id, + InventorySupplementary.quantity > 0 + ).first() + return inv is not None + + +@app.route('/api/product//recommendations') +def get_product_recommendations(product_idx): + """ + 특정 APDB 제품의 통합 추천 목록 조회 + - 관련 APDB 제품 (약품 → 약품) + - 관련 보조제품 (약품 → 건기식/기구/케어) + """ + try: + result = { + "success": True, + "product_idx": product_idx, + "apdb_recommendations": [], + "supplementary_recommendations": [] + } + + # 1. APDB → APDB 추천 + apdb_recs = session.query(UnifiedProductRecommendation).filter( + UnifiedProductRecommendation.source_type == 'apdb', + UnifiedProductRecommendation.source_id == product_idx, + UnifiedProductRecommendation.target_type == 'apdb', + UnifiedProductRecommendation.is_active == True + ).order_by(UnifiedProductRecommendation.priority.desc()).all() + + for rec in apdb_recs: + product = session.query(APDB).filter(APDB.idx == rec.target_id).first() + if not product: + continue + + # 재고 확인 + has_stock = session.query(Inventory).filter( + Inventory.apdb_id == product.idx, + Inventory.transaction_type == 'INBOUND' + ).first() is not None + + if has_stock: + result["apdb_recommendations"].append({ + "idx": product.idx, + "name": product.product_name, + "image_url": get_product_image(product), + "relation_type": rec.relation_type, + "reason": rec.recommendation_reason, + "priority": rec.priority + }) + + # 2. APDB → Supplementary 추천 + supp_recs = session.query(UnifiedProductRecommendation).filter( + UnifiedProductRecommendation.source_type == 'apdb', + UnifiedProductRecommendation.source_id == product_idx, + UnifiedProductRecommendation.target_type == 'supplementary', + UnifiedProductRecommendation.is_active == True + ).order_by(UnifiedProductRecommendation.priority.desc()).all() + + for rec in supp_recs: + supp = session.query(SupplementaryProduct).filter( + SupplementaryProduct.id == rec.target_id, + SupplementaryProduct.is_active == True + ).first() + + if supp and has_supplementary_stock(supp.id): + result["supplementary_recommendations"].append({ + "id": supp.id, + "name": supp.product_name, + "brand": supp.brand, + "category": supp.category, + "sub_category": supp.sub_category, + "image_url": get_supplementary_image(supp), + "relation_type": rec.relation_type, + "reason": rec.recommendation_reason, + "priority": rec.priority, + "price": float(supp.price) if supp.price else None, + "efficacy_tags": supp.efficacy_tags + }) + + result["total_apdb"] = len(result["apdb_recommendations"]) + result["total_supplementary"] = len(result["supplementary_recommendations"]) + + return jsonify(result) + + except Exception as e: + print(f"통합 추천 조회 오류: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'message': f'오류가 발생했습니다: {str(e)}' + }) + + +@app.route('/api/supplementary/') +def get_supplementary_detail(supp_id): + """보조제품 상세 정보 조회""" + try: + supp = session.query(SupplementaryProduct).filter( + SupplementaryProduct.id == supp_id + ).first() + + if not supp: + return jsonify({ + 'success': False, + 'message': '제품을 찾을 수 없습니다.' + }) + + # 재고 정보 + inv = session.query(InventorySupplementary).filter( + InventorySupplementary.supplementary_id == supp_id + ).first() + + return jsonify({ + 'success': True, + 'product': { + 'id': supp.id, + 'product_name': supp.product_name, + 'product_name_en': supp.product_name_en, + 'brand': supp.brand, + 'manufacturer': supp.manufacturer, + 'barcode': supp.barcode, + 'product_code': supp.product_code, + 'category': supp.category, + 'sub_category': supp.sub_category, + 'description': supp.description, + 'usage_instructions': supp.usage_instructions, + 'ingredients': supp.ingredients, + 'main_ingredient': supp.main_ingredient, + 'ingredient_details': supp.ingredient_details, + 'efficacy': supp.efficacy, + 'efficacy_tags': supp.efficacy_tags, + 'target_animal': supp.target_animal, + 'target_age': supp.target_age, + 'target_size': supp.target_size, + 'precautions': supp.precautions, + 'warnings': supp.warnings, + 'image_url': supp.image_url, + 'image_url_2': supp.image_url_2, + 'price': float(supp.price) if supp.price else None, + 'unit': supp.unit, + 'package_size': supp.package_size, + 'external_url': supp.external_url, + 'stock': inv.quantity if inv else 0 + } + }) + + except Exception as e: + print(f"보조제품 상세 조회 오류: {e}") + return jsonify({ + 'success': False, + 'message': f'오류가 발생했습니다: {str(e)}' + }) + + +@app.route('/api/supplementary') +def list_supplementary_products(): + """보조제품 목록 조회 (카테고리별 필터링)""" + try: + category = request.args.get('category') # supplement, equipment, care + sub_category = request.args.get('sub_category') + target_animal = request.args.get('target_animal') + + query = session.query(SupplementaryProduct).filter( + SupplementaryProduct.is_active == True + ) + + if category: + query = query.filter(SupplementaryProduct.category == category) + if sub_category: + query = query.filter(SupplementaryProduct.sub_category == sub_category) + if target_animal: + query = query.filter( + or_( + SupplementaryProduct.target_animal == target_animal, + SupplementaryProduct.target_animal == 'all' + ) + ) + + products = query.order_by(SupplementaryProduct.product_name).all() + + result = [] + for supp in products: + if has_supplementary_stock(supp.id): + result.append({ + 'id': supp.id, + 'product_name': supp.product_name, + 'brand': supp.brand, + 'category': supp.category, + 'sub_category': supp.sub_category, + 'main_ingredient': supp.main_ingredient, + 'efficacy_tags': supp.efficacy_tags, + 'target_animal': supp.target_animal, + 'image_url': get_supplementary_image(supp), + 'price': float(supp.price) if supp.price else None, + 'package_size': supp.package_size + }) + + return jsonify({ + 'success': True, + 'products': result, + 'total': len(result) + }) + + except Exception as e: + print(f"보조제품 목록 조회 오류: {e}") + return jsonify({ + 'success': False, + 'message': f'오류가 발생했습니다: {str(e)}' + }) + + +@app.route('/api/symptoms/supplementary-recommendations', methods=['POST']) +def get_symptom_supplementary_recommendations(): + """증상 선택 시 보조제품 추천""" + try: + data = request.get_json() + symptom_codes = data.get('symptom_codes', []) + animal_type = data.get('animal_type') # dog, cat + + if not symptom_codes: + return jsonify({ + 'success': False, + 'message': '증상을 선택해주세요.' + }) + + # 증상 → 보조제품 매핑 조회 + recommendations = session.query(SymptomSupplementaryRecommendation).filter( + SymptomSupplementaryRecommendation.symptom_code.in_(symptom_codes), + SymptomSupplementaryRecommendation.is_active == True + ).order_by(SymptomSupplementaryRecommendation.priority.desc()).all() + + result = [] + seen_ids = set() + + for rec in recommendations: + if rec.supplementary_id in seen_ids: + continue + + supp = session.query(SupplementaryProduct).filter( + SupplementaryProduct.id == rec.supplementary_id, + SupplementaryProduct.is_active == True + ).first() + + if not supp: + continue + + # 동물 타입 필터링 + if animal_type and supp.target_animal: + if supp.target_animal != 'all' and supp.target_animal != animal_type: + continue + + # 재고 확인 + if not has_supplementary_stock(supp.id): + continue + + seen_ids.add(rec.supplementary_id) + + # 매칭된 증상 정보 조회 + symptom = session.query(Symptoms).filter( + Symptoms.symptom_code == rec.symptom_code + ).first() + + result.append({ + 'id': supp.id, + 'product_name': supp.product_name, + 'brand': supp.brand, + 'category': supp.category, + 'sub_category': supp.sub_category, + 'main_ingredient': supp.main_ingredient, + 'efficacy_tags': supp.efficacy_tags, + 'image_url': get_supplementary_image(supp), + 'price': float(supp.price) if supp.price else None, + 'relation_type': rec.relation_type, + 'reason': rec.recommendation_reason, + 'matched_symptom': symptom.description if symptom else rec.symptom_code + }) + + return jsonify({ + 'success': True, + 'supplementary_recommendations': result, + 'total': len(result) + }) + + except Exception as e: + print(f"증상 기반 보조제품 추천 오류: {e}") + import traceback + traceback.print_exc() + return jsonify({ + 'success': False, + 'message': f'오류가 발생했습니다: {str(e)}' + }) + + +# ============================================================ +# 실행 +# ============================================================ + +if __name__ == '__main__': + print("=" * 60) + print("🐾 동물약 추천 MVP 웹앱") + print("=" * 60) + print("접속 주소: http://localhost:7001") + print("종료: Ctrl+C") + print("=" * 60) + + app.run(host='0.0.0.0', port=7001, debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ca3ccc8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.0 +Flask-Cors==4.0.0 +SQLAlchemy==2.0.25 +openai==1.99.1 +psycopg2-binary==2.9.9 +gunicorn==23.0.0