From 1f778ab9753c500bec1d9ff0a2f9a1b8973d0ec5 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 17 Jun 2026 15:58:38 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata/backend:=20=EC=83=81=ED=92=88?= =?UTF-8?q?=C2=B7=ED=98=91=EB=A0=A5=EC=82=AC=C2=B7=EA=B2=AC=EC=A0=81=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20CRUD=20+=20delivery=5Ftype=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=ED=99=94=20+=20=EA=B3=B5=EC=9A=A9=20/v1/enum?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - item/supplier/quotation/quotation_setting CRUD·service·router 추가 - item protocol delivery_type str→int (ERD/스키마 SMALLINT 일치) - DeliveryType enum + 한글 라벨, 공용 GET /v1/enums (도메인 코드 메타데이터) - CompanyBrief → CompanyData 로 *Data 네이밍 통일 - CORS: WebServerConfig.client_url(단일) 도입 (config_models/router) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/common/database/model/models.py | 153 ++++++++++++++- negodata/backend/common/enums.py | 74 +++++++ negodata/backend/common/models/gmodel.py | 6 +- negodata/backend/config/config_models.py | 1 + negodata/backend/conftest.py | 24 ++- negodata/backend/crud/item_crud.py | 101 ++++++++++ negodata/backend/crud/quotation_crud.py | 116 +++++++++++ .../backend/crud/quotation_setting_crud.py | 104 ++++++++++ negodata/backend/crud/supplier_crud.py | 105 ++++++++++ negodata/backend/crud/user_crud.py | 45 +++-- negodata/backend/requirements.txt | 3 + negodata/backend/router/router.py | 21 ++ negodata/backend/router/v1/auth/account.py | 19 +- negodata/backend/router/v1/auth/protocol.py | 34 +++- negodata/backend/router/v1/enums/enums.py | 21 ++ negodata/backend/router/v1/enums/protocol.py | 15 ++ negodata/backend/router/v1/item/item.py | 84 ++++++++ negodata/backend/router/v1/item/protocol.py | 111 +++++++++++ .../backend/router/v1/quotation/protocol.py | 155 +++++++++++++++ .../backend/router/v1/quotation/quotation.py | 90 +++++++++ .../router/v1/quotation_setting/protocol.py | 48 +++++ .../v1/quotation_setting/quotation_setting.py | 52 +++++ .../backend/router/v1/supplier/protocol.py | 66 +++++++ .../backend/router/v1/supplier/supplier.py | 68 +++++++ .../router/v1/validator/dependencies.py | 4 +- negodata/backend/services/auth_service.py | 104 +++++++--- negodata/backend/services/item_service.py | 109 +++++++++++ .../backend/services/quotation_service.py | 184 ++++++++++++++++++ .../services/quotation_setting_service.py | 112 +++++++++++ negodata/backend/services/supplier_service.py | 109 +++++++++++ negodata/backend/tests/test_auth.py | 49 +++-- negodata/backend/tests/test_features.py | 68 +++++++ negodata/backend/tests/test_item.py | 85 ++++++++ 33 files changed, 2255 insertions(+), 85 deletions(-) create mode 100644 negodata/backend/crud/item_crud.py create mode 100644 negodata/backend/crud/quotation_crud.py create mode 100644 negodata/backend/crud/quotation_setting_crud.py create mode 100644 negodata/backend/crud/supplier_crud.py create mode 100644 negodata/backend/router/v1/enums/enums.py create mode 100644 negodata/backend/router/v1/enums/protocol.py create mode 100644 negodata/backend/router/v1/item/item.py create mode 100644 negodata/backend/router/v1/item/protocol.py create mode 100644 negodata/backend/router/v1/quotation/protocol.py create mode 100644 negodata/backend/router/v1/quotation/quotation.py create mode 100644 negodata/backend/router/v1/quotation_setting/protocol.py create mode 100644 negodata/backend/router/v1/quotation_setting/quotation_setting.py create mode 100644 negodata/backend/router/v1/supplier/protocol.py create mode 100644 negodata/backend/router/v1/supplier/supplier.py create mode 100644 negodata/backend/services/item_service.py create mode 100644 negodata/backend/services/quotation_service.py create mode 100644 negodata/backend/services/quotation_setting_service.py create mode 100644 negodata/backend/services/supplier_service.py create mode 100644 negodata/backend/tests/test_features.py create mode 100644 negodata/backend/tests/test_item.py diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 0da66c6..98aee18 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -1,26 +1,161 @@ +import uuid + from sqlalchemy.orm import declarative_base -from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy import Column, Integer, SmallInteger, BigInteger, Numeric, Float, String, Boolean, DateTime +from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.sql import text -from common.enums import DBType +from common.enums import DBType, UserStatus, UserRole, CompanyStatus # 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. MAIN_BASE = declarative_base() -class tbl_account(MAIN_BASE): - # 모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택). +# 공통 mixin +# DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다. +def _utc_now_sql(): + return text("(now() AT TIME ZONE 'utc')") + + +class _DBTypeMixin: + """모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택).""" + @staticmethod def DBType(): return DBType.MAIN.value + +# ERD 공통 컬럼(created/updated/deleted). negodata 도메인 테이블은 단일 MAIN DB 에서 soft-delete 를 쓴다. +class MainTableMixin(_DBTypeMixin): + created_at = Column(DateTime, nullable=False, server_default=_utc_now_sql()) + updated_at = Column(DateTime, nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql()) + deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False) + + +# 스캐폴드 예시 계정 (negosium 골격 유산) +# negodata 인증은 ERD 의 users/companies 를 쓴다(아래). tbl_account 는 스캐폴드 +# 테스트 호환을 위해 남겨둔다 (negodata_db 의 postgres-init 이 생성). +class tbl_account(_DBTypeMixin, MAIN_BASE): __tablename__ = "tbl_account" uid = Column(Integer, primary_key=True, autoincrement=True) - id = Column(String(45), nullable=False, unique=True) # 로그인 ID. 중복 가입 방지 위해 unique. - pw = Column(String(255), nullable=False, default="") # bcrypt 해시 저장 + id = Column(String(45), nullable=False, unique=True) + pw = Column(String(255), nullable=False, default="") nickname = Column(String(45), nullable=False, default="") is_blocked = Column(Boolean, nullable=False, default=False) - # PostgreSQL UTC now: now() 는 timestamptz 이므로 utc 로 변환해 timestamp 로 저장. - last_login_at = Column(DateTime, nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) - create_at = Column(DateTime, server_default=text("(now() AT TIME ZONE 'utc')")) + last_login_at = Column(DateTime, nullable=False, server_default=_utc_now_sql()) + create_at = Column(DateTime, server_default=_utc_now_sql()) + + +# ERD 도메인 모델 (negosium.sql 기준, UUID PK) +class companies(MainTableMixin, MAIN_BASE): + __tablename__ = "companies" + + company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String(100), nullable=False) + business_number = Column(String(30), nullable=True) + code = Column(Integer, nullable=True) # 내부 인덱스용 + representative_name = Column(String(50), nullable=True) + email = Column(String(255), nullable=True) + contact_number = Column(String(20), nullable=True) + website_url = Column(String(255), nullable=True) + industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT) + status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus + + +class users(MainTableMixin, MAIN_BASE): + __tablename__ = "users" + + user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + company_id = Column(UUID(as_uuid=True), nullable=False, index=True) + id = Column(String(20), nullable=False, unique=True, index=True) # 로그인 아이디 + password = Column(String(255), nullable=False) # bcrypt 해시 (ERD VARCHAR(30)→255 확장) + name = Column(String(50), nullable=True) + email = Column(String(255), nullable=True) + contact_number = Column(String(20), nullable=True) + last_accessed_at = Column(DateTime, nullable=False, server_default=_utc_now_sql()) + status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) # UserStatus + role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) # UserRole + + +class items(MainTableMixin, MAIN_BASE): + __tablename__ = "items" + + item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + company_id = Column(UUID(as_uuid=True), nullable=False, index=True) + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 등록한 유저 + + name = Column(String(100), nullable=False) + code = Column(String(30), nullable=True) + category = Column(String(255), nullable=True) + category_type = Column(Integer, nullable=False, default=1) # 카테고리 탐색용 자동 증가 숫자 + + image_url = Column(String(255), nullable=True) + model_name = Column(String(100), nullable=True) + spec = Column(String(255), nullable=True) + manufacturer = Column(String(50), nullable=True) + made_in = Column(String(100), nullable=True) + + price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT + internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용 + + moq = Column(String(50), nullable=True) # 최소 주문 수량 + lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간 + quantity_unit = Column(String(50), nullable=True) # 단위 라벨(자유입력): EA/BOX/SET/ROLL ... + delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 코드 + vat_yn = Column(Boolean, nullable=True) + delivery_fee_yn = Column(Boolean, nullable=True) + + +class suppliers(MainTableMixin, MAIN_BASE): + __tablename__ = "suppliers" + + supplier_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + company_id = Column(UUID(as_uuid=True), nullable=False, index=True) + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 등록한 유저 + + name = Column(String(100), nullable=False) + code = Column(String(20), nullable=True) + manager_name = Column(String(50), nullable=True) + manager_email = Column(String(255), nullable=True) + manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정 + priority = Column(String(10), nullable=True) # True/False 가 아닌 string value 가능 + + +class quotation_settings(MainTableMixin, MAIN_BASE): + __tablename__ = "quotation_settings" + + qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저 + target_margin_rate = Column(Numeric(8, 6), nullable=False) + anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01) + card_count = Column(Integer, nullable=False, default=3) # 한 협상 내 협상카드 사용 횟수 + + +class quotations(MainTableMixin, MAIN_BASE): + __tablename__ = "quotations" + + qt_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) + qt_setting_id = Column(UUID(as_uuid=True), nullable=False, index=True) + version_id = Column(UUID(as_uuid=True), nullable=False, index=True) + + name = Column(String(50), nullable=False) + number = Column(String(30), nullable=False) + type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N) + round = Column(Integer, nullable=False, default=1) # 재견적 진행 시 증가 + status = Column(SmallInteger, nullable=False) # 진행 상태 코드 + start_time = Column(DateTime, nullable=False) + end_time = Column(DateTime, nullable=False) + + manager_name = Column(String(50), nullable=True) + manager_email = Column(String(255), nullable=True) + manager_contact_number = Column(String(20), nullable=True) + memo = Column(String(100), nullable=True) + + iteration = Column(Integer, nullable=False, default=0) + preferred_sp_yn = Column(Boolean, nullable=True) + preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) + preferred_sp_name = Column(String(20), nullable=True) + equal_bid_yn = Column(Boolean, nullable=True) + equal_bid_data = Column(JSONB, nullable=True) diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 8793ea9..9a627ef 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -36,6 +36,18 @@ class ErrorType(Enum): ACCOUNT_ALREADY_EXIST = auto() ACCOUNT_BLOCKED_USER = auto() + # 상품 관련 에러 + ITEM_NOT_FOUND = 1300 + + # 협력사 관련 에러 + SUPPLIER_NOT_FOUND = 1400 + + # 견적 관련 에러 + QUOTATION_NOT_FOUND = 1500 + + # 견적 설정 관련 에러 + QUOTATION_SETTING_NOT_FOUND = 1600 + # ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name) @@ -59,3 +71,65 @@ class DBWRType(Enum): DB_READ = 1 DB_WRITE = 2 + + +# 도메인 코드값 +class UserStatus(Enum): + """users.status 코드값.""" + + ACTIVE = 1 + INACTIVE = 2 + + +class UserRole(Enum): + """users.role 코드값.""" + + USER = 1 + MANAGER = 2 + + +class CompanyStatus(Enum): + """companies.status 코드값.""" + + ACTIVE = 1 + INACTIVE = 2 + + +class QuotationType(Enum): + """quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N).""" + + RENEGO = 1 + REQUOTE = 2 + + +class DeliveryType(Enum): + """items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합.""" + + PARTNER = 1 # 협력사배송 + COURIER = 2 # 지정택배배송 + PICKUP = 3 # 픽업배송 + + +# 도메인 enum 한글 라벨. 프론트 드롭다운 표시는 이 라벨을 쓴다(값=코드). +ENUM_LABELS = { + UserStatus.ACTIVE: "활성", + UserStatus.INACTIVE: "비활성", + UserRole.USER: "일반", + UserRole.MANAGER: "관리자", + CompanyStatus.ACTIVE: "활성", + CompanyStatus.INACTIVE: "비활성", + QuotationType.RENEGO: "재협상", + QuotationType.REQUOTE: "재견적", + DeliveryType.PARTNER: "협력사배송", + DeliveryType.COURIER: "지정택배배송", + DeliveryType.PICKUP: "픽업배송", +} + +# 프론트로 내려주는 도메인 코드 enum 모음. 새 코드 enum 추가 시 여기에 등록한다. +DOMAIN_ENUMS = { + "user_status": UserStatus, + "user_role": UserRole, + "company_status": CompanyStatus, + "quotation_type": QuotationType, + "delivery_type": DeliveryType, +} diff --git a/negodata/backend/common/models/gmodel.py b/negodata/backend/common/models/gmodel.py index b09f002..694ab6a 100644 --- a/negodata/backend/common/models/gmodel.py +++ b/negodata/backend/common/models/gmodel.py @@ -48,9 +48,9 @@ class Res_WebPacketProtocol(WebPacketProtocol): class UserInfo(StructModel): """JWT subject 로 인코딩되는 유저 식별 정보.""" - uid: int - id: str - nickname: str + user_id: str # users.user_id (uuid) — 데이터 스코프 키 + id: str # users.id (로그인 아이디) — get_me 재조회 키 + company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키 def __init__(self, *args, **kwargs) -> None: super().__init__() diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py index d5af607..688873c 100644 --- a/negodata/backend/config/config_models.py +++ b/negodata/backend/config/config_models.py @@ -7,6 +7,7 @@ class WebServerConfig(ConfigModel): process_count: int = 1 is_ssl: bool = False is_test: bool = False + client_url: str = "" class LogConfig(ConfigModel): diff --git a/negodata/backend/conftest.py b/negodata/backend/conftest.py index d5a3fe8..8a8b2aa 100644 --- a/negodata/backend/conftest.py +++ b/negodata/backend/conftest.py @@ -4,6 +4,8 @@ import os os.environ.setdefault("APP_ENV", "local") +import uuid + import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy import text @@ -28,11 +30,31 @@ async def db_engine(): engine = create_async_engine(_write_url(main_db_config)) async with engine.begin() as conn: await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip - await conn.execute(text("TRUNCATE TABLE tbl_account")) + # negodata 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망) + await conn.execute( + text( + "TRUNCATE TABLE tbl_account, users, companies, items, suppliers, " + "quotation_settings, quotations RESTART IDENTITY CASCADE" + ) + ) yield engine await engine.dispose() +@pytest_asyncio.fixture +async def company_id(db_engine) -> str: + """테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다. + users 는 company_id 를 요구하므로 계정 생성 테스트의 선행 조건이다. + """ + cid = uuid.uuid4() + async with db_engine.begin() as conn: + await conn.execute( + text("INSERT INTO companies (company_id, name) VALUES (:cid, :name)"), + {"cid": cid, "name": "테스트사"}, + ) + return str(cid) + + @pytest_asyncio.fixture(scope="session", autouse=True) async def _dispose_app_engines(): """테스트 세션이 끝날 때 앱 싱글톤 엔진을 정리한다. diff --git a/negodata/backend/crud/item_crud.py b/negodata/backend/crud/item_crud.py new file mode 100644 index 0000000..51ac97e --- /dev/null +++ b/negodata/backend/crud/item_crud.py @@ -0,0 +1,101 @@ +from abc import ABC, abstractmethod +from typing import Optional, Tuple + +from sqlalchemy import select, func, and_, or_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import items +from common.enums import ErrorType +from common.logger import LOG +from common.utils.gtime import GTime + + +# 상품 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트). +class IItemCRUD(ABC): + @abstractmethod + async def search(self, cdb: AsyncSession, company_id, search, category, skip, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def get_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]: + pass + + @abstractmethod + async def add_item(self, cdb: AsyncSession, item: items) -> ErrorType: + pass + + @abstractmethod + async def update_item(self, cdb: AsyncSession, item_id, data: dict) -> ErrorType: + pass + + @abstractmethod + async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType: + pass + + +class ItemCRUD(IItemCRUD): + async def search( + self, cdb: AsyncSession, company_id, search: Optional[str], category: Optional[str], skip: int, limit: int + ) -> Tuple[ErrorType, list, int]: + try: + conditions = [items.deleted == False, items.company_id == company_id] # noqa: E712 + if search: + conditions.append(or_(items.name.ilike(f"%{search}%"), items.code.ilike(f"%{search}%"))) + if category: + conditions.append(items.category == category) + where = and_(*conditions) + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(items).where(where)) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + list_err, rows = await DB_SESSION_MNG.execute( + cdb, + select(items).where(where).order_by(items.created_at.desc()).offset(skip).limit(limit), + ) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def get_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]: + try: + query = select(items).where(items.item_id == item_id, items.deleted == False).limit(1) # noqa: E712 + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def add_item(self, cdb: AsyncSession, item: items) -> ErrorType: + try: + return await DB_SESSION_MNG.insert(cdb, item) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update_item(self, cdb: AsyncSession, item_id, data: dict) -> ErrorType: + try: + if not data: + return ErrorType.SUCCESS + query = update(items).where(items.item_id == item_id).values(**data) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType: + try: + query = update(items).where(items.item_id == item_id).values(deleted=True, updated_at=GTime.UTC()) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py new file mode 100644 index 0000000..8527fbf --- /dev/null +++ b/negodata/backend/crud/quotation_crud.py @@ -0,0 +1,116 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Optional, Tuple + +from sqlalchemy import select, func, and_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import quotations +from common.enums import ErrorType +from common.logger import LOG +from common.utils.gtime import GTime + + +# 견적 CRUD. quotations 테이블에는 company_id 가 없어 회사 스코프는 하지 않는다(토큰 검증만). +# user_id 는 생성 시 소유자로 기록만 한다. +class IQuotationCRUD(ABC): + @abstractmethod + async def search( + self, cdb: AsyncSession, status, type_, start_from, start_to, skip, limit + ) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def get_by_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]: + pass + + @abstractmethod + async def add_quotation(self, cdb: AsyncSession, quotation: quotations) -> ErrorType: + pass + + @abstractmethod + async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType: + pass + + @abstractmethod + async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: + pass + + +class QuotationCRUD(IQuotationCRUD): + async def search( + self, + cdb: AsyncSession, + status: Optional[str], + type_: Optional[str], + start_from: Optional[datetime], + start_to: Optional[datetime], + skip: int, + limit: int, + ) -> Tuple[ErrorType, list, int]: + try: + conditions = [quotations.deleted == False] # noqa: E712 + if status: + conditions.append(quotations.status == status) + if type_: + conditions.append(quotations.type == type_) + if start_from: + conditions.append(quotations.start_time >= start_from) + if start_to: + conditions.append(quotations.start_time <= start_to) + where = and_(*conditions) + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(quotations).where(where)) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + list_err, rows = await DB_SESSION_MNG.execute( + cdb, + select(quotations).where(where).order_by(quotations.created_at.desc()).offset(skip).limit(limit), + ) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def get_by_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]: + try: + query = select(quotations).where(quotations.qt_id == qt_id, quotations.deleted == False).limit(1) # noqa: E712 + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def add_quotation(self, cdb: AsyncSession, quotation: quotations) -> ErrorType: + try: + return await DB_SESSION_MNG.insert(cdb, quotation) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType: + try: + if not data: + return ErrorType.SUCCESS + query = update(quotations).where(quotations.qt_id == qt_id).values(**data) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: + try: + query = update(quotations).where(quotations.qt_id == qt_id).values(deleted=True, updated_at=GTime.UTC()) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/crud/quotation_setting_crud.py b/negodata/backend/crud/quotation_setting_crud.py new file mode 100644 index 0000000..562923a --- /dev/null +++ b/negodata/backend/crud/quotation_setting_crud.py @@ -0,0 +1,104 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from sqlalchemy import select, func, and_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import quotation_settings +from common.enums import ErrorType +from common.logger import LOG +from common.utils.gtime import GTime + + +# 견적 설정 CRUD. 모든 조회/변경은 user_id 로 스코프된다(유저별 설정). +class IQuotationSettingCRUD(ABC): + @abstractmethod + async def list_by_user(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def get_by_id(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, quotation_settings]: + pass + + @abstractmethod + async def add_setting(self, cdb: AsyncSession, setting: quotation_settings) -> ErrorType: + pass + + @abstractmethod + async def update_setting(self, cdb: AsyncSession, qt_setting_id, data: dict) -> ErrorType: + pass + + @abstractmethod + async def soft_delete(self, cdb: AsyncSession, qt_setting_id) -> ErrorType: + pass + + +class QuotationSettingCRUD(IQuotationSettingCRUD): + async def list_by_user(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, list, int]: + try: + where = and_(quotation_settings.deleted == False, quotation_settings.user_id == user_id) # noqa: E712 + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute( + cdb, select(func.count()).select_from(quotation_settings).where(where) + ) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + list_err, rows = await DB_SESSION_MNG.execute( + cdb, + select(quotation_settings).where(where).order_by(quotation_settings.created_at.desc()), + ) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def get_by_id(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, quotation_settings]: + try: + query = ( + select(quotation_settings) + .where(quotation_settings.qt_setting_id == qt_setting_id, quotation_settings.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def add_setting(self, cdb: AsyncSession, setting: quotation_settings) -> ErrorType: + try: + return await DB_SESSION_MNG.insert(cdb, setting) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update_setting(self, cdb: AsyncSession, qt_setting_id, data: dict) -> ErrorType: + try: + if not data: + return ErrorType.SUCCESS + query = update(quotation_settings).where(quotation_settings.qt_setting_id == qt_setting_id).values(**data) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def soft_delete(self, cdb: AsyncSession, qt_setting_id) -> ErrorType: + try: + query = ( + update(quotation_settings) + .where(quotation_settings.qt_setting_id == qt_setting_id) + .values(deleted=True, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/crud/supplier_crud.py b/negodata/backend/crud/supplier_crud.py new file mode 100644 index 0000000..9a8ccca --- /dev/null +++ b/negodata/backend/crud/supplier_crud.py @@ -0,0 +1,105 @@ +from abc import ABC, abstractmethod +from typing import Optional, Tuple + +from sqlalchemy import select, func, and_, or_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import suppliers +from common.enums import ErrorType +from common.logger import LOG +from common.utils.gtime import GTime + + +# 협력사 CRUD. 모든 조회/변경은 company_id 로 스코프된다(멀티테넌트). +class ISupplierCRUD(ABC): + @abstractmethod + async def search(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def get_by_id(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, suppliers]: + pass + + @abstractmethod + async def add_supplier(self, cdb: AsyncSession, supplier: suppliers) -> ErrorType: + pass + + @abstractmethod + async def update_supplier(self, cdb: AsyncSession, supplier_id, data: dict) -> ErrorType: + pass + + @abstractmethod + async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType: + pass + + +class SupplierCRUD(ISupplierCRUD): + async def search( + self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int + ) -> Tuple[ErrorType, list, int]: + try: + conditions = [suppliers.deleted == False, suppliers.company_id == company_id] # noqa: E712 + if search: + conditions.append( + or_( + suppliers.name.ilike(f"%{search}%"), + suppliers.code.ilike(f"%{search}%"), + suppliers.manager_name.ilike(f"%{search}%"), + ) + ) + where = and_(*conditions) + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(suppliers).where(where)) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + list_err, rows = await DB_SESSION_MNG.execute( + cdb, + select(suppliers).where(where).order_by(suppliers.created_at.desc()).offset(skip).limit(limit), + ) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def get_by_id(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, suppliers]: + try: + query = select(suppliers).where(suppliers.supplier_id == supplier_id, suppliers.deleted == False).limit(1) # noqa: E712 + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def add_supplier(self, cdb: AsyncSession, supplier: suppliers) -> ErrorType: + try: + return await DB_SESSION_MNG.insert(cdb, supplier) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update_supplier(self, cdb: AsyncSession, supplier_id, data: dict) -> ErrorType: + try: + if not data: + return ErrorType.SUCCESS + query = update(suppliers).where(suppliers.supplier_id == supplier_id).values(**data) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType: + try: + query = update(suppliers).where(suppliers.supplier_id == supplier_id).values(deleted=True, updated_at=GTime.UTC()) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/crud/user_crud.py b/negodata/backend/crud/user_crud.py index b2a7ac6..58a1cbf 100644 --- a/negodata/backend/crud/user_crud.py +++ b/negodata/backend/crud/user_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import tbl_account +from common.database.model.models import users, companies from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -16,27 +16,31 @@ from common.utils.gtime import GTime # - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다. class IUserCRUD(ABC): @abstractmethod - async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]: + async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: pass @abstractmethod - async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType: + async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType: pass @abstractmethod - async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType: + async def add_user(self, cdb: AsyncSession, user: users) -> ErrorType: pass @abstractmethod - async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType: + async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType: + pass + + @abstractmethod + async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: pass class UserCRUD(IUserCRUD): - async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]: + async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: try: - query = select(tbl_account).where(tbl_account.id == user_id).limit(1) - err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_account_by_id(ID:{user_id}) failed.") + query = select(users).where(users.id == login_id, users.deleted == False).limit(1) # noqa: E712 + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_user_by_login_id(ID:{login_id}) failed.") if err_type != ErrorType.SUCCESS: return err_type, None if len(row_list) != 1: @@ -46,9 +50,9 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None - async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType: + async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType: try: - query = select(tbl_account).where(tbl_account.id == user_id).limit(1) + query = select(users).where(users.id == login_id, users.deleted == False).limit(1) # noqa: E712 err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: return err_type @@ -59,17 +63,30 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED - async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType: + async def add_user(self, cdb: AsyncSession, user: users) -> ErrorType: try: - return await DB_SESSION_MNG.insert(cdb, account) + return await DB_SESSION_MNG.insert(cdb, user) except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED - async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType: + async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType: try: - query = update(tbl_account).where(tbl_account.uid == user_uid).values(last_login_at=GTime.UTC()) + query = update(users).where(users.user_id == user_id).values(last_accessed_at=GTime.UTC()) return await DB_SESSION_MNG.add(cdb, query) except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: + try: + query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712 + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt index 2f928b6..13fcb66 100644 --- a/negodata/backend/requirements.txt +++ b/negodata/backend/requirements.txt @@ -1,8 +1,11 @@ fastapi uvicorn[standard] sqlalchemy>=2.0 +greenlet asyncpg python-jose[cryptography] bcrypt orjson pydantic>=2.0 +python-multipart +openpyxl diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index e108613..3d1c0b5 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -2,12 +2,19 @@ import time from contextlib import asynccontextmanager from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from common.database.db_session_manager import DB_SESSION_MNG from common.logger import LOG from common.utils.gtime import GTime +from config.server_configs import web_server_config import router.v1.auth.account +import router.v1.item.item +import router.v1.supplier.supplier +import router.v1.quotation.quotation +import router.v1.quotation_setting.quotation_setting +import router.v1.enums.enums API_SERVER_START_TIME = GTime.UTCStr() @@ -22,6 +29,15 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Negodata Api Server", lifespan=lifespan) +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=[web_server_config.client_url], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축. app.add_middleware(GZipMiddleware, minimum_size=1000) @@ -42,3 +58,8 @@ async def healthz(): # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. app.include_router(router.v1.auth.account.router) +app.include_router(router.v1.item.item.router) +app.include_router(router.v1.supplier.supplier.router) +app.include_router(router.v1.quotation.quotation.router) +app.include_router(router.v1.quotation_setting.quotation_setting.router) +app.include_router(router.v1.enums.enums.router) diff --git a/negodata/backend/router/v1/auth/account.py b/negodata/backend/router/v1/auth/account.py index b5d4648..f2a7d3c 100644 --- a/negodata/backend/router/v1/auth/account.py +++ b/negodata/backend/router/v1/auth/account.py @@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse from services.auth_service import AuthService -from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_RefreshToken +from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken security = HTTPBearer() @@ -14,12 +14,14 @@ router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"descripti @router.post(path="/login", response_model=Res_Login, summary="로그인", description="id/pw 로 로그인하고 JWT 토큰을 발급한다.") async def login(request: Request, req: Req_Login, service: AuthService = Depends()): - return RemoveNoneResponse(await service.attempt_login(req.id, req.pw, request.client.host)) + return RemoveNoneResponse(await service.attempt_login(req.id, req.password, request.client.host)) @router.post(path="/create", response_model=Res_CreateAccount, summary="계정 생성", description="새 계정을 생성한다.") -async def create_account(request: Request, req: Req_CreateAccount, service: AuthService = Depends()): - return RemoveNoneResponse(await service.create_account(req.id, req.pw, req.nickname, request.client.host)) +async def create_account(req: Req_CreateAccount, service: AuthService = Depends()): + return RemoveNoneResponse( + await service.create_account(req.id, req.password, req.company_id, req.name, req.email, req.contact_number, req.role) + ) @router.post( @@ -35,8 +37,9 @@ async def refresh_token(service: AuthService = Depends(), credentials: HTTPAutho @router.get( path="/me", - summary="내 정보 (보호된 엔드포인트 예시)", - description="유효한 access 토큰이 있어야 호출 가능. 토큰 검증 결과 UserInfo 를 주입받는다.", + response_model=Res_Me, + summary="내 정보", + description="유효한 access 토큰이 있어야 호출 가능. 토큰의 유저+회사 정보를 반환한다.", ) -async def me(user_info: UserInfo = Depends(IsValidAccessToken)): - return {"uid": user_info.uid, "id": user_info.id, "nickname": user_info.nickname} +async def me(service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_me(user_info)) diff --git a/negodata/backend/router/v1/auth/protocol.py b/negodata/backend/router/v1/auth/protocol.py index 2ef226e..e8e332f 100644 --- a/negodata/backend/router/v1/auth/protocol.py +++ b/negodata/backend/router/v1/auth/protocol.py @@ -1,5 +1,8 @@ +from typing import Optional + from pydantic import Field +from common.enums import UserRole from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -10,25 +13,44 @@ class AuthProtocol(WebPacketProtocol): class Req_Login(AuthProtocol): id: str = "" - pw: str = "" + password: str = "" class Res_Login(Res_WebPacketProtocol): - uid: int = Field(0, description="user uid", json_schema_extra={"format": "int64"}) - nickname: str = "" access_token: str = "" refresh_token: str = "" + token_type: str = "bearer" class Req_CreateAccount(AuthProtocol): id: str = "" - pw: str = "" - nickname: str = "" + password: str = "" + company_id: str = "" + name: str = "" + email: str = "" + contact_number: str = "" + role: int = UserRole.USER.value class Res_CreateAccount(Res_WebPacketProtocol): - uid: int = Field(0, description="생성된 user uid", json_schema_extra={"format": "int64"}) + user_id: str = "" class Res_RefreshToken(Res_WebPacketProtocol): access_token: str = "" + token_type: str = "bearer" + + +class CompanyData(WebPacketProtocol): + company_id: str = "" + name: str = "" + + +class Res_Me(Res_WebPacketProtocol): + user_id: str = "" + id: str = "" + name: Optional[str] = None + email: Optional[str] = None + contact_number: Optional[str] = None + role: int = UserRole.USER.value + company: Optional[CompanyData] = Field(default=None) diff --git a/negodata/backend/router/v1/enums/enums.py b/negodata/backend/router/v1/enums/enums.py new file mode 100644 index 0000000..07b45bf --- /dev/null +++ b/negodata/backend/router/v1/enums/enums.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter + +from common.enums import DOMAIN_ENUMS, ENUM_LABELS +from router.v1.validator.dependencies import RemoveNoneResponse +from .protocol import EnumOption, Res_Enums + +# 도메인 코드 enum 메타데이터(공용). 프론트가 페이지 진입 시 드롭다운을 이걸로 채운다. +router = APIRouter(prefix="/v1", tags=["Enums"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/enums", response_model=Res_Enums, summary="도메인 코드 enum 전체") +async def list_enums(): + res = Res_Enums() + res.enums = { + key: [ + EnumOption(value=member.value, name=member.name, label=ENUM_LABELS.get(member, member.name)) + for member in enum_cls + ] + for key, enum_cls in DOMAIN_ENUMS.items() + } + return RemoveNoneResponse(res) diff --git a/negodata/backend/router/v1/enums/protocol.py b/negodata/backend/router/v1/enums/protocol.py new file mode 100644 index 0000000..044351f --- /dev/null +++ b/negodata/backend/router/v1/enums/protocol.py @@ -0,0 +1,15 @@ +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class EnumsProtocol(WebPacketProtocol): + pass + + +class EnumOption(WebPacketProtocol): + value: int + name: str + label: str + + +class Res_Enums(Res_WebPacketProtocol): + enums: dict[str, list[EnumOption]] = {} diff --git a/negodata/backend/router/v1/item/item.py b/negodata/backend/router/v1/item/item.py new file mode 100644 index 0000000..e1dd640 --- /dev/null +++ b/negodata/backend/router/v1/item/item.py @@ -0,0 +1,84 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Query, UploadFile + +from common.models.gmodel import UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.item_service import ItemService +from .protocol import ( + Req_CreateItem, + Req_UpdateItem, + Res_DeleteItem, + Res_ExcelUpload, + Res_Item, + Res_ItemList, + Res_LowestPriceResult, + Res_LowestPriceTrigger, +) + +# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 company_id 로 스코프. +router = APIRouter(prefix="/v1/item", tags=["Item"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/list", response_model=Res_ItemList, summary="상품 목록") +async def list_items( + service: ItemService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + search: str | None = Query(None, description="상품명/상품코드 검색"), + category: str | None = Query(None, description="카테고리 필터"), + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), +): + return RemoveNoneResponse(await service.list_items(user_info.company_id, search, category, page, size)) + + +@router.post(path="/create", response_model=Res_Item, summary="상품 등록") +async def create_item(req: Req_CreateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse( + await service.create_item(user_info.company_id, user_info.user_id, req.model_dump(exclude_unset=True)) + ) + + +@router.post(path="/upload-excel", response_model=Res_ExcelUpload, summary="엑셀 일괄 등록(스텁)") +async def upload_items_excel( + service: ItemService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + file: UploadFile = File(...), +): + return RemoveNoneResponse( + Res_ExcelUpload(received_filename=file.filename, status="not_implemented", message="엑셀 일괄 등록은 추후 구현") + ) + + +@router.get(path="/{item_id}", response_model=Res_Item, summary="상품 조회") +async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_item(user_info.company_id, str(item_id))) + + +@router.patch(path="/update/{item_id}", response_model=Res_Item, summary="상품 수정") +async def update_item( + item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req.model_dump(exclude_unset=True))) + + +@router.delete(path="/delete/{item_id}", response_model=Res_DeleteItem, summary="상품 삭제") +async def delete_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id))) + + +@router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)") +async def trigger_lowest_price(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + # 존재/소유 확인만 (없으면 result 에 ITEM_NOT_FOUND) + got = await service.get_item(user_info.company_id, str(item_id)) + if got.item is None: + return RemoveNoneResponse(got) + return RemoveNoneResponse(Res_LowestPriceTrigger(item_id=str(item_id), status="queued", message="최저가 수집 요청됨(스텁)")) + + +@router.get(path="/{item_id}/lowest-price", response_model=Res_LowestPriceResult, summary="최저가 수집 결과(스텁)") +async def get_lowest_price(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + got = await service.get_item(user_info.company_id, str(item_id)) + if got.item is None: + return RemoveNoneResponse(got) + return RemoveNoneResponse(Res_LowestPriceResult(item_id=str(item_id), results=[], message="최저가 수집 결과 없음(스텁)")) diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py new file mode 100644 index 0000000..cef45da --- /dev/null +++ b/negodata/backend/router/v1/item/protocol.py @@ -0,0 +1,111 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import ConfigDict + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class ItemProtocol(WebPacketProtocol): + pass + + +class Req_CreateItem(ItemProtocol): + name: str = "" + code: Optional[str] = None + category: Optional[str] = None + category_type: int = 1 + image_url: Optional[str] = None + model_name: Optional[str] = None + spec: Optional[str] = None + manufacturer: Optional[str] = None + made_in: Optional[str] = None + price: Optional[int] = None + internet_lowest_price_yn: bool = False + moq: Optional[str] = None + lead_time: Optional[int] = None + quantity_unit: Optional[str] = None + delivery_type: Optional[int] = None + vat_yn: Optional[bool] = None + delivery_fee_yn: Optional[bool] = None + + +class Req_UpdateItem(ItemProtocol): + name: Optional[str] = None + code: Optional[str] = None + category: Optional[str] = None + category_type: Optional[int] = None + image_url: Optional[str] = None + model_name: Optional[str] = None + spec: Optional[str] = None + manufacturer: Optional[str] = None + made_in: Optional[str] = None + price: Optional[int] = None + internet_lowest_price_yn: Optional[bool] = None + moq: Optional[str] = None + lead_time: Optional[int] = None + quantity_unit: Optional[str] = None + delivery_type: Optional[int] = None + vat_yn: Optional[bool] = None + delivery_fee_yn: Optional[bool] = None + + +class ItemData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + item_id: uuid.UUID + company_id: uuid.UUID + user_id: uuid.UUID + name: str + code: Optional[str] = None + category: Optional[str] = None + category_type: int = 1 + image_url: Optional[str] = None + model_name: Optional[str] = None + spec: Optional[str] = None + manufacturer: Optional[str] = None + made_in: Optional[str] = None + price: Optional[int] = None + internet_lowest_price_yn: bool = False + moq: Optional[str] = None + lead_time: Optional[int] = None + quantity_unit: Optional[str] = None + delivery_type: Optional[int] = None + vat_yn: Optional[bool] = None + delivery_fee_yn: Optional[bool] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class Res_Item(Res_WebPacketProtocol): + item: Optional[ItemData] = None + + +class Res_ItemList(Res_WebPacketProtocol): + items: list[ItemData] = [] + total: int = 0 + page: int = 0 + size: int = 0 + + +class Res_DeleteItem(Res_WebPacketProtocol): + pass + + +class Res_ExcelUpload(Res_WebPacketProtocol): + received_filename: Optional[str] = None + status: str = "" + message: str = "" + + +class Res_LowestPriceTrigger(Res_WebPacketProtocol): + item_id: str = "" + status: str = "" + message: str = "" + + +class Res_LowestPriceResult(Res_WebPacketProtocol): + item_id: str = "" + results: list = [] + message: str = "" diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py new file mode 100644 index 0000000..fd2da1a --- /dev/null +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -0,0 +1,155 @@ +import uuid +from datetime import datetime +from typing import Any, Optional + +from pydantic import ConfigDict + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class QuotationProtocol(WebPacketProtocol): + pass + + +class Req_CreateQuotation(QuotationProtocol): + qt_setting_id: uuid.UUID + version_id: uuid.UUID + name: str = "" + number: str = "" + type: int = 0 + status: int = 0 + start_time: datetime + end_time: datetime + round: int = 1 + manager_name: Optional[str] = None + manager_email: Optional[str] = None + manager_contact_number: Optional[str] = None + memo: Optional[str] = None + + +class QuotationData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + qt_id: uuid.UUID + user_id: uuid.UUID + qt_setting_id: uuid.UUID + version_id: uuid.UUID + name: str + number: str + type: int + round: int = 1 + status: int + start_time: datetime + end_time: datetime + manager_name: Optional[str] = None + manager_email: Optional[str] = None + manager_contact_number: Optional[str] = None + memo: Optional[str] = None + iteration: int = 0 + preferred_sp_yn: Optional[bool] = None + preferred_sp_id: Optional[uuid.UUID] = None + preferred_sp_name: Optional[str] = None + equal_bid_yn: Optional[bool] = None + equal_bid_data: Optional[Any] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class Res_Quotation(Res_WebPacketProtocol): + quotation: Optional[QuotationData] = None + + +class Res_QuotationList(Res_WebPacketProtocol): + quotations: list[QuotationData] = [] + total: int = 0 + page: int = 0 + size: int = 0 + + +class Res_DeleteQuotation(Res_WebPacketProtocol): + pass + + +class AsyncJob(WebPacketProtocol): + status: str = "" + message: str = "" + + +class Res_CreateQuotation(Res_WebPacketProtocol): + quotation: Optional[QuotationData] = None + async_job: Optional[AsyncJob] = None + + +class Res_QuotationStatus(Res_WebPacketProtocol): + qt_id: Optional[uuid.UUID] = None + job_status: int = 0 + message: str = "" + + +class SessionData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + session_id: uuid.UUID + qt_id: uuid.UUID + supplier_id: uuid.UUID + item_id: uuid.UUID + qt_number: str + qt_round: int + qt_type: int + target_price: int + status: int + bid_price: Optional[int] = None + bid_at: Optional[datetime] = None + end_time: datetime + reject_reason: Optional[str] = None + reject_price: Optional[int] = None + reject_delivery_type: Optional[int] = None + + +class Res_QuotationSessions(Res_WebPacketProtocol): + qt_id: Optional[uuid.UUID] = None + sessions: list[SessionData] = [] + total: int = 0 + + +class ChatMessageData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + chat_id: uuid.UUID + session_id: uuid.UUID + card_id: Optional[uuid.UUID] = None + index: int + sender: int + target_price: int + card_used_yn: Optional[bool] = None + indicator_value: Optional[float] = None + card_type: Optional[int] = None + + +class Res_SessionChat(Res_WebPacketProtocol): + session_id: Optional[uuid.UUID] = None + messages: list[ChatMessageData] = [] + + +class Res_QuotationResult(Res_WebPacketProtocol): + qt_id: Optional[uuid.UUID] = None + winner_supplier_id: Optional[uuid.UUID] = None + winner_supplier_name: Optional[str] = None + is_equal_bid: Optional[bool] = None + equal_bid_data: Optional[Any] = None + result_count: int = 0 + + +class QuotationCardData(WebPacketProtocol): + session_card_id: uuid.UUID + qt_id: Optional[uuid.UUID] = None + nego_card_id: Optional[uuid.UUID] = None + wild_card_id: Optional[uuid.UUID] = None + type: Optional[int] = None + name: Optional[str] = None + script: Optional[str] = None + + +class Res_QuotationCards(Res_WebPacketProtocol): + qt_id: Optional[uuid.UUID] = None + cards: list[QuotationCardData] = [] diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py new file mode 100644 index 0000000..41f78f1 --- /dev/null +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -0,0 +1,90 @@ +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from common.models.gmodel import UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.quotation_service import QuotationService +from .protocol import ( + Req_CreateQuotation, + Res_CreateQuotation, + Res_DeleteQuotation, + Res_Quotation, + Res_QuotationCards, + Res_QuotationList, + Res_QuotationResult, + Res_QuotationSessions, + Res_QuotationStatus, + Res_SessionChat, +) + +# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받는다. +# quotations 테이블에 company_id 가 없어 회사 스코핑은 하지 않는다(토큰 검증만). +# 라우팅 주의: 정적/하위 경로(/list, /create, /{qt_id}/status ...)를 /{qt_id} 보다 먼저 선언해야 +# /{qt_id} 가 /list 등을 가로채지 않는다. +router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: {"description": "Not found"}}) + + +# ----- 실제 엔드포인트 (DB) ----- +@router.get(path="/list", response_model=Res_QuotationList, summary="견적 목록") +async def list_quotations( + service: QuotationService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + status: str | None = Query(None, description="상태 필터(정확히 일치)"), + type: str | None = Query(None, description="유형 필터(정확히 일치)"), + start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"), + start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"), + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), +): + return RemoveNoneResponse(await service.list_quotations(status, type, start_from, start_to, page, size)) + + +@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성") +async def create_quotation( + req: Req_CreateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.create_quotation(user_info.user_id, req.model_dump(exclude_unset=True))) + + +@router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감") +async def stop_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.stop_quotation(str(qt_id))) + + +# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) ----- +@router.get(path="/{qt_id}/status", response_model=Res_QuotationStatus, summary="견적 상태 조회") +async def get_quotation_status(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_status(str(qt_id))) + + +@router.get(path="/{qt_id}/sessions", response_model=Res_QuotationSessions, summary="참여현황") +async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.list_sessions(str(qt_id))) + + +@router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세") +async def get_session_chat(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.list_chats(str(session_id))) + + +@router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과") +async def get_quotation_result(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_result(str(qt_id))) + + +@router.get(path="/{qt_id}/cards", response_model=Res_QuotationCards, summary="견적 사용 카드") +async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.list_cards(str(qt_id))) + + +@router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제") +async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.delete_quotation(str(qt_id))) + + +# ----- 단건 조회 (정적/하위 경로 뒤에 선언) ----- +@router.get(path="/{qt_id}", response_model=Res_Quotation, summary="견적 조회") +async def get_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_quotation(str(qt_id))) diff --git a/negodata/backend/router/v1/quotation_setting/protocol.py b/negodata/backend/router/v1/quotation_setting/protocol.py new file mode 100644 index 0000000..0518f40 --- /dev/null +++ b/negodata/backend/router/v1/quotation_setting/protocol.py @@ -0,0 +1,48 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import ConfigDict + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class QuotationSettingProtocol(WebPacketProtocol): + pass + + +class Req_CreateQuotationSetting(QuotationSettingProtocol): + target_margin_rate: float + anchoring_value: float = 0.01 + card_count: int = 3 + + +class Req_UpdateQuotationSetting(QuotationSettingProtocol): + target_margin_rate: Optional[float] = None + anchoring_value: Optional[float] = None + card_count: Optional[int] = None + + +class QuotationSettingData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + qt_setting_id: uuid.UUID + user_id: Optional[uuid.UUID] = None + target_margin_rate: float + anchoring_value: float + card_count: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class Res_QuotationSetting(Res_WebPacketProtocol): + setting: Optional[QuotationSettingData] = None + + +class Res_QuotationSettingList(Res_WebPacketProtocol): + settings: list[QuotationSettingData] = [] + total: int = 0 + + +class Res_DeleteQuotationSetting(Res_WebPacketProtocol): + pass diff --git a/negodata/backend/router/v1/quotation_setting/quotation_setting.py b/negodata/backend/router/v1/quotation_setting/quotation_setting.py new file mode 100644 index 0000000..5901404 --- /dev/null +++ b/negodata/backend/router/v1/quotation_setting/quotation_setting.py @@ -0,0 +1,52 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends + +from common.models.gmodel import UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.quotation_setting_service import QuotationSettingService +from .protocol import ( + Req_CreateQuotationSetting, + Req_UpdateQuotationSetting, + Res_DeleteQuotationSetting, + Res_QuotationSetting, + Res_QuotationSettingList, +) + +# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 user_id 로 스코프. +router = APIRouter( + prefix="/v1/quotation-setting", tags=["QuotationSetting"], responses={404: {"description": "Not found"}} +) + + +@router.get(path="/list", response_model=Res_QuotationSettingList, summary="견적 설정 목록") +async def list_settings(service: QuotationSettingService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.list_settings(user_info.user_id)) + + +@router.post(path="/create", response_model=Res_QuotationSetting, summary="견적 설정 등록") +async def create_setting( + req: Req_CreateQuotationSetting, + service: QuotationSettingService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + return RemoveNoneResponse(await service.create_setting(user_info.user_id, req.model_dump(exclude_unset=True))) + + +@router.patch(path="/update/{qt_setting_id}", response_model=Res_QuotationSetting, summary="견적 설정 수정") +async def update_setting( + qt_setting_id: UUID, + req: Req_UpdateQuotationSetting, + service: QuotationSettingService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + return RemoveNoneResponse( + await service.update_setting(user_info.user_id, str(qt_setting_id), req.model_dump(exclude_unset=True)) + ) + + +@router.delete(path="/delete/{qt_setting_id}", response_model=Res_DeleteQuotationSetting, summary="견적 설정 삭제") +async def delete_setting( + qt_setting_id: UUID, service: QuotationSettingService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.delete_setting(user_info.user_id, str(qt_setting_id))) diff --git a/negodata/backend/router/v1/supplier/protocol.py b/negodata/backend/router/v1/supplier/protocol.py new file mode 100644 index 0000000..4b4f5a3 --- /dev/null +++ b/negodata/backend/router/v1/supplier/protocol.py @@ -0,0 +1,66 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import ConfigDict + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class SupplierProtocol(WebPacketProtocol): + pass + + +class Req_CreateSupplier(SupplierProtocol): + name: str = "" + code: Optional[str] = None + manager_name: Optional[str] = None + manager_email: Optional[str] = None + manager_contact_number: Optional[str] = None + priority: Optional[str] = None + + +class Req_UpdateSupplier(SupplierProtocol): + name: Optional[str] = None + code: Optional[str] = None + manager_name: Optional[str] = None + manager_email: Optional[str] = None + manager_contact_number: Optional[str] = None + priority: Optional[str] = None + + +class SupplierData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + supplier_id: uuid.UUID + company_id: uuid.UUID + user_id: uuid.UUID + name: str + code: Optional[str] = None + manager_name: Optional[str] = None + manager_email: Optional[str] = None + manager_contact_number: Optional[str] = None + priority: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class Res_Supplier(Res_WebPacketProtocol): + supplier: Optional[SupplierData] = None + + +class Res_SupplierList(Res_WebPacketProtocol): + suppliers: list[SupplierData] = [] + total: int = 0 + page: int = 0 + size: int = 0 + + +class Res_DeleteSupplier(Res_WebPacketProtocol): + pass + + +class Res_ExcelUpload(Res_WebPacketProtocol): + received_filename: Optional[str] = None + status: str = "" + message: str = "" diff --git a/negodata/backend/router/v1/supplier/supplier.py b/negodata/backend/router/v1/supplier/supplier.py new file mode 100644 index 0000000..bfa34c6 --- /dev/null +++ b/negodata/backend/router/v1/supplier/supplier.py @@ -0,0 +1,68 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Query, UploadFile + +from common.models.gmodel import UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.supplier_service import SupplierService +from .protocol import ( + Req_CreateSupplier, + Req_UpdateSupplier, + Res_DeleteSupplier, + Res_ExcelUpload, + Res_Supplier, + Res_SupplierList, +) + +# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 company_id 로 스코프. +router = APIRouter(prefix="/v1/supplier", tags=["Supplier"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/list", response_model=Res_SupplierList, summary="협력사 목록") +async def list_suppliers( + service: SupplierService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + search: str | None = Query(None, description="협력사명/코드/담당자명 검색"), + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), +): + return RemoveNoneResponse(await service.list_suppliers(user_info.company_id, search, page, size)) + + +@router.post(path="/create", response_model=Res_Supplier, summary="협력사 등록") +async def create_supplier( + req: Req_CreateSupplier, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse( + await service.create_supplier(user_info.company_id, user_info.user_id, req.model_dump(exclude_unset=True)) + ) + + +@router.post(path="/upload-excel", response_model=Res_ExcelUpload, summary="협력사 엑셀 일괄 등록(스텁)") +async def upload_suppliers_excel( + service: SupplierService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + file: UploadFile = File(...), +): + return RemoveNoneResponse( + Res_ExcelUpload(received_filename=file.filename, status="not_implemented", message="엑셀 일괄 등록은 추후 구현") + ) + + +@router.get(path="/{supplier_id}", response_model=Res_Supplier, summary="협력사 조회") +async def get_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_supplier(user_info.company_id, str(supplier_id))) + + +@router.patch(path="/update/{supplier_id}", response_model=Res_Supplier, summary="협력사 수정") +async def update_supplier( + supplier_id: UUID, req: Req_UpdateSupplier, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse( + await service.update_supplier(user_info.company_id, str(supplier_id), req.model_dump(exclude_unset=True)) + ) + + +@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제") +async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id))) diff --git a/negodata/backend/router/v1/validator/dependencies.py b/negodata/backend/router/v1/validator/dependencies.py index 73ad74e..6de767e 100644 --- a/negodata/backend/router/v1/validator/dependencies.py +++ b/negodata/backend/router/v1/validator/dependencies.py @@ -110,4 +110,6 @@ def RemoveNoneValues(obj: Any) -> Any: def RemoveNoneResponse(obj) -> ORJSONResponse: - return ORJSONResponse(content=RemoveNoneValues(obj.model_dump())) + # mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서 + # JSON 안전한 문자열로 변환한다. (python 모드면 orjson 이 asyncpg.UUID 를 직렬화 못 함) + return ORJSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json"))) diff --git a/negodata/backend/services/auth_service.py b/negodata/backend/services/auth_service.py index 1d41675..d3c39af 100644 --- a/negodata/backend/services/auth_service.py +++ b/negodata/backend/services/auth_service.py @@ -1,12 +1,14 @@ +import uuid + from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import tbl_account -from common.enums import DBWRType, ErrorType +from common.database.model.models import users +from common.enums import DBWRType, ErrorType, UserStatus, UserRole from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD -from router.v1.auth.protocol import Res_CreateAccount, Res_Login, Res_RefreshToken +from router.v1.auth.protocol import CompanyData, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken from router.v1.validator.dependencies import ( CreateAccessToken, CreateRefreshToken, @@ -29,59 +31,68 @@ class AuthService: def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)): self.user_crud = user_crud - async def attempt_login(self, id: str, pw: str, connect_ip: str) -> Res_Login: - LOG.i(f"LOGIN : {id=}") + @staticmethod + def _user_info(user: users) -> UserInfo: + # uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다. + return UserInfo( + user_id=str(user.user_id), + id=user.id, + company_id=str(user.company_id), + ) + + async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login: + LOG.i(f"LOGIN : id={login_id}") res = Res_Login() # 1) 계정 조회 (Read DB) - err_type, account = await DB_SESSION_MNG.execute_lambda( - tbl_account.DBType(), + err_type, user = await DB_SESSION_MNG.execute_lambda( + users.DBType(), DBWRType.DB_READ.value, - lambda s: self.user_crud.get_account_by_id(s, id), + lambda s: self.user_crud.get_user_by_login_id(s, login_id), ) if err_type != ErrorType.SUCCESS: # 계정 없음/조회 실패 모두 로그인 실패로 일반화 res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) return res - account: tbl_account + user: users # 2) 비밀번호 검증 - if not await VerifyPW(pw, account.pw): + if not await VerifyPW(password, user.password): res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) return res - # 3) 차단 여부 - if account.is_blocked: + # 3) 상태 확인 (활성 아니면 차단) + if user.status != UserStatus.ACTIVE.value: res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER) return res # 4) 토큰 발급 - user_info = UserInfo(uid=account.uid, id=account.id, nickname=account.nickname) + user_info = self._user_info(user) res.access_token = CreateAccessToken(user_info) res.refresh_token = CreateRefreshToken(user_info) - # 5) 마지막 로그인 시간 갱신 (Write DB, 트랜잭션) + # 5) 마지막 접속 시간 갱신 (Write DB, 트랜잭션) err_type = await DB_SESSION_MNG.execute_lambda_run( - [tbl_account.DBType()], - [lambda s: self.user_crud.update_last_login(s, account.uid)], + [users.DBType()], + [lambda s: self.user_crud.update_last_accessed(s, user.user_id)], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - res.uid = account.uid - res.nickname = account.nickname return res - async def create_account(self, id: str, pw: str, nickname: str, connect_ip: str) -> Res_CreateAccount: - LOG.i(f"CREATE : {id=}, {nickname=}") + async def create_account( + self, login_id: str, password: str, company_id: str, name: str, email: str, contact_number: str, role: int + ) -> Res_CreateAccount: + LOG.i(f"CREATE : id={login_id}, company_id={company_id}") res = Res_CreateAccount() # 1) 중복 ID 확인 (Read DB) err_type = await DB_SESSION_MNG.execute_lambda( - tbl_account.DBType(), + users.DBType(), DBWRType.DB_READ.value, - lambda s: self.user_crud.is_account(s, id), + lambda s: self.user_crud.is_user(s, login_id), ) if err_type == ErrorType.DB_ALREADY_SAME_KEY: res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) @@ -91,10 +102,18 @@ class AuthService: return res # 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장) - account = tbl_account(id=id, pw=await GetHashedPW(pw), nickname=nickname or id) + user = users( + company_id=uuid.UUID(company_id), + id=login_id, + password=await GetHashedPW(password), + name=name or None, + email=email or None, + contact_number=contact_number or None, + role=role or UserRole.USER.value, + ) err_type = await DB_SESSION_MNG.execute_lambda_run( - [tbl_account.DBType()], - [lambda s: self.user_crud.add_account(s, account)], + [users.DBType()], + [lambda s: self.user_crud.add_user(s, user)], ) if err_type != ErrorType.SUCCESS: # 사전 검사와 INSERT 사이의 경쟁 조건에서 unique 위반이 나면 동일 코드로 매핑. @@ -104,7 +123,40 @@ class AuthService: res.result.SetResult(err_type) return res - res.uid = account.uid + res.user_id = str(user.user_id) + return res + + async def get_me(self, user_info: UserInfo) -> Res_Me: + res = Res_Me() + + # 1) 유저 조회 + err_type, user = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_user_by_login_id(s, user_info.id), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) + return res + user: users + + # 2) 소속사 조회 (없어도 치명적 아님) + company = None + c_err, company_row = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_company(s, user.company_id), + ) + if c_err == ErrorType.SUCCESS and company_row is not None: + company = CompanyData(company_id=str(company_row.company_id), name=company_row.name) + + res.user_id = str(user.user_id) + res.id = user.id + res.name = user.name + res.email = user.email + res.contact_number = user.contact_number + res.role = user.role + res.company = company return res async def refresh_token(self, refresh_token: str) -> Res_RefreshToken: diff --git a/negodata/backend/services/item_service.py b/negodata/backend/services/item_service.py new file mode 100644 index 0000000..18ff4b9 --- /dev/null +++ b/negodata/backend/services/item_service.py @@ -0,0 +1,109 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import items +from common.enums import DBWRType, ErrorType +from common.logger import LOG +from crud.item_crud import IItemCRUD, ItemCRUD +from router.v1.item.protocol import ItemData, Res_DeleteItem, Res_Item, Res_ItemList + + +class ItemService: + """상품 비즈니스 로직. company_id 로 소유권을 확인한다(멀티테넌트).""" + + def __init__(self, item_crud: IItemCRUD = Depends(ItemCRUD)): + self.item_crud = item_crud + + async def _fetch_owned(self, company_uuid: uuid.UUID, item_id: uuid.UUID): + """item 조회 + 소유권 확인. (ErrorType, item|None) 반환.""" + err_type, item = await DB_SESSION_MNG.execute_lambda( + items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.item_crud.get_by_id(s, item_id), + ) + if err_type != ErrorType.SUCCESS or item is None: + return ErrorType.ITEM_NOT_FOUND, None + if item.company_id != company_uuid: + return ErrorType.ITEM_NOT_FOUND, None + return ErrorType.SUCCESS, item + + async def list_items(self, company_id: str, search, category, page: int, size: int) -> Res_ItemList: + res = Res_ItemList(page=page, size=size) + company_uuid = uuid.UUID(company_id) + skip = (page - 1) * size + + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.item_crud.search(s, company_uuid, search, category, skip, size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.items = [ItemData.model_validate(r) for r in rows] + res.total = total + return res + + async def get_item(self, company_id: str, item_id: str) -> Res_Item: + res = Res_Item() + err_type, item = await self._fetch_owned(uuid.UUID(company_id), uuid.UUID(item_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.item = ItemData.model_validate(item) + return res + + async def create_item(self, company_id: str, user_id: str, data: dict) -> Res_Item: + res = Res_Item() + item = items(**data, company_id=uuid.UUID(company_id), user_id=uuid.UUID(user_id)) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [items.DBType()], + [lambda s: self.item_crud.add_item(s, item)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + # 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다. + return await self.get_item(company_id, str(item.item_id)) + + async def update_item(self, company_id: str, item_id: str, data: dict) -> Res_Item: + res = Res_Item() + company_uuid = uuid.UUID(company_id) + item_uuid = uuid.UUID(item_id) + + # 소유권 확인 + err_type, _ = await self._fetch_owned(company_uuid, item_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [items.DBType()], + [lambda s: self.item_crud.update_item(s, item_uuid, data)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 갱신 후 재조회 + return await self.get_item(company_id, item_id) + + async def delete_item(self, company_id: str, item_id: str) -> Res_DeleteItem: + res = Res_DeleteItem() + company_uuid = uuid.UUID(company_id) + item_uuid = uuid.UUID(item_id) + + err_type, _ = await self._fetch_owned(company_uuid, item_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [items.DBType()], + [lambda s: self.item_crud.soft_delete(s, item_uuid)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py new file mode 100644 index 0000000..07a0567 --- /dev/null +++ b/negodata/backend/services/quotation_service.py @@ -0,0 +1,184 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import quotations +from common.enums import DBWRType, ErrorType +from crud.quotation_crud import IQuotationCRUD, QuotationCRUD +from router.v1.quotation.protocol import ( + AsyncJob, + QuotationData, + Res_CreateQuotation, + Res_DeleteQuotation, + Res_Quotation, + Res_QuotationCards, + Res_QuotationList, + Res_QuotationResult, + Res_QuotationSessions, + Res_QuotationStatus, + Res_SessionChat, +) + + +class QuotationService: + """견적 비즈니스 로직. + + quotations 테이블에는 company_id 가 없어 회사 스코핑은 하지 않는다(토큰 검증만). + user_id 는 생성 시 소유자로만 기록한다(조회/변경 시 소유권 필터 없음). + """ + + def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)): + self.quotation_crud = quotation_crud + + async def _fetch(self, qt_id: uuid.UUID): + """견적 단건 조회. (ErrorType, quotation|None) 반환. (회사 스코프 없음)""" + err_type, quotation = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_by_id(s, qt_id), + ) + if err_type != ErrorType.SUCCESS or quotation is None: + return ErrorType.QUOTATION_NOT_FOUND, None + return ErrorType.SUCCESS, quotation + + async def list_quotations(self, status, type_, start_from, start_to, page: int, size: int) -> Res_QuotationList: + res = Res_QuotationList(page=page, size=size) + skip = (page - 1) * size + + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.search(s, status, type_, start_from, start_to, skip, size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.quotations = [QuotationData.model_validate(r) for r in rows] + res.total = total + return res + + async def get_quotation(self, qt_id: str) -> Res_Quotation: + res = Res_Quotation() + err_type, quotation = await self._fetch(uuid.UUID(qt_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.quotation = QuotationData.model_validate(quotation) + return res + + async def create_quotation(self, user_id: str, data: dict) -> Res_CreateQuotation: + res = Res_CreateQuotation() + quotation = quotations(**data, user_id=uuid.UUID(user_id)) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [quotations.DBType()], + [lambda s: self.quotation_crud.add_quotation(s, quotation)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + # 서버 기본값(created_at/updated_at) 로드 위해 재조회 (응답 shape 은 Res_CreateQuotation 유지). + f_err, fresh = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_by_id(s, quotation.qt_id), + ) + res.quotation = QuotationData.model_validate(fresh if f_err == ErrorType.SUCCESS and fresh is not None else quotation) + # 네고시움 백엔드 비동기 요청은 스텁이므로 row 만 생성한다. + res.async_job = AsyncJob(status="submitted", message="견적 생성 작업 요청됨(스텁)") + return res + + async def stop_quotation(self, qt_id: str) -> Res_Quotation: + res = Res_Quotation() + qt_uuid = uuid.UUID(qt_id) + + # 존재 확인 + err_type, _ = await self._fetch(qt_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 상태를 '견적마감'으로 변경(실제 DB 업데이트) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [quotations.DBType()], + [lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": "견적마감"})], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 갱신 후 재조회 + return await self.get_quotation(qt_id) + + async def delete_quotation(self, qt_id: str) -> Res_DeleteQuotation: + res = Res_DeleteQuotation() + qt_uuid = uuid.UUID(qt_id) + + err_type, _ = await self._fetch(qt_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [quotations.DBType()], + [lambda s: self.quotation_crud.soft_delete(s, qt_uuid)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + async def get_status(self, qt_id: str) -> Res_QuotationStatus: + res = Res_QuotationStatus() + err_type, quotation = await self._fetch(uuid.UUID(qt_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.qt_id = quotation.qt_id + res.job_status = quotation.status + res.message = "ok" + return res + + async def get_result(self, qt_id: str) -> Res_QuotationResult: + res = Res_QuotationResult() + err_type, quotation = await self._fetch(uuid.UUID(qt_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + # 낙찰 결과는 quotations 컬럼에서 직접 노출. results 테이블 미존재로 result_count 는 0. + res.qt_id = quotation.qt_id + res.winner_supplier_id = quotation.preferred_sp_id + res.winner_supplier_name = quotation.preferred_sp_name + res.is_equal_bid = quotation.equal_bid_yn + res.equal_bid_data = quotation.equal_bid_data + res.result_count = 0 + return res + + async def list_sessions(self, qt_id: str) -> Res_QuotationSessions: + # sessions 모델 미존재 — 존재 검증 후 빈 목록 반환(스텁). + res = Res_QuotationSessions() + err_type, quotation = await self._fetch(uuid.UUID(qt_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.qt_id = quotation.qt_id + res.sessions = [] + res.total = 0 + return res + + async def list_chats(self, session_id: str) -> Res_SessionChat: + # chats 모델 미존재 — 빈 목록 반환(스텁). + res = Res_SessionChat() + res.session_id = uuid.UUID(session_id) + res.messages = [] + return res + + async def list_cards(self, qt_id: str) -> Res_QuotationCards: + # quotation_cards 모델 미존재 — 존재 검증 후 빈 목록 반환(스텁). + res = Res_QuotationCards() + err_type, quotation = await self._fetch(uuid.UUID(qt_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.qt_id = quotation.qt_id + res.cards = [] + return res diff --git a/negodata/backend/services/quotation_setting_service.py b/negodata/backend/services/quotation_setting_service.py new file mode 100644 index 0000000..5d67854 --- /dev/null +++ b/negodata/backend/services/quotation_setting_service.py @@ -0,0 +1,112 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import quotation_settings +from common.enums import DBWRType, ErrorType +from crud.quotation_setting_crud import IQuotationSettingCRUD, QuotationSettingCRUD +from router.v1.quotation_setting.protocol import ( + QuotationSettingData, + Res_DeleteQuotationSetting, + Res_QuotationSetting, + Res_QuotationSettingList, +) + + +class QuotationSettingService: + """견적 설정 비즈니스 로직. user_id 로 소유권을 확인한다(유저별 설정).""" + + def __init__(self, qs_crud: IQuotationSettingCRUD = Depends(QuotationSettingCRUD)): + self.qs_crud = qs_crud + + async def _fetch_owned(self, user_uuid: uuid.UUID, qt_setting_id: uuid.UUID): + """설정 조회 + 소유권 확인. (ErrorType, setting|None) 반환.""" + err_type, setting = await DB_SESSION_MNG.execute_lambda( + quotation_settings.DBType(), + DBWRType.DB_READ.value, + lambda s: self.qs_crud.get_by_id(s, qt_setting_id), + ) + if err_type != ErrorType.SUCCESS or setting is None: + return ErrorType.QUOTATION_SETTING_NOT_FOUND, None + if setting.user_id != user_uuid: + return ErrorType.QUOTATION_SETTING_NOT_FOUND, None + return ErrorType.SUCCESS, setting + + async def list_settings(self, user_id: str) -> Res_QuotationSettingList: + res = Res_QuotationSettingList() + user_uuid = uuid.UUID(user_id) + + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + quotation_settings.DBType(), + DBWRType.DB_READ.value, + lambda s: self.qs_crud.list_by_user(s, user_uuid), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.settings = [QuotationSettingData.model_validate(r) for r in rows] + res.total = total + return res + + async def get_setting(self, user_id: str, qt_setting_id: str) -> Res_QuotationSetting: + res = Res_QuotationSetting() + err_type, setting = await self._fetch_owned(uuid.UUID(user_id), uuid.UUID(qt_setting_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.setting = QuotationSettingData.model_validate(setting) + return res + + async def create_setting(self, user_id: str, data: dict) -> Res_QuotationSetting: + res = Res_QuotationSetting() + setting = quotation_settings(**data, user_id=uuid.UUID(user_id)) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [quotation_settings.DBType()], + [lambda s: self.qs_crud.add_setting(s, setting)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + # 서버 기본값(created_at/updated_at)은 insert 후 객체에 실리지 않으므로 재조회한다. + return await self.get_setting(user_id, str(setting.qt_setting_id)) + + async def update_setting(self, user_id: str, qt_setting_id: str, data: dict) -> Res_QuotationSetting: + res = Res_QuotationSetting() + user_uuid = uuid.UUID(user_id) + setting_uuid = uuid.UUID(qt_setting_id) + + # 소유권 확인 + err_type, _ = await self._fetch_owned(user_uuid, setting_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [quotation_settings.DBType()], + [lambda s: self.qs_crud.update_setting(s, setting_uuid, data)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 갱신 후 재조회 + return await self.get_setting(user_id, qt_setting_id) + + async def delete_setting(self, user_id: str, qt_setting_id: str) -> Res_DeleteQuotationSetting: + res = Res_DeleteQuotationSetting() + user_uuid = uuid.UUID(user_id) + setting_uuid = uuid.UUID(qt_setting_id) + + err_type, _ = await self._fetch_owned(user_uuid, setting_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [quotation_settings.DBType()], + [lambda s: self.qs_crud.soft_delete(s, setting_uuid)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res diff --git a/negodata/backend/services/supplier_service.py b/negodata/backend/services/supplier_service.py new file mode 100644 index 0000000..c000b07 --- /dev/null +++ b/negodata/backend/services/supplier_service.py @@ -0,0 +1,109 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import suppliers +from common.enums import DBWRType, ErrorType +from common.logger import LOG +from crud.supplier_crud import ISupplierCRUD, SupplierCRUD +from router.v1.supplier.protocol import Res_DeleteSupplier, Res_Supplier, Res_SupplierList, SupplierData + + +class SupplierService: + """협력사 비즈니스 로직. company_id 로 소유권을 확인한다(멀티테넌트).""" + + def __init__(self, supplier_crud: ISupplierCRUD = Depends(SupplierCRUD)): + self.supplier_crud = supplier_crud + + async def _fetch_owned(self, company_uuid: uuid.UUID, supplier_id: uuid.UUID): + """supplier 조회 + 소유권 확인. (ErrorType, supplier|None) 반환.""" + err_type, supplier = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_crud.get_by_id(s, supplier_id), + ) + if err_type != ErrorType.SUCCESS or supplier is None: + return ErrorType.SUPPLIER_NOT_FOUND, None + if supplier.company_id != company_uuid: + return ErrorType.SUPPLIER_NOT_FOUND, None + return ErrorType.SUCCESS, supplier + + async def list_suppliers(self, company_id: str, search, page: int, size: int) -> Res_SupplierList: + res = Res_SupplierList(page=page, size=size) + company_uuid = uuid.UUID(company_id) + skip = (page - 1) * size + + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_crud.search(s, company_uuid, search, skip, size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.suppliers = [SupplierData.model_validate(r) for r in rows] + res.total = total + return res + + async def get_supplier(self, company_id: str, supplier_id: str) -> Res_Supplier: + res = Res_Supplier() + err_type, supplier = await self._fetch_owned(uuid.UUID(company_id), uuid.UUID(supplier_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.supplier = SupplierData.model_validate(supplier) + return res + + async def create_supplier(self, company_id: str, user_id: str, data: dict) -> Res_Supplier: + res = Res_Supplier() + supplier = suppliers(**data, company_id=uuid.UUID(company_id), user_id=uuid.UUID(user_id)) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [suppliers.DBType()], + [lambda s: self.supplier_crud.add_supplier(s, supplier)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + # 서버 기본값(created_at/updated_at)은 insert 후 객체에 실리지 않으므로 재조회한다. + return await self.get_supplier(company_id, str(supplier.supplier_id)) + + async def update_supplier(self, company_id: str, supplier_id: str, data: dict) -> Res_Supplier: + res = Res_Supplier() + company_uuid = uuid.UUID(company_id) + supplier_uuid = uuid.UUID(supplier_id) + + # 소유권 확인 + err_type, _ = await self._fetch_owned(company_uuid, supplier_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [suppliers.DBType()], + [lambda s: self.supplier_crud.update_supplier(s, supplier_uuid, data)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + # 갱신 후 재조회 + return await self.get_supplier(company_id, supplier_id) + + async def delete_supplier(self, company_id: str, supplier_id: str) -> Res_DeleteSupplier: + res = Res_DeleteSupplier() + company_uuid = uuid.UUID(company_id) + supplier_uuid = uuid.UUID(supplier_id) + + err_type, _ = await self._fetch_owned(company_uuid, supplier_uuid) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [suppliers.DBType()], + [lambda s: self.supplier_crud.soft_delete(s, supplier_uuid)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res diff --git a/negodata/backend/tests/test_auth.py b/negodata/backend/tests/test_auth.py index a23ba5a..ea23cce 100644 --- a/negodata/backend/tests/test_auth.py +++ b/negodata/backend/tests/test_auth.py @@ -1,39 +1,48 @@ -"""auth 도메인 e2e 테스트. +"""auth 도메인 e2e 테스트 (negodata: users/companies 기반). -실행 전제: docker-compose 로 PostgreSQL 이 떠 있어야 한다 (negodata_db 사용). +실행 전제: PostgreSQL(negodata_db)이 떠 있어야 한다. docker compose up -d # 또는 로컬 postgres cd negodata/backend && python -m pytest +계정 생성은 company_id 를 요구하므로 company_id 픽스처(conftest)가 소속사를 시드한다. """ -async def test_create_and_login_flow(client): - # 1) 계정 생성 - r = await client.post("/v1/auth/create", json={"id": "user1", "pw": "pw1234", "nickname": "닉네임"}) +async def test_create_and_login_flow(client, company_id): + # 1) 계정 생성 (회사 하위로) + r = await client.post( + "/v1/auth/create", + json={"id": "user1", "password": "pw1234", "company_id": company_id, "name": "홍길동"}, + ) assert r.status_code == 200 body = r.json() assert body["result"]["success"] is True - assert body["uid"] > 0 + assert body["user_id"] # 2) 로그인 -> 토큰 발급 - r = await client.post("/v1/auth/login", json={"id": "user1", "pw": "pw1234"}) + r = await client.post("/v1/auth/login", json={"id": "user1", "password": "pw1234"}) assert r.status_code == 200 body = r.json() assert body["result"]["success"] is True assert body["access_token"] assert body["refresh_token"] - assert body["nickname"] == "닉네임" access_token = body["access_token"] - # 3) 보호된 엔드포인트 호출 + # 3) 보호된 엔드포인트(/me) — 토큰의 유저 + 소속사 반환 r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"}) assert r.status_code == 200 - assert r.json()["id"] == "user1" + me = r.json() + assert me["id"] == "user1" + assert me["name"] == "홍길동" + assert me["company"]["company_id"] == company_id -async def test_login_with_wrong_password(client): - await client.post("/v1/auth/create", json={"id": "user2", "pw": "correct", "nickname": "n"}) +async def test_login_with_wrong_password(client, company_id): + await client.post( + "/v1/auth/create", + json={"id": "user2", "password": "correct", "company_id": company_id, "name": "n"}, + ) - r = await client.post("/v1/auth/login", json={"id": "user2", "pw": "wrong"}) + r = await client.post("/v1/auth/login", json={"id": "user2", "password": "wrong"}) assert r.status_code == 200 body = r.json() assert body["result"]["success"] is False @@ -43,15 +52,21 @@ async def test_login_with_wrong_password(client): async def test_login_nonexistent_account(client): - r = await client.post("/v1/auth/login", json={"id": "ghost", "pw": "whatever"}) + r = await client.post("/v1/auth/login", json={"id": "ghost", "password": "whatever"}) assert r.json()["result"]["success"] is False -async def test_duplicate_account_create(client): - r1 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw1234", "nickname": "n"}) +async def test_duplicate_account_create(client, company_id): + r1 = await client.post( + "/v1/auth/create", + json={"id": "dup", "password": "pw1234", "company_id": company_id, "name": "n"}, + ) assert r1.json()["result"]["success"] is True - r2 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw5678", "nickname": "n2"}) + r2 = await client.post( + "/v1/auth/create", + json={"id": "dup", "password": "pw5678", "company_id": company_id, "name": "n2"}, + ) body = r2.json() assert body["result"]["success"] is False # ACCOUNT_ALREADY_EXIST(1201) diff --git a/negodata/backend/tests/test_features.py b/negodata/backend/tests/test_features.py new file mode 100644 index 0000000..05eb5ca --- /dev/null +++ b/negodata/backend/tests/test_features.py @@ -0,0 +1,68 @@ +"""supplier / quotation_setting / quotation 슬라이스 런타임 스모크. + +create(재조회로 created_at 적재) + list + get 경로를 라이브 DB 로 확인한다. +""" +import uuid + + +async def _headers(client, company_id, login_id): + await client.post( + "/v1/auth/create", + json={"id": login_id, "password": "pw1234", "company_id": company_id, "name": "n"}, + ) + r = await client.post("/v1/auth/login", json={"id": login_id, "password": "pw1234"}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +async def test_supplier_crud(client, company_id): + h = await _headers(client, company_id, "supuser") + r = await client.post("/v1/supplier/create", json={"name": "공급사A", "code": "S1"}, headers=h) + body = r.json() + assert body["result"]["success"] is True + sup = body["supplier"] + assert sup["name"] == "공급사A" + assert sup["created_at"] # 재조회 픽스: 서버 기본값 적재 확인 + sid = sup["supplier_id"] + + r = await client.get("/v1/supplier/list", headers=h) + assert r.json()["total"] == 1 + + r = await client.get(f"/v1/supplier/{sid}", headers=h) + assert r.json()["supplier"]["supplier_id"] == sid + + +async def test_quotation_setting_crud(client, company_id): + h = await _headers(client, company_id, "qsuser") + r = await client.post("/v1/quotation-setting/create", json={"target_margin_rate": 0.15}, headers=h) + body = r.json() + assert body["result"]["success"] is True + st = body["setting"] + assert st["target_margin_rate"] == 0.15 + assert st["card_count"] == 3 # 기본값 + assert st["created_at"] + + r = await client.get("/v1/quotation-setting/list", headers=h) + assert r.json()["total"] >= 1 + + +async def test_quotation_create(client, company_id): + h = await _headers(client, company_id, "qtuser") + body = { + "qt_setting_id": str(uuid.uuid4()), + "version_id": str(uuid.uuid4()), + "name": "견적A", + "number": "Q-001", + "type": "재견적", + "status": "진행중", + "start_time": "2026-06-16T00:00:00", + "end_time": "2026-06-17T00:00:00", + } + r = await client.post("/v1/quotation/create", json=body, headers=h) + res = r.json() + assert res["result"]["success"] is True + q = res["quotation"] + assert q["name"] == "견적A" + assert q["created_at"] # 재조회 픽스 + + r = await client.get("/v1/quotation/list", headers=h) + assert r.json()["total"] >= 1 diff --git a/negodata/backend/tests/test_item.py b/negodata/backend/tests/test_item.py new file mode 100644 index 0000000..c8c1cf8 --- /dev/null +++ b/negodata/backend/tests/test_item.py @@ -0,0 +1,85 @@ +"""item 도메인 e2e — CRUD + company 멀티테넌트 스코프 검증. + +실행 전제: PostgreSQL(negodata_db). docker compose up -d 후 python -m pytest. +""" +import uuid + +import pytest_asyncio +from sqlalchemy import text + + +async def _headers(client, company_id, login_id="itemuser", pw="pw1234"): + await client.post( + "/v1/auth/create", + json={"id": login_id, "password": pw, "company_id": company_id, "name": "n"}, + ) + r = await client.post("/v1/auth/login", json={"id": login_id, "password": pw}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +@pytest_asyncio.fixture +async def other_company_id(db_engine) -> str: + cid = uuid.uuid4() + async with db_engine.begin() as conn: + await conn.execute( + text("INSERT INTO companies (company_id, name) VALUES (:cid, :name)"), + {"cid": cid, "name": "다른회사"}, + ) + return str(cid) + + +async def test_item_crud_flow(client, company_id): + h = await _headers(client, company_id) + + # 등록 + r = await client.post("/v1/item/create", json={"name": "상품A", "price": 1000, "code": "C1"}, headers=h) + assert r.status_code == 200 + body = r.json() + assert body["result"]["success"] is True + item_id = body["item"]["item_id"] + assert body["item"]["name"] == "상품A" + + # 목록 + r = await client.get("/v1/item/list", headers=h) + body = r.json() + assert body["total"] == 1 and len(body["items"]) == 1 + + # 단건 조회 + r = await client.get(f"/v1/item/{item_id}", headers=h) + assert r.json()["item"]["item_id"] == item_id + + # 수정 (부분) + r = await client.patch(f"/v1/item/update/{item_id}", json={"price": 2000}, headers=h) + assert r.json()["item"]["price"] == 2000 + assert r.json()["item"]["name"] == "상품A" # 미지정 필드 유지 + + # 삭제 (soft) + r = await client.delete(f"/v1/item/delete/{item_id}", headers=h) + assert r.json()["result"]["success"] is True + + # 삭제 후 목록 0 + r = await client.get("/v1/item/list", headers=h) + assert r.json()["total"] == 0 + + +async def test_item_not_found(client, company_id): + h = await _headers(client, company_id) + r = await client.get(f"/v1/item/{uuid.uuid4()}", headers=h) + body = r.json() + assert body["result"]["success"] is False + assert body["result"]["code"] == 1300 # ITEM_NOT_FOUND + + +async def test_item_company_scope(client, company_id, other_company_id): + # 회사 A 가 상품 등록 + ha = await _headers(client, company_id, login_id="userA") + r = await client.post("/v1/item/create", json={"name": "A상품"}, headers=ha) + a_item_id = r.json()["item"]["item_id"] + + # 회사 B 유저는 A 의 상품을 목록/단건에서 볼 수 없다 + hb = await _headers(client, other_company_id, login_id="userB") + r = await client.get("/v1/item/list", headers=hb) + assert r.json()["total"] == 0 + + r = await client.get(f"/v1/item/{a_item_id}", headers=hb) + assert r.json()["result"]["code"] == 1300 # 타사 자원은 ITEM_NOT_FOUND